From 67c6a1d4368f2eb59c2e51b250de4eed996e1558 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 6 Feb 2025 09:04:49 +0100 Subject: [PATCH 0001/1941] Fix hassio test using wrong fixture (#137516) --- tests/components/hassio/test_backup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index cf03ac35f52..0dd2adc99ed 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -1990,7 +1990,7 @@ async def test_reader_writer_restore( assert response["result"] is None -@pytest.mark.usefixtures("hassio_client", "setup_integration") +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_report_progress( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, From 850416253962a45c5ebb1a775984760a9360c985 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Thu, 6 Feb 2025 08:01:45 +1300 Subject: [PATCH 0002/1941] Change Electric Kiwi authentication (#135231) Co-authored-by: Joostlek --- .../components/electric_kiwi/__init__.py | 64 +++++- homeassistant/components/electric_kiwi/api.py | 26 ++- .../components/electric_kiwi/config_flow.py | 37 +++- .../components/electric_kiwi/const.py | 2 +- .../components/electric_kiwi/coordinator.py | 18 +- .../components/electric_kiwi/manifest.json | 2 +- .../components/electric_kiwi/select.py | 4 +- .../components/electric_kiwi/sensor.py | 24 ++- .../components/electric_kiwi/strings.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/electric_kiwi/__init__.py | 12 ++ tests/components/electric_kiwi/conftest.py | 162 +++++++++----- .../fixtures/account_balance.json | 28 --- .../fixtures/account_summary.json | 43 ++++ .../fixtures/connection_details.json | 73 +++++++ .../electric_kiwi/fixtures/get_hop.json | 20 +- .../electric_kiwi/fixtures/hop_intervals.json | 199 +++++++++--------- .../electric_kiwi/fixtures/session.json | 23 ++ .../fixtures/session_no_services.json | 16 ++ .../electric_kiwi/test_config_flow.py | 127 ++++++----- tests/components/electric_kiwi/test_init.py | 135 ++++++++++++ tests/components/electric_kiwi/test_sensor.py | 27 ++- 23 files changed, 753 insertions(+), 296 deletions(-) delete mode 100644 tests/components/electric_kiwi/fixtures/account_balance.json create mode 100644 tests/components/electric_kiwi/fixtures/account_summary.json create mode 100644 tests/components/electric_kiwi/fixtures/connection_details.json create mode 100644 tests/components/electric_kiwi/fixtures/session.json create mode 100644 tests/components/electric_kiwi/fixtures/session_no_services.json create mode 100644 tests/components/electric_kiwi/test_init.py diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index de8d87553a3..825dbc54013 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -4,12 +4,16 @@ from __future__ import annotations import aiohttp from electrickiwi_api import ElectricKiwiApi -from electrickiwi_api.exceptions import ApiException +from electrickiwi_api.exceptions import ApiException, AuthException from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + entity_registry as er, +) from . import api from .coordinator import ( @@ -44,7 +48,9 @@ async def async_setup_entry( raise ConfigEntryNotReady from err ek_api = ElectricKiwiApi( - api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + api.ConfigEntryElectricKiwiAuth( + aiohttp_client.async_get_clientsession(hass), session + ) ) hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api) account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api) @@ -53,6 +59,8 @@ async def async_setup_entry( await ek_api.set_active_session() await hop_coordinator.async_config_entry_first_refresh() await account_coordinator.async_config_entry_first_refresh() + except AuthException as err: + raise ConfigEntryAuthFailed from err except ApiException as err: raise ConfigEntryNotReady from err @@ -70,3 +78,53 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: ElectricKiwiConfigEntry +) -> bool: + """Migrate old entry.""" + if config_entry.version == 1 and config_entry.minor_version == 1: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, config_entry + ) + ) + + session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + + ek_api = ElectricKiwiApi( + api.ConfigEntryElectricKiwiAuth( + aiohttp_client.async_get_clientsession(hass), session + ) + ) + try: + await ek_api.set_active_session() + connection_details = await ek_api.get_connection_details() + except AuthException: + config_entry.async_start_reauth(hass) + return False + except ApiException: + return False + unique_id = str(ek_api.customer_number) + identifier = ek_api.electricity.identifier + hass.config_entries.async_update_entry( + config_entry, unique_id=unique_id, minor_version=2 + ) + entity_registry = er.async_get(hass) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_id=config_entry.entry_id + ) + + for entity in entity_entries: + assert entity.config_entry_id + entity_registry.async_update_entity( + entity.entity_id, + new_unique_id=entity.unique_id.replace( + f"{unique_id}_{connection_details.id}", f"{unique_id}_{identifier}" + ), + ) + + return True diff --git a/homeassistant/components/electric_kiwi/api.py b/homeassistant/components/electric_kiwi/api.py index dead8a6a3c0..9f7ff333378 100644 --- a/homeassistant/components/electric_kiwi/api.py +++ b/homeassistant/components/electric_kiwi/api.py @@ -2,17 +2,16 @@ from __future__ import annotations -from typing import cast - from aiohttp import ClientSession from electrickiwi_api import AbstractAuth -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .const import API_BASE_URL -class AsyncConfigEntryAuth(AbstractAuth): +class ConfigEntryElectricKiwiAuth(AbstractAuth): """Provide Electric Kiwi authentication tied to an OAuth2 based config entry.""" def __init__( @@ -29,4 +28,21 @@ class AsyncConfigEntryAuth(AbstractAuth): """Return a valid access token.""" await self._oauth_session.async_ensure_token_valid() - return cast(str, self._oauth_session.token["access_token"]) + return str(self._oauth_session.token["access_token"]) + + +class ConfigFlowElectricKiwiAuth(AbstractAuth): + """Provide Electric Kiwi authentication tied to an OAuth2 based config flow.""" + + def __init__( + self, + hass: HomeAssistant, + token: str, + ) -> None: + """Initialize ConfigFlowFitbitApi.""" + super().__init__(aiohttp_client.async_get_clientsession(hass), API_BASE_URL) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Electric Kiwi API.""" + return self._token diff --git a/homeassistant/components/electric_kiwi/config_flow.py b/homeassistant/components/electric_kiwi/config_flow.py index b74ab4268e2..b83fd89c4c6 100644 --- a/homeassistant/components/electric_kiwi/config_flow.py +++ b/homeassistant/components/electric_kiwi/config_flow.py @@ -6,9 +6,14 @@ from collections.abc import Mapping import logging from typing import Any -from homeassistant.config_entries import ConfigFlowResult +from electrickiwi_api import ElectricKiwiApi +from electrickiwi_api.exceptions import ApiException + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_NAME from homeassistant.helpers import config_entry_oauth2_flow +from . import api from .const import DOMAIN, SCOPE_VALUES @@ -17,6 +22,8 @@ class ElectricKiwiOauth2FlowHandler( ): """Config flow to handle Electric Kiwi OAuth2 authentication.""" + VERSION = 1 + MINOR_VERSION = 2 DOMAIN = DOMAIN @property @@ -40,12 +47,30 @@ class ElectricKiwiOauth2FlowHandler( ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" if user_input is None: - return self.async_show_form(step_id="reauth_confirm") + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: self._get_reauth_entry().title}, + ) return await self.async_step_user() async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: """Create an entry for Electric Kiwi.""" - existing_entry = await self.async_set_unique_id(DOMAIN) - if existing_entry: - return self.async_update_reload_and_abort(existing_entry, data=data) - return await super().async_oauth_create_entry(data) + ek_api = ElectricKiwiApi( + api.ConfigFlowElectricKiwiAuth(self.hass, data["token"]["access_token"]) + ) + + try: + session = await ek_api.get_active_session() + except ApiException: + return self.async_abort(reason="connection_error") + + unique_id = str(session.data.customer_number) + await self.async_set_unique_id(unique_id) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=unique_id, data=data) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py index 907b6247172..c51422a7c72 100644 --- a/homeassistant/components/electric_kiwi/const.py +++ b/homeassistant/components/electric_kiwi/const.py @@ -8,4 +8,4 @@ OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize" OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" API_BASE_URL = "https://api.electrickiwi.co.nz" -SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" +SCOPE_VALUES = "read_customer_details read_connection_detail read_connection read_billing_address get_bill_address read_billing_frequency read_billing_details read_billing_bills read_billing_bill read_billing_bill_id read_billing_bill_file read_account_running_balance read_customer_account_summary read_consumption_summary download_consumption_file read_consumption_averages get_consumption_averages read_hop_intervals_config read_hop_intervals read_hop_connection read_hop_specific_connection save_hop_connection save_hop_specific_connection read_outage_contact get_outage_contact_info_for_icp read_session read_session_data_login" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 2065da5d668..635b55b2bc0 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -10,7 +10,7 @@ import logging from electrickiwi_api import ElectricKiwiApi from electrickiwi_api.exceptions import ApiException, AuthException -from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from electrickiwi_api.model import AccountSummary, Hop, HopIntervals from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -34,7 +34,7 @@ class ElectricKiwiRuntimeData: type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData] -class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): +class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountSummary]): """ElectricKiwi Account Data object.""" def __init__( @@ -51,13 +51,13 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): name="Electric Kiwi Account Data", update_interval=ACCOUNT_SCAN_INTERVAL, ) - self._ek_api = ek_api + self.ek_api = ek_api - async def _async_update_data(self) -> AccountBalance: + async def _async_update_data(self) -> AccountSummary: """Fetch data from Account balance API endpoint.""" try: async with asyncio.timeout(60): - return await self._ek_api.get_account_balance() + return await self.ek_api.get_account_summary() except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: @@ -85,7 +85,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): # Polling interval. Will only be polled if there are subscribers. update_interval=HOP_SCAN_INTERVAL, ) - self._ek_api = ek_api + self.ek_api = ek_api self.hop_intervals: HopIntervals | None = None def get_hop_options(self) -> dict[str, int]: @@ -100,7 +100,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): async def async_update_hop(self, hop_interval: int) -> Hop: """Update selected hop and data.""" try: - self.async_set_updated_data(await self._ek_api.post_hop(hop_interval)) + self.async_set_updated_data(await self.ek_api.post_hop(hop_interval)) except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: @@ -118,7 +118,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): try: async with asyncio.timeout(60): if self.hop_intervals is None: - hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals() + hop_intervals: HopIntervals = await self.ek_api.get_hop_intervals() hop_intervals.intervals = OrderedDict( filter( lambda pair: pair[1].active == 1, @@ -127,7 +127,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): ) self.hop_intervals = hop_intervals - return await self._ek_api.get_hop() + return await self.ek_api.get_hop() except AuthException as auth_err: raise ConfigEntryAuthFailed from auth_err except ApiException as api_err: diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json index 8ddb4c1af7c..9afe487d368 100644 --- a/homeassistant/components/electric_kiwi/manifest.json +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["electrickiwi-api==0.8.5"] + "requirements": ["electrickiwi-api==0.9.12"] } diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index fa111381612..30e02b5c5b9 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -53,8 +53,8 @@ class ElectricKiwiSelectHOPEntity( """Initialise the HOP selection entity.""" super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description self.values_dict = coordinator.get_hop_options() diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index e070f9495c1..410d70808c3 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from electrickiwi_api.model import AccountBalance, Hop +from electrickiwi_api.model import AccountSummary, Hop from homeassistant.components.sensor import ( SensorDeviceClass, @@ -39,7 +39,15 @@ ATTR_HOP_PERCENTAGE = "hop_percentage" class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription): """Describes Electric Kiwi sensor entity.""" - value_func: Callable[[AccountBalance], float | datetime] + value_func: Callable[[AccountSummary], float | datetime] + + +def _get_hop_percentage(account_balance: AccountSummary) -> float: + """Return the hop percentage from account summary.""" + if power := account_balance.services.get("power"): + if connection := power.connections[0]: + return float(connection.hop_percentage) + return 0.0 ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( @@ -72,9 +80,7 @@ ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( translation_key="hop_power_savings", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - value_func=lambda account_balance: float( - account_balance.connections[0].hop_percentage - ), + value_func=_get_hop_percentage, ), ) @@ -165,8 +171,8 @@ class ElectricKiwiAccountEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description @@ -194,8 +200,8 @@ class ElectricKiwiHOPEntity( super().__init__(coordinator) self._attr_unique_id = ( - f"{coordinator._ek_api.customer_number}" # noqa: SLF001 - f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 + f"{coordinator.ek_api.customer_number}" + f"_{coordinator.ek_api.electricity.identifier}_{description.key}" ) self.entity_description = description diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 410d32909ba..5e0a2ef168d 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -21,7 +21,8 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", - "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/requirements_all.txt b/requirements_all.txt index b1028c3efad..72e9157b0c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -821,7 +821,7 @@ ecoaliface==0.4.0 eheimdigital==1.0.5 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.12 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03779787e33..535acb73353 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -699,7 +699,7 @@ easyenergy==2.1.2 eheimdigital==1.0.5 # homeassistant.components.electric_kiwi -electrickiwi-api==0.8.5 +electrickiwi-api==0.9.12 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/tests/components/electric_kiwi/__init__.py b/tests/components/electric_kiwi/__init__.py index 7f5e08a56b5..936557ac3bf 100644 --- a/tests/components/electric_kiwi/__init__.py +++ b/tests/components/electric_kiwi/__init__.py @@ -1 +1,13 @@ """Tests for the Electric Kiwi integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Fixture for setting up the integration with args.""" + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 010efcb7b5f..cc967631be4 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -2,11 +2,18 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Generator from time import time from unittest.mock import AsyncMock, patch -from electrickiwi_api.model import AccountBalance, Hop, HopIntervals +from electrickiwi_api.model import ( + AccountSummary, + CustomerConnection, + Hop, + HopIntervals, + Service, + Session, +) import pytest from homeassistant.components.application_credentials import ( @@ -23,37 +30,55 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" -type YieldFixture = Generator[AsyncMock] -type ComponentSetup = Callable[[], Awaitable[bool]] + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup application credentials component.""" + await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) @pytest.fixture(autouse=True) -async def request_setup(current_request_with_host: None) -> None: - """Request setup.""" - - -@pytest.fixture -def component_setup( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> ComponentSetup: - """Fixture for setting up the integration.""" - - async def _setup_func() -> bool: - assert await async_setup_component(hass, "application_credentials", {}) - await hass.async_block_till_done() - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - DOMAIN, +def electrickiwi_api() -> Generator[AsyncMock]: + """Mock ek api and return values.""" + with ( + patch( + "homeassistant.components.electric_kiwi.ElectricKiwiApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.electric_kiwi.config_flow.ElectricKiwiApi", + new=mock_client, + ), + ): + client = mock_client.return_value + client.customer_number = 123456 + client.electricity = Service( + identifier="00000000DDA", + service="electricity", + service_status="Y", + is_primary_service=True, ) - await hass.async_block_till_done() - config_entry.add_to_hass(hass) - result = await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - return result - - return _setup_func + client.get_active_session.return_value = Session.from_dict( + load_json_value_fixture("session.json", DOMAIN) + ) + client.get_hop_intervals.return_value = HopIntervals.from_dict( + load_json_value_fixture("hop_intervals.json", DOMAIN) + ) + client.get_hop.return_value = Hop.from_dict( + load_json_value_fixture("get_hop.json", DOMAIN) + ) + client.get_account_summary.return_value = AccountSummary.from_dict( + load_json_value_fixture("account_summary.json", DOMAIN) + ) + client.get_connection_details.return_value = CustomerConnection.from_dict( + load_json_value_fixture("connection_details.json", DOMAIN) + ) + yield client @pytest.fixture(name="config_entry") @@ -63,7 +88,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: title="Electric Kiwi", domain=DOMAIN, data={ - "id": "12345", + "id": "123456", "auth_implementation": DOMAIN, "token": { "refresh_token": "mock-refresh-token", @@ -74,6 +99,54 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, }, unique_id=DOMAIN, + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="config_entry2") +def mock_config_entry2(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123457", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="1234567", + version=1, + minor_version=1, + ) + + +@pytest.fixture(name="migrated_config_entry") +def mock_migrated_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "123456", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="123456", + version=1, + minor_version=2, ) @@ -87,35 +160,10 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="ek_auth") -def electric_kiwi_auth() -> YieldFixture: +def electric_kiwi_auth() -> Generator[AsyncMock]: """Patch access to electric kiwi access token.""" with patch( - "homeassistant.components.electric_kiwi.api.AsyncConfigEntryAuth" + "homeassistant.components.electric_kiwi.api.ConfigEntryElectricKiwiAuth" ) as mock_auth: mock_auth.return_value.async_get_access_token = AsyncMock("auth_token") yield mock_auth - - -@pytest.fixture(name="ek_api") -def ek_api() -> YieldFixture: - """Mock ek api and return values.""" - with patch( - "homeassistant.components.electric_kiwi.ElectricKiwiApi", autospec=True - ) as mock_ek_api: - mock_ek_api.return_value.customer_number = 123456 - mock_ek_api.return_value.connection_id = 123456 - mock_ek_api.return_value.set_active_session.return_value = None - mock_ek_api.return_value.get_hop_intervals.return_value = ( - HopIntervals.from_dict( - load_json_value_fixture("hop_intervals.json", DOMAIN) - ) - ) - mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( - load_json_value_fixture("get_hop.json", DOMAIN) - ) - mock_ek_api.return_value.get_account_balance.return_value = ( - AccountBalance.from_dict( - load_json_value_fixture("account_balance.json", DOMAIN) - ) - ) - yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/account_balance.json b/tests/components/electric_kiwi/fixtures/account_balance.json deleted file mode 100644 index 25bc57784ee..00000000000 --- a/tests/components/electric_kiwi/fixtures/account_balance.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "data": { - "connections": [ - { - "hop_percentage": "3.5", - "id": 3, - "running_balance": "184.09", - "start_date": "2020-10-04", - "unbilled_days": 15 - } - ], - "last_billed_amount": "-66.31", - "last_billed_date": "2020-10-03", - "next_billing_date": "2020-11-03", - "is_prepay": "N", - "summary": { - "credits": "0.0", - "electricity_used": "184.09", - "other_charges": "0.00", - "payments": "-220.0" - }, - "total_account_balance": "-102.22", - "total_billing_days": 30, - "total_running_balance": "184.09", - "type": "account_running_balance" - }, - "status": 1 -} diff --git a/tests/components/electric_kiwi/fixtures/account_summary.json b/tests/components/electric_kiwi/fixtures/account_summary.json new file mode 100644 index 00000000000..6a05d6a3fe7 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/account_summary.json @@ -0,0 +1,43 @@ +{ + "data": { + "type": "account_summary", + "total_running_balance": "184.09", + "total_account_balance": "-102.22", + "total_billing_days": 31, + "next_billing_date": "2025-02-19", + "service_names": ["power"], + "services": { + "power": { + "connections": [ + { + "id": 515363, + "running_balance": "12.98", + "unbilled_days": 5, + "hop_percentage": "11.2", + "start_date": "2025-01-19", + "service_label": "Power" + } + ] + } + }, + "date_to_pay": "", + "invoice_id": "", + "total_invoiced_charges": "", + "default_to_pay": "", + "invoice_exists": 1, + "display_date": "2025-01-19", + "last_billed_date": "2025-01-18", + "last_billed_amount": "-21.02", + "summary": { + "electricity_used": "12.98", + "other_charges": "0.00", + "payments": "0.00", + "credits": "0.00", + "mobile_charges": "0.00", + "broadband_charges": "0.00", + "addon_unbilled_charges": {} + }, + "is_prepay": "N" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/connection_details.json b/tests/components/electric_kiwi/fixtures/connection_details.json new file mode 100644 index 00000000000..5b446659aab --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/connection_details.json @@ -0,0 +1,73 @@ +{ + "data": { + "type": "connection", + "id": 515363, + "customer_id": 273941, + "customer_number": 34030646, + "icp_identifier": "00000000DDA", + "address": "", + "short_address": "", + "physical_address_unit": "", + "physical_address_number": "555", + "physical_address_street": "RACECOURSE ROAD", + "physical_address_suburb": "", + "physical_address_town": "Blah", + "physical_address_region": "Blah", + "physical_address_postcode": "0000", + "is_active": "Y", + "pricing_plan": { + "id": 51423, + "usage": "0.0000", + "fixed": "0.6000", + "usage_rate_inc_gst": "0.0000", + "supply_rate_inc_gst": "0.6900", + "plan_description": "MoveMaster Anytime Residential (Low User)", + "plan_type": "movemaster_tou", + "signup_price_plan_blurb": "Better rates every day during off-peak, and all day on weekends. Plus half price nights (11pm-7am) and our best solar buyback.", + "signup_price_plan_label": "MoveMaster", + "app_price_plan_label": "Your MoveMaster rates are...", + "solar_rate_excl_gst": "0.1250", + "solar_rate_incl_gst": "0.1438", + "pricing_type": "tou_plus", + "tou_plus": { + "fixed_rate_excl_gst": "0.6000", + "fixed_rate_incl_gst": "0.6900", + "interval_types": ["peak", "off_peak_shoulder", "off_peak_night"], + "peak": { + "price_excl_gst": "0.5390", + "price_incl_gst": "0.6199", + "display_text": { + "Weekdays": "7am-9am, 5pm-9pm" + }, + "tou_plus_label": "Peak" + }, + "off_peak_shoulder": { + "price_excl_gst": "0.3234", + "price_incl_gst": "0.3719", + "display_text": { + "Weekdays": "9am-5pm, 9pm-11pm", + "Weekends": "7am-11pm" + }, + "tou_plus_label": "Off-peak shoulder" + }, + "off_peak_night": { + "price_excl_gst": "0.2695", + "price_incl_gst": "0.3099", + "display_text": { + "Every day": "11pm-7am" + }, + "tou_plus_label": "Off-peak night" + } + } + }, + "hop": { + "start_time": "9:00 PM", + "end_time": "10:00 PM", + "interval_start": "43", + "interval_end": "44" + }, + "start_date": "2022-03-03", + "end_date": "", + "property_type": "residential" + } +} diff --git a/tests/components/electric_kiwi/fixtures/get_hop.json b/tests/components/electric_kiwi/fixtures/get_hop.json index d29825391e9..2b126bfc017 100644 --- a/tests/components/electric_kiwi/fixtures/get_hop.json +++ b/tests/components/electric_kiwi/fixtures/get_hop.json @@ -1,16 +1,18 @@ { "data": { - "connection_id": "3", - "customer_number": 1000001, - "end": { - "end_time": "5:00 PM", - "interval": "34" - }, + "type": "hop_customer", + "customer_id": 123456, + "service_type": "electricity", + "connection_id": 515363, + "billing_id": 1247975, "start": { - "start_time": "4:00 PM", - "interval": "33" + "interval": "33", + "start_time": "4:00 PM" }, - "type": "hop_customer" + "end": { + "interval": "34", + "end_time": "5:00 PM" + } }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/hop_intervals.json b/tests/components/electric_kiwi/fixtures/hop_intervals.json index 15ecc174f13..860630b000a 100644 --- a/tests/components/electric_kiwi/fixtures/hop_intervals.json +++ b/tests/components/electric_kiwi/fixtures/hop_intervals.json @@ -1,249 +1,250 @@ { "data": { - "hop_duration": "60", "type": "hop_intervals", + "hop_duration": "60", "intervals": { "1": { - "active": 1, + "start_time": "12:00 AM", "end_time": "1:00 AM", - "start_time": "12:00 AM" + "active": 1 }, "2": { - "active": 1, + "start_time": "12:30 AM", "end_time": "1:30 AM", - "start_time": "12:30 AM" + "active": 1 }, "3": { - "active": 1, + "start_time": "1:00 AM", "end_time": "2:00 AM", - "start_time": "1:00 AM" + "active": 1 }, "4": { - "active": 1, + "start_time": "1:30 AM", "end_time": "2:30 AM", - "start_time": "1:30 AM" + "active": 1 }, "5": { - "active": 1, + "start_time": "2:00 AM", "end_time": "3:00 AM", - "start_time": "2:00 AM" + "active": 1 }, "6": { - "active": 1, + "start_time": "2:30 AM", "end_time": "3:30 AM", - "start_time": "2:30 AM" + "active": 1 }, "7": { - "active": 1, + "start_time": "3:00 AM", "end_time": "4:00 AM", - "start_time": "3:00 AM" + "active": 1 }, "8": { - "active": 1, + "start_time": "3:30 AM", "end_time": "4:30 AM", - "start_time": "3:30 AM" + "active": 1 }, "9": { - "active": 1, + "start_time": "4:00 AM", "end_time": "5:00 AM", - "start_time": "4:00 AM" + "active": 1 }, "10": { - "active": 1, + "start_time": "4:30 AM", "end_time": "5:30 AM", - "start_time": "4:30 AM" + "active": 1 }, "11": { - "active": 1, + "start_time": "5:00 AM", "end_time": "6:00 AM", - "start_time": "5:00 AM" + "active": 1 }, "12": { - "active": 1, + "start_time": "5:30 AM", "end_time": "6:30 AM", - "start_time": "5:30 AM" + "active": 1 }, "13": { - "active": 1, + "start_time": "6:00 AM", "end_time": "7:00 AM", - "start_time": "6:00 AM" + "active": 1 }, "14": { - "active": 1, + "start_time": "6:30 AM", "end_time": "7:30 AM", - "start_time": "6:30 AM" + "active": 0 }, "15": { - "active": 1, + "start_time": "7:00 AM", "end_time": "8:00 AM", - "start_time": "7:00 AM" + "active": 0 }, "16": { - "active": 1, + "start_time": "7:30 AM", "end_time": "8:30 AM", - "start_time": "7:30 AM" + "active": 0 }, "17": { - "active": 1, + "start_time": "8:00 AM", "end_time": "9:00 AM", - "start_time": "8:00 AM" + "active": 0 }, "18": { - "active": 1, + "start_time": "8:30 AM", "end_time": "9:30 AM", - "start_time": "8:30 AM" + "active": 0 }, "19": { - "active": 1, + "start_time": "9:00 AM", "end_time": "10:00 AM", - "start_time": "9:00 AM" + "active": 1 }, "20": { - "active": 1, + "start_time": "9:30 AM", "end_time": "10:30 AM", - "start_time": "9:30 AM" + "active": 1 }, "21": { - "active": 1, + "start_time": "10:00 AM", "end_time": "11:00 AM", - "start_time": "10:00 AM" + "active": 1 }, "22": { - "active": 1, + "start_time": "10:30 AM", "end_time": "11:30 AM", - "start_time": "10:30 AM" + "active": 1 }, "23": { - "active": 1, + "start_time": "11:00 AM", "end_time": "12:00 PM", - "start_time": "11:00 AM" + "active": 1 }, "24": { - "active": 1, + "start_time": "11:30 AM", "end_time": "12:30 PM", - "start_time": "11:30 AM" + "active": 1 }, "25": { - "active": 1, + "start_time": "12:00 PM", "end_time": "1:00 PM", - "start_time": "12:00 PM" + "active": 1 }, "26": { - "active": 1, + "start_time": "12:30 PM", "end_time": "1:30 PM", - "start_time": "12:30 PM" + "active": 1 }, "27": { - "active": 1, + "start_time": "1:00 PM", "end_time": "2:00 PM", - "start_time": "1:00 PM" + "active": 1 }, "28": { - "active": 1, + "start_time": "1:30 PM", "end_time": "2:30 PM", - "start_time": "1:30 PM" + "active": 1 }, "29": { - "active": 1, + "start_time": "2:00 PM", "end_time": "3:00 PM", - "start_time": "2:00 PM" + "active": 1 }, "30": { - "active": 1, + "start_time": "2:30 PM", "end_time": "3:30 PM", - "start_time": "2:30 PM" + "active": 1 }, "31": { - "active": 1, + "start_time": "3:00 PM", "end_time": "4:00 PM", - "start_time": "3:00 PM" + "active": 1 }, "32": { - "active": 1, + "start_time": "3:30 PM", "end_time": "4:30 PM", - "start_time": "3:30 PM" + "active": 1 }, "33": { - "active": 1, + "start_time": "4:00 PM", "end_time": "5:00 PM", - "start_time": "4:00 PM" + "active": 1 }, "34": { - "active": 1, + "start_time": "4:30 PM", "end_time": "5:30 PM", - "start_time": "4:30 PM" + "active": 0 }, "35": { - "active": 1, + "start_time": "5:00 PM", "end_time": "6:00 PM", - "start_time": "5:00 PM" + "active": 0 }, "36": { - "active": 1, + "start_time": "5:30 PM", "end_time": "6:30 PM", - "start_time": "5:30 PM" + "active": 0 }, "37": { - "active": 1, + "start_time": "6:00 PM", "end_time": "7:00 PM", - "start_time": "6:00 PM" + "active": 0 }, "38": { - "active": 1, + "start_time": "6:30 PM", "end_time": "7:30 PM", - "start_time": "6:30 PM" + "active": 0 }, "39": { - "active": 1, + "start_time": "7:00 PM", "end_time": "8:00 PM", - "start_time": "7:00 PM" + "active": 0 }, "40": { - "active": 1, + "start_time": "7:30 PM", "end_time": "8:30 PM", - "start_time": "7:30 PM" + "active": 0 }, "41": { - "active": 1, + "start_time": "8:00 PM", "end_time": "9:00 PM", - "start_time": "8:00 PM" + "active": 0 }, "42": { - "active": 1, + "start_time": "8:30 PM", "end_time": "9:30 PM", - "start_time": "8:30 PM" + "active": 0 }, "43": { - "active": 1, + "start_time": "9:00 PM", "end_time": "10:00 PM", - "start_time": "9:00 PM" + "active": 1 }, "44": { - "active": 1, + "start_time": "9:30 PM", "end_time": "10:30 PM", - "start_time": "9:30 PM" + "active": 1 }, "45": { - "active": 1, - "end_time": "11:00 AM", - "start_time": "10:00 PM" + "start_time": "10:00 PM", + "end_time": "11:00 PM", + "active": 1 }, "46": { - "active": 1, + "start_time": "10:30 PM", "end_time": "11:30 PM", - "start_time": "10:30 PM" + "active": 1 }, "47": { - "active": 1, + "start_time": "11:00 PM", "end_time": "12:00 AM", - "start_time": "11:00 PM" + "active": 1 }, "48": { - "active": 1, + "start_time": "11:30 PM", "end_time": "12:30 AM", - "start_time": "11:30 PM" + "active": 0 } - } + }, + "service_type": "electricity" }, "status": 1 } diff --git a/tests/components/electric_kiwi/fixtures/session.json b/tests/components/electric_kiwi/fixtures/session.json new file mode 100644 index 00000000000..ee04aaca549 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session.json @@ -0,0 +1,23 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [ + { + "service": "Electricity", + "identifier": "00000000DDA", + "is_primary_service": true, + "service_status": "Y" + } + ], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/session_no_services.json b/tests/components/electric_kiwi/fixtures/session_no_services.json new file mode 100644 index 00000000000..62ae7aea20a --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/session_no_services.json @@ -0,0 +1,16 @@ +{ + "data": { + "data": { + "type": "session", + "avatar": [], + "customer_number": 123456, + "customer_name": "Joe Dirt", + "email": "joe@dirt.kiwi", + "customer_status": "Y", + "services": [], + "res_partner_id": 285554, + "nuid": "EK_GUID" + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py index 681320972b5..ab643a0ddf1 100644 --- a/tests/components/electric_kiwi/test_config_flow.py +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -3,70 +3,40 @@ from __future__ import annotations from http import HTTPStatus -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +from electrickiwi_api.exceptions import ApiException import pytest -from homeassistant import config_entries -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.electric_kiwi.const import ( DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, SCOPE_VALUES, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.setup import async_setup_component -from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI +from .conftest import CLIENT_ID, REDIRECT_URI from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - -@pytest.fixture -async def setup_credentials(hass: HomeAssistant) -> None: - """Fixture to setup application credentials component.""" - await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - -async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: - """Test config flow base case with no credentials registered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "missing_credentials" - - -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "electrickiwi_api") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, mock_setup_entry: AsyncMock, ) -> None: """Check full flow.""" - await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) - ) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, @@ -76,13 +46,13 @@ async def test_full_flow( }, ) - URL_SCOPE = SCOPE_VALUES.replace(" ", "+") + url_scope = SCOPE_VALUES.replace(" ", "+") assert result["url"] == ( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URI}" f"&state={state}" - f"&scope={URL_SCOPE}" + f"&scope={url_scope}" ) client = await hass_client_no_auth() @@ -90,6 +60,7 @@ async def test_full_flow( assert resp.status == HTTPStatus.OK assert resp.headers["content-type"] == "text/html; charset=utf-8" + aioclient_mock.clear_requests() aioclient_mock.post( OAUTH2_TOKEN, json={ @@ -106,20 +77,73 @@ async def test_full_flow( assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_failure( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + electrickiwi_api: AsyncMock, +) -> None: + """Check failure on creation of entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + url_scope = SCOPE_VALUES.replace(" ", "+") + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URI}" + f"&state={state}" + f"&scope={url_scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + electrickiwi_api.get_active_session.side_effect = ApiException() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "connection_error" + + @pytest.mark.usefixtures("current_request_with_host") async def test_existing_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials: None, - config_entry: MockConfigEntry, + migrated_config_entry: MockConfigEntry, ) -> None: """Check existing entry.""" - config_entry.add_to_hass(hass) + migrated_config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + DOMAIN, context={"source": SOURCE_USER, "entry_id": DOMAIN} ) state = config_entry_oauth2_flow._encode_jwt( @@ -145,7 +169,9 @@ async def test_existing_entry( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -154,13 +180,13 @@ async def test_reauthentication( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_setup_entry: MagicMock, - config_entry: MockConfigEntry, - setup_credentials: None, + mock_setup_entry: AsyncMock, + migrated_config_entry: MockConfigEntry, ) -> None: """Test Electric Kiwi reauthentication.""" - config_entry.add_to_hass(hass) - result = await config_entry.start_reauth_flow(hass) + migrated_config_entry.add_to_hass(hass) + + result = await migrated_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" @@ -189,8 +215,11 @@ async def test_reauthentication( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" diff --git a/tests/components/electric_kiwi/test_init.py b/tests/components/electric_kiwi/test_init.py new file mode 100644 index 00000000000..947f788ad55 --- /dev/null +++ b/tests/components/electric_kiwi/test_init.py @@ -0,0 +1,135 @@ +"""Test the Electric Kiwi init.""" + +import http +from unittest.mock import AsyncMock, patch + +from aiohttp import RequestInfo +from aiohttp.client_exceptions import ClientResponseError +from electrickiwi_api.exceptions import ApiException, AuthException +import pytest + +from homeassistant.components.electric_kiwi.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a successful setup entry and unload of entry.""" + await init_integration(hass, config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_async_setup_multiple_entries( + hass: HomeAssistant, + config_entry: MockConfigEntry, + config_entry2: MockConfigEntry, +) -> None: + """Test a successful setup and unload of multiple entries.""" + + for entry in (config_entry, config_entry2): + await init_integration(hass, entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + for entry in (config_entry, config_entry2): + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("status", "expected_state"), + [ + ( + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_refresh_token_validity_failures( + hass: HomeAssistant, + config_entry: MockConfigEntry, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test token refresh failure status.""" + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + RequestInfo("", "POST", {}, ""), None, status=status + ), + ) as mock_async_ensure_token_valid: + await init_integration(hass, config_entry) + mock_async_ensure_token_valid.assert_called_once() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_unique_id_migration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the unique ID is migrated to the customer number.""" + + config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, "123456_515363_sensor", config_entry=config_entry + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + new_entry = hass.config_entries.async_get_entry(config_entry.entry_id) + assert new_entry.minor_version == 2 + assert new_entry.unique_id == "123456" + entity_entry = entity_registry.async_get( + "sensor.electric_kiwi_123456_515363_sensor" + ) + assert entity_entry.unique_id == "123456_00000000DDA_sensor" + + +async def test_unique_id_migration_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = ApiException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_unique_id_migration_auth_failure( + hass: HomeAssistant, config_entry: MockConfigEntry, electrickiwi_api: AsyncMock +) -> None: + """Test that the unique ID is migrated to the customer number.""" + electrickiwi_api.set_active_session.side_effect = AuthException() + await init_integration(hass, config_entry) + + assert config_entry.minor_version == 1 + assert config_entry.unique_id == DOMAIN + assert config_entry.state is ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index a85eb16a986..3e58b33a998 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import dt as dt_util -from .conftest import ComponentSetup, YieldFixture +from . import init_integration from tests.common import MockConfigEntry @@ -47,10 +47,9 @@ def restore_timezone(): async def test_hop_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: Mock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, ) -> None: @@ -61,7 +60,7 @@ async def test_hop_sensors( sensor state should be set to today at 4pm or if now is past 4pm, then tomorrow at 4pm. """ - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -70,8 +69,7 @@ async def test_hop_sensors( state = hass.states.get(sensor) assert state - api = ek_api(Mock()) - hop_data = await api.get_hop() + hop_data = await electrickiwi_api.get_hop() value = _check_and_move_time(hop_data, sensor_state) @@ -98,20 +96,19 @@ async def test_hop_sensors( ), ( "sensor.next_billing_date", - "2020-11-03T00:00:00", + "2025-02-19T00:00:00", SensorDeviceClass.DATE, None, ), - ("sensor.hour_of_power_savings", "3.5", None, SensorStateClass.MEASUREMENT), + ("sensor.hour_of_power_savings", "11.2", None, SensorStateClass.MEASUREMENT), ], ) async def test_account_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - ek_api: YieldFixture, - ek_auth: YieldFixture, + electrickiwi_api: AsyncMock, + ek_auth: AsyncMock, entity_registry: EntityRegistry, - component_setup: ComponentSetup, sensor: str, sensor_state: str, device_class: str, @@ -119,7 +116,7 @@ async def test_account_sensors( ) -> None: """Test Account sensors for the Electric Kiwi integration.""" - assert await component_setup() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED entity = entity_registry.async_get(sensor) @@ -133,9 +130,9 @@ async def test_account_sensors( assert state.attributes.get(ATTR_STATE_CLASS) == state_class -async def test_check_and_move_time(ek_api: AsyncMock) -> None: +async def test_check_and_move_time(electrickiwi_api: AsyncMock) -> None: """Test correct time is returned depending on time of day.""" - hop = await ek_api(Mock()).get_hop() + hop = await electrickiwi_api.get_hop() test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TEST_TIMEZONE) dt_util.set_default_time_zone(TEST_TIMEZONE) From 627377872b248e3381b18bd4bb619a790fa1a9da Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Feb 2025 23:01:46 +0100 Subject: [PATCH 0003/1941] Update govee-ble to 0.42.1 (#137371) --- homeassistant/components/govee_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 5a123de7066..4d871a991a6 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -131,5 +131,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.42.0"] + "requirements": ["govee-ble==0.42.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 72e9157b0c2..7737e6cea33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1049,7 +1049,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.0 +govee-ble==0.42.1 # homeassistant.components.govee_light_local govee-local-api==1.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 535acb73353..c53362b552a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -899,7 +899,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.0 +govee-ble==0.42.1 # homeassistant.components.govee_light_local govee-local-api==1.5.3 From db7c2dab5217b96f01611c6b1974318e35ede2ae Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 5 Feb 2025 21:13:42 +0100 Subject: [PATCH 0004/1941] Bump holidays to 0.66 (#137449) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index edf3ebe7f04..6952d48ef32 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.65", "babel==2.15.0"] + "requirements": ["holidays==0.66", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 4b9d072f747..cbb11a06aec 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.65"] + "requirements": ["holidays==0.66"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7737e6cea33..5029444863e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.65 +holidays==0.66 # homeassistant.components.frontend home-assistant-frontend==20250205.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c53362b552a..8658b8601aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -969,7 +969,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.65 +holidays==0.66 # homeassistant.components.frontend home-assistant-frontend==20250205.0 From 1c769418fba22057a5c5554ec13ace3ef3207d7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 21:04:52 -0600 Subject: [PATCH 0005/1941] Bump aiohttp-asyncmdnsresolver to 0.1.0 (#137492) changelog: https://github.com/aio-libs/aiohttp-asyncmdnsresolver/compare/v0.0.3...v0.1.0 Switches to the new AsyncDualMDNSResolver class to which tries via mDNS and DNS for .local domains since we have so many different user DNS configurations to support fixes #137479 fixes #136922 --- homeassistant/helpers/aiohttp_client.py | 6 +++--- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index b5f5ee9a961..3d8dc247857 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -15,7 +15,7 @@ import aiohttp from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout -from aiohttp_asyncmdnsresolver.api import AsyncMDNSResolver +from aiohttp_asyncmdnsresolver.api import AsyncDualMDNSResolver from homeassistant import config_entries from homeassistant.components import zeroconf @@ -377,5 +377,5 @@ def _async_get_connector( @callback -def _async_make_resolver(hass: HomeAssistant) -> AsyncMDNSResolver: - return AsyncMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) +def _async_make_resolver(hass: HomeAssistant) -> AsyncDualMDNSResolver: + return AsyncDualMDNSResolver(async_zeroconf=zeroconf.async_get_async_zeroconf(hass)) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2c3513589b2..3f7a34d594a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.0.3 aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp-asyncmdnsresolver==0.0.3 +aiohttp-asyncmdnsresolver==0.1.0 aiohttp-fast-zlib==0.2.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index d7c0761887f..93396d4c0f9 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.3", + "aiohttp-asyncmdnsresolver==0.1.0", "aiozoneinfo==0.2.1", "astral==2.2", "async-interrupt==1.2.0", diff --git a/requirements.txt b/requirements.txt index 0f5ac0ba7d6..6507279cd50 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.3.0 aiohttp==3.11.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 -aiohttp-asyncmdnsresolver==0.0.3 +aiohttp-asyncmdnsresolver==0.1.0 aiozoneinfo==0.2.1 astral==2.2 async-interrupt==1.2.0 From f27fe365c5d05ac538636f4101cd715a8bf43a5b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Feb 2025 21:05:12 -0600 Subject: [PATCH 0006/1941] Bump aiohttp to 3.11.12 (#137494) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.11...v3.11.12 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3f7a34d594a..a53534f4f6d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.0 aiohttp-fast-zlib==0.2.0 -aiohttp==3.11.11 +aiohttp==3.11.12 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.1 diff --git a/pyproject.toml b/pyproject.toml index 93396d4c0f9..bf1b8890461 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.0", - "aiohttp==3.11.11", + "aiohttp==3.11.12", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.0", "aiohttp-asyncmdnsresolver==0.1.0", diff --git a/requirements.txt b/requirements.txt index 6507279cd50..a99034ee9cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp==3.11.11 +aiohttp==3.11.12 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.0 aiohttp-asyncmdnsresolver==0.1.0 From cd40232beb347debf74f0d3ae91870299210ab6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Feb 2025 01:31:23 -0600 Subject: [PATCH 0007/1941] Bump govee-ble to 0.43.0 to fix compat with new H5179 firmware (#137508) changelog: https://github.com/Bluetooth-Devices/govee-ble/compare/v0.42.1...v0.43.0 fixes #136969 --- homeassistant/components/govee_ble/manifest.json | 6 +++++- homeassistant/generated/bluetooth.py | 5 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 4d871a991a6..1c61ae31010 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -38,6 +38,10 @@ "local_name": "GV5126*", "connectable": false }, + { + "local_name": "GV5179*", + "connectable": false + }, { "local_name": "GVH5127*", "connectable": false @@ -131,5 +135,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.42.1"] + "requirements": ["govee-ble==0.43.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 8a5880dcde9..447b6d284f0 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -187,6 +187,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "govee_ble", "local_name": "GV5126*", }, + { + "connectable": False, + "domain": "govee_ble", + "local_name": "GV5179*", + }, { "connectable": False, "domain": "govee_ble", diff --git a/requirements_all.txt b/requirements_all.txt index 5029444863e..378d0a0a65e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1049,7 +1049,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.1 +govee-ble==0.43.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8658b8601aa..ed5b48ee548 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -899,7 +899,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.42.1 +govee-ble==0.43.0 # homeassistant.components.govee_light_local govee-local-api==1.5.3 From 30b131d3b95342423bac75c2246d12dc44b63cd0 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Feb 2025 08:32:56 +0100 Subject: [PATCH 0008/1941] Bump habiticalib to v0.3.5 (#137510) --- .../components/habitica/manifest.json | 2 +- homeassistant/components/habitica/services.py | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../habitica/snapshots/test_diagnostics.ambr | 8 +- .../habitica/snapshots/test_services.ambr | 1820 ++++++++--------- 6 files changed, 919 insertions(+), 918 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 6ace6d45509..9ea346a0dcb 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], - "requirements": ["habiticalib==0.3.4"] + "requirements": ["habiticalib==0.3.5"] } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index a28aada85fa..ed4a6444ea2 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -510,7 +510,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 or (task.notes and keyword in task.notes.lower()) or any(keyword in item.text.lower() for item in task.checklist) ] - result: dict[str, Any] = {"tasks": response} + result: dict[str, Any] = {"tasks": [task.to_dict() for task in response]} + return result hass.services.async_register( diff --git a/requirements_all.txt b/requirements_all.txt index 378d0a0a65e..483417a6972 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,7 +1097,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.4 +habiticalib==0.3.5 # homeassistant.components.bluetooth habluetooth==3.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ed5b48ee548..53da87361cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.4 +habiticalib==0.3.5 # homeassistant.components.bluetooth habluetooth==3.21.1 diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index 1f3a14fade1..2fe3513a646 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -8,7 +8,6 @@ 'habitica_data': dict({ 'tasks': list([ dict({ - 'Type': 'habit', 'alias': None, 'attribute': 'str', 'byHabitica': False, @@ -71,6 +70,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'habit', 'up': True, 'updatedAt': '2024-10-10T15:57:14.287000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', @@ -80,7 +80,6 @@ 'yesterDaily': None, }), dict({ - 'Type': 'todo', 'alias': None, 'attribute': 'str', 'byHabitica': True, @@ -143,6 +142,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'todo', 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', @@ -152,7 +152,6 @@ 'yesterDaily': None, }), dict({ - 'Type': 'reward', 'alias': None, 'attribute': 'str', 'byHabitica': False, @@ -215,6 +214,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'reward', 'up': None, 'updatedAt': '2024-10-10T15:57:14.290000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', @@ -224,7 +224,6 @@ 'yesterDaily': None, }), dict({ - 'Type': 'daily', 'alias': None, 'attribute': 'str', 'byHabitica': False, @@ -341,6 +340,7 @@ 'tags': list([ ]), 'text': 'task text', + 'type': 'daily', 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index f40d50ded98..e25ed8db313 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -3,9 +3,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -20,13 +19,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -44,12 +43,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -66,18 +65,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -92,13 +91,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -117,19 +116,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -146,18 +145,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -172,13 +171,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -196,12 +195,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -218,18 +217,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -244,13 +243,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -269,19 +268,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -298,18 +297,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -321,7 +320,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -329,13 +328,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -354,7 +353,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -362,7 +361,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -370,7 +369,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -378,7 +377,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -386,7 +385,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -394,7 +393,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -402,7 +401,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -410,7 +409,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -418,7 +417,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -426,7 +425,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -434,25 +433,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -464,23 +463,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -495,13 +494,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -520,7 +519,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -528,7 +527,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -536,7 +535,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -544,7 +543,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -552,7 +551,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -560,7 +559,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -568,7 +567,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -576,7 +575,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -584,7 +583,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -592,30 +591,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -627,23 +626,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -655,7 +654,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -663,13 +662,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -687,18 +686,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -710,24 +709,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -742,8 +741,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -766,12 +765,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -786,22 +785,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -816,8 +815,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -840,17 +839,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -867,18 +866,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -893,7 +892,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -917,12 +916,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -939,18 +938,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -965,8 +964,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -989,12 +988,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1009,21 +1008,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1038,7 +1037,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1062,12 +1061,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1084,18 +1083,18 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1110,13 +1109,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1134,18 +1133,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1157,14 +1156,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -1172,9 +1172,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1189,13 +1188,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1213,18 +1212,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1236,23 +1235,23 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1267,7 +1266,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1291,12 +1290,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -1311,21 +1310,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1340,7 +1339,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -1364,12 +1363,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -1384,12 +1383,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -1402,9 +1402,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1416,7 +1415,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -1424,13 +1423,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1449,7 +1448,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1457,7 +1456,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1465,7 +1464,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1473,7 +1472,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1481,7 +1480,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1489,7 +1488,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1497,7 +1496,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1505,7 +1504,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1513,7 +1512,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1521,7 +1520,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1529,25 +1528,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1559,14 +1558,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), @@ -1579,9 +1579,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1596,13 +1595,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1620,12 +1619,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1642,18 +1641,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1668,13 +1667,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1693,19 +1692,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1722,9 +1721,10 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -1737,9 +1737,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1751,7 +1750,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -1759,13 +1758,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1783,18 +1782,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -1806,24 +1805,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1838,8 +1837,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -1862,12 +1861,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -1882,13 +1881,14 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -1901,9 +1901,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -1918,13 +1917,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -1943,7 +1942,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1951,7 +1950,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1959,7 +1958,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1967,7 +1966,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1975,7 +1974,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1983,7 +1982,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1991,7 +1990,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -1999,7 +1998,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2007,7 +2006,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2015,30 +2014,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -2050,14 +2049,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), @@ -2070,9 +2070,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2087,13 +2086,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2112,19 +2111,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2141,18 +2140,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2164,7 +2163,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -2172,13 +2171,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2197,7 +2196,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2205,7 +2204,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2213,7 +2212,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2221,7 +2220,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2229,7 +2228,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2237,7 +2236,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2245,7 +2244,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2253,7 +2252,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2261,7 +2260,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2269,7 +2268,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2277,25 +2276,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2307,14 +2306,15 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), @@ -2327,9 +2327,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2344,13 +2343,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2368,12 +2367,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2390,18 +2389,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2416,13 +2415,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2441,19 +2440,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2470,18 +2469,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2496,13 +2495,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2520,12 +2519,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2542,18 +2541,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2568,13 +2567,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2593,19 +2592,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2622,18 +2621,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2645,7 +2644,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -2653,13 +2652,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2678,7 +2677,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2686,7 +2685,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2694,7 +2693,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2702,7 +2701,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2710,7 +2709,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2718,7 +2717,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2726,7 +2725,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2734,7 +2733,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2742,7 +2741,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2750,7 +2749,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2758,25 +2757,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -2788,23 +2787,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2819,13 +2818,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -2844,7 +2843,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2852,7 +2851,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2860,7 +2859,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2868,7 +2867,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2876,7 +2875,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2884,7 +2883,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2892,7 +2891,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2900,7 +2899,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2908,7 +2907,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -2916,30 +2915,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -2951,23 +2950,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -2982,8 +2981,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -3006,12 +3005,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3026,22 +3025,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3056,8 +3055,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -3080,17 +3079,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -3107,18 +3106,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3133,7 +3132,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3157,12 +3156,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3179,18 +3178,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3205,8 +3204,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -3229,12 +3228,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3249,21 +3248,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3278,7 +3277,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3302,12 +3301,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3324,18 +3323,18 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3350,13 +3349,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3374,18 +3373,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3397,14 +3396,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -3412,9 +3412,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3429,13 +3428,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3453,18 +3452,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3476,14 +3475,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), @@ -3502,9 +3502,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3516,7 +3515,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -3524,13 +3523,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3548,18 +3547,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -3571,24 +3570,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3603,7 +3602,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3627,12 +3626,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -3647,12 +3646,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -3665,9 +3665,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3682,7 +3681,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -3706,12 +3705,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -3726,12 +3725,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -3744,9 +3744,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3761,13 +3760,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3785,12 +3784,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3807,18 +3806,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3833,13 +3832,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3858,19 +3857,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3887,18 +3886,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3913,13 +3912,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -3937,12 +3936,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -3959,18 +3958,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -3985,13 +3984,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4010,19 +4009,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4039,18 +4038,18 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4062,7 +4061,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4070,13 +4069,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4095,7 +4094,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4103,7 +4102,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4111,7 +4110,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4119,7 +4118,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4127,7 +4126,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4135,7 +4134,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4143,7 +4142,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4151,7 +4150,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4159,7 +4158,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4167,7 +4166,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4175,25 +4174,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4205,23 +4204,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4236,13 +4235,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4261,7 +4260,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4269,7 +4268,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4277,7 +4276,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4285,7 +4284,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4293,7 +4292,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4301,7 +4300,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4309,7 +4308,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4317,7 +4316,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4325,7 +4324,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4333,30 +4332,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -4368,23 +4367,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4396,7 +4395,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4404,13 +4403,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4428,18 +4427,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -4451,24 +4450,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4483,13 +4482,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4507,18 +4506,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4530,14 +4529,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -4545,9 +4545,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4562,13 +4561,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4586,18 +4585,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4609,14 +4608,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), @@ -4629,9 +4629,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': 'alias_zahnseide_benutzen', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4643,7 +4642,7 @@ 'checklist': list([ dict({ 'completed': False, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4651,13 +4650,13 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4676,7 +4675,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 6, 749000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:06.749000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4684,7 +4683,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 292000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.292000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4692,7 +4691,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 719000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.719000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4700,7 +4699,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 9, 44, 56, 907000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T09:44:56.907000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4708,7 +4707,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 243000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.243000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4716,7 +4715,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 20, 20, 19, 56, 447000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-20T20:19:56.447000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4724,7 +4723,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 692000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.692000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4732,7 +4731,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 640000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.640000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4740,7 +4739,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 542000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.542000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4748,7 +4747,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 608000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.608000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4756,25 +4755,25 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 9, 21, 22, 24, 20, 150000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:24:20.150000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -2.9663035443712333, }), ]), - 'id': UUID('564b9ac9-c53d-4638-9e7f-1cd96fe19baa'), + 'id': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 23, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 24, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 25, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 26, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 27, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), - datetime.datetime(2024, 9, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200), 'GMT')), + '2024-09-23T00:00:00+02:00', + '2024-09-24T00:00:00+02:00', + '2024-09-25T00:00:00+02:00', + '2024-09-26T00:00:00+02:00', + '2024-09-27T00:00:00+02:00', + '2024-09-28T00:00:00+02:00', ]), 'notes': 'Klicke um Änderungen zu machen!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -4786,23 +4785,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Zahnseide benutzen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 24, 20, 154000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:24:20.154000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -2.9663035443712333, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4817,13 +4816,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -4842,7 +4841,7 @@ 'history': list([ dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 7, 17, 55, 3, 74000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T17:55:03.074000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4850,7 +4849,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 17, 15, 11, 291000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T17:15:11.291000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4858,7 +4857,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 7, 9, 22, 31, 46, 717000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-09T22:31:46.717000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4866,7 +4865,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 11, 7, 20, 59, 722000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-11T07:20:59.722000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4874,7 +4873,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 9, 58, 45, 246000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T09:58:45.246000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4882,7 +4881,7 @@ }), dict({ 'completed': True, - 'date': datetime.datetime(2024, 7, 12, 10, 1, 32, 219000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-12T10:01:32.219000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4890,7 +4889,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 8, 21, 15, 55, 7, 691000, tzinfo=datetime.timezone.utc), + 'date': '2024-08-21T15:55:07.691000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4898,7 +4897,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 20, 15, 29, 23, 638000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-20T15:29:23.638000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4906,7 +4905,7 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 21, 23, 7, 540000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T21:23:07.540000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, @@ -4914,30 +4913,30 @@ }), dict({ 'completed': False, - 'date': datetime.datetime(2024, 9, 21, 22, 1, 55, 607000, tzinfo=datetime.timezone.utc), + 'date': '2024-09-21T22:01:55.607000+00:00', 'isDue': True, 'scoredDown': None, 'scoredUp': None, 'value': -1.919611992979862, }), ]), - 'id': UUID('f2c85972-1a19-4426-bc6d-ce3337b9d99f'), + 'id': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 22, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 23, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 25, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 26, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-22T22:00:00+00:00', + '2024-09-23T22:00:00+00:00', + '2024-09-24T22:00:00+00:00', + '2024-09-25T22:00:00+00:00', + '2024-09-26T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', ]), 'notes': 'Klicke um Deinen Terminplan festzulegen!', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('1491d640-6b21-4d0c-8940-0b7aa61c8836'), + 'id': '1491d640-6b21-4d0c-8940-0b7aa61c8836', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 20, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T20:00:00+00:00', }), ]), 'repeat': dict({ @@ -4949,23 +4948,23 @@ 'th': True, 'w': True, }), - 'startDate': datetime.datetime(2024, 7, 6, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-07-06T22:00:00+00:00', 'streak': 0, 'tags': list([ ]), 'text': '5 Minuten ruhig durchatmen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 51, 41, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:51:41.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -1.919611992979862, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -4977,7 +4976,7 @@ 'checklist': list([ dict({ 'completed': True, - 'id': UUID('c8662c16-8cd3-4104-a3b2-b1e54f61b8ca'), + 'id': 'c8662c16-8cd3-4104-a3b2-b1e54f61b8ca', 'text': 'Checklist-item1', }), ]), @@ -4985,13 +4984,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-22T11:44:43.774000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5009,18 +5008,18 @@ }), 'history': list([ ]), - 'id': UUID('2c6d136c-a1c3-4bef-b7c4-fa980784b1e1'), + 'id': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', 'isDue': True, 'nextDue': list([ - datetime.datetime(2024, 9, 24, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 27, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 9, 28, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 1, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 4, 22, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2024, 10, 8, 22, 0, tzinfo=datetime.timezone.utc), + '2024-09-24T22:00:00+00:00', + '2024-09-27T22:00:00+00:00', + '2024-09-28T22:00:00+00:00', + '2024-10-01T22:00:00+00:00', + '2024-10-04T22:00:00+00:00', + '2024-10-08T22:00:00+00:00', ]), 'notes': 'Ein einstündiges Workout im Fitnessstudio absolvieren.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -5032,24 +5031,24 @@ 'th': False, 'w': True, }), - 'startDate': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-21T22:00:00+00:00', 'streak': 0, 'tags': list([ - UUID('6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab'), + '6aa65cbb-dc08-4fdd-9a66-7dedb7ba4cab', ]), 'text': 'Fitnessstudio besuchen', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 22, 11, 44, 43, 774000, tzinfo=datetime.timezone.utc), - 'userId': UUID('1343a9af-d891-4027-841a-956d105ca408'), + 'updatedAt': '2024-09-22T11:44:43.774000+00:00', + 'userId': '1343a9af-d891-4027-841a-956d105ca408', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5064,13 +5063,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'monthly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5088,18 +5087,18 @@ }), 'history': list([ ]), - 'id': UUID('6e53f1f5-a315-4edd-984d-8d762e4a08ef'), + 'id': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Klicke um den Namen Deines aktuellen Projekts anzugeben & setze einen Terminplan!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5111,14 +5110,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Arbeite an einem kreativen Projekt', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ 3, @@ -5126,9 +5126,8 @@ 'yesterDaily': True, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5143,13 +5142,13 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 10, 10, 15, 57, 14, 304000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-10-10T15:57:14.304000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': None, 'everyX': 1, - 'frequency': , + 'frequency': 'weekly', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5167,18 +5166,18 @@ }), 'history': list([ ]), - 'id': UUID('7d92278b-9361-4854-83b6-0a66b57dce20'), + 'id': '7d92278b-9361-4854-83b6-0a66b57dce20', 'isDue': False, 'nextDue': list([ - datetime.datetime(2024, 12, 14, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 1, 18, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 2, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 3, 15, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 4, 19, 23, 0, tzinfo=datetime.timezone.utc), - datetime.datetime(2025, 5, 17, 23, 0, tzinfo=datetime.timezone.utc), + '2024-12-14T23:00:00+00:00', + '2025-01-18T23:00:00+00:00', + '2025-02-15T23:00:00+00:00', + '2025-03-15T23:00:00+00:00', + '2025-04-19T23:00:00+00:00', + '2025-05-17T23:00:00+00:00', ]), 'notes': 'Wähle eine Programmiersprache aus, die du noch nicht kennst, und lerne die Grundlagen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5190,14 +5189,15 @@ 'th': False, 'w': False, }), - 'startDate': datetime.datetime(2024, 9, 20, 23, 0, tzinfo=datetime.timezone.utc), + 'startDate': '2024-09-20T23:00:00+00:00', 'streak': 1, 'tags': list([ ]), 'text': 'Lerne eine neue Programmiersprache', + 'type': 'daily', 'up': None, - 'updatedAt': datetime.datetime(2024, 11, 27, 23, 47, 29, 986000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-11-27T23:47:29.986000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': -0.9215181434950852, 'weeksOfMonth': list([ ]), @@ -5210,9 +5210,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5227,13 +5226,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.268000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5251,12 +5250,12 @@ }), 'history': list([ ]), - 'id': UUID('f21fa608-cfc6-4413-9fc7-0eb1b48ca43a'), + 'id': 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5273,18 +5272,18 @@ 'tags': list([ ]), 'text': 'Gesundes Essen/Junkfood', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 268000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.268000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5299,13 +5298,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5324,19 +5323,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 324000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.324000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('1d147de6-5c02-4740-8e2f-71d3015a37f4'), + 'id': '1d147de6-5c02-4740-8e2f-71d3015a37f4', 'isDue': None, 'nextDue': list([ ]), 'notes': '', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5353,18 +5352,18 @@ 'tags': list([ ]), 'text': 'Eine kurze Pause machen', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5379,13 +5378,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.265000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': True, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5403,12 +5402,12 @@ }), 'history': list([ ]), - 'id': UUID('bc1d1855-b2b8-4663-98ff-62e7b763dfc4'), + 'id': 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5425,18 +5424,18 @@ 'tags': list([ ]), 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', + 'type': 'habit', 'up': False, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 265000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.265000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'create_a_task', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5451,13 +5450,13 @@ 'completed': None, 'counterDown': 0, 'counterUp': 0, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 264000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.264000+00:00', 'date': None, 'daysOfMonth': list([ ]), 'down': False, 'everyX': None, - 'frequency': , + 'frequency': 'daily', 'group': dict({ 'assignedDate': None, 'assignedUsers': list([ @@ -5476,19 +5475,19 @@ 'history': list([ dict({ 'completed': None, - 'date': datetime.datetime(2024, 7, 7, 18, 26, 3, 140000, tzinfo=datetime.timezone.utc), + 'date': '2024-07-07T18:26:03.140000+00:00', 'isDue': None, 'scoredDown': 0, 'scoredUp': 1, 'value': 1.0, }), ]), - 'id': UUID('e97659e0-2c42-4599-a7bb-00282adc410d'), + 'id': 'e97659e0-2c42-4599-a7bb-00282adc410d', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5505,9 +5504,10 @@ 'tags': list([ ]), 'text': 'Füge eine Aufgabe zu Habitica hinzu', + 'type': 'habit', 'up': True, - 'updatedAt': datetime.datetime(2024, 7, 12, 9, 58, 45, 438000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-12T09:58:45.438000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), @@ -5520,9 +5520,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5537,7 +5536,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5561,12 +5560,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5583,9 +5582,10 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), @@ -5598,9 +5598,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5615,8 +5614,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5639,12 +5638,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5659,22 +5658,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5689,8 +5688,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5713,17 +5712,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -5740,18 +5739,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5766,7 +5765,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5790,12 +5789,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5812,18 +5811,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5838,8 +5837,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -5862,12 +5861,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5882,21 +5881,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5911,7 +5910,7 @@ 'completed': None, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-07-07T17:51:53.266000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -5935,12 +5934,12 @@ }), 'history': list([ ]), - 'id': UUID('5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b'), + 'id': '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -5957,18 +5956,18 @@ 'tags': list([ ]), 'text': 'Belohne Dich selbst', + 'type': 'reward', 'up': None, - 'updatedAt': datetime.datetime(2024, 7, 7, 17, 51, 53, 266000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-07-07T17:51:53.266000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 10.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -5983,7 +5982,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6007,12 +6006,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -6027,21 +6026,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6056,7 +6055,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6080,12 +6079,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -6100,12 +6099,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), @@ -6118,9 +6118,8 @@ dict({ 'tasks': list([ dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6135,8 +6134,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 27, 22, 17, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:57.816000+00:00', + 'date': '2024-09-27T22:17:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6159,12 +6158,12 @@ }), 'history': list([ ]), - 'id': UUID('88de7cd9-af2b-49ce-9afd-bf941d87336b'), + 'id': '88de7cd9-af2b-49ce-9afd-bf941d87336b', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6179,22 +6178,22 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('20409521-c096-447f-9a90-23e8da615710'), - UUID('8515e4ae-2f4b-455a-b4a4-8939e04b1bfd'), + '20409521-c096-447f-9a90-23e8da615710', + '8515e4ae-2f4b-455a-b4a4-8939e04b1bfd', ]), 'text': 'Buch zu Ende lesen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 17, 57, 816000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:17:57.816000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': 'pay_bills', - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6209,8 +6208,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 17, 19, 513000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 8, 31, 22, 16, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:17:19.513000+00:00', + 'date': '2024-08-31T22:16:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6233,17 +6232,17 @@ }), 'history': list([ ]), - 'id': UUID('2f6fcabc-f670-4ec3-ba65-817e8deea490'), + 'id': '2f6fcabc-f670-4ec3-ba65-817e8deea490', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Strom- und Internetrechnungen rechtzeitig überweisen.', - 'priority': , + 'priority': 1, 'reminders': list([ dict({ - 'id': UUID('91c09432-10ac-4a49-bd20-823081ec29ed'), + 'id': '91c09432-10ac-4a49-bd20-823081ec29ed', 'startDate': None, - 'time': datetime.datetime(2024, 9, 22, 2, 0, tzinfo=datetime.timezone.utc), + 'time': '2024-09-22T02:00:00+00:00', }), ]), 'repeat': dict({ @@ -6260,18 +6259,18 @@ 'tags': list([ ]), 'text': 'Rechnungen bezahlen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 35, 576000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:35.576000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6286,7 +6285,7 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:38.153000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6310,12 +6309,12 @@ }), 'history': list([ ]), - 'id': UUID('1aa3137e-ef72-4d1f-91ee-41933602f438'), + 'id': '1aa3137e-ef72-4d1f-91ee-41933602f438', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Rasen mähen und die Pflanzen gießen.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6332,18 +6331,18 @@ 'tags': list([ ]), 'text': 'Garten pflegen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 38, 153000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:38.153000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6358,8 +6357,8 @@ 'completed': False, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'date': datetime.datetime(2024, 9, 21, 22, 0, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:16:16.756000+00:00', + 'date': '2024-09-21T22:00:00+00:00', 'daysOfMonth': list([ ]), 'down': None, @@ -6382,12 +6381,12 @@ }), 'history': list([ ]), - 'id': UUID('86ea2475-d1b5-4020-bdcc-c188c7996afa'), + 'id': '86ea2475-d1b5-4020-bdcc-c188c7996afa', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Den Ausflug für das kommende Wochenende organisieren.', - 'priority': , + 'priority': 1, 'reminders': list([ ]), 'repeat': dict({ @@ -6402,21 +6401,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('51076966-2970-4b40-b6ba-d58c6a756dd7'), + '51076966-2970-4b40-b6ba-d58c6a756dd7', ]), 'text': 'Wochenendausflug planen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 16, 16, 756000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:16:16.756000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 0.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6431,7 +6430,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 19, 10, 919000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:19:10.919000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6455,12 +6454,12 @@ }), 'history': list([ ]), - 'id': UUID('162f0bbe-a097-4a06-b4f4-8fbeed85d2ba'), + 'id': '162f0bbe-a097-4a06-b4f4-8fbeed85d2ba', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Lebensmittel und Haushaltsbedarf für die Woche einkaufen.', - 'priority': , + 'priority': 1.5, 'reminders': list([ ]), 'repeat': dict({ @@ -6475,21 +6474,21 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wocheneinkauf erledigen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 19, 15, 484000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:19:15.484000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), 'yesterDaily': None, }), dict({ - 'Type': , 'alias': None, - 'attribute': , + 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ 'broken': None, @@ -6504,7 +6503,7 @@ 'completed': True, 'counterDown': None, 'counterUp': None, - 'createdAt': datetime.datetime(2024, 9, 21, 22, 18, 30, 646000, tzinfo=datetime.timezone.utc), + 'createdAt': '2024-09-21T22:18:30.646000+00:00', 'date': None, 'daysOfMonth': list([ ]), @@ -6528,12 +6527,12 @@ }), 'history': list([ ]), - 'id': UUID('3fa06743-aa0f-472b-af1a-f27c755e329c'), + 'id': '3fa06743-aa0f-472b-af1a-f27c755e329c', 'isDue': None, 'nextDue': list([ ]), 'notes': 'Wohnzimmer und Küche gründlich aufräumen.', - 'priority': , + 'priority': 2, 'reminders': list([ ]), 'repeat': dict({ @@ -6548,12 +6547,13 @@ 'startDate': None, 'streak': None, 'tags': list([ - UUID('64235347-55d0-4ba1-a86a-3428dcfdf319'), + '64235347-55d0-4ba1-a86a-3428dcfdf319', ]), 'text': 'Wohnung aufräumen', + 'type': 'todo', 'up': None, - 'updatedAt': datetime.datetime(2024, 9, 21, 22, 18, 34, 663000, tzinfo=datetime.timezone.utc), - 'userId': UUID('5f359083-ef78-4af0-985a-0b2c6d05797c'), + 'updatedAt': '2024-09-21T22:18:34.663000+00:00', + 'userId': '5f359083-ef78-4af0-985a-0b2c6d05797c', 'value': 1.0, 'weeksOfMonth': list([ ]), From 3ebb58f7807fdd917ccf7e2c6b8d07e81a13a72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Thu, 6 Feb 2025 10:09:29 +0100 Subject: [PATCH 0009/1941] Fix Mill issue, where no sensors were shown (#137521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix mill issue #137477 Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/mill/climate.py | 4 +--- homeassistant/components/mill/entity.py | 4 ++-- homeassistant/components/mill/number.py | 6 +++--- homeassistant/components/mill/sensor.py | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 0df2fe9335e..3cd9247c63a 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -105,10 +105,8 @@ class MillHeater(MillBaseEntity, ClimateEntity): self, coordinator: MillDataUpdateCoordinator, device: mill.Heater ) -> None: """Initialize the thermostat.""" - - super().__init__(coordinator, device) self._attr_unique_id = device.device_id - self._update_attr(device) + super().__init__(coordinator, device) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/mill/entity.py b/homeassistant/components/mill/entity.py index f24dbeb2c26..06056aba336 100644 --- a/homeassistant/components/mill/entity.py +++ b/homeassistant/components/mill/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import abstractmethod -from mill import Heater, MillDevice +from mill import MillDevice from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -45,7 +45,7 @@ class MillBaseEntity(CoordinatorEntity[MillDataUpdateCoordinator]): @abstractmethod @callback - def _update_attr(self, device: MillDevice | Heater) -> None: + def _update_attr(self, device: MillDevice) -> None: """Update the attribute of the entity.""" @property diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py index af27159caf0..b4ef7bdd2c2 100644 --- a/homeassistant/components/mill/number.py +++ b/homeassistant/components/mill/number.py @@ -2,7 +2,7 @@ from __future__ import annotations -from mill import MillDevice +from mill import Heater, MillDevice from homeassistant.components.number import NumberDeviceClass, NumberEntity from homeassistant.config_entries import ConfigEntry @@ -27,6 +27,7 @@ async def async_setup_entry( async_add_entities( MillNumber(mill_data_coordinator, mill_device) for mill_device in mill_data_coordinator.data.values() + if isinstance(mill_device, Heater) ) @@ -45,9 +46,8 @@ class MillNumber(MillBaseEntity, NumberEntity): mill_device: MillDevice, ) -> None: """Initialize the number.""" - super().__init__(coordinator, mill_device) self._attr_unique_id = f"{mill_device.device_id}_max_heating_power" - self._update_attr(mill_device) + super().__init__(coordinator, mill_device) @callback def _update_attr(self, device: MillDevice) -> None: diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 018b9466deb..57eead9be18 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -192,9 +192,9 @@ class MillSensor(MillBaseEntity, SensorEntity): mill_device: mill.Socket | mill.Heater, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, mill_device) self.entity_description = entity_description self._attr_unique_id = f"{mill_device.device_id}_{entity_description.key}" + super().__init__(coordinator, mill_device) @callback def _update_attr(self, device): From 3390fb32a8a610318e55a832fab65a47cfd37a6b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 6 Feb 2025 15:18:37 +0100 Subject: [PATCH 0010/1941] Don't overwrite setup state in async_set_domains_to_be_loaded (#137547) --- homeassistant/setup.py | 8 ++++- tests/test_setup.py | 76 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 1fa93a80cd5..dc4d0988b91 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -132,7 +132,13 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Keep track of domains which will load but have not yet finished loading """ setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) - setup_done_futures.update({domain: hass.loop.create_future() for domain in domains}) + setup_futures = hass.data.setdefault(DATA_SETUP, {}) + old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components + if overlap := old_domains & domains: + _LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap) + setup_done_futures.update( + {domain: hass.loop.create_future() for domain in domains - old_domains} + ) def setup_component(hass: core.HomeAssistant, domain: str, config: ConfigType) -> bool: diff --git a/tests/test_setup.py b/tests/test_setup.py index 2d15c670cf7..bb221c7cb4c 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -363,20 +363,24 @@ async def test_component_failing_setup(hass: HomeAssistant) -> None: async def test_component_exception_setup(hass: HomeAssistant) -> None: """Test component that raises exception during setup.""" - setup.async_set_domains_to_be_loaded(hass, {"comp"}) + domain = "comp" + setup.async_set_domains_to_be_loaded(hass, {domain}) def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Raise exception.""" raise Exception("fail!") # noqa: TRY002 - mock_integration(hass, MockModule("comp", setup=exception_setup)) + mock_integration(hass, MockModule(domain, setup=exception_setup)) - assert not await setup.async_setup_component(hass, "comp", {}) - assert "comp" not in hass.config.components + assert not await setup.async_setup_component(hass, domain, {}) + assert domain in hass.data[setup.DATA_SETUP] + assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain not in hass.config.components async def test_component_base_exception_setup(hass: HomeAssistant) -> None: """Test component that raises exception during setup.""" + domain = "comp" setup.async_set_domains_to_be_loaded(hass, {"comp"}) def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -389,7 +393,69 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, "comp", {}) assert str(exc_info.value) == "fail!" - assert "comp" not in hass.config.components + assert domain in hass.data[setup.DATA_SETUP] + assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain not in hass.config.components + + +async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: + """Test async_set_domains_to_be_loaded.""" + domain_good = "comp_good" + domain_bad = "comp_bad" + domain_base_exception = "comp_base_exception" + domain_exception = "comp_exception" + domains = {domain_good, domain_bad, domain_exception, domain_base_exception} + setup.async_set_domains_to_be_loaded(hass, domains) + + assert set(hass.data[setup.DATA_SETUP_DONE]) == domains + setup_done = dict(hass.data[setup.DATA_SETUP_DONE]) + + # Calling async_set_domains_to_be_loaded again should not create new futures + setup.async_set_domains_to_be_loaded(hass, domains) + assert setup_done == hass.data[setup.DATA_SETUP_DONE] + + def good_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Success.""" + return True + + def bad_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Fail.""" + return False + + def base_exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Raise exception.""" + raise BaseException("fail!") # noqa: TRY002 + + def exception_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Raise exception.""" + raise Exception("fail!") # noqa: TRY002 + + mock_integration(hass, MockModule(domain_good, setup=good_setup)) + mock_integration(hass, MockModule(domain_bad, setup=bad_setup)) + mock_integration( + hass, MockModule(domain_base_exception, setup=base_exception_setup) + ) + mock_integration(hass, MockModule(domain_exception, setup=exception_setup)) + + # Set up the four components + assert await setup.async_setup_component(hass, domain_good, {}) + assert not await setup.async_setup_component(hass, domain_bad, {}) + assert not await setup.async_setup_component(hass, domain_exception, {}) + with pytest.raises(BaseException, match="fail!"): + await setup.async_setup_component(hass, domain_base_exception, {}) + + # Check the result of the setup + assert not hass.data[setup.DATA_SETUP_DONE] + assert set(hass.data[setup.DATA_SETUP]) == { + domain_bad, + domain_exception, + domain_base_exception, + } + assert set(hass.config.components) == {domain_good} + + # Calling async_set_domains_to_be_loaded again should not create any new futures + setup.async_set_domains_to_be_loaded(hass, domains) + assert not hass.data[setup.DATA_SETUP_DONE] async def test_component_setup_with_validation_and_dependency( From bec569caf94a3c2a0395bdade742bc9cd6dd89f7 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 7 Feb 2025 16:06:33 +0100 Subject: [PATCH 0011/1941] Use separate metadata files for onedrive (#137549) --- homeassistant/components/onedrive/__init__.py | 43 ++++++++- homeassistant/components/onedrive/backup.py | 95 ++++++++++++------- .../components/onedrive/strings.json | 3 + tests/components/onedrive/conftest.py | 10 +- tests/components/onedrive/const.py | 25 ++++- tests/components/onedrive/test_backup.py | 2 +- tests/components/onedrive/test_init.py | 37 ++++++++ 7 files changed, 178 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 5feefb2cf7d..9716f692ec8 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass +from html import unescape +from json import dumps, loads import logging from typing import cast @@ -13,6 +15,7 @@ from onedrive_personal_sdk.exceptions import ( HttpRequestException, OneDriveException, ) +from onedrive_personal_sdk.models.items import ItemUpdate from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN @@ -45,7 +48,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Set up OneDrive from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) - session = OAuth2Session(hass, entry, implementation) async def get_access_token() -> str: @@ -89,6 +91,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> backup_folder_id=backup_folder.id, ) + try: + await _migrate_backup_files(client, backup_folder.id) + except OneDriveException as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_migrate_files", + ) from err + _async_notify_backup_listeners_soon(hass) return True @@ -108,3 +118,34 @@ def _async_notify_backup_listeners(hass: HomeAssistant) -> None: @callback def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: hass.loop.call_soon(_async_notify_backup_listeners, hass) + + +async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None: + """Migrate backup files to metadata version 2.""" + files = await client.list_drive_items(backup_folder_id) + for file in files: + if file.description and '"metadata_version": 1' in ( + metadata_json := unescape(file.description) + ): + metadata = loads(metadata_json) + del metadata["metadata_version"] + metadata_filename = file.name.rsplit(".", 1)[0] + ".metadata.json" + metadata_file = await client.upload_file( + backup_folder_id, + metadata_filename, + dumps(metadata), # type: ignore[arg-type] + ) + metadata_description = { + "metadata_version": 2, + "backup_id": metadata["backup_id"], + "backup_file_id": file.id, + } + await client.update_drive_item( + path_or_id=metadata_file.id, + data=ItemUpdate(description=dumps(metadata_description)), + ) + await client.update_drive_item( + path_or_id=file.id, + data=ItemUpdate(description=""), + ) + _LOGGER.debug("Migrated backup file %s", file.name) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 78bdcb24b8c..182e29aa63f 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -4,8 +4,8 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps -import html -import json +from html import unescape +from json import dumps, loads import logging from typing import Any, Concatenate @@ -34,6 +34,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours +METADATA_VERSION = 2 async def async_get_backup_agents( @@ -120,11 +121,19 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AsyncIterator[bytes]: """Download a backup file.""" - item = await self._find_item_by_backup_id(backup_id) - if item is None: + metadata_item = await self._find_item_by_backup_id(backup_id) + if ( + metadata_item is None + or metadata_item.description is None + or "backup_file_id" not in metadata_item.description + ): raise BackupAgentError("Backup not found") - stream = await self._client.download_drive_item(item.id, timeout=TIMEOUT) + metadata_info = loads(unescape(metadata_item.description)) + + stream = await self._client.download_drive_item( + metadata_info["backup_file_id"], timeout=TIMEOUT + ) return stream.iter_chunked(1024) @handle_backup_errors @@ -136,15 +145,15 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Upload a backup.""" - + filename = suggested_filename(backup) file = FileInfo( - suggested_filename(backup), + filename, backup.size, self._folder_id, await open_stream(), ) try: - item = await LargeFileUploadClient.upload( + backup_file = await LargeFileUploadClient.upload( self._token_function, file, session=async_get_clientsession(self._hass) ) except HashMismatchError as err: @@ -152,15 +161,25 @@ class OneDriveBackupAgent(BackupAgent): "Hash validation failed, backup file might be corrupt" ) from err - # store metadata in description - backup_dict = backup.as_dict() - backup_dict["metadata_version"] = 1 # version of the backup metadata - description = json.dumps(backup_dict) + # store metadata in metadata file + description = dumps(backup.as_dict()) _LOGGER.debug("Creating metadata: %s", description) + metadata_filename = filename.rsplit(".", 1)[0] + ".metadata.json" + metadata_file = await self._client.upload_file( + self._folder_id, + metadata_filename, + description, # type: ignore[arg-type] + ) + # add metadata to the metadata file + metadata_description = { + "metadata_version": METADATA_VERSION, + "backup_id": backup.backup_id, + "backup_file_id": backup_file.id, + } await self._client.update_drive_item( - path_or_id=item.id, - data=ItemUpdate(description=description), + path_or_id=metadata_file.id, + data=ItemUpdate(description=dumps(metadata_description)), ) @handle_backup_errors @@ -170,18 +189,28 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - item = await self._find_item_by_backup_id(backup_id) - if item is None: + metadata_item = await self._find_item_by_backup_id(backup_id) + if ( + metadata_item is None + or metadata_item.description is None + or "backup_file_id" not in metadata_item.description + ): return - await self._client.delete_drive_item(item.id) + metadata_info = loads(unescape(metadata_item.description)) + + await self._client.delete_drive_item(metadata_info["backup_file_id"]) + await self._client.delete_drive_item(metadata_item.id) @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" + items = await self._client.list_drive_items(self._folder_id) return [ - self._backup_from_description(item.description) - for item in await self._client.list_drive_items(self._folder_id) - if item.description and "homeassistant_version" in item.description + await self._download_backup_metadata(item.id) + for item in items + if item.description + and "backup_id" in item.description + and f'"metadata_version": {METADATA_VERSION}' in unescape(item.description) ] @handle_backup_errors @@ -189,19 +218,11 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AgentBackup | None: """Return a backup.""" - item = await self._find_item_by_backup_id(backup_id) - return ( - self._backup_from_description(item.description) - if item and item.description - else None - ) + metadata_file = await self._find_item_by_backup_id(backup_id) + if metadata_file is None or metadata_file.description is None: + return None - def _backup_from_description(self, description: str) -> AgentBackup: - """Create a backup object from a description.""" - description = html.unescape( - description - ) # OneDrive encodes the description on save automatically - return AgentBackup.from_dict(json.loads(description)) + return await self._download_backup_metadata(metadata_file.id) async def _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None: """Find an item by backup ID.""" @@ -209,7 +230,15 @@ class OneDriveBackupAgent(BackupAgent): ( item for item in await self._client.list_drive_items(self._folder_id) - if item.description and backup_id in item.description + if item.description + and backup_id in item.description + and f'"metadata_version": {METADATA_VERSION}' + in unescape(item.description) ), None, ) + + async def _download_backup_metadata(self, item_id: str) -> AgentBackup: + metadata_stream = await self._client.download_drive_item(item_id) + metadata_json = loads(await metadata_stream.read()) + return AgentBackup.from_dict(metadata_json) diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 7686e83e2a5..ebc46d3eb12 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -35,6 +35,9 @@ }, "failed_to_get_folder": { "message": "Failed to get {folder} folder" + }, + "failed_to_migrate_files": { + "message": "Failed to migrate metadata to separate files" } } } diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 0d6ee09d587..8a0da9f584e 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -1,6 +1,7 @@ """Fixtures for OneDrive tests.""" from collections.abc import AsyncIterator, Generator +from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch @@ -15,11 +16,13 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .const import ( + BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET, MOCK_APPROOT, MOCK_BACKUP_FILE, MOCK_BACKUP_FOLDER, + MOCK_METADATA_FILE, ) from tests.common import MockConfigEntry @@ -89,13 +92,17 @@ def mock_onedrive_client(mock_onedrive_client_init: MagicMock) -> Generator[Magi client = mock_onedrive_client_init.return_value client.get_approot.return_value = MOCK_APPROOT client.create_folder.return_value = MOCK_BACKUP_FOLDER - client.list_drive_items.return_value = [MOCK_BACKUP_FILE] + client.list_drive_items.return_value = [MOCK_BACKUP_FILE, MOCK_METADATA_FILE] client.get_drive_item.return_value = MOCK_BACKUP_FILE + client.upload_file.return_value = MOCK_METADATA_FILE class MockStreamReader: async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: yield b"backup data" + async def read(self) -> bytes: + return dumps(BACKUP_METADATA).encode() + client.download_drive_item.return_value = MockStreamReader() return client @@ -107,6 +114,7 @@ def mock_large_file_upload_client() -> Generator[AsyncMock]: with patch( "homeassistant.components.onedrive.backup.LargeFileUploadClient.upload" ) as mock_upload: + mock_upload.return_value = MOCK_BACKUP_FILE yield mock_upload diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index ee3a5ce3dc4..3739369887d 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -72,6 +72,29 @@ MOCK_BACKUP_FILE = File( quick_xor_hash="hash", ), mime_type="application/x-tar", - description=escape(dumps(BACKUP_METADATA)), + description="", + created_by=CONTRIBUTOR, +) + +MOCK_METADATA_FILE = File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + description=escape( + dumps( + { + "metadata_version": 2, + "backup_id": "23e64aec", + "backup_file_id": "id", + } + ) + ), created_by=CONTRIBUTOR, ) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 0277c3da02e..dd4f4d253d0 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -152,7 +152,7 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_onedrive_client.delete_drive_item.assert_called_once() + assert mock_onedrive_client.delete_drive_item.call_count == 2 async def test_agents_upload( diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index a6ad55442aa..7ceab98ff21 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -1,5 +1,7 @@ """Test the OneDrive setup.""" +from html import escape +from json import dumps from unittest.mock import MagicMock from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException @@ -9,6 +11,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import setup_integration +from .const import BACKUP_METADATA, MOCK_BACKUP_FILE from tests.common import MockConfigEntry @@ -17,6 +20,7 @@ async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_onedrive_client_init: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test loading and unloading the integration.""" await setup_integration(hass, mock_config_entry) @@ -25,6 +29,10 @@ async def test_load_unload_config_entry( token_callback = mock_onedrive_client_init.call_args[0][0] assert await token_callback() == "mock-access-token" + # make sure metadata migration is not called + assert mock_onedrive_client.upload_file.call_count == 0 + assert mock_onedrive_client.update_drive_item.call_count == 0 + assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) @@ -64,3 +72,32 @@ async def test_get_integration_folder_error( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert "Failed to get backups_9f86d081 folder" in caplog.text + + +async def test_migrate_metadata_files( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test migration of metadata files.""" + MOCK_BACKUP_FILE.description = escape( + dumps({**BACKUP_METADATA, "metadata_version": 1}) + ) + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + mock_onedrive_client.upload_file.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 2 + assert mock_onedrive_client.update_drive_item.call_args[1]["data"].description == "" + + +async def test_migrate_metadata_files_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test migration of metadata files errors.""" + mock_onedrive_client.list_drive_items.side_effect = OneDriveException() + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From cb937bc11528f4209db078725a569b0be6ba9f1b Mon Sep 17 00:00:00 2001 From: Jasper Wiegratz <656460+jwhb@users.noreply.github.com> Date: Thu, 6 Feb 2025 19:42:28 +0100 Subject: [PATCH 0012/1941] Fix sending polls to Telegram threads (#137553) Fix sending poll to Telegram thread --- homeassistant/components/telegram_bot/__init__.py | 2 ++ tests/components/telegram_bot/test_telegram_bot.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index f744265e1c2..fa3ec1dc4f7 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -175,6 +175,7 @@ BASE_SERVICE_SCHEMA = vol.Schema( vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, vol.Optional(ATTR_TIMEOUT): cv.positive_int, vol.Optional(ATTR_MESSAGE_TAG): cv.string, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), }, extra=vol.ALLOW_EXTRA, ) @@ -216,6 +217,7 @@ SERVICE_SCHEMA_SEND_POLL = vol.Schema( vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, vol.Optional(ATTR_TIMEOUT): cv.positive_int, + vol.Optional(ATTR_MESSAGE_THREAD_ID): vol.Coerce(int), } ) diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index be6b5b31325..c9038003cfc 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -184,7 +184,7 @@ async def test_send_message_thread(hass: HomeAssistant, webhook_platform) -> Non assert len(events) == 1 assert events[0].context == context - assert events[0].data[ATTR_MESSAGE_THREAD_ID] == "123" + assert events[0].data[ATTR_MESSAGE_THREAD_ID] == 123 async def test_webhook_endpoint_generates_telegram_text_event( From 42b6f83e7c7dc3ae3208e55df6fafdc40701fee2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 6 Feb 2025 16:41:27 +0100 Subject: [PATCH 0013/1941] Skip building wheels for electrickiwi-api (#137556) --- script/gen_requirements_all.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ef57b9140ce..dc4f2383b64 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -50,6 +50,12 @@ INCLUDED_REQUIREMENTS_WHEELS = { "pyuserinput", } +EXCLUDED_REQUIREMENTS_WHEELS = { + # Exclude 'electrickiwi-api' temporarily, until <3.13 pin is removed upstream. + # https://github.com/mikey0000/EK-API/pull/1 + "electrickiwi-api", +} + # Requirements to exclude or include when running github actions. # Requirements listed in "exclude" will be commented-out in @@ -64,7 +70,7 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { "markers": {}, }, "wheels_aarch64": { - "exclude": set(), + "exclude": EXCLUDED_REQUIREMENTS_WHEELS, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, @@ -73,22 +79,23 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { # "flimsy" on 386). The following packages depend on pandas, # so we comment them out. "wheels_armhf": { - "exclude": {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, + "exclude": EXCLUDED_REQUIREMENTS_WHEELS + | {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_armv7": { - "exclude": set(), + "exclude": EXCLUDED_REQUIREMENTS_WHEELS, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_amd64": { - "exclude": set(), + "exclude": EXCLUDED_REQUIREMENTS_WHEELS, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_i386": { - "exclude": set(), + "exclude": EXCLUDED_REQUIREMENTS_WHEELS, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, From a033e4c88d9ab7ef311dc58359d167b7c7618f9f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 6 Feb 2025 10:53:55 -0600 Subject: [PATCH 0014/1941] Add excluded domains to broadcast intent (#137566) --- .../components/assist_satellite/intent.py | 46 +++++++++++++------ .../assist_satellite/test_intent.py | 46 +++++++++++++------ 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/assist_satellite/intent.py b/homeassistant/components/assist_satellite/intent.py index 75396cf138f..7612753e8c4 100644 --- a/homeassistant/components/assist_satellite/intent.py +++ b/homeassistant/components/assist_satellite/intent.py @@ -1,5 +1,7 @@ """Assist Satellite intents.""" +from typing import Final + import voluptuous as vol from homeassistant.core import HomeAssistant @@ -7,6 +9,8 @@ from homeassistant.helpers import entity_registry as er, intent from .const import DOMAIN, AssistSatelliteEntityFeature +EXCLUDED_DOMAINS: Final[set[str]] = {"voip"} + async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the intents.""" @@ -30,19 +34,36 @@ class BroadcastIntentHandler(intent.IntentHandler): ent_reg = er.async_get(hass) # Find all assist satellite entities that are not the one invoking the intent - entities = { - entity: entry - for entity in hass.states.async_entity_ids(DOMAIN) - if (entry := ent_reg.async_get(entity)) - and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE - } + entities: dict[str, er.RegistryEntry] = {} + for entity in hass.states.async_entity_ids(DOMAIN): + entry = ent_reg.async_get(entity) + if ( + (entry is None) + or ( + # Supports announce + not ( + entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE + ) + ) + # Not the invoking device + or (intent_obj.device_id and (entry.device_id == intent_obj.device_id)) + ): + # Skip satellite + continue - if intent_obj.device_id: - entities = { - entity: entry - for entity, entry in entities.items() - if entry.device_id != intent_obj.device_id - } + # Check domain of config entry against excluded domains + if ( + entry.config_entry_id + and ( + config_entry := hass.config_entries.async_get_entry( + entry.config_entry_id + ) + ) + and (config_entry.domain in EXCLUDED_DOMAINS) + ): + continue + + entities[entity] = entry await hass.services.async_call( DOMAIN, @@ -54,7 +75,6 @@ class BroadcastIntentHandler(intent.IntentHandler): ) response = intent_obj.create_response() - response.async_set_speech("Done") response.response_type = intent.IntentResponseType.ACTION_DONE response.async_set_results( success_results=[ diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py index 27107c7d2e9..9304229dbe3 100644 --- a/tests/components/assist_satellite/test_intent.py +++ b/tests/components/assist_satellite/test_intent.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from .conftest import MockAssistSatellite +from .conftest import TEST_DOMAIN, MockAssistSatellite @pytest.fixture @@ -65,12 +65,7 @@ async def test_broadcast_intent( }, "language": "en", "response_type": "action_done", - "speech": { - "plain": { - "extra_data": None, - "speech": "Done", - } - }, + "speech": {}, # response comes from intents } assert len(entity.announcements) == 1 assert len(entity2.announcements) == 1 @@ -99,12 +94,37 @@ async def test_broadcast_intent( }, "language": "en", "response_type": "action_done", - "speech": { - "plain": { - "extra_data": None, - "speech": "Done", - } - }, + "speech": {}, # response comes from intents } assert len(entity.announcements) == 1 assert len(entity2.announcements) == 2 + + +async def test_broadcast_intent_excluded_domains( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + entity2: MockAssistSatellite, + mock_tts: None, +) -> None: + """Test that the broadcast intent filters out entities in excluded domains.""" + + # Exclude the "test" domain + with patch( + "homeassistant.components.assist_satellite.intent.EXCLUDED_DOMAINS", + new={TEST_DOMAIN}, + ): + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [], # no satellites + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": {}, + } From dda90bc04c3aceb8c297f948601fd84b6f2f1932 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Feb 2025 10:00:29 -0600 Subject: [PATCH 0015/1941] Revert "Add `PaddleSwitchPico` (Pico Paddle Remote) device trigger to Lutron Caseta" (#137571) --- .../components/lutron_caseta/device_trigger.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 79b792935a8..0b432f88045 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -277,20 +277,6 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( } ) -PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = { - "button_0": 2, - "button_2": 4, -} -PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = { - "button_0": 0, - "button_2": 2, -} -PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( - { - vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP), - } -) - DEVICE_TYPE_SCHEMA_MAP = { "Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA, @@ -302,7 +288,6 @@ DEVICE_TYPE_SCHEMA_MAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, "FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, - "PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, } DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { @@ -315,7 +300,6 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP, - "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP, } DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { @@ -328,7 +312,6 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP, - "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP, } LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = { @@ -343,7 +326,6 @@ TRIGGER_SCHEMA = vol.Any( PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, - PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, ) From bea201f9f6094cbd26d843e2ea8160aa78e2cfc4 Mon Sep 17 00:00:00 2001 From: Dennis Effing Date: Thu, 6 Feb 2025 17:37:10 +0100 Subject: [PATCH 0016/1941] Fix Overseerr webhook configuration JSON (#137572) Co-authored-by: Lars Jouon --- homeassistant/components/overseerr/const.py | 2 +- tests/components/overseerr/fixtures/webhook_config.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 5c33ca3fcec..2aa0879ffed 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -27,7 +27,7 @@ REGISTERED_NOTIFICATIONS = ( JSON_PAYLOAD = ( '"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}' '}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":' - '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t' + '{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_id\\":\\"{{media_tmdbid}}\\",\\"t' 'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k' '\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id' '}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna' diff --git a/tests/components/overseerr/fixtures/webhook_config.json b/tests/components/overseerr/fixtures/webhook_config.json index 40028e1f80f..2b3388444d2 100644 --- a/tests/components/overseerr/fixtures/webhook_config.json +++ b/tests/components/overseerr/fixtures/webhook_config.json @@ -2,7 +2,7 @@ "enabled": true, "types": 222, "options": { - "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_idd\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", + "jsonPayload": "{\"notification_type\":\"{{notification_type}}\",\"subject\":\"{{subject}}\",\"message\":\"{{message}}\",\"image\":\"{{image}}\",\"{{media}}\":{\"media_type\":\"{{media_type}}\",\"tmdb_id\":\"{{media_tmdbid}}\",\"tvdb_id\":\"{{media_tvdbid}}\",\"status\":\"{{media_status}}\",\"status4k\":\"{{media_status4k}}\"},\"{{request}}\":{\"request_id\":\"{{request_id}}\",\"requested_by_email\":\"{{requestedBy_email}}\",\"requested_by_username\":\"{{requestedBy_username}}\",\"requested_by_avatar\":\"{{requestedBy_avatar}}\",\"requested_by_settings_discord_id\":\"{{requestedBy_settings_discordId}}\",\"requested_by_settings_telegram_chat_id\":\"{{requestedBy_settings_telegramChatId}}\"},\"{{issue}}\":{\"issue_id\":\"{{issue_id}}\",\"issue_type\":\"{{issue_type}}\",\"issue_status\":\"{{issue_status}}\",\"reported_by_email\":\"{{reportedBy_email}}\",\"reported_by_username\":\"{{reportedBy_username}}\",\"reported_by_avatar\":\"{{reportedBy_avatar}}\",\"reported_by_settings_discord_id\":\"{{reportedBy_settings_discordId}}\",\"reported_by_settings_telegram_chat_id\":\"{{reportedBy_settings_telegramChatId}}\"},\"{{comment}}\":{\"comment_message\":\"{{comment_message}}\",\"commented_by_email\":\"{{commentedBy_email}}\",\"commented_by_username\":\"{{commentedBy_username}}\",\"commented_by_avatar\":\"{{commentedBy_avatar}}\",\"commented_by_settings_discord_id\":\"{{commentedBy_settings_discordId}}\",\"commented_by_settings_telegram_chat_id\":\"{{commentedBy_settings_telegramChatId}}\"}}", "webhookUrl": "http://10.10.10.10:8123/api/webhook/test-webhook-id" } } From c71ab054f1b02dac27bbd57f96d929e19503b4d9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:33:40 -0500 Subject: [PATCH 0017/1941] Do not rely on pyserial for port scanning with the CM5 + ZHA (#137585) Do not rely on pyserial for port scanning with the CM5 --- homeassistant/components/zha/config_flow.py | 11 ++++++++--- tests/components/zha/test_config_flow.py | 13 +++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index d41ae7dbfee..b98e53f98d8 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -113,9 +113,14 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: except HomeAssistantError: pass else: - yellow_radio = next(p for p in ports if p.device == "/dev/ttyAMA1") - yellow_radio.description = "Yellow Zigbee module" - yellow_radio.manufacturer = "Nabu Casa" + # PySerial does not properly handle the Yellow's serial port with the CM5 + # so we manually include it + port = ListPortInfo(device="/dev/ttyAMA1", skip_link_detection=True) + port.description = "Yellow Zigbee module" + port.manufacturer = "Nabu Casa" + + ports = [p for p in ports if not p.device.startswith("/dev/ttyAMA")] + ports.insert(0, port) if is_hassio(hass): # Present the multi-PAN addon as a setup option, if it's available diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 573a04e9b57..94566be2f87 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1914,9 +1914,18 @@ async def test_options_flow_migration_reset_old_adapter( assert result4["step_id"] == "choose_serial_port" -async def test_config_flow_port_yellow_port_name(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "device", + [ + "/dev/ttyAMA1", # CM4 + "/dev/ttyAMA10", # CM5, erroneously detected by pyserial + ], +) +async def test_config_flow_port_yellow_port_name( + hass: HomeAssistant, device: str +) -> None: """Test config flow serial port name for Yellow Zigbee radio.""" - port = com_port(device="/dev/ttyAMA1") + port = com_port(device=device) port.serial_number = None port.manufacturer = None port.description = None From 568ac22ce89f1116914be0750b43fcdf8cf002c2 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Thu, 6 Feb 2025 20:29:47 +0100 Subject: [PATCH 0018/1941] Bump eheimdigital to 1.0.6 (#137587) --- homeassistant/components/eheimdigital/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 7747ca4f95d..1d1ca6f84c7 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "bronze", - "requirements": ["eheimdigital==1.0.5"], + "requirements": ["eheimdigital==1.0.6"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/requirements_all.txt b/requirements_all.txt index 483417a6972..f14bcb4fbe6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -818,7 +818,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.0.5 +eheimdigital==1.0.6 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53da87361cf..ba3f505f37f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -696,7 +696,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.0.5 +eheimdigital==1.0.6 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.12 From 81e501aba13c67bda956f0a869c9b6c057752edf Mon Sep 17 00:00:00 2001 From: Ron Date: Thu, 6 Feb 2025 20:19:42 +0100 Subject: [PATCH 0019/1941] Bump pyfireservicerota to 0.0.46 (#137589) --- homeassistant/components/fireservicerota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fireservicerota/manifest.json b/homeassistant/components/fireservicerota/manifest.json index 7826115fa3f..945ef141887 100644 --- a/homeassistant/components/fireservicerota/manifest.json +++ b/homeassistant/components/fireservicerota/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/fireservicerota", "iot_class": "cloud_polling", "loggers": ["pyfireservicerota"], - "requirements": ["pyfireservicerota==0.0.43"] + "requirements": ["pyfireservicerota==0.0.46"] } diff --git a/requirements_all.txt b/requirements_all.txt index f14bcb4fbe6..ca6b9e198da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1954,7 +1954,7 @@ pyfibaro==0.8.0 pyfido==2.1.2 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.43 +pyfireservicerota==0.0.46 # homeassistant.components.flic pyflic==2.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba3f505f37f..453d60e8a75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1592,7 +1592,7 @@ pyfibaro==0.8.0 pyfido==2.1.2 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.43 +pyfireservicerota==0.0.46 # homeassistant.components.flic pyflic==2.0.4 From 7b20299de7abc516f93043d2ca00623cc210fa63 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 6 Feb 2025 20:23:28 +0100 Subject: [PATCH 0020/1941] Bump reolink-aio to 0.11.10 (#137591) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index fb3c096ee41..505358a07f7 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.9"] + "requirements": ["reolink-aio==0.11.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index ca6b9e198da..80a6226e03e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2603,7 +2603,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.9 +reolink-aio==0.11.10 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 453d60e8a75..8e3cb4ce249 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2106,7 +2106,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.9 +reolink-aio==0.11.10 # homeassistant.components.rflink rflink==0.0.66 From e09ae1c83d1bf8a5ab5c83c1aee37f971093fdd2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 6 Feb 2025 21:11:39 +0100 Subject: [PATCH 0021/1941] Allow to omit the payload attribute to MQTT publish action to allow an empty payload to be sent by default (#137595) Allow to omit the payload attribute to MQTT publish actionto allow an empty payload to be sent by default --- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/components/mqtt/services.yaml | 1 - homeassistant/components/mqtt/strings.json | 6 +----- tests/components/mqtt/test_init.py | 19 +++++++++++++++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 8b16e9fa53d..6656afe2c8a 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -236,7 +236,7 @@ CONFIG_SCHEMA = vol.Schema( MQTT_PUBLISH_SCHEMA = vol.Schema( { vol.Required(ATTR_TOPIC): valid_publish_topic, - vol.Required(ATTR_PAYLOAD): cv.string, + vol.Required(ATTR_PAYLOAD, default=None): vol.Any(cv.string, None), vol.Optional(ATTR_EVALUATE_PAYLOAD): cv.boolean, vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema, vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index c5e4f372bd6..f6fac1d2c1e 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -8,7 +8,6 @@ publish: selector: text: payload: - required: true example: "The temperature is {{ states('sensor.temperature') }}" selector: template: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 3815b6adbd5..3228f912740 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -246,11 +246,7 @@ }, "payload": { "name": "Payload", - "description": "The payload to publish." - }, - "payload_template": { - "name": "Payload template", - "description": "Template to render as a payload value. If a payload is provided, the template is ignored." + "description": "The payload to publish. Publishes an empty message if not provided." }, "qos": { "name": "QoS", diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index d05c340dac2..b2dd3d048ec 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -391,6 +391,25 @@ async def test_service_call_with_ascii_qos_retain_flags( blocking=True, ) assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] == "" + assert mqtt_mock.async_publish.call_args[0][2] == 2 + assert not mqtt_mock.async_publish.call_args[0][3] + + mqtt_mock.reset_mock() + + # Test service call without payload + await hass.services.async_call( + mqtt.DOMAIN, + mqtt.SERVICE_PUBLISH, + { + mqtt.ATTR_TOPIC: "test/topic", + mqtt.ATTR_QOS: "2", + mqtt.ATTR_RETAIN: "no", + }, + blocking=True, + ) + assert mqtt_mock.async_publish.called + assert mqtt_mock.async_publish.call_args[0][1] is None assert mqtt_mock.async_publish.call_args[0][2] == 2 assert not mqtt_mock.async_publish.call_args[0][3] From 5faa189fef7641ce5f6c5c8fb5759f6e69e7669a Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 7 Feb 2025 09:04:34 -0600 Subject: [PATCH 0022/1941] Handle previously migrated HEOS device identifier (#137596) --- homeassistant/components/heos/__init__.py | 19 ++++++++++++++--- tests/components/heos/test_init.py | 25 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index d735469c5cb..0c268b612ea 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -39,9 +39,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool ): for domain, player_id in device.identifiers: if domain == DOMAIN and not isinstance(player_id, str): - device_registry.async_update_device( # type: ignore[unreachable] - device.id, new_identifiers={(DOMAIN, str(player_id))} - ) + # Create set of identifiers excluding this integration + identifiers = { # type: ignore[unreachable] + (domain, identifier) + for domain, identifier in device.identifiers + if domain != DOMAIN + } + migrated_identifiers = {(DOMAIN, str(player_id))} + # Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded + if not device_registry.async_get_device(migrated_identifiers): + identifiers.update(migrated_identifiers) + if len(identifiers) > 0: + device_registry.async_update_device( + device.id, new_identifiers=identifiers + ) + else: + device_registry.async_remove_device(device.id) break coordinator = HeosCoordinator(hass, entry) diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 27dea82dcf2..81acb7b3b8b 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -193,13 +193,36 @@ async def test_device_id_migration( # Create a device with a legacy identifier device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, 1)}, # type: ignore[arg-type] + identifiers={(DOMAIN, 1), ("Other", "1")}, # type: ignore[arg-type] ) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("Other", 1)}, # type: ignore[arg-type] ) assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) assert device_registry.async_get_device({("Other", 1)}) is not None # type: ignore[arg-type] assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] assert device_registry.async_get_device({(DOMAIN, "1")}) is not None + assert device_registry.async_get_device({("Other", "1")}) is not None + + +async def test_device_id_migration_both_present( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test that legacy non-string devices are removed when both devices present.""" + config_entry.add_to_hass(hass) + # Create a device with a legacy identifier AND a new identifier + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, 1)}, # type: ignore[arg-type] + ) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "1")} + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] + assert device_registry.async_get_device({(DOMAIN, "1")}) is not None From 62bc6e4bf66e3b0a6c51f9a832124573ef5c6d6a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 6 Feb 2025 21:34:11 +0100 Subject: [PATCH 0023/1941] Bump `aioshelly` to version `12.4.1` (#137598) * Bump aioshelly to 12.4.0 * Bump to 12.4.1 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index e0d8c03ffc4..4cfb49b680f 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==12.3.2"], + "requirements": ["aioshelly==12.4.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 80a6226e03e..52fbca26431 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.2 +aioshelly==12.4.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e3cb4ce249..bd07805bada 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.3.2 +aioshelly==12.4.1 # homeassistant.components.skybell aioskybell==22.7.0 From 3abd7b8ba3b9d659c935ac9037e74ef1597f2ab7 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 7 Feb 2025 10:47:53 +1300 Subject: [PATCH 0024/1941] Bump electrickiwi-api to 0.9.13 (#137601) * bump ek api version to fix deps * Revert "Skip building wheels for electrickiwi-api (#137556)" This reverts commit 5f6068eea4b23d4b8100de0830ee06532638524f. --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .../components/electric_kiwi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 17 +++++------------ 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json index 9afe487d368..1d4e26d5e0d 100644 --- a/homeassistant/components/electric_kiwi/manifest.json +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["electrickiwi-api==0.9.12"] + "requirements": ["electrickiwi-api==0.9.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 52fbca26431..45560753c6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -821,7 +821,7 @@ ecoaliface==0.4.0 eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.9.12 +electrickiwi-api==0.9.13 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd07805bada..4d51a5c7735 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -699,7 +699,7 @@ easyenergy==2.1.2 eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.9.12 +electrickiwi-api==0.9.13 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index dc4f2383b64..ef57b9140ce 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -50,12 +50,6 @@ INCLUDED_REQUIREMENTS_WHEELS = { "pyuserinput", } -EXCLUDED_REQUIREMENTS_WHEELS = { - # Exclude 'electrickiwi-api' temporarily, until <3.13 pin is removed upstream. - # https://github.com/mikey0000/EK-API/pull/1 - "electrickiwi-api", -} - # Requirements to exclude or include when running github actions. # Requirements listed in "exclude" will be commented-out in @@ -70,7 +64,7 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { "markers": {}, }, "wheels_aarch64": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS, + "exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, @@ -79,23 +73,22 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { # "flimsy" on 386). The following packages depend on pandas, # so we comment them out. "wheels_armhf": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS - | {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, + "exclude": {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_armv7": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS, + "exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_amd64": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS, + "exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, "wheels_i386": { - "exclude": EXCLUDED_REQUIREMENTS_WHEELS, + "exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS, "markers": {}, }, From 30073f349374945f59ae8d0bd99bac9bc3df2690 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 7 Feb 2025 01:33:07 +0100 Subject: [PATCH 0025/1941] Bump ZHA to 0.0.48 (#137610) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 6a42bc986e9..821159afb22 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.47"], + "requirements": ["zha==0.0.48"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 45560753c6d..916e013b005 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zeroconf==0.143.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.47 +zha==0.0.48 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4d51a5c7735..0d8bfccc175 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ zeroconf==0.143.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.47 +zha==0.0.48 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From ac84970da8ce69688461485f1486b5a9c228cb68 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 7 Feb 2025 21:36:30 +1300 Subject: [PATCH 0026/1941] Bump Electrickiwi-api to 0.9.14 (#137614) * bump library to fix bug with post * rebuild --- homeassistant/components/electric_kiwi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json index 1d4e26d5e0d..45bb09ca475 100644 --- a/homeassistant/components/electric_kiwi/manifest.json +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["electrickiwi-api==0.9.13"] + "requirements": ["electrickiwi-api==0.9.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 916e013b005..4b5e8eed2e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -821,7 +821,7 @@ ecoaliface==0.4.0 eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.9.13 +electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs elevenlabs==1.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d8bfccc175..fd61bda13d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -699,7 +699,7 @@ easyenergy==2.1.2 eheimdigital==1.0.6 # homeassistant.components.electric_kiwi -electrickiwi-api==0.9.13 +electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs elevenlabs==1.9.0 From 7508c14a5312b46c597926a60a658c82ef743a92 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 7 Feb 2025 00:33:58 -0800 Subject: [PATCH 0027/1941] Update google-nest-sdm to 7.1.3 (#137625) * Update google-nest-sdm to 7.1.2 * Bump nest to 7.1.3 --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index cd961276082..a0d8bc06640 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -19,5 +19,5 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], - "requirements": ["google-nest-sdm==7.1.1"] + "requirements": ["google-nest-sdm==7.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4b5e8eed2e5..7bbd141347b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1033,7 +1033,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.1 +google-nest-sdm==7.1.3 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd61bda13d5..51be41c684c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -883,7 +883,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==7.1.1 +google-nest-sdm==7.1.3 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 657e3488ba7d70c8d8083aad0c178310a98cc225 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 7 Feb 2025 16:32:28 +0100 Subject: [PATCH 0028/1941] Don't use the current temperature from Shelly BLU TRV as a state for External Temperature number entity (#137658) Introduce RpcBluTrvExtTempNumber for External Temperature entity --- homeassistant/components/shelly/number.py | 20 ++++++- .../shelly/snapshots/test_number.ambr | 2 +- tests/components/shelly/test_number.py | 56 ++++++++++++++++--- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index c4420783bbb..1fc47b23bdb 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -139,6 +139,24 @@ class RpcBluTrvNumber(RpcNumber): ) +class RpcBluTrvExtTempNumber(RpcBluTrvNumber): + """Represent a RPC BluTrv External Temperature number.""" + + _reported_value: float | None = None + + @property + def native_value(self) -> float | None: + """Return value of number.""" + return self._reported_value + + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + await super().async_set_native_value(value) + + self._reported_value = value + self.async_write_ha_state() + + NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { ("device", "valvePos"): BlockNumberDescription( key="device|valvepos", @@ -175,7 +193,7 @@ RPC_NUMBERS: Final = { "method": "Trv.SetExternalTemperature", "params": {"id": 0, "t_C": value}, }, - entity_class=RpcBluTrvNumber, + entity_class=RpcBluTrvExtTempNumber, ), "number": RpcNumberDescription( key="number", diff --git a/tests/components/shelly/snapshots/test_number.ambr b/tests/components/shelly/snapshots/test_number.ambr index 965d44698c2..811101abe21 100644 --- a/tests/components/shelly/snapshots/test_number.ambr +++ b/tests/components/shelly/snapshots/test_number.ambr @@ -52,7 +52,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '15.2', + 'state': 'unknown', }) # --- # name: test_blu_trv_number_entity[number.trv_name_valve_position-entry] diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 15ed098093b..b1b65d99ab5 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -417,24 +417,23 @@ async def test_blu_trv_number_entity( assert entry == snapshot(name=f"{entity_id}-entry") -async def test_blu_trv_set_value( - hass: HomeAssistant, - mock_blu_trv: Mock, - monkeypatch: pytest.MonkeyPatch, +async def test_blu_trv_ext_temp_set_value( + hass: HomeAssistant, mock_blu_trv: Mock ) -> None: - """Test the set value action for BLU TRV number entity.""" + """Test the set value action for BLU TRV External Temperature number entity.""" await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) entity_id = f"{NUMBER_DOMAIN}.trv_name_external_temperature" - assert hass.states.get(entity_id).state == "15.2" + # After HA start the state should be unknown because there was no previous external + # temperature report + assert hass.states.get(entity_id).state is STATE_UNKNOWN - monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "current_C", 22.2) await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.trv_name_external_temperature", + ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 22.2, }, blocking=True, @@ -451,3 +450,44 @@ async def test_blu_trv_set_value( ) assert hass.states.get(entity_id).state == "22.2" + + +async def test_blu_trv_valve_pos_set_value( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the set value action for BLU TRV Valve Position number entity.""" + # disable automatic temperature control to enable valve position entity + monkeypatch.setitem(mock_blu_trv.config["blutrv:200"], "enable", False) + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_GEN3) + + entity_id = f"{NUMBER_DOMAIN}.trv_name_valve_position" + + assert hass.states.get(entity_id).state == "0" + + monkeypatch.setitem(mock_blu_trv.status["blutrv:200"], "pos", 20) + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 20.0, + }, + blocking=True, + ) + mock_blu_trv.mock_update() + mock_blu_trv.call_rpc.assert_called_once_with( + "BluTRV.Call", + { + "id": 200, + "method": "Trv.SetPosition", + "params": {"id": 0, "pos": 20}, + }, + BLU_TRV_TIMEOUT, + ) + # device only accepts int for 'pos' value + assert isinstance(mock_blu_trv.call_rpc.call_args[0][1]["params"]["pos"], int) + + assert hass.states.get(entity_id).state == "20" From e3d649d34978967b66749c584138051213cdf8fa Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 7 Feb 2025 16:25:09 +0200 Subject: [PATCH 0029/1941] Fix LG webOS TV turn off when device is already off (#137675) --- homeassistant/components/webostv/media_player.py | 2 +- tests/components/webostv/test_media_player.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index c8b871b3bf2..ab5dc770468 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -125,7 +125,7 @@ def cmd[_R, **_P]( self: LgWebOSMediaPlayerEntity, *args: _P.args, **kwargs: _P.kwargs ) -> _R: """Wrap all command methods.""" - if self.state is MediaPlayerState.OFF: + if self.state is MediaPlayerState.OFF and func.__name__ != "async_turn_off": raise HomeAssistantError( translation_domain=DOMAIN, translation_key="device_off", diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 820ab856ebb..679092efe3b 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -553,6 +553,17 @@ async def test_control_error_handling( assert client.play.call_count == int(is_on) +async def test_turn_off_when_device_is_off(hass: HomeAssistant, client) -> None: + """Test no error when turning off device that is already off.""" + await setup_webostv(hass) + client.is_on = False + await client.mock_state_update() + + data = {ATTR_ENTITY_ID: ENTITY_ID} + await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data, True) + assert client.power_off.call_count == 1 + + async def test_supported_features(hass: HomeAssistant, client) -> None: """Test test supported features.""" client.sound_output = "lineout" From 73ad4caf94282469b674600f60c2d5c6392b18fd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Feb 2025 16:39:53 +0000 Subject: [PATCH 0030/1941] Bump version to 2025.2.1 --- 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 111595ea83f..6c49cab3d41 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __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 bf1b8890461..14fc8fda870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.0" +version = "2025.2.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 292409f1d5d59d9fce5269953fb9654e8982bcb6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 22:16:09 +0100 Subject: [PATCH 0031/1941] Explicitly pass in the config_entry in aurora_abb_powerone coordinator init (#137715) --- .../components/aurora_abb_powerone/__init__.py | 2 +- .../aurora_abb_powerone/coordinator.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 749d40aeb5c..91166d5c8f5 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AuroraAbbConfigEntry) -> comport = entry.data[CONF_PORT] address = entry.data[CONF_ADDRESS] - coordinator = AuroraAbbDataUpdateCoordinator(hass, comport, address) + coordinator = AuroraAbbDataUpdateCoordinator(hass, entry, comport, address) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/aurora_abb_powerone/coordinator.py b/homeassistant/components/aurora_abb_powerone/coordinator.py index c3d05da95f3..d38f0716b44 100644 --- a/homeassistant/components/aurora_abb_powerone/coordinator.py +++ b/homeassistant/components/aurora_abb_powerone/coordinator.py @@ -21,12 +21,26 @@ type AuroraAbbConfigEntry = ConfigEntry[AuroraAbbDataUpdateCoordinator] class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): """Class to manage fetching AuroraAbbPowerone data.""" - def __init__(self, hass: HomeAssistant, comport: str, address: int) -> None: + config_entry: AuroraAbbConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AuroraAbbConfigEntry, + comport: str, + address: int, + ) -> None: """Initialize the data update coordinator.""" self.available_prev = False self.available = False self.client = AuroraSerialClient(address, comport, parity="N", timeout=1) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) def _update_data(self) -> dict[str, float]: """Fetch new state data for the sensors. From 2d9db62828b16bde716327e980b7db6846919682 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 23:20:08 +0100 Subject: [PATCH 0032/1941] Explicitly pass in the config_entry in arve coordinator init (#137712) explicitly pass in the config_entry in arve coordinator init --- homeassistant/components/arve/__init__.py | 2 +- homeassistant/components/arve/coordinator.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/arve/__init__.py b/homeassistant/components/arve/__init__.py index a1b4aa7042e..c5900967bde 100644 --- a/homeassistant/components/arve/__init__.py +++ b/homeassistant/components/arve/__init__.py @@ -13,7 +13,7 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ArveConfigEntry) -> bool: """Set up Arve from a config entry.""" - coordinator = ArveCoordinator(hass) + coordinator = ArveCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/arve/coordinator.py b/homeassistant/components/arve/coordinator.py index f02220e28e2..4b08efd639e 100644 --- a/homeassistant/components/arve/coordinator.py +++ b/homeassistant/components/arve/coordinator.py @@ -30,11 +30,12 @@ class ArveCoordinator(DataUpdateCoordinator[ArveSensProData]): config_entry: ArveConfigEntry devices: ArveDevices - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ArveConfigEntry) -> None: """Initialize Arve coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), ) From ee80966a1004b9e8b6e4462f90b33bff0ce3f2b6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 23:30:57 +0100 Subject: [PATCH 0033/1941] =?UTF-8?q?Explicitly=20pass=20in=20the=20config?= =?UTF-8?q?=5Fentry=20in=20android=5Fip=5Fwebcam=20coordinator=20=E2=80=A6?= =?UTF-8?q?=20(#137705)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit explicitly pass in the config_entry in android_ip_webcam coordinator init --- homeassistant/components/android_ip_webcam/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/android_ip_webcam/coordinator.py b/homeassistant/components/android_ip_webcam/coordinator.py index fd6e1fcc4b9..c72d6ae1177 100644 --- a/homeassistant/components/android_ip_webcam/coordinator.py +++ b/homeassistant/components/android_ip_webcam/coordinator.py @@ -35,6 +35,7 @@ class AndroidIPCamDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( self.hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN} {config_entry.data[CONF_HOST]}", update_interval=timedelta(seconds=10), ) From 1d3cef5c6f3d4f356cb463c335d296f6b5f6bc56 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 23:38:52 +0100 Subject: [PATCH 0034/1941] Explicitly pass in the config_entry in analytics_insight coordinator init (#137706) explicitly pass in the config_entry in analytics_insight coordinator init --- homeassistant/components/analytics_insights/__init__.py | 2 +- homeassistant/components/analytics_insights/coordinator.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index 69ad98db9df..ee7f6611c65 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -48,7 +48,7 @@ async def async_setup_entry( continue names[integration] = integrations[integration].title - coordinator = HomeassistantAnalyticsDataUpdateCoordinator(hass, client) + coordinator = HomeassistantAnalyticsDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/analytics_insights/coordinator.py b/homeassistant/components/analytics_insights/coordinator.py index 701f1a8dbd4..fefd43ed8df 100644 --- a/homeassistant/components/analytics_insights/coordinator.py +++ b/homeassistant/components/analytics_insights/coordinator.py @@ -46,12 +46,16 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic config_entry: AnalyticsInsightsConfigEntry def __init__( - self, hass: HomeAssistant, client: HomeassistantAnalyticsClient + self, + hass: HomeAssistant, + config_entry: AnalyticsInsightsConfigEntry, + client: HomeassistantAnalyticsClient, ) -> None: """Initialize the Homeassistant Analytics data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(hours=12), ) From fd1213b70d174eccacd00a5255aec74d5639c505 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Feb 2025 23:55:41 +0100 Subject: [PATCH 0035/1941] Explicitly pass in the config_entry in apcupsd coordinator init (#137709) explicitly pass in the config_entry in apcupsd coordinator init --- homeassistant/components/apcupsd/__init__.py | 7 ++----- homeassistant/components/apcupsd/binary_sensor.py | 3 +-- homeassistant/components/apcupsd/coordinator.py | 13 +++++++++++-- homeassistant/components/apcupsd/diagnostics.py | 2 +- homeassistant/components/apcupsd/sensor.py | 3 +-- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 44edc5c151f..e444f1cd735 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -4,13 +4,10 @@ from __future__ import annotations from typing import Final -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from .coordinator import APCUPSdCoordinator - -type APCUPSdConfigEntry = ConfigEntry[APCUPSdCoordinator] +from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator PLATFORMS: Final = (Platform.BINARY_SENSOR, Platform.SENSOR) @@ -20,7 +17,7 @@ async def async_setup_entry( ) -> bool: """Use config values to set up a function enabling status retrieval.""" host, port = config_entry.data[CONF_HOST], config_entry.data[CONF_PORT] - coordinator = APCUPSdCoordinator(hass, host, port) + coordinator = APCUPSdCoordinator(hass, config_entry, host, port) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index cd9e60f7ae4..2a44845618e 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -12,8 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import APCUPSdConfigEntry -from .coordinator import APCUPSdCoordinator +from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 1ae12d8c4b0..e2c1af50cee 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -25,6 +25,8 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL: Final = timedelta(seconds=60) REQUEST_REFRESH_COOLDOWN: Final = 5 +type APCUPSdConfigEntry = ConfigEntry[APCUPSdCoordinator] + class APCUPSdData(dict[str, str]): """Store data about an APCUPSd and provide a few helper methods for easier accesses.""" @@ -57,13 +59,20 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]): updates from the server. """ - config_entry: ConfigEntry + config_entry: APCUPSdConfigEntry - def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: APCUPSdConfigEntry, + host: str, + port: int, + ) -> None: """Initialize the data object.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=UPDATE_INTERVAL, request_refresh_debouncer=Debouncer( diff --git a/homeassistant/components/apcupsd/diagnostics.py b/homeassistant/components/apcupsd/diagnostics.py index fa0908f3144..a4bbf2191d2 100644 --- a/homeassistant/components/apcupsd/diagnostics.py +++ b/homeassistant/components/apcupsd/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import APCUPSdConfigEntry +from .coordinator import APCUPSdConfigEntry TO_REDACT = {"SERIALNO", "HOSTNAME"} diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 9e0abcb1dd9..b3c396daf5e 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -24,9 +24,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import APCUPSdConfigEntry from .const import LAST_S_TEST -from .coordinator import APCUPSdCoordinator +from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator PARALLEL_UPDATES = 0 From 07fdec76e11a82675b924b37e602420a68608a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 7 Feb 2025 23:56:48 +0100 Subject: [PATCH 0036/1941] Explicitly pass in the config_entry in letpot coordinator (#137759) --- homeassistant/components/letpot/__init__.py | 7 ++----- homeassistant/components/letpot/coordinator.py | 14 +++++++++----- homeassistant/components/letpot/switch.py | 3 +-- homeassistant/components/letpot/time.py | 3 +-- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 5bfc4edfc0c..bc84c22d4a2 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -9,7 +9,6 @@ from letpot.converters import CONVERTERS from letpot.exceptions import LetPotAuthenticationException, LetPotException from letpot.models import AuthenticationInfo -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -21,12 +20,10 @@ from .const import ( CONF_REFRESH_TOKEN_EXPIRES, CONF_USER_ID, ) -from .coordinator import LetPotDeviceCoordinator +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME] -type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: """Set up LetPot from a config entry.""" @@ -67,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bo raise ConfigEntryNotReady from exc coordinators: list[LetPotDeviceCoordinator] = [ - LetPotDeviceCoordinator(hass, auth, device) + LetPotDeviceCoordinator(hass, entry, auth, device) for device in devices if any(converter.supports_type(device.device_type) for converter in CONVERTERS) ] diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index a2a35d566c6..bd787157482 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -4,23 +4,22 @@ from __future__ import annotations import asyncio import logging -from typing import TYPE_CHECKING from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import REQUEST_UPDATE_TIMEOUT -if TYPE_CHECKING: - from . import LetPotConfigEntry - _LOGGER = logging.getLogger(__name__) +type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] + class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): """Class to handle data updates for a specific garden.""" @@ -31,12 +30,17 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): device_client: LetPotDeviceClient def __init__( - self, hass: HomeAssistant, info: AuthenticationInfo, device: LetPotDevice + self, + hass: HomeAssistant, + config_entry: LetPotConfigEntry, + info: AuthenticationInfo, + device: LetPotDevice, ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"LetPot {device.serial_number}", ) self._info = info diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 36d07276c48..ab02f2860c6 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -12,8 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LetPotConfigEntry -from .coordinator import LetPotDeviceCoordinator +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator from .entity import LetPotEntity, exception_handler # Each change pushes a 'full' device status with the change. The library will cache diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index 80ce9743d8c..cca088c8e61 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -13,8 +13,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LetPotConfigEntry -from .coordinator import LetPotDeviceCoordinator +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator from .entity import LetPotEntity, exception_handler # Each change pushes a 'full' device status with the change. The library will cache From 0cbef18b73b503b3e30e52e88d934d23b86a32fe Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 01:01:14 +0100 Subject: [PATCH 0037/1941] Explicitly pass in the config_entry in eheimdigital coordinator (#137738) --- homeassistant/components/eheimdigital/__init__.py | 7 ++----- homeassistant/components/eheimdigital/climate.py | 3 +-- .../components/eheimdigital/coordinator.py | 14 +++++++++++--- homeassistant/components/eheimdigital/light.py | 3 +-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py index a555a87cfbc..26e6bea4d4a 100644 --- a/homeassistant/components/eheimdigital/__init__.py +++ b/homeassistant/components/eheimdigital/__init__.py @@ -2,25 +2,22 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN -from .coordinator import EheimDigitalUpdateCoordinator +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.LIGHT] -type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, entry: EheimDigitalConfigEntry ) -> bool: """Set up EHEIM Digital from a config entry.""" - coordinator = EheimDigitalUpdateCoordinator(hass) + coordinator = EheimDigitalUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 7ad06659089..f0038982965 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -23,9 +23,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EheimDigitalConfigEntry from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE -from .coordinator import EheimDigitalUpdateCoordinator +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator from .entity import EheimDigitalEntity # Coordinator is used to centralize the data updates diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index ee4f09426b7..4359a314494 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -22,18 +22,26 @@ type AsyncSetupDeviceEntitiesCallback = Callable[ [str | dict[str, EheimDigitalDevice]], None ] +type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] + class EheimDigitalUpdateCoordinator( DataUpdateCoordinator[dict[str, EheimDigitalDevice]] ): """The EHEIM Digital data update coordinator.""" - config_entry: ConfigEntry + config_entry: EheimDigitalConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: EheimDigitalConfigEntry + ) -> None: """Initialize the EHEIM Digital data update coordinator.""" super().__init__( - hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) self.hub = EheimDigitalHub( host=self.config_entry.data[CONF_HOST], diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index 5ae0a6e866a..25498cf3af1 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -19,9 +19,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import brightness_to_value, value_to_brightness -from . import EheimDigitalConfigEntry from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE -from .coordinator import EheimDigitalUpdateCoordinator +from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator from .entity import EheimDigitalEntity BRIGHTNESS_SCALE = (1, 100) From 7fc92e4c25cec0f4e730137de426134817645f2d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 01:02:49 +0100 Subject: [PATCH 0038/1941] Explicitly pass in the config_entry in dremel_3d_printer coordinator (#137740) --- homeassistant/components/dremel_3d_printer/__init__.py | 2 +- homeassistant/components/dremel_3d_printer/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dremel_3d_printer/__init__.py b/homeassistant/components/dremel_3d_printer/__init__.py index 632c42d9b54..33a8ad0e67f 100644 --- a/homeassistant/components/dremel_3d_printer/__init__.py +++ b/homeassistant/components/dremel_3d_printer/__init__.py @@ -29,7 +29,7 @@ async def async_setup_entry( f"Unable to connect to Dremel 3D Printer: {ex}" ) from ex - coordinator = Dremel3DPrinterDataUpdateCoordinator(hass, api) + coordinator = Dremel3DPrinterDataUpdateCoordinator(hass, config_entry, api) await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = coordinator platforms = list(PLATFORMS) diff --git a/homeassistant/components/dremel_3d_printer/coordinator.py b/homeassistant/components/dremel_3d_printer/coordinator.py index 3323569c05f..f2c1876fe0a 100644 --- a/homeassistant/components/dremel_3d_printer/coordinator.py +++ b/homeassistant/components/dremel_3d_printer/coordinator.py @@ -18,11 +18,14 @@ class Dremel3DPrinterDataUpdateCoordinator(DataUpdateCoordinator[None]): config_entry: DremelConfigEntry - def __init__(self, hass: HomeAssistant, api: Dremel3DPrinter) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: DremelConfigEntry, api: Dremel3DPrinter + ) -> None: """Initialize Dremel 3D Printer data update coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) From e9bfb6baeecfc910239e35c8cfcb4de9b9da0f6b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 01:04:05 +0100 Subject: [PATCH 0039/1941] Explicitly pass in the config_entry in emoncms coordinator (#137743) --- homeassistant/components/emoncms/__init__.py | 11 ++++------- homeassistant/components/emoncms/coordinator.py | 7 +++++++ homeassistant/components/emoncms/sensor.py | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 581948bbc6f..012abcc8c9a 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -2,7 +2,6 @@ from pyemoncms import EmoncmsClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -10,12 +9,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, EMONCMS_UUID_DOC_URL, LOGGER -from .coordinator import EmoncmsCoordinator +from .coordinator import EmonCMSConfigEntry, EmoncmsCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator] - def _migrate_unique_id( hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_unique_id: str @@ -68,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b session=async_get_clientsession(hass), ) await _check_unique_id_migration(hass, entry, emoncms_client) - coordinator = EmoncmsCoordinator(hass, emoncms_client) + coordinator = EmoncmsCoordinator(hass, entry, emoncms_client) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -77,11 +74,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry): +async def update_listener(hass: HomeAssistant, entry: EmonCMSConfigEntry): """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/emoncms/coordinator.py b/homeassistant/components/emoncms/coordinator.py index c6fda5ed7c8..ec439b400d5 100644 --- a/homeassistant/components/emoncms/coordinator.py +++ b/homeassistant/components/emoncms/coordinator.py @@ -5,24 +5,31 @@ from typing import Any from pyemoncms import EmoncmsClient +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_MESSAGE, CONF_SUCCESS, LOGGER +type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator] + class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]): """Emoncms Data Update Coordinator.""" + config_entry: EmonCMSConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: EmonCMSConfigEntry, emoncms_client: EmoncmsClient, ) -> None: """Initialize the emoncms data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="emoncms_coordinator", update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 1920e06a8e8..e3483d3f5d7 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, @@ -53,7 +53,7 @@ from .const import ( FEED_NAME, FEED_TAG, ) -from .coordinator import EmoncmsCoordinator +from .coordinator import EmonCMSConfigEntry, EmoncmsCoordinator SENSORS: dict[str | None, SensorEntityDescription] = { "kWh": SensorEntityDescription( @@ -288,7 +288,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: EmonCMSConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the emoncms sensors.""" From 61d1b34cefda6f6e1f23f583ad31f888d414511a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 01:04:45 +0100 Subject: [PATCH 0040/1941] Explicitly pass in the config_entry in dwd weather warnings coordinator (#137737) --- .../components/dwd_weather_warnings/__init__.py | 2 +- .../components/dwd_weather_warnings/coordinator.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 7a56299a35b..727fcf95339 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -16,7 +16,7 @@ async def async_setup_entry( device_registry = dr.async_get(hass) if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}): device_registry.async_clear_config_entry(entry.entry_id) - coordinator = DwdWeatherWarningsCoordinator(hass) + coordinator = DwdWeatherWarningsCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py index be61304bc06..61656a82de6 100644 --- a/homeassistant/components/dwd_weather_warnings/coordinator.py +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -28,10 +28,16 @@ class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): config_entry: DwdWeatherWarningsConfigEntry api: DwdWeatherWarningsAPI - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: DwdWeatherWarningsConfigEntry + ) -> None: """Initialize the dwd_weather_warnings coordinator.""" super().__init__( - hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) self._device_tracker = None From 7883106e7ce9632a4ae962692cbff15b20fe0472 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Feb 2025 21:25:21 -0500 Subject: [PATCH 0041/1941] Make sure we always have agent_id in ConversationInput (#137679) * Make sure we always have agent_id in ConversationInput * fix type --- homeassistant/components/conversation/agent_manager.py | 3 +++ homeassistant/components/conversation/default_agent.py | 2 +- homeassistant/components/conversation/http.py | 2 +- homeassistant/components/conversation/models.py | 2 +- .../google_generative_ai_conversation/conversation.py | 2 -- .../components/openai_conversation/conversation.py | 1 - tests/components/conversation/test_init.py | 3 +++ tests/components/conversation/test_trigger.py | 10 +++++----- 8 files changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index ce3a0cf028d..5ff47977d88 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -79,6 +79,9 @@ async def async_converse( extra_system_prompt: str | None = None, ) -> ConversationResult: """Process text and get intent.""" + if agent_id is None: + agent_id = HOME_ASSISTANT_AGENT + agent = async_get_agent(hass, agent_id) if agent is None: diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index bd7450e5a0f..23c201d7579 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -381,7 +381,7 @@ class DefaultAgent(ConversationEntity): speech: str = response.speech.get("plain", {}).get("speech", "") chat_log.async_add_assistant_content_without_tools( AssistantContent( - agent_id=user_input.agent_id, # type: ignore[arg-type] + agent_id=user_input.agent_id, content=speech, ) ) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 8134ecb0eee..4d8526a4fd4 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -195,7 +195,7 @@ async def websocket_hass_agent_debug( conversation_id=None, device_id=msg.get("device_id"), language=msg.get("language", hass.config.language), - agent_id=None, + agent_id=agent.entity_id, ) result_dict: dict[str, Any] | None = None diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 9462c597f23..08a68fa0164 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -37,7 +37,7 @@ class ConversationInput: language: str """Language of the request.""" - agent_id: str | None = None + agent_id: str """Agent to use for processing.""" extra_system_prompt: str | None = None diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 8a6c5563601..0f26c93da25 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -261,8 +261,6 @@ class GoogleGenerativeAIConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - - assert user_input.agent_id options = self.entry.options try: diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 73dafa1c48d..eaa62bd1adc 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -198,7 +198,6 @@ class OpenAIConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - assert user_input.agent_id options = self.entry.options try: diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 6900ba2d419..9ac5c7d16a4 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -271,6 +271,7 @@ async def test_async_handle_sentence_triggers( text="my trigger", context=Context(), conversation_id=None, + agent_id=conversation.HOME_ASSISTANT_AGENT, device_id=device_id, language=hass.config.language, ), @@ -306,6 +307,7 @@ async def test_async_handle_intents(hass: HomeAssistant) -> None: ConversationInput( text="I'd like to order a stout", context=Context(), + agent_id=conversation.HOME_ASSISTANT_AGENT, conversation_id=None, device_id=None, language=hass.config.language, @@ -321,6 +323,7 @@ async def test_async_handle_intents(hass: HomeAssistant) -> None: hass, ConversationInput( text="this sentence does not exist", + agent_id=conversation.HOME_ASSISTANT_AGENT, context=Context(), conversation_id=None, device_id=None, diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 9b57bb43b58..3aa8ae2939f 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -5,7 +5,7 @@ import logging import pytest import voluptuous as vol -from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation import HOME_ASSISTANT_AGENT, default_agent from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.conversation.models import ConversationInput from homeassistant.core import Context, HomeAssistant, ServiceCall @@ -82,7 +82,7 @@ async def test_if_fires_on_event( "details": {}, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, @@ -230,7 +230,7 @@ async def test_response_same_sentence( "details": {}, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, @@ -408,7 +408,7 @@ async def test_same_trigger_multiple_sentences( "details": {}, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, @@ -636,7 +636,7 @@ async def test_wildcards(hass: HomeAssistant, service_calls: list[ServiceCall]) }, "device_id": None, "user_input": { - "agent_id": None, + "agent_id": HOME_ASSISTANT_AGENT, "context": context.as_dict(), "conversation_id": None, "device_id": None, From aa19207ea4a12abc592781baeff674027ece33dd Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 7 Feb 2025 19:41:09 -0800 Subject: [PATCH 0042/1941] Clear statistics when you unload the Opower integration (#135908) * Clear statistics when you remove the Opower integration * fix --- homeassistant/components/opower/__init__.py | 6 +----- homeassistant/components/opower/coordinator.py | 14 ++++++++++++++ homeassistant/components/opower/sensor.py | 3 +-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index 136a1a4e57a..b8e4f4381d0 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -2,18 +2,14 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import OpowerCoordinator +from .coordinator import OpowerConfigEntry, OpowerCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type OpowerConfigEntry = ConfigEntry[OpowerCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Set up Opower from a config entry.""" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 6957ae4984c..8d7ef1ace94 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -23,6 +23,7 @@ from homeassistant.components.recorder.statistics import ( get_last_statistics, statistics_during_period, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed @@ -34,10 +35,14 @@ from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) +type OpowerConfigEntry = ConfigEntry[OpowerCoordinator] + class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): """Handle fetching Opower data, updating sensors and inserting statistics.""" + config_entry: OpowerConfigEntry + def __init__( self, hass: HomeAssistant, @@ -59,6 +64,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): entry_data[CONF_PASSWORD], entry_data.get(CONF_TOTP_SECRET), ) + self._statistic_ids: set[str] = set() @callback def _dummy_listener() -> None: @@ -70,6 +76,12 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # _async_update_data not periodically getting called which is needed for _insert_statistics. self.async_add_listener(_dummy_listener) + self.config_entry.async_on_unload(self._clear_statistics) + + def _clear_statistics(self) -> None: + """Clear statistics.""" + get_instance(self.hass).async_clear_statistics(list(self._statistic_ids)) + async def _async_update_data( self, ) -> dict[str, Forecast]: @@ -115,6 +127,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ) cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" + self._statistic_ids.add(cost_statistic_id) + self._statistic_ids.add(consumption_statistic_id) _LOGGER.debug( "Updating Statistics for %s and %s", cost_statistic_id, diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index f9d0fe62332..1b3aa0fd710 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -20,9 +20,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OpowerConfigEntry from .const import DOMAIN -from .coordinator import OpowerCoordinator +from .coordinator import OpowerConfigEntry, OpowerCoordinator @dataclass(frozen=True, kw_only=True) From f64b494282e416cfafc4d2c27cd3ff4d242e2569 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 8 Feb 2025 01:06:16 -0500 Subject: [PATCH 0043/1941] Conversation chat log cleanup and optimization (#137784) --- .../components/assist_pipeline/pipeline.py | 52 ++++++------ .../components/conversation/chat_log.py | 79 +++++++++++++------ .../components/conversation/test_chat_log.py | 62 +++++++++++---- 3 files changed, 130 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 94e2b04d7ae..ef26e1a5a6d 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1093,16 +1093,18 @@ class PipelineRun: agent_id = conversation.HOME_ASSISTANT_AGENT processed_locally = True - # It was already handled, create response and add to chat history - if intent_response is not None: - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log( - self.hass, session, user_input - ) as chat_log, - ): + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log( + self.hass, + session, + user_input, + ) as chat_log, + ): + # It was already handled, create response and add to chat history + if intent_response is not None: speech: str = intent_response.speech.get("plain", {}).get( "speech", "" ) @@ -1117,21 +1119,21 @@ class PipelineRun: conversation_id=session.conversation_id, ) - else: - # Fall back to pipeline conversation agent - conversation_result = await conversation.async_converse( - hass=self.hass, - text=user_input.text, - conversation_id=user_input.conversation_id, - device_id=user_input.device_id, - context=user_input.context, - language=user_input.language, - agent_id=user_input.agent_id, - extra_system_prompt=user_input.extra_system_prompt, - ) - speech = conversation_result.response.speech.get("plain", {}).get( - "speech", "" - ) + else: + # Fall back to pipeline conversation agent + conversation_result = await conversation.async_converse( + hass=self.hass, + text=user_input.text, + conversation_id=user_input.conversation_id, + device_id=user_input.device_id, + context=user_input.context, + language=user_input.language, + agent_id=user_input.agent_id, + extra_system_prompt=user_input.extra_system_prompt, + ) + speech = conversation_result.response.speech.get("plain", {}).get( + "speech", "" + ) except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index ad7a9d0ce9e..e4ff1904e7c 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -1,9 +1,11 @@ -"""Conversation history.""" +"""Conversation chat log.""" from __future__ import annotations +import asyncio from collections.abc import AsyncGenerator, Generator from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import dataclass, field, replace import logging @@ -19,10 +21,14 @@ from . import trace from .const import DOMAIN from .models import ConversationInput, ConversationResult -DATA_CHAT_HISTORY: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_log") +DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs") LOGGER = logging.getLogger(__name__) +current_chat_log: ContextVar[ChatLog | None] = ContextVar( + "current_chat_log", default=None +) + @contextmanager def async_get_chat_log( @@ -31,41 +37,50 @@ def async_get_chat_log( user_input: ConversationInput | None = None, ) -> Generator[ChatLog]: """Return chat log for a specific chat session.""" - all_history = hass.data.get(DATA_CHAT_HISTORY) - if all_history is None: - all_history = {} - hass.data[DATA_CHAT_HISTORY] = all_history + if chat_log := current_chat_log.get(): + # If a chat log is already active and it's the requested conversation ID, + # return that. We won't update the last updated time in this case. + if chat_log.conversation_id == session.conversation_id: + yield chat_log + return - history = all_history.get(session.conversation_id) + all_chat_logs = hass.data.get(DATA_CHAT_LOGS) + if all_chat_logs is None: + all_chat_logs = {} + hass.data[DATA_CHAT_LOGS] = all_chat_logs - if history: - history = replace(history, content=history.content.copy()) + chat_log = all_chat_logs.get(session.conversation_id) + + if chat_log: + chat_log = replace(chat_log, content=chat_log.content.copy()) else: - history = ChatLog(hass, session.conversation_id) + chat_log = ChatLog(hass, session.conversation_id) if user_input is not None: - history.async_add_user_content(UserContent(content=user_input.text)) + chat_log.async_add_user_content(UserContent(content=user_input.text)) - last_message = history.content[-1] + last_message = chat_log.content[-1] - yield history + token = current_chat_log.set(chat_log) + yield chat_log + current_chat_log.reset(token) - if history.content[-1] is last_message: + if chat_log.content[-1] is last_message: LOGGER.debug( - "History opened but no assistant message was added, ignoring update" + "Chat Log opened but no assistant message was added, ignoring update" ) return - if session.conversation_id not in all_history: + if session.conversation_id not in all_chat_logs: @callback def do_cleanup() -> None: """Handle cleanup.""" - all_history.pop(session.conversation_id) + all_chat_logs.pop(session.conversation_id) session.async_on_cleanup(do_cleanup) - all_history[session.conversation_id] = history + all_chat_logs[session.conversation_id] = chat_log class ConverseError(HomeAssistantError): @@ -112,7 +127,7 @@ class AssistantContent: role: str = field(init=False, default="assistant") agent_id: str - content: str + content: str | None = None tool_calls: list[llm.ToolInput] | None = None @@ -143,6 +158,7 @@ class ChatLog: @callback def async_add_user_content(self, content: UserContent) -> None: """Add user content to the log.""" + LOGGER.debug("Adding user content: %s", content) self.content.append(content) @callback @@ -150,14 +166,24 @@ class ChatLog: self, content: AssistantContent ) -> None: """Add assistant content to the log.""" + LOGGER.debug("Adding assistant content: %s", content) if content.tool_calls is not None: raise ValueError("Tool calls not allowed") self.content.append(content) async def async_add_assistant_content( - self, content: AssistantContent + self, + content: AssistantContent, + /, + tool_call_tasks: dict[str, asyncio.Task] | None = None, ) -> AsyncGenerator[ToolResultContent]: - """Add assistant content.""" + """Add assistant content and execute tool calls. + + tool_call_tasks can contains tasks for tool calls that are already in progress. + + This method is an async generator and will yield the tool results as they come in. + """ + LOGGER.debug("Adding assistant content: %s", content) self.content.append(content) if content.tool_calls is None: @@ -166,13 +192,22 @@ class ChatLog: if self.llm_api is None: raise ValueError("No LLM API configured") + if tool_call_tasks is None: + tool_call_tasks = {} + for tool_input in content.tool_calls: + if tool_input.id not in tool_call_tasks: + tool_call_tasks[tool_input.id] = self.hass.async_create_task( + self.llm_api.async_call_tool(tool_input), + name=f"llm_tool_{tool_input.id}", + ) + for tool_input in content.tool_calls: LOGGER.debug( "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args ) try: - tool_result = await self.llm_api.async_call_tool(tool_input) + tool_result = await tool_call_tasks[tool_input.id] except (HomeAssistantError, vol.Invalid) as e: tool_result = {"error": type(e).__name__} if str(e): diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index c22a90e6928..1f659b8005e 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -15,7 +15,7 @@ from homeassistant.components.conversation import ( ToolResultContent, async_get_chat_log, ) -from homeassistant.components.conversation.chat_log import DATA_CHAT_HISTORY +from homeassistant.components.conversation.chat_log import DATA_CHAT_LOGS from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, llm @@ -63,7 +63,7 @@ async def test_cleanup( ) ) - assert conversation_id in hass.data[DATA_CHAT_HISTORY] + assert conversation_id in hass.data[DATA_CHAT_LOGS] # Set the last updated to be older than the timeout hass.data[chat_session.DATA_CHAT_SESSION][conversation_id].last_updated = ( @@ -75,7 +75,7 @@ async def test_cleanup( dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT * 2 + timedelta(seconds=1), ) - assert conversation_id not in hass.data[DATA_CHAT_HISTORY] + assert conversation_id not in hass.data[DATA_CHAT_LOGS] async def test_default_content( @@ -279,9 +279,18 @@ async def test_extra_systen_prompt( assert chat_log.content[0].content.endswith(extra_system_prompt2) +@pytest.mark.parametrize( + "prerun_tool_tasks", + [ + None, + ("mock-tool-call-id",), + ("mock-tool-call-id", "mock-tool-call-id-2"), + ], +) async def test_tool_call( hass: HomeAssistant, mock_conversation_input: ConversationInput, + prerun_tool_tasks: tuple[str] | None, ) -> None: """Test using the session tool calling API.""" @@ -316,26 +325,47 @@ async def test_tool_call( id="mock-tool-call-id", tool_name="test_tool", tool_args={"param1": "Test Param"}, - ) + ), + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ), ], ) + tool_call_tasks = None + if prerun_tool_tasks: + tool_call_tasks = { + tool_call_id: hass.async_create_task( + chat_log.llm_api.async_call_tool(content.tool_calls[0]), + tool_call_id, + ) + for tool_call_id in prerun_tool_tasks + } + with pytest.raises(ValueError): chat_log.async_add_assistant_content_without_tools(content) - result = None - async for tool_result_content in chat_log.async_add_assistant_content( - content - ): - assert result is None - result = tool_result_content + results = [ + tool_result_content + async for tool_result_content in chat_log.async_add_assistant_content( + content, tool_call_tasks=tool_call_tasks + ) + ] - assert result == ToolResultContent( - agent_id=mock_conversation_input.agent_id, - tool_call_id="mock-tool-call-id", - tool_result="Test response", - tool_name="test_tool", - ) + assert results[0] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result="Test response", + tool_name="test_tool", + ) + assert results[1] == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id-2", + tool_result="Test response", + tool_name="test_tool", + ) async def test_tool_call_exception( From 0e0129968bef32f35ccd1ad1bca55b22d4543bec Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 8 Feb 2025 07:38:49 +0100 Subject: [PATCH 0044/1941] Bump onedrive_personal_sdk to 0.0.9 (#137729) --- homeassistant/components/onedrive/__init__.py | 2 +- homeassistant/components/onedrive/backup.py | 2 +- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 9716f692ec8..8355cddb0b5 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -133,7 +133,7 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) - metadata_file = await client.upload_file( backup_folder_id, metadata_filename, - dumps(metadata), # type: ignore[arg-type] + dumps(metadata), ) metadata_description = { "metadata_version": 2, diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 182e29aa63f..9926bd9cbc7 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -168,7 +168,7 @@ class OneDriveBackupAgent(BackupAgent): metadata_file = await self._client.upload_file( self._folder_id, metadata_filename, - description, # type: ignore[arg-type] + description, ) # add metadata to the metadata file diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 88d51e6d73a..fcc922b3e46 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.8"] + "requirements": ["onedrive-personal-sdk==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 306e5891c86..fe7ea7fc5ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1559,7 +1559,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.8 +onedrive-personal-sdk==0.0.9 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index efe7b9d5418..a686bbd0633 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1307,7 +1307,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.8 +onedrive-personal-sdk==0.0.9 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 6c74824ac11d7fe53620fd8ab39cc8b9e9e0a7d5 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Sat, 8 Feb 2025 08:51:33 +0100 Subject: [PATCH 0045/1941] Add discovery for Nanoleaf Blocks and 4D (#137792) --- homeassistant/components/nanoleaf/manifest.json | 8 +++++++- homeassistant/generated/ssdp.py | 6 ++++++ homeassistant/generated/zeroconf.py | 8 ++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json index 4b4c026260d..7af7465bbd0 100644 --- a/homeassistant/components/nanoleaf/manifest.json +++ b/homeassistant/components/nanoleaf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanoleaf", "homekit": { - "models": ["NL29", "NL42", "NL47", "NL48", "NL52", "NL59"] + "models": ["NL29", "NL42", "NL47", "NL48", "NL52", "NL59", "NL69", "NL81"] }, "iot_class": "local_push", "loggers": ["aionanoleaf"], @@ -22,6 +22,12 @@ }, { "st": "nanoleaf:nl52" + }, + { + "st": "nanoleaf:nl69" + }, + { + "st": "inanoleaf:nl81" } ], "zeroconf": ["_nanoleafms._tcp.local.", "_nanoleafapi._tcp.local."] diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 89d1aa30cb8..5bbc178ba17 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -211,6 +211,12 @@ SSDP = { { "st": "nanoleaf:nl52", }, + { + "st": "nanoleaf:nl69", + }, + { + "st": "inanoleaf:nl81", + }, ], "netgear": [ { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 8244f19660f..ab965e27472 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -208,6 +208,14 @@ HOMEKIT = { "always_discover": False, "domain": "nanoleaf", }, + "NL69": { + "always_discover": False, + "domain": "nanoleaf", + }, + "NL81": { + "always_discover": False, + "domain": "nanoleaf", + }, "Netatmo Relay": { "always_discover": True, "domain": "netatmo", From 5f5ad34176a70569274618e028679b100ec93687 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 7 Feb 2025 23:59:20 -0800 Subject: [PATCH 0046/1941] Info log when Android TV Remote is unavailable (#137794) --- .../components/androidtv_remote/__init__.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 6a55e9971ac..28a372da4ea 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -35,16 +35,12 @@ async def async_setup_entry( @callback def is_available_updated(is_available: bool) -> None: - if is_available: - _LOGGER.info( - "Reconnected to %s at %s", entry.data[CONF_NAME], entry.data[CONF_HOST] - ) - else: - _LOGGER.warning( - "Disconnected from %s at %s", - entry.data[CONF_NAME], - entry.data[CONF_HOST], - ) + _LOGGER.info( + "%s %s at %s", + "Reconnected to" if is_available else "Disconnected from", + entry.data[CONF_NAME], + entry.data[CONF_HOST], + ) api.add_is_available_updated_callback(is_available_updated) From 64886f717d3a7e7737cf5b0ca497996fc2ab9aae Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Sat, 8 Feb 2025 08:59:47 +0100 Subject: [PATCH 0047/1941] Add quality_scale to motionmount (#137012) * Mark as ready for Bronze * Add rest of quality scale rules * Evaluate all quality scale rules * Exempt repair-issues * Update dynamic-devices comment Co-authored-by: Josef Zweck --------- Co-authored-by: Josef Zweck --- .../components/motionmount/manifest.json | 1 + .../components/motionmount/quality_scale.yaml | 78 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/motionmount/quality_scale.yaml diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 422be417006..2665836ffd4 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", "iot_class": "local_push", + "quality_scale": "bronze", "requirements": ["python-MotionMount==2.3.0"], "zeroconf": ["_tvm._tcp.local."] } diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml new file mode 100644 index 00000000000..e4a6a04ceeb --- /dev/null +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not have actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: done + comment: Integration does register actions aside from entity actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not register events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not have actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Single device per config entry + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not need user intervention + stale-devices: + status: exempt + comment: Integration does not support dynamic devices + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: Device doesn't make http requests. + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index a1ad52e6aa8..e5eee2f4157 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -669,7 +669,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "motion_blinds", "motionblinds_ble", "motioneye", - "motionmount", "mpd", "mqtt_eventstream", "mqtt_json", @@ -1748,7 +1747,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "motion_blinds", "motionblinds_ble", "motioneye", - "motionmount", "mpd", "mqtt_eventstream", "mqtt_json", From 332a0c5082a2e6bee7aea52f285dd47910c471ba Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 8 Feb 2025 03:14:00 -0500 Subject: [PATCH 0048/1941] LaCrosse View new endpoint (#137284) * Switch to new endpoint in LaCrosse View * Coverage * Avoid merge conflict * Switch to UpdateFailed --- .../components/lacrosse_view/coordinator.py | 37 ++++++---- .../components/lacrosse_view/sensor.py | 2 +- tests/components/lacrosse_view/__init__.py | 67 ++++++++++++++++--- .../snapshots/test_diagnostics.ambr | 2 +- .../lacrosse_view/test_diagnostics.py | 7 +- tests/components/lacrosse_view/test_init.py | 31 ++++++--- tests/components/lacrosse_view/test_sensor.py | 50 +++++++++++--- 7 files changed, 151 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 5ec02a86709..8d7e44ecd99 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -10,8 +10,8 @@ from lacrosse_view import HTTPError, LaCrosse, Location, LoginError, Sensor from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import SCAN_INTERVAL @@ -26,6 +26,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): name: str id: str hass: HomeAssistant + devices: list[Sensor] | None = None def __init__( self, @@ -60,24 +61,34 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): except LoginError as error: raise ConfigEntryAuthFailed from error + if self.devices is None: + _LOGGER.debug("Getting devices") + try: + self.devices = await self.api.get_devices( + location=Location(id=self.id, name=self.name), + ) + except HTTPError as error: + raise UpdateFailed from error + try: # Fetch last hour of data - sensors = await self.api.get_sensors( - location=Location(id=self.id, name=self.name), - tz=self.hass.config.time_zone, - start=str(now - 3600), - end=str(now), - ) - except HTTPError as error: - raise ConfigEntryNotReady from error + for sensor in self.devices: + sensor.data = ( + await self.api.get_sensor_status( + sensor=sensor, + tz=self.hass.config.time_zone, + ) + )["data"]["current"] + _LOGGER.debug("Got data: %s", sensor.data) - _LOGGER.debug("Got data: %s", sensors) + except HTTPError as error: + raise UpdateFailed from error # Verify that we have permission to read the sensors - for sensor in sensors: + for sensor in self.devices: if not sensor.permissions.get("read", False): raise ConfigEntryAuthFailed( f"This account does not have permission to read {sensor.name}" ) - return sensors + return self.devices diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index fceddeb9b2c..5c56a0328a2 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -48,7 +48,7 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: field_data = sensor.data.get(field) if sensor.data is not None else None if field_data is None: return None - value = field_data["values"][-1]["s"] + value = field_data["spot"]["value"] try: value = float(value) except ValueError: diff --git a/tests/components/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 913f6c72f24..860156beb6c 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -15,7 +15,13 @@ TEST_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -26,7 +32,13 @@ TEST_NO_PERMISSION_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": False}, model="Test", ) @@ -37,7 +49,16 @@ TEST_UNSUPPORTED_SENSOR = Sensor( sensor_id="2", sensor_field_names=["SomeUnsupportedField"], location=Location(id="1", name="Test"), - data={"SomeUnsupportedField": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "SomeUnsupportedField": { + "spot": {"value": "2"}, + "unit": "degrees_celsius", + } + } + } + }, permissions={"read": True}, model="Test", ) @@ -48,7 +69,13 @@ TEST_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2.3"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2.3"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -59,7 +86,9 @@ TEST_STRING_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WetDry"], location=Location(id="1", name="Test"), - data={"WetDry": {"values": [{"s": "dry"}], "unit": "wet_dry"}}, + data={ + "data": {"current": {"WetDry": {"spot": {"value": "dry"}, "unit": "wet_dry"}}} + }, permissions={"read": True}, model="Test", ) @@ -70,7 +99,13 @@ TEST_ALREADY_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["HeatIndex"], location=Location(id="1", name="Test"), - data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_fahrenheit"}}, + data={ + "data": { + "current": { + "HeatIndex": {"spot": {"value": 2.3}, "unit": "degrees_fahrenheit"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -81,7 +116,13 @@ TEST_ALREADY_INT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WindSpeed"], location=Location(id="1", name="Test"), - data={"WindSpeed": {"values": [{"s": 2}], "unit": "kilometers_per_hour"}}, + data={ + "data": { + "current": { + "WindSpeed": {"spot": {"value": 2}, "unit": "kilometers_per_hour"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -92,7 +133,7 @@ TEST_NO_FIELD_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={}, + data={"data": {"current": {}}}, permissions={"read": True}, model="Test", ) @@ -103,7 +144,7 @@ TEST_MISSING_FIELD_DATA_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": None}, + data={"data": {"current": {"Temperature": None}}}, permissions={"read": True}, model="Test", ) @@ -114,7 +155,13 @@ TEST_UNITS_OVERRIDE_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2.1"}], "unit": "degrees_fahrenheit"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2.1"}, "unit": "degrees_fahrenheit"} + } + } + }, permissions={"read": True}, model="Test", ) diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 201bbbc971e..bfbfa2901a6 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -4,7 +4,7 @@ 'coordinator_data': list([ dict({ '__type': "", - 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'values': [{'s': '2'}], 'unit': 'degrees_celsius'}})", + 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'spot': {'value': '2'}, 'unit': 'degrees_celsius'}})", }), ]), 'entry': dict({ diff --git a/tests/components/lacrosse_view/test_diagnostics.py b/tests/components/lacrosse_view/test_diagnostics.py index dc48f160113..4306173c6b3 100644 --- a/tests/components/lacrosse_view/test_diagnostics.py +++ b/tests/components/lacrosse_view/test_diagnostics.py @@ -26,9 +26,14 @@ async def test_entry_diagnostics( ) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index 51fa7e5abf4..af92d0e64f1 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -20,12 +20,17 @@ async def test_unload_entry(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -68,7 +73,7 @@ async def test_http_error(hass: HomeAssistant) -> None: with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", side_effect=HTTPError), + patch("lacrosse_view.LaCrosse.get_devices", side_effect=HTTPError), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -84,12 +89,17 @@ async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -103,7 +113,7 @@ async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", + "lacrosse_view.LaCrosse.get_devices", return_value=[TEST_SENSOR], ), ): @@ -121,12 +131,17 @@ async def test_failed_token( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 11faaf8877e..74e9f001792 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -32,9 +32,14 @@ async def test_entities_added(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -54,12 +59,17 @@ async def test_sensor_permission( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_NO_PERMISSION_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_NO_PERMISSION_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -79,11 +89,14 @@ async def test_field_not_supported( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_UNSUPPORTED_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch( - "lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_UNSUPPORTED_SENSOR] - ), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -114,12 +127,17 @@ async def test_field_types( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = test_input.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", + "lacrosse_view.LaCrosse.get_devices", return_value=[test_input], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -137,12 +155,17 @@ async def test_no_field(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_NO_FIELD_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_NO_FIELD_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -160,12 +183,17 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_MISSING_FIELD_DATA_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_MISSING_FIELD_DATA_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 6cb020103183834d6221a7eb8650b86c429a6e6b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 8 Feb 2025 09:57:00 +0100 Subject: [PATCH 0049/1941] Limit google_sheets ConfigEntrySelect to integration domain (#137766) --- homeassistant/components/google_sheets/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 942db675b5a..faf1ff1ee0b 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -39,7 +39,7 @@ SERVICE_APPEND_SHEET = "append_sheet" SHEET_SERVICE_SCHEMA = vol.All( { - vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Optional(WORKSHEET): cv.string, vol.Required(DATA): vol.Any(cv.ensure_list, [dict]), }, From ae55e2654607c5da51cb17654e0b20ab033a4c9b Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sat, 8 Feb 2025 10:07:22 +0100 Subject: [PATCH 0050/1941] Group helpers of set_up_integrations in bootstrap (#137673) --- homeassistant/bootstrap.py | 212 ++++++++++++++++++------------------- tests/test_bootstrap.py | 10 +- 2 files changed, 111 insertions(+), 111 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 490ce5559a9..58150ae7926 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -716,109 +716,6 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: return domains -class _WatchPendingSetups: - """Periodic log and dispatch of setups that are pending.""" - - def __init__( - self, - hass: core.HomeAssistant, - setup_started: dict[tuple[str, str | None], float], - ) -> None: - """Initialize the WatchPendingSetups class.""" - self._hass = hass - self._setup_started = setup_started - self._duration_count = 0 - self._handle: asyncio.TimerHandle | None = None - self._previous_was_empty = True - self._loop = hass.loop - - def _async_watch(self) -> None: - """Periodic log of setups that are pending.""" - now = monotonic() - self._duration_count += SLOW_STARTUP_CHECK_INTERVAL - - remaining_with_setup_started: defaultdict[str, float] = defaultdict(float) - for integration_group, start_time in self._setup_started.items(): - domain, _ = integration_group - remaining_with_setup_started[domain] += now - start_time - - if remaining_with_setup_started: - _LOGGER.debug("Integration remaining: %s", remaining_with_setup_started) - elif waiting_tasks := self._hass._active_tasks: # noqa: SLF001 - _LOGGER.debug("Waiting on tasks: %s", waiting_tasks) - self._async_dispatch(remaining_with_setup_started) - if ( - self._setup_started - and self._duration_count % LOG_SLOW_STARTUP_INTERVAL == 0 - ): - # We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done - # once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up - _LOGGER.warning( - "Waiting on integrations to complete setup: %s", - self._setup_started, - ) - - _LOGGER.debug("Running timeout Zones: %s", self._hass.timeout.zones) - self._async_schedule_next() - - def _async_dispatch(self, remaining_with_setup_started: dict[str, float]) -> None: - """Dispatch the signal.""" - if remaining_with_setup_started or not self._previous_was_empty: - async_dispatcher_send_internal( - self._hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started - ) - self._previous_was_empty = not remaining_with_setup_started - - def _async_schedule_next(self) -> None: - """Schedule the next call.""" - self._handle = self._loop.call_later( - SLOW_STARTUP_CHECK_INTERVAL, self._async_watch - ) - - def async_start(self) -> None: - """Start watching.""" - self._async_schedule_next() - - def async_stop(self) -> None: - """Stop watching.""" - self._async_dispatch({}) - if self._handle: - self._handle.cancel() - self._handle = None - - -async def async_setup_multi_components( - hass: core.HomeAssistant, - domains: set[str], - config: dict[str, Any], -) -> None: - """Set up multiple domains. Log on failure.""" - # Avoid creating tasks for domains that were setup in a previous stage - domains_not_yet_setup = domains - hass.config.components - # Create setup tasks for base platforms first since everything will have - # to wait to be imported, and the sooner we can get the base platforms - # loaded the sooner we can start loading the rest of the integrations. - futures = { - domain: hass.async_create_task_internal( - async_setup_component(hass, domain, config), - f"setup component {domain}", - eager_start=True, - ) - for domain in sorted( - domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True - ) - } - results = await asyncio.gather(*futures.values(), return_exceptions=True) - for idx, domain in enumerate(futures): - result = results[idx] - if isinstance(result, BaseException): - _LOGGER.error( - "Error setting up integration %s - received exception", - domain, - exc_info=(type(result), result, result.__traceback__), - ) - - async def _async_resolve_domains_to_setup( hass: core.HomeAssistant, config: dict[str, Any] ) -> tuple[set[str], dict[str, loader.Integration]]: @@ -1038,7 +935,7 @@ async def _async_set_up_integrations( for dep in integration.all_dependencies ) async_set_domains_to_be_loaded(hass, to_be_loaded) - await async_setup_multi_components(hass, domain_group, config) + await _async_setup_multi_components(hass, domain_group, config) # Enables after dependencies when setting up stage 1 domains async_set_domains_to_be_loaded(hass, stage_1_domains) @@ -1050,7 +947,7 @@ async def _async_set_up_integrations( async with hass.timeout.async_timeout( STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME ): - await async_setup_multi_components(hass, stage_1_domains, config) + await _async_setup_multi_components(hass, stage_1_domains, config) except TimeoutError: _LOGGER.warning( "Setup timed out for stage 1 waiting on %s - moving forward", @@ -1066,7 +963,7 @@ async def _async_set_up_integrations( async with hass.timeout.async_timeout( STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME ): - await async_setup_multi_components(hass, stage_2_domains, config) + await _async_setup_multi_components(hass, stage_2_domains, config) except TimeoutError: _LOGGER.warning( "Setup timed out for stage 2 waiting on %s - moving forward", @@ -1092,3 +989,106 @@ async def _async_set_up_integrations( "Integration setup times: %s", dict(sorted(setup_time.items(), key=itemgetter(1), reverse=True)), ) + + +class _WatchPendingSetups: + """Periodic log and dispatch of setups that are pending.""" + + def __init__( + self, + hass: core.HomeAssistant, + setup_started: dict[tuple[str, str | None], float], + ) -> None: + """Initialize the WatchPendingSetups class.""" + self._hass = hass + self._setup_started = setup_started + self._duration_count = 0 + self._handle: asyncio.TimerHandle | None = None + self._previous_was_empty = True + self._loop = hass.loop + + def _async_watch(self) -> None: + """Periodic log of setups that are pending.""" + now = monotonic() + self._duration_count += SLOW_STARTUP_CHECK_INTERVAL + + remaining_with_setup_started: defaultdict[str, float] = defaultdict(float) + for integration_group, start_time in self._setup_started.items(): + domain, _ = integration_group + remaining_with_setup_started[domain] += now - start_time + + if remaining_with_setup_started: + _LOGGER.debug("Integration remaining: %s", remaining_with_setup_started) + elif waiting_tasks := self._hass._active_tasks: # noqa: SLF001 + _LOGGER.debug("Waiting on tasks: %s", waiting_tasks) + self._async_dispatch(remaining_with_setup_started) + if ( + self._setup_started + and self._duration_count % LOG_SLOW_STARTUP_INTERVAL == 0 + ): + # We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done + # once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up + _LOGGER.warning( + "Waiting on integrations to complete setup: %s", + self._setup_started, + ) + + _LOGGER.debug("Running timeout Zones: %s", self._hass.timeout.zones) + self._async_schedule_next() + + def _async_dispatch(self, remaining_with_setup_started: dict[str, float]) -> None: + """Dispatch the signal.""" + if remaining_with_setup_started or not self._previous_was_empty: + async_dispatcher_send_internal( + self._hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started + ) + self._previous_was_empty = not remaining_with_setup_started + + def _async_schedule_next(self) -> None: + """Schedule the next call.""" + self._handle = self._loop.call_later( + SLOW_STARTUP_CHECK_INTERVAL, self._async_watch + ) + + def async_start(self) -> None: + """Start watching.""" + self._async_schedule_next() + + def async_stop(self) -> None: + """Stop watching.""" + self._async_dispatch({}) + if self._handle: + self._handle.cancel() + self._handle = None + + +async def _async_setup_multi_components( + hass: core.HomeAssistant, + domains: set[str], + config: dict[str, Any], +) -> None: + """Set up multiple domains. Log on failure.""" + # Avoid creating tasks for domains that were setup in a previous stage + domains_not_yet_setup = domains - hass.config.components + # Create setup tasks for base platforms first since everything will have + # to wait to be imported, and the sooner we can get the base platforms + # loaded the sooner we can start loading the rest of the integrations. + futures = { + domain: hass.async_create_task_internal( + async_setup_component(hass, domain, config), + f"setup component {domain}", + eager_start=True, + ) + for domain in sorted( + domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True + ) + } + results = await asyncio.gather(*futures.values(), return_exceptions=True) + for idx, domain in enumerate(futures): + result = results[idx] + if isinstance(result, BaseException): + _LOGGER.error( + "Error setting up integration %s - received exception", + domain, + exc_info=(type(result), result, result.__traceback__), + ) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 5adfe4fc40b..4317df6cf4a 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1176,7 +1176,7 @@ async def test_bootstrap_is_cancellation_safe( @pytest.mark.parametrize("load_registries", [False]) async def test_bootstrap_empty_integrations(hass: HomeAssistant) -> None: """Test setting up an empty integrations does not raise.""" - await bootstrap.async_setup_multi_components(hass, set(), {}) + await bootstrap._async_setup_multi_components(hass, set(), {}) await hass.async_block_till_done() @@ -1311,7 +1311,7 @@ async def test_bootstrap_dependencies( ), ): bootstrap.async_set_domains_to_be_loaded(hass, {integration}) - await bootstrap.async_setup_multi_components(hass, {integration}, {}) + await bootstrap._async_setup_multi_components(hass, {integration}, {}) await hass.async_block_till_done() for assertion in assertions: @@ -1407,7 +1407,7 @@ async def test_cancellation_does_not_leak_upward_from_async_setup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test setting up an integration that raises asyncio.CancelledError.""" - await bootstrap.async_setup_multi_components( + await bootstrap._async_setup_multi_components( hass, {"test_package_raises_cancelled_error"}, {} ) await hass.async_block_till_done() @@ -1428,12 +1428,12 @@ async def test_cancellation_does_not_leak_upward_from_async_setup_entry( domain="test_package_raises_cancelled_error_config_entry", data={} ) entry.add_to_hass(hass) - await bootstrap.async_setup_multi_components( + await bootstrap._async_setup_multi_components( hass, {"test_package_raises_cancelled_error_config_entry"}, {} ) await hass.async_block_till_done() - await bootstrap.async_setup_multi_components(hass, {"test_package"}, {}) + await bootstrap._async_setup_multi_components(hass, {"test_package"}, {}) await hass.async_block_till_done() assert ( "Error setting up entry Mock Title for test_package_raises_cancelled_error_config_entry" From c370fa0489557caad51ef4b02bae7a54552eb4a5 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 8 Feb 2025 01:16:10 -0800 Subject: [PATCH 0051/1941] Use the external URL set in Settings > System > Network if my is disabled as redirect URL for Google Drive instructions (#137791) * Use the Assistant URL set in Settings > System > Network if my is disabled * fix * Remove async_get_redirect_uri --- .../google_drive/application_credentials.py | 12 +++++-- .../test_application_credentials.py | 36 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/components/google_drive/test_application_credentials.py diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py index 1c4421623d4..8bcab2b039c 100644 --- a/homeassistant/components/google_drive/application_credentials.py +++ b/homeassistant/components/google_drive/application_credentials.py @@ -2,7 +2,10 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import ( + AUTH_CALLBACK_PATH, + MY_AUTH_CALLBACK_PATH, +) async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -15,9 +18,14 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: """Return description placeholders for the credentials dialog.""" + if "my" in hass.config.components: + redirect_url = MY_AUTH_CALLBACK_PATH + else: + ha_host = hass.config.external_url or "https://YOUR_DOMAIN:PORT" + redirect_url = f"{ha_host}{AUTH_CALLBACK_PATH}" return { "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", - "redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass), + "redirect_url": redirect_url, } diff --git a/tests/components/google_drive/test_application_credentials.py b/tests/components/google_drive/test_application_credentials.py new file mode 100644 index 00000000000..ec46db510a5 --- /dev/null +++ b/tests/components/google_drive/test_application_credentials.py @@ -0,0 +1,36 @@ +"""Test the Google Drive application_credentials.""" + +import pytest + +from homeassistant import setup +from homeassistant.components.google_drive.application_credentials import ( + async_get_description_placeholders, +) +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + ("additional_components", "external_url", "expected_redirect_uri"), + [ + ([], "https://example.com", "https://example.com/auth/external/callback"), + ([], None, "https://YOUR_DOMAIN:PORT/auth/external/callback"), + (["my"], "https://example.com", "https://my.home-assistant.io/redirect/oauth"), + ], +) +async def test_description_placeholders( + hass: HomeAssistant, + additional_components: list[str], + external_url: str | None, + expected_redirect_uri: str, +) -> None: + """Test description placeholders.""" + for component in additional_components: + assert await setup.async_setup_component(hass, component, {}) + hass.config.external_url = external_url + placeholders = await async_get_description_placeholders(hass) + assert placeholders == { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": expected_redirect_uri, + } From 327811c89aad4b9af0baedddc8b05ed3f79a9b78 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 10:53:13 +0100 Subject: [PATCH 0052/1941] Explicitly pass in the config_entry in co2signal coordinator (#137732) explicitly pass in the config_entry in coordinator --- homeassistant/components/co2signal/__init__.py | 7 ++----- .../components/co2signal/coordinator.py | 17 ++++++++++++++--- .../components/co2signal/diagnostics.py | 2 +- homeassistant/components/co2signal/sensor.py | 3 +-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index e84ba387194..612610eff43 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -4,23 +4,20 @@ from __future__ import annotations from aioelectricitymaps import ElectricityMaps -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import CO2SignalCoordinator +from .coordinator import CO2SignalConfigEntry, CO2SignalCoordinator PLATFORMS = [Platform.SENSOR] -type CO2SignalConfigEntry = ConfigEntry[CO2SignalCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: CO2SignalConfigEntry) -> bool: """Set up CO2 Signal from a config entry.""" session = async_get_clientsession(hass) coordinator = CO2SignalCoordinator( - hass, ElectricityMaps(token=entry.data[CONF_API_KEY], session=session) + hass, entry, ElectricityMaps(token=entry.data[CONF_API_KEY], session=session) ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 475ebd1225d..be2036292e3 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -22,16 +22,27 @@ from .helpers import fetch_latest_carbon_intensity _LOGGER = logging.getLogger(__name__) +type CO2SignalConfigEntry = ConfigEntry[CO2SignalCoordinator] + class CO2SignalCoordinator(DataUpdateCoordinator[CarbonIntensityResponse]): """Data update coordinator.""" - config_entry: ConfigEntry + config_entry: CO2SignalConfigEntry - def __init__(self, hass: HomeAssistant, client: ElectricityMaps) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: CO2SignalConfigEntry, + client: ElectricityMaps, + ) -> None: """Initialize the coordinator.""" super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=15), ) self.client = client diff --git a/homeassistant/components/co2signal/diagnostics.py b/homeassistant/components/co2signal/diagnostics.py index a071950440f..840ba759a7b 100644 --- a/homeassistant/components/co2signal/diagnostics.py +++ b/homeassistant/components/co2signal/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from . import CO2SignalConfigEntry +from .coordinator import CO2SignalConfigEntry TO_REDACT = {CONF_API_KEY} diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 1b964edf591..92f88b8ae82 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -18,9 +18,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import CO2SignalConfigEntry from .const import ATTRIBUTION, DOMAIN -from .coordinator import CO2SignalCoordinator +from .coordinator import CO2SignalConfigEntry, CO2SignalCoordinator @dataclass(frozen=True, kw_only=True) From 32ef37cd5822e36c033e44ff20a944ef20107885 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 11:48:55 +0100 Subject: [PATCH 0053/1941] Explicitly pass in the config_entry in airq coordinator init (#137704) explicitly pass in the config_entry in airq coordinator init --- homeassistant/components/airq/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 362b65b5828..b48d8047910 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -22,6 +22,8 @@ _LOGGER = logging.getLogger(__name__) class AirQCoordinator(DataUpdateCoordinator): """Coordinator is responsible for querying the device at a specified route.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -33,6 +35,7 @@ class AirQCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) From 1522f7b3a870c2c3fe53257b48d4e5d2e9a9495b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:03:26 +0100 Subject: [PATCH 0054/1941] Explicitly pass in the config_entry in airzone_cloud coordinator init (#137703) explicitly pass in the config_entry in airzone_cloud coordinator init --- homeassistant/components/airzone_cloud/__init__.py | 7 ++----- .../components/airzone_cloud/binary_sensor.py | 3 +-- homeassistant/components/airzone_cloud/climate.py | 3 +-- .../components/airzone_cloud/coordinator.py | 13 ++++++++++++- .../components/airzone_cloud/diagnostics.py | 2 +- homeassistant/components/airzone_cloud/select.py | 3 +-- homeassistant/components/airzone_cloud/sensor.py | 3 +-- homeassistant/components/airzone_cloud/switch.py | 3 +-- .../components/airzone_cloud/water_heater.py | 3 +-- 9 files changed, 21 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index 5baa0bcea10..a5a29263140 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -5,12 +5,11 @@ from __future__ import annotations from aioairzone_cloud.cloudapi import AirzoneCloudApi from aioairzone_cloud.common import ConnectionOptions -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -21,8 +20,6 @@ PLATFORMS: list[Platform] = [ Platform.WATER_HEATER, ] -type AirzoneCloudConfigEntry = ConfigEntry[AirzoneUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, entry: AirzoneCloudConfigEntry @@ -42,7 +39,7 @@ async def async_setup_entry( airzone.select_installation(inst) await airzone.update_installation(inst) - coordinator = AirzoneUpdateCoordinator(hass, airzone) + coordinator = AirzoneUpdateCoordinator(hass, entry, airzone) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 3d6f6b42901..4a7b5441b68 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -28,8 +28,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import ( AirzoneAidooEntity, AirzoneEntity, diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index b98473072e4..69b10d2a69e 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -58,8 +58,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import ( AirzoneAidooEntity, AirzoneEntity, diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py index e510dcfb401..840bfec0d1b 100644 --- a/homeassistant/components/airzone_cloud/coordinator.py +++ b/homeassistant/components/airzone_cloud/coordinator.py @@ -10,6 +10,7 @@ from typing import Any from aioairzone_cloud.cloudapi import AirzoneCloudApi from aioairzone_cloud.exceptions import AirzoneCloudError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,11 +20,20 @@ SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) +type AirzoneCloudConfigEntry = ConfigEntry[AirzoneUpdateCoordinator] + class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Airzone Cloud device.""" - def __init__(self, hass: HomeAssistant, airzone: AirzoneCloudApi) -> None: + config_entry: AirzoneCloudConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AirzoneCloudConfigEntry, + airzone: AirzoneCloudApi, + ) -> None: """Initialize.""" self.airzone = airzone self.airzone.set_update_callback(self.async_set_updated_data) @@ -31,6 +41,7 @@ class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py index b6744e36d8c..04aac7e2aa8 100644 --- a/homeassistant/components/airzone_cloud/diagnostics.py +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -25,7 +25,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import AirzoneCloudConfigEntry +from .coordinator import AirzoneCloudConfigEntry TO_REDACT_API = [ API_CITY, diff --git a/homeassistant/components/airzone_cloud/select.py b/homeassistant/components/airzone_cloud/select.py index 895796a1073..e0c595a80e8 100644 --- a/homeassistant/components/airzone_cloud/select.py +++ b/homeassistant/components/airzone_cloud/select.py @@ -23,8 +23,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index 70d2fd079d4..4b13e09d126 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -49,8 +49,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import ( AirzoneAidooEntity, AirzoneEntity, diff --git a/homeassistant/components/airzone_cloud/switch.py b/homeassistant/components/airzone_cloud/switch.py index 0eb907ff792..8de0685e15e 100644 --- a/homeassistant/components/airzone_cloud/switch.py +++ b/homeassistant/components/airzone_cloud/switch.py @@ -15,8 +15,7 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity diff --git a/homeassistant/components/airzone_cloud/water_heater.py b/homeassistant/components/airzone_cloud/water_heater.py index 51228ae6b90..381dce913fe 100644 --- a/homeassistant/components/airzone_cloud/water_heater.py +++ b/homeassistant/components/airzone_cloud/water_heater.py @@ -31,8 +31,7 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirzoneCloudConfigEntry -from .coordinator import AirzoneUpdateCoordinator +from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneHotWaterEntity OPERATION_LIB_TO_HASS: Final[dict[HotWaterOperation, str]] = { From af87e36048a6654a1fd4fc8d77dfcc190be42283 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:12:07 +0100 Subject: [PATCH 0055/1941] Explicitly pass in the config_entry in fjaraskupan coordinator (#137825) explicitly pass in the config_entry in coordinator --- homeassistant/components/fjaraskupan/__init__.py | 2 +- .../components/fjaraskupan/coordinator.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index d95cb1d1006..2703fc5a30e 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -76,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator: FjaraskupanCoordinator = FjaraskupanCoordinator( - hass, device, device_info + hass, entry, device, device_info ) coordinator.detection_callback(service_info) diff --git a/homeassistant/components/fjaraskupan/coordinator.py b/homeassistant/components/fjaraskupan/coordinator.py index 90b2c617239..bfea5e5f4fc 100644 --- a/homeassistant/components/fjaraskupan/coordinator.py +++ b/homeassistant/components/fjaraskupan/coordinator.py @@ -21,6 +21,7 @@ from homeassistant.components.bluetooth import ( async_address_present, async_ble_device_from_address, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -64,8 +65,14 @@ class UnableToConnect(HomeAssistantError): class FjaraskupanCoordinator(DataUpdateCoordinator[State]): """Update coordinator for each device.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, device: Device, device_info: DeviceInfo + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + device: Device, + device_info: DeviceInfo, ) -> None: """Initialize the coordinator.""" self.device = device @@ -73,7 +80,11 @@ class FjaraskupanCoordinator(DataUpdateCoordinator[State]): self._refresh_was_scheduled = False super().__init__( - hass, _LOGGER, name="Fjäråskupan", update_interval=timedelta(seconds=120) + hass, + _LOGGER, + config_entry=config_entry, + name="Fjäråskupan", + update_interval=timedelta(seconds=120), ) async def _async_refresh( From c71116cc12cb931654b6973e8a20471f51819328 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:46:05 +0100 Subject: [PATCH 0056/1941] Explicitly pass in the config_entry in aosmith coordinator init (#137710) explicitly pass in the config_entry in aosmith coordinator init --- homeassistant/components/aosmith/__init__.py | 25 ++++-------- .../components/aosmith/coordinator.py | 39 +++++++++++++++++-- .../components/aosmith/diagnostics.py | 2 +- homeassistant/components/aosmith/sensor.py | 7 +++- .../components/aosmith/water_heater.py | 3 +- 5 files changed, 51 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py index dd60f69c4b9..7593365c573 100644 --- a/homeassistant/components/aosmith/__init__.py +++ b/homeassistant/components/aosmith/__init__.py @@ -2,31 +2,22 @@ from __future__ import annotations -from dataclasses import dataclass - from py_aosmith import AOSmithAPIClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN -from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator +from .coordinator import ( + AOSmithConfigEntry, + AOSmithData, + AOSmithEnergyCoordinator, + AOSmithStatusCoordinator, +) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER] -type AOSmithConfigEntry = ConfigEntry[AOSmithData] - - -@dataclass -class AOSmithData: - """Data for the A. O. Smith integration.""" - - client: AOSmithAPIClient - status_coordinator: AOSmithStatusCoordinator - energy_coordinator: AOSmithEnergyCoordinator - async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> bool: """Set up A. O. Smith from a config entry.""" @@ -36,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> b session = aiohttp_client.async_get_clientsession(hass) client = AOSmithAPIClient(email, password, session) - status_coordinator = AOSmithStatusCoordinator(hass, client) + status_coordinator = AOSmithStatusCoordinator(hass, entry, client) await status_coordinator.async_config_entry_first_refresh() device_registry = dr.async_get(hass) @@ -53,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> b ) energy_coordinator = AOSmithEnergyCoordinator( - hass, client, list(status_coordinator.data) + hass, entry, client, list(status_coordinator.data) ) await energy_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py index 3bf97e49cae..26029fee750 100644 --- a/homeassistant/components/aosmith/coordinator.py +++ b/homeassistant/components/aosmith/coordinator.py @@ -1,5 +1,6 @@ """The data update coordinator for the A. O. Smith integration.""" +from dataclasses import dataclass import logging from py_aosmith import ( @@ -9,6 +10,7 @@ from py_aosmith import ( ) from py_aosmith.models import Device as AOSmithDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,13 +19,37 @@ from .const import DOMAIN, ENERGY_USAGE_INTERVAL, FAST_INTERVAL, REGULAR_INTERVA _LOGGER = logging.getLogger(__name__) +type AOSmithConfigEntry = ConfigEntry[AOSmithData] + + +@dataclass +class AOSmithData: + """Data for the A. O. Smith integration.""" + + client: AOSmithAPIClient + status_coordinator: "AOSmithStatusCoordinator" + energy_coordinator: "AOSmithEnergyCoordinator" + class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, AOSmithDevice]]): """Coordinator for device status, updating with a frequent interval.""" - def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: + config_entry: AOSmithConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AOSmithConfigEntry, + client: AOSmithAPIClient, + ) -> None: """Initialize the coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=REGULAR_INTERVAL, + ) self.client = client async def _async_update_data(self) -> dict[str, AOSmithDevice]: @@ -51,15 +77,22 @@ class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, AOSmithDevice]]): class AOSmithEnergyCoordinator(DataUpdateCoordinator[dict[str, float]]): """Coordinator for energy usage data, updating with a slower interval.""" + config_entry: AOSmithConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: AOSmithConfigEntry, client: AOSmithAPIClient, junction_ids: list[str], ) -> None: """Initialize the coordinator.""" super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=ENERGY_USAGE_INTERVAL + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=ENERGY_USAGE_INTERVAL, ) self.client = client self.junction_ids = junction_ids diff --git a/homeassistant/components/aosmith/diagnostics.py b/homeassistant/components/aosmith/diagnostics.py index 94726731f75..4019bee4dc8 100644 --- a/homeassistant/components/aosmith/diagnostics.py +++ b/homeassistant/components/aosmith/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import AOSmithConfigEntry +from .coordinator import AOSmithConfigEntry TO_REDACT = { "address", diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py index b1c9852f647..8a7a98115fa 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -15,8 +15,11 @@ from homeassistant.const import PERCENTAGE, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AOSmithConfigEntry -from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator +from .coordinator import ( + AOSmithConfigEntry, + AOSmithEnergyCoordinator, + AOSmithStatusCoordinator, +) from .entity import AOSmithEnergyEntity, AOSmithStatusEntity diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index f3dc8b3413f..110f997065b 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -17,8 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AOSmithConfigEntry -from .coordinator import AOSmithStatusCoordinator +from .coordinator import AOSmithConfigEntry, AOSmithStatusCoordinator from .entity import AOSmithStatusEntity MODE_HA_TO_AOSMITH = { From 92234f86e8da5f55ac265b0aa14592f91a78d290 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:46:51 +0100 Subject: [PATCH 0057/1941] Explicitly pass in the config_entry in aseko_pool_live coordinator init (#137711) explicitly pass in the config_entry in aseko_pool_live coordinator init --- homeassistant/components/aseko_pool_live/__init__.py | 2 +- homeassistant/components/aseko_pool_live/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/aseko_pool_live/__init__.py b/homeassistant/components/aseko_pool_live/__init__.py index 52d74398818..012b5a19b0f 100644 --- a/homeassistant/components/aseko_pool_live/__init__.py +++ b/homeassistant/components/aseko_pool_live/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AsekoConfigEntry) -> boo except AsekoNotLoggedIn as err: raise ConfigEntryAuthFailed from err - coordinator = AsekoDataUpdateCoordinator(hass, aseko) + coordinator = AsekoDataUpdateCoordinator(hass, entry, aseko) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/aseko_pool_live/coordinator.py b/homeassistant/components/aseko_pool_live/coordinator.py index 96893912361..d54aa756ddd 100644 --- a/homeassistant/components/aseko_pool_live/coordinator.py +++ b/homeassistant/components/aseko_pool_live/coordinator.py @@ -21,13 +21,18 @@ type AsekoConfigEntry = ConfigEntry[AsekoDataUpdateCoordinator] class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Unit]]): """Class to manage fetching Aseko unit data from single endpoint.""" - def __init__(self, hass: HomeAssistant, aseko: Aseko) -> None: + config_entry: AsekoConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: AsekoConfigEntry, aseko: Aseko + ) -> None: """Initialize global Aseko unit data updater.""" self._aseko = aseko super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=2), ) From 4893cdaa80bb3a636b39877a362e6ef3b7ee946b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:47:36 +0100 Subject: [PATCH 0058/1941] Explicitly pass in the config_entry in aurora coordinator init (#137714) explicitly pass in the config_entry in aurora coordinator init --- homeassistant/components/aurora/__init__.py | 7 ++----- homeassistant/components/aurora/binary_sensor.py | 2 +- homeassistant/components/aurora/coordinator.py | 10 +++++----- homeassistant/components/aurora/sensor.py | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index b6c47cf36b2..a48d704141f 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -1,20 +1,17 @@ """The aurora component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import CONF_THRESHOLD, DEFAULT_THRESHOLD -from .coordinator import AuroraDataUpdateCoordinator +from .coordinator import AuroraConfigEntry, AuroraDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type AuroraConfigEntry = ConfigEntry[AuroraDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: AuroraConfigEntry) -> bool: """Set up Aurora from a config entry.""" - coordinator = AuroraDataUpdateCoordinator(hass=hass) + coordinator = AuroraDataUpdateCoordinator(hass=hass, config_entry=entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index b8fb5002ff5..648f6de08c9 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AuroraConfigEntry +from .coordinator import AuroraConfigEntry from .entity import AuroraEntity diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index 9771cc53652..a7b87baec22 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -4,11 +4,11 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import TYPE_CHECKING from aiohttp import ClientError from auroranoaa import AuroraForecast +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -16,23 +16,23 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_THRESHOLD, DEFAULT_THRESHOLD -if TYPE_CHECKING: - from . import AuroraConfigEntry - _LOGGER = logging.getLogger(__name__) +type AuroraConfigEntry = ConfigEntry[AuroraDataUpdateCoordinator] + class AuroraDataUpdateCoordinator(DataUpdateCoordinator[int]): """Class to manage fetching data from the NOAA Aurora API.""" config_entry: AuroraConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: AuroraConfigEntry) -> None: """Initialize the data updater.""" super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name="Aurora", update_interval=timedelta(minutes=5), ) diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index 35d39289598..ec1b82c3c4d 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -7,7 +7,7 @@ from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AuroraConfigEntry +from .coordinator import AuroraConfigEntry from .entity import AuroraEntity From a797b09bcb42a1b7a591fedc6dd7d96047aad232 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 12:47:57 +0100 Subject: [PATCH 0059/1941] Explicitly pass in the config_entry in gardena_bluetooth coordinator (#137830) explicitly pass in the config_entry in coordinator --- .../components/gardena_bluetooth/__init__.py | 11 ++++++----- .../components/gardena_bluetooth/binary_sensor.py | 2 +- homeassistant/components/gardena_bluetooth/button.py | 2 +- .../components/gardena_bluetooth/coordinator.py | 7 +++++++ homeassistant/components/gardena_bluetooth/number.py | 3 +-- homeassistant/components/gardena_bluetooth/sensor.py | 3 +-- homeassistant/components/gardena_bluetooth/switch.py | 3 +-- homeassistant/components/gardena_bluetooth/valve.py | 3 +-- 8 files changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 47034e61fb9..34f72bf0a5a 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -10,7 +10,6 @@ from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation from gardena_bluetooth.exceptions import CommunicationFailure from homeassistant.components import bluetooth -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -18,7 +17,11 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import DeviceUnavailable, GardenaBluetoothCoordinator +from .coordinator import ( + DeviceUnavailable, + GardenaBluetoothConfigEntry, + GardenaBluetoothCoordinator, +) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -32,8 +35,6 @@ LOGGER = logging.getLogger(__name__) TIMEOUT = 20.0 DISCONNECT_DELAY = 5 -type GardenaBluetoothConfigEntry = ConfigEntry[GardenaBluetoothCoordinator] - def get_connection(hass: HomeAssistant, address: str) -> CachedConnection: """Set up a cached client that keeps connection after last use.""" @@ -80,7 +81,7 @@ async def async_setup_entry( ) coordinator = GardenaBluetoothCoordinator( - hass, LOGGER, client, uuids, device, address + hass, entry, LOGGER, client, uuids, device, address ) entry.runtime_data = coordinator diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index d3ae096e291..4ee3dd511e9 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry +from .coordinator import GardenaBluetoothConfigEntry from .entity import GardenaBluetoothDescriptorEntity diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 9d87cba2446..8390baa5943 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -12,7 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry +from .coordinator import GardenaBluetoothConfigEntry from .entity import GardenaBluetoothDescriptorEntity diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py index 5caafe0e794..f85fb839657 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -12,6 +12,7 @@ from gardena_bluetooth.exceptions import ( ) from gardena_bluetooth.parse import Characteristic, CharacteristicType +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -20,6 +21,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda SCAN_INTERVAL = timedelta(seconds=60) LOGGER = logging.getLogger(__name__) +type GardenaBluetoothConfigEntry = ConfigEntry[GardenaBluetoothCoordinator] + class DeviceUnavailable(HomeAssistantError): """Raised if device can't be found.""" @@ -28,9 +31,12 @@ class DeviceUnavailable(HomeAssistantError): class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): """Class to manage fetching data.""" + config_entry: GardenaBluetoothConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: GardenaBluetoothConfigEntry, logger: logging.Logger, client: Client, characteristics: set[str], @@ -41,6 +47,7 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): super().__init__( hass=hass, logger=logger, + config_entry=config_entry, name="Gardena Bluetooth Data Update Coordinator", update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index b55630fa797..eb95d9ff814 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -21,8 +21,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry -from .coordinator import GardenaBluetoothCoordinator +from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index c07d2ba6866..29d1a3155de 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -19,8 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import GardenaBluetoothConfigEntry -from .coordinator import GardenaBluetoothCoordinator +from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py index f82c39025a5..73c4867d040 100644 --- a/homeassistant/components/gardena_bluetooth/switch.py +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -11,8 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry -from .coordinator import GardenaBluetoothCoordinator +from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothEntity diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index ae6bf56a7ff..e51e5aa22ca 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -10,8 +10,7 @@ from homeassistant.components.valve import ValveEntity, ValveEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GardenaBluetoothConfigEntry -from .coordinator import GardenaBluetoothCoordinator +from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothEntity FALLBACK_WATERING_TIME_IN_SECONDS = 60 * 60 From 36a0c49ceebdb3c8d8896dc1fd5bc038e8751717 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:15:13 +0100 Subject: [PATCH 0060/1941] Explicitly pass in the config_entry in gree coordinator (#137844) explicitly pass in the config_entry in coordinator --- homeassistant/components/gree/__init__.py | 2 +- homeassistant/components/gree/coordinator.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/gree/__init__.py b/homeassistant/components/gree/__init__.py index c385ce45262..7cb4f0f0921 100644 --- a/homeassistant/components/gree/__init__.py +++ b/homeassistant/components/gree/__init__.py @@ -26,7 +26,7 @@ PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Gree Climate from a config entry.""" hass.data.setdefault(DOMAIN, {}) - gree_discovery = DiscoveryService(hass) + gree_discovery = DiscoveryService(hass, entry) hass.data[DATA_DISCOVERY_SERVICE] = gree_discovery async def _async_scan_update(_=None): diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index 42d6734a6b2..0d1aa60deaa 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -11,6 +11,7 @@ from greeclimate.discovery import Discovery, Listener from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError from greeclimate.network import Response +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.json import json_dumps @@ -32,12 +33,16 @@ _LOGGER = logging.getLogger(__name__) class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Manages polling for state changes from the device.""" - def __init__(self, hass: HomeAssistant, device: Device) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: Device + ) -> None: """Initialize the data update coordinator.""" - DataUpdateCoordinator.__init__( - self, + super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN}-{device.device_info.name}", update_interval=timedelta(seconds=UPDATE_INTERVAL), always_update=False, @@ -117,10 +122,11 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): class DiscoveryService(Listener): """Discovery event handler for gree devices.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize discovery service.""" super().__init__() self.hass = hass + self.entry = entry self.discovery = Discovery(DISCOVERY_TIMEOUT) self.discovery.add_listener(self) @@ -144,7 +150,7 @@ class DiscoveryService(Listener): device.device_info.ip, device.device_info.port, ) - coordo = DeviceDataUpdateCoordinator(self.hass, device) + coordo = DeviceDataUpdateCoordinator(self.hass, self.entry, device) self.hass.data[DOMAIN][COORDINATORS].append(coordo) await coordo.async_refresh() From 86e44fc1cf22c20a579e623fd234a943a8205fbb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:15:24 +0100 Subject: [PATCH 0061/1941] Explicitly pass in the config_entry in govee_light_local coordinator (#137843) explicitly pass in the config_entry in coordinator --- homeassistant/components/govee_light_local/__init__.py | 2 +- homeassistant/components/govee_light_local/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index 44dbc825665..ee04dd81088 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -> bool: """Set up Govee light local from a config entry.""" - coordinator = GoveeLocalApiCoordinator(hass=hass) + coordinator = GoveeLocalApiCoordinator(hass, entry) async def await_cleanup(): cleanup_complete: asyncio.Event = coordinator.cleanup() diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py index 240313a34b8..ecbed0c4f65 100644 --- a/homeassistant/components/govee_light_local/coordinator.py +++ b/homeassistant/components/govee_light_local/coordinator.py @@ -26,11 +26,16 @@ type GoveeLocalConfigEntry = ConfigEntry[GoveeLocalApiCoordinator] class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]): """Govee light local coordinator.""" - def __init__(self, hass: HomeAssistant) -> None: + config_entry: GoveeLocalConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: GoveeLocalConfigEntry + ) -> None: """Initialize my coordinator.""" super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name="GoveeLightLocalApi", update_interval=SCAN_INTERVAL, ) From f8ac48fc7896c669d07e9ed6064ab336a37a594c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:15:37 +0100 Subject: [PATCH 0062/1941] Explicitly pass in the config_entry in flo coordinator (#137819) explicitly pass in the config_entry in coordinator --- homeassistant/components/flo/__init__.py | 4 +++- homeassistant/components/flo/coordinator.py | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flo/__init__.py b/homeassistant/components/flo/__init__.py index b619df91d59..6a497f5140d 100644 --- a/homeassistant/components/flo/__init__.py +++ b/homeassistant/components/flo/__init__.py @@ -37,7 +37,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Flo user information with locations: %s", user_info) hass.data[DOMAIN][entry.entry_id]["devices"] = devices = [ - FloDeviceDataUpdateCoordinator(hass, client, location["id"], device["id"]) + FloDeviceDataUpdateCoordinator( + hass, entry, client, location["id"], device["id"] + ) for location in user_info["locations"] for device in location["devices"] ] diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index d0dd38bd490..f5dc34a50cd 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -10,6 +10,7 @@ from aioflo.api import API from aioflo.errors import RequestError from orjson import JSONDecodeError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -20,10 +21,16 @@ from .const import DOMAIN as FLO_DOMAIN, LOGGER class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): """Flo device object.""" + config_entry: ConfigEntry _failure_count: int = 0 def __init__( - self, hass: HomeAssistant, api_client: API, location_id: str, device_id: str + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api_client: API, + location_id: str, + device_id: str, ) -> None: """Initialize the device.""" self.hass: HomeAssistant = hass @@ -36,6 +43,7 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, LOGGER, + config_entry=config_entry, name=f"{FLO_DOMAIN}-{device_id}", update_interval=timedelta(seconds=60), ) From 0b1afc68b028f1c9f198fc9ded6fc9b18a6f254b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:15:50 +0100 Subject: [PATCH 0063/1941] Explicitly pass in the config_entry in flume coordinator (#137822) explicitly pass in the config_entry in coordinator --- homeassistant/components/flume/__init__.py | 6 ++--- .../components/flume/binary_sensor.py | 2 +- homeassistant/components/flume/coordinator.py | 27 ++++++++++++++++--- homeassistant/components/flume/sensor.py | 2 +- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 24ed41b21c1..d229665ca62 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -7,7 +7,7 @@ from requests import Session from requests.exceptions import RequestException import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, @@ -41,7 +41,7 @@ LIST_NOTIFICATIONS_SERVICE_SCHEMA = vol.All( def _setup_entry( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: FlumeConfigEntry ) -> tuple[FlumeAuth, FlumeDeviceList, Session]: """Config entry set up in executor.""" config = entry.data @@ -79,7 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FlumeConfigEntry) -> boo _setup_entry, hass, entry ) notification_coordinator = FlumeNotificationDataUpdateCoordinator( - hass=hass, auth=flume_auth + hass=hass, config_entry=entry, auth=flume_auth ) entry.runtime_data = FlumeRuntimeData( diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index 67cb71c5767..cb0add90443 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -80,7 +80,7 @@ async def async_setup_entry( ] = [] connection_coordinator = FlumeDeviceConnectionUpdateCoordinator( - hass=hass, flume_devices=flume_devices + hass=hass, config_entry=config_entry, flume_devices=flume_devices ) notification_coordinator = flume_domain_data.notifications_coordinator flume_devices = get_valid_flume_devices(flume_devices) diff --git a/homeassistant/components/flume/coordinator.py b/homeassistant/components/flume/coordinator.py index fc76600cad4..1dabf5726b2 100644 --- a/homeassistant/components/flume/coordinator.py +++ b/homeassistant/components/flume/coordinator.py @@ -38,10 +38,18 @@ type FlumeConfigEntry = ConfigEntry[FlumeRuntimeData] class FlumeDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for an individual flume device.""" - def __init__(self, hass: HomeAssistant, flume_device: FlumeData) -> None: + config_entry: FlumeConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: FlumeConfigEntry, + flume_device: FlumeData, + ) -> None: """Initialize the Coordinator.""" super().__init__( hass, + config_entry=config_entry, name=DOMAIN, logger=_LOGGER, update_interval=DEVICE_SCAN_INTERVAL, @@ -65,10 +73,18 @@ class FlumeDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): class FlumeDeviceConnectionUpdateCoordinator(DataUpdateCoordinator[None]): """Date update coordinator to read connected status from Devices endpoint.""" - def __init__(self, hass: HomeAssistant, flume_devices: FlumeDeviceList) -> None: + config_entry: FlumeConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: FlumeConfigEntry, + flume_devices: FlumeDeviceList, + ) -> None: """Initialize the Coordinator.""" super().__init__( hass, + config_entry=config_entry, name=DOMAIN, logger=_LOGGER, update_interval=DEVICE_CONNECTION_SCAN_INTERVAL, @@ -96,10 +112,15 @@ class FlumeDeviceConnectionUpdateCoordinator(DataUpdateCoordinator[None]): class FlumeNotificationDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for flume notifications.""" - def __init__(self, hass: HomeAssistant, auth: FlumeAuth) -> None: + config_entry: FlumeConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: FlumeConfigEntry, auth: FlumeAuth + ) -> None: """Initialize the Coordinator.""" super().__init__( hass, + config_entry=config_entry, name=DOMAIN, logger=_LOGGER, update_interval=NOTIFICATION_SCAN_INTERVAL, diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 6c7cc0ab37d..aea0aa60093 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -132,7 +132,7 @@ async def async_setup_entry( flume_device = flume_datas[device_id] coordinator = FlumeDeviceDataUpdateCoordinator( - hass=hass, flume_device=flume_device + hass=hass, config_entry=config_entry, flume_device=flume_device ) flume_entity_list.extend( From 5e99b061260e8f3496ce9f97e0885f1b79806166 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:16:03 +0100 Subject: [PATCH 0064/1941] Explicitly pass in the config_entry in forecast_solar coordinator (#137824) explicitly pass in the config_entry in coordinator --- .../components/forecast_solar/__init__.py | 17 ++++++++++------- .../components/forecast_solar/coordinator.py | 15 +++++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 00be13f1235..171341f7226 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -12,14 +11,14 @@ from .const import ( CONF_DAMPING_MORNING, CONF_MODULES_POWER, ) -from .coordinator import ForecastSolarDataUpdateCoordinator +from .coordinator import ForecastSolarConfigEntry, ForecastSolarDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] - -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, entry: ForecastSolarConfigEntry +) -> bool: """Migrate old config entry.""" if entry.version == 1: @@ -53,11 +52,15 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: ForecastSolarConfigEntry +) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options( + hass: HomeAssistant, entry: ForecastSolarConfigEntry +) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/forecast_solar/coordinator.py b/homeassistant/components/forecast_solar/coordinator.py index c9c062a0c88..efed954e490 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -23,15 +23,16 @@ from .const import ( LOGGER, ) +type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] + class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): """The Forecast.Solar Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: ForecastSolarConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ForecastSolarConfigEntry) -> None: """Initialize the Forecast.Solar coordinator.""" - self.config_entry = entry # Our option flow may cause it to be an empty string, # this if statement is here to catch that. @@ -61,7 +62,13 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): if api_key is not None: update_interval = timedelta(minutes=30) - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self) -> Estimate: """Fetch Forecast.Solar estimates.""" From 2634f0aba0d459e4d542b3e8189c542952956a9c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:16:16 +0100 Subject: [PATCH 0065/1941] Explicitly pass in the config_entry in atag coordinator init (#137716) explicitly pass in the config_entry in atag coordinator init --- homeassistant/components/atag/coordinator.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/atag/coordinator.py b/homeassistant/components/atag/coordinator.py index 6d542471384..f590bc1dc6a 100644 --- a/homeassistant/components/atag/coordinator.py +++ b/homeassistant/components/atag/coordinator.py @@ -19,17 +19,22 @@ type AtagConfigEntry = ConfigEntry[AtagDataUpdateCoordinator] class AtagDataUpdateCoordinator(DataUpdateCoordinator[None]): """Atag data update coordinator.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: AtagConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: AtagConfigEntry) -> None: """Initialize Atag coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Atag", update_interval=timedelta(seconds=60), ) self.atag = AtagOne( - session=async_get_clientsession(hass), **entry.data, device=entry.unique_id + session=async_get_clientsession(hass), + **config_entry.data, + device=config_entry.unique_id, ) async def _async_update_data(self) -> None: From 39d6aaf294b1c9c936151d33686ccad2e5ed82bb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:16:32 +0100 Subject: [PATCH 0066/1941] Explicitly pass in the config_entry in gogogate2 coordinator (#137837) explicitly pass in the config_entry in coordinator --- homeassistant/components/gogogate2/common.py | 1 + homeassistant/components/gogogate2/coordinator.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 52b1788c23e..8506414ca33 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -62,6 +62,7 @@ def get_data_update_coordinator( config_entry_data[DATA_UPDATE_COORDINATOR] = DeviceDataUpdateCoordinator( hass, + config_entry, _LOGGER, api, # Name of the data. For logging purposes. diff --git a/homeassistant/components/gogogate2/coordinator.py b/homeassistant/components/gogogate2/coordinator.py index 7c15e8b1c32..c2e7cc47b46 100644 --- a/homeassistant/components/gogogate2/coordinator.py +++ b/homeassistant/components/gogogate2/coordinator.py @@ -8,6 +8,7 @@ import logging from ismartgate import AbstractGateApi, GogoGate2InfoResponse, ISmartGateInfoResponse +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -18,9 +19,12 @@ class DeviceDataUpdateCoordinator( ): """Manages polling for state changes from the device.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, logger: logging.Logger, api: AbstractGateApi, *, @@ -33,10 +37,10 @@ class DeviceDataUpdateCoordinator( request_refresh_debouncer: Debouncer | None = None, ) -> None: """Initialize the data update coordinator.""" - DataUpdateCoordinator.__init__( - self, + super().__init__( hass, logger, + config_entry=config_entry, name=name, update_interval=update_interval, update_method=update_method, From 9bdd8d04c5fe40338bbc78ec14ff9b4508f8d7de Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:16:49 +0100 Subject: [PATCH 0067/1941] Explicitly pass in the config_entry in goalzero coordinator (#137836) explicitly pass in the config_entry in coordinator --- homeassistant/components/goalzero/__init__.py | 2 +- homeassistant/components/goalzero/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 6698d1efc99..4a34927a585 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoalZeroConfigEntry) -> except exceptions.ConnectError as ex: raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex - entry.runtime_data = GoalZeroDataUpdateCoordinator(hass, api) + entry.runtime_data = GoalZeroDataUpdateCoordinator(hass, entry, api) await entry.runtime_data.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/goalzero/coordinator.py b/homeassistant/components/goalzero/coordinator.py index 3c7cd967482..97a7f537759 100644 --- a/homeassistant/components/goalzero/coordinator.py +++ b/homeassistant/components/goalzero/coordinator.py @@ -18,11 +18,14 @@ class GoalZeroDataUpdateCoordinator(DataUpdateCoordinator[None]): config_entry: GoalZeroConfigEntry - def __init__(self, hass: HomeAssistant, api: Yeti) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: GoalZeroConfigEntry, api: Yeti + ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) From 0efdceef277191cd9fd560eff75503f7fbe843fc Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:17:06 +0100 Subject: [PATCH 0068/1941] Explicitly pass in the config_entry in glances coordinator (#137835) explicitly pass in the config_entry in coordinator --- homeassistant/components/glances/__init__.py | 5 +---- homeassistant/components/glances/coordinator.py | 10 +++++++--- homeassistant/components/glances/sensor.py | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index 9d09e63606e..d7b645d9e11 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -10,7 +10,6 @@ from glances_api.exceptions import ( GlancesApiNoDataAvailable, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -29,15 +28,13 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers.httpx_client import get_async_client -from .coordinator import GlancesDataUpdateCoordinator +from .coordinator import GlancesConfigEntry, GlancesDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -type GlancesConfigEntry = ConfigEntry[GlancesDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, config_entry: GlancesConfigEntry diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 8882b097ba9..28cf40aae6e 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -17,21 +17,25 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) +type GlancesConfigEntry = ConfigEntry[GlancesDataUpdateCoordinator] + class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get the latest data from Glances api.""" - config_entry: ConfigEntry + config_entry: GlancesConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: Glances) -> None: + def __init__( + self, hass: HomeAssistant, entry: GlancesConfigEntry, api: Glances + ) -> None: """Initialize the Glances data.""" self.hass = hass - self.config_entry = entry self.host: str = entry.data[CONF_HOST] self.api = api super().__init__( hass, _LOGGER, + config_entry=entry, name=f"{DOMAIN} - {self.host}", update_interval=DEFAULT_SCAN_INTERVAL, ) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 0741926296e..61d88b744bf 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -22,8 +22,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GlancesConfigEntry, GlancesDataUpdateCoordinator from .const import CPU_ICON, DOMAIN +from .coordinator import GlancesConfigEntry, GlancesDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) From 13f6f045f58105c93a474f2dca6ba9077b9dcf56 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:17:25 +0100 Subject: [PATCH 0069/1941] Explicitly pass in the config_entry in github coordinator (#137834) explicitly pass in the config_entry in coordinator --- homeassistant/components/github/__init__.py | 11 ++++------- homeassistant/components/github/coordinator.py | 7 +++++++ homeassistant/components/github/diagnostics.py | 5 +++-- homeassistant/components/github/sensor.py | 3 +-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index 74575e38e09..dea2acf4f1b 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from aiogithubapi import GitHubAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -14,14 +13,11 @@ from homeassistant.helpers.aiohttp_client import ( ) from .const import CONF_REPOSITORIES, DOMAIN, LOGGER -from .coordinator import GitHubDataUpdateCoordinator +from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]] - - async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool: """Set up GitHub from a config entry.""" client = GitHubAPI( @@ -36,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo for repository in repositories: coordinator = GitHubDataUpdateCoordinator( hass=hass, + config_entry=entry, client=client, repository=repository, ) @@ -57,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo @callback def async_cleanup_device_registry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GithubConfigEntry, ) -> None: """Remove entries form device registry if we no longer track the repository.""" device_registry = dr.async_get(hass) @@ -92,6 +89,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> b return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/github/coordinator.py b/homeassistant/components/github/coordinator.py index e73e02932e9..adeda1fd88a 100644 --- a/homeassistant/components/github/coordinator.py +++ b/homeassistant/components/github/coordinator.py @@ -13,6 +13,7 @@ from aiogithubapi import ( GitHubResponseModel, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -98,13 +99,18 @@ query ($owner: String!, $repository: String!) { } """ +type GithubConfigEntry = ConfigEntry[dict[str, GitHubDataUpdateCoordinator]] + class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Data update coordinator for the GitHub integration.""" + config_entry: GithubConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: GithubConfigEntry, client: GitHubAPI, repository: str, ) -> None: @@ -118,6 +124,7 @@ class GitHubDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, LOGGER, + config_entry=config_entry, name=repository, update_interval=FALLBACK_UPDATE_INTERVAL, ) diff --git a/homeassistant/components/github/diagnostics.py b/homeassistant/components/github/diagnostics.py index 8d2d496a813..41fef9406a4 100644 --- a/homeassistant/components/github/diagnostics.py +++ b/homeassistant/components/github/diagnostics.py @@ -6,7 +6,6 @@ from typing import Any from aiogithubapi import GitHubAPI, GitHubException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import ( @@ -14,10 +13,12 @@ from homeassistant.helpers.aiohttp_client import ( async_get_clientsession, ) +from .coordinator import GithubConfigEntry + async def async_get_config_entry_diagnostics( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: GithubConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" data = {"options": {**config_entry.options}} diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 614ebe254c4..a7ecb4ec8da 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -18,9 +18,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GithubConfigEntry from .const import DOMAIN -from .coordinator import GitHubDataUpdateCoordinator +from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) From 239408aa5ddb8206549aabd5e3c7feab0499ae0e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:17:42 +0100 Subject: [PATCH 0070/1941] Explicitly pass in the config_entry in garages_amsterdam coordinator (#137829) explicitly pass in the config_entry in coordinator --- homeassistant/components/garages_amsterdam/__init__.py | 10 +++++----- .../components/garages_amsterdam/binary_sensor.py | 6 ++++-- .../components/garages_amsterdam/coordinator.py | 7 +++++++ homeassistant/components/garages_amsterdam/sensor.py | 6 ++++-- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index 99d751cfcc8..854e41f2d89 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -4,24 +4,24 @@ from __future__ import annotations from odp_amsterdam import ODPAmsterdam -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import GaragesAmsterdamDataUpdateCoordinator +from .coordinator import ( + GaragesAmsterdamConfigEntry, + GaragesAmsterdamDataUpdateCoordinator, +) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -type GaragesAmsterdamConfigEntry = ConfigEntry[GaragesAmsterdamDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, entry: GaragesAmsterdamConfigEntry ) -> bool: """Set up Garages Amsterdam from a config entry.""" client = ODPAmsterdam(session=async_get_clientsession(hass)) - coordinator = GaragesAmsterdamDataUpdateCoordinator(hass, client) + coordinator = GaragesAmsterdamDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py index b93b43e1173..cf4b29f0af8 100644 --- a/homeassistant/components/garages_amsterdam/binary_sensor.py +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -15,8 +15,10 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GaragesAmsterdamConfigEntry -from .coordinator import GaragesAmsterdamDataUpdateCoordinator +from .coordinator import ( + GaragesAmsterdamConfigEntry, + GaragesAmsterdamDataUpdateCoordinator, +) from .entity import GaragesAmsterdamEntity diff --git a/homeassistant/components/garages_amsterdam/coordinator.py b/homeassistant/components/garages_amsterdam/coordinator.py index 3d06aba79e2..74f2361980d 100644 --- a/homeassistant/components/garages_amsterdam/coordinator.py +++ b/homeassistant/components/garages_amsterdam/coordinator.py @@ -4,24 +4,31 @@ from __future__ import annotations from odp_amsterdam import Garage, ODPAmsterdam, VehicleType +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type GaragesAmsterdamConfigEntry = ConfigEntry[GaragesAmsterdamDataUpdateCoordinator] + class GaragesAmsterdamDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Garage]]): """Class to manage fetching Garages Amsterdam data from single endpoint.""" + config_entry: GaragesAmsterdamConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: GaragesAmsterdamConfigEntry, client: ODPAmsterdam, ) -> None: """Initialize global Garages Amsterdam data updater.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index b562fff841a..8c16260c58b 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -16,8 +16,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import GaragesAmsterdamConfigEntry -from .coordinator import GaragesAmsterdamDataUpdateCoordinator +from .coordinator import ( + GaragesAmsterdamConfigEntry, + GaragesAmsterdamDataUpdateCoordinator, +) from .entity import GaragesAmsterdamEntity From 2f0e6615690798d4f126ad88967d38df65e6c11e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:19:06 +0100 Subject: [PATCH 0071/1941] Explicitly pass in the config_entry in apsystems coordinator init (#137708) explicitly pass in the config_entry in apsystems coordinator init --- .../components/apsystems/__init__.py | 20 +++--------------- .../components/apsystems/binary_sensor.py | 3 +-- .../components/apsystems/coordinator.py | 21 ++++++++++++++++++- homeassistant/components/apsystems/entity.py | 2 +- homeassistant/components/apsystems/number.py | 2 +- homeassistant/components/apsystems/sensor.py | 3 +-- homeassistant/components/apsystems/switch.py | 2 +- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py index c437f5584db..cdc4563b92d 100644 --- a/homeassistant/components/apsystems/__init__.py +++ b/homeassistant/components/apsystems/__init__.py @@ -2,16 +2,13 @@ from __future__ import annotations -from dataclasses import dataclass - from APsystemsEZ1 import APsystemsEZ1M -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant from .const import DEFAULT_PORT -from .coordinator import ApSystemsDataCoordinator +from .coordinator import ApSystemsConfigEntry, ApSystemsData, ApSystemsDataCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -21,17 +18,6 @@ PLATFORMS: list[Platform] = [ ] -@dataclass -class ApSystemsData: - """Store runtime data.""" - - coordinator: ApSystemsDataCoordinator - device_id: str - - -type ApSystemsConfigEntry = ConfigEntry[ApSystemsData] - - async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> bool: """Set up this integration using UI.""" api = APsystemsEZ1M( @@ -40,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> timeout=8, enable_debounce=True, ) - coordinator = ApSystemsDataCoordinator(hass, api) + coordinator = ApSystemsDataCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() assert entry.unique_id entry.runtime_data = ApSystemsData( @@ -51,6 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ApSystemsConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/apsystems/binary_sensor.py b/homeassistant/components/apsystems/binary_sensor.py index 9e361ca883e..863a50ca455 100644 --- a/homeassistant/components/apsystems/binary_sensor.py +++ b/homeassistant/components/apsystems/binary_sensor.py @@ -17,8 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ApSystemsConfigEntry, ApSystemsData -from .coordinator import ApSystemsDataCoordinator +from .coordinator import ApSystemsConfigEntry, ApSystemsData, ApSystemsDataCoordinator from .entity import ApSystemsEntity diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index 2535c66c4ac..ca423055176 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -12,6 +12,7 @@ from APsystemsEZ1 import ( ReturnOutputData, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,16 +27,34 @@ class ApSystemsSensorData: alarm_info: ReturnAlarmInfo +@dataclass +class ApSystemsData: + """Store runtime data.""" + + coordinator: ApSystemsDataCoordinator + device_id: str + + +type ApSystemsConfigEntry = ConfigEntry[ApSystemsData] + + class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): """Coordinator used for all sensors.""" + config_entry: ApSystemsConfigEntry device_version: str - def __init__(self, hass: HomeAssistant, api: APsystemsEZ1M) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ApSystemsConfigEntry, + api: APsystemsEZ1M, + ) -> None: """Initialize my coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="APSystems Data", update_interval=timedelta(seconds=12), ) diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py index 7770b451680..9ba7d046b60 100644 --- a/homeassistant/components/apsystems/entity.py +++ b/homeassistant/components/apsystems/entity.py @@ -5,8 +5,8 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from . import ApSystemsData from .const import DOMAIN +from .coordinator import ApSystemsData class ApSystemsEntity(Entity): diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py index b5ed60a7754..f7bdc7c2711 100644 --- a/homeassistant/components/apsystems/number.py +++ b/homeassistant/components/apsystems/number.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType -from . import ApSystemsConfigEntry, ApSystemsData +from .coordinator import ApSystemsConfigEntry, ApSystemsData from .entity import ApSystemsEntity diff --git a/homeassistant/components/apsystems/sensor.py b/homeassistant/components/apsystems/sensor.py index f87bc0f3f26..673dba05acc 100644 --- a/homeassistant/components/apsystems/sensor.py +++ b/homeassistant/components/apsystems/sensor.py @@ -19,8 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ApSystemsConfigEntry, ApSystemsData -from .coordinator import ApSystemsDataCoordinator +from .coordinator import ApSystemsConfigEntry, ApSystemsData, ApSystemsDataCoordinator from .entity import ApSystemsEntity diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index 73914845445..2d3b0cfd08f 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ApSystemsConfigEntry, ApSystemsData +from .coordinator import ApSystemsConfigEntry, ApSystemsData from .entity import ApSystemsEntity From 0a842d171b47d46024ea21962b3b83b8336a92be Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:20:30 +0100 Subject: [PATCH 0072/1941] Limit habitica ConfigEntrySelect to integration domain (#137767) --- homeassistant/components/habitica/services.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index ed4a6444ea2..2537655dbfb 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -77,7 +77,7 @@ SERVICE_API_CALL_SCHEMA = vol.Schema( SERVICE_CAST_SKILL_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_SKILL): cv.string, vol.Optional(ATTR_TASK): cv.string, } @@ -85,12 +85,12 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema( SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), } ) SERVICE_SCORE_TASK_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_DIRECTION): cv.string, } @@ -98,7 +98,7 @@ SERVICE_SCORE_TASK_SCHEMA = vol.Schema( SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_ITEM): cv.string, vol.Required(ATTR_TARGET): cv.string, } @@ -106,7 +106,7 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( SERVICE_GET_TASKS_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Optional(ATTR_TYPE): vol.All( cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskType}))] ), From 2d72b814d6f89ab316fd89b162c00a7f76518406 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:21:13 +0100 Subject: [PATCH 0073/1941] Explicitly pass in the config_entry in cert_expiry coordinator init (#137728) explicitly pass in the config_entry in cert_expiry coordinator init --- homeassistant/components/cert_expiry/__init__.py | 9 +++------ .../components/cert_expiry/coordinator.py | 14 +++++++++++++- homeassistant/components/cert_expiry/sensor.py | 3 +-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index bc6ae29ee8e..adf1e0e981c 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -2,24 +2,21 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from .coordinator import CertExpiryDataUpdateCoordinator +from .coordinator import CertExpiryConfigEntry, CertExpiryDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type CertExpiryConfigEntry = ConfigEntry[CertExpiryDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: CertExpiryConfigEntry) -> bool: """Load the saved entities.""" host: str = entry.data[CONF_HOST] port: int = entry.data[CONF_PORT] - coordinator = CertExpiryDataUpdateCoordinator(hass, host, port) + coordinator = CertExpiryDataUpdateCoordinator(hass, entry, host, port) entry.runtime_data = coordinator @@ -34,6 +31,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CertExpiryConfigEntry) - return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: CertExpiryConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/cert_expiry/coordinator.py b/homeassistant/components/cert_expiry/coordinator.py index 80c91f1d890..644e3ee3d00 100644 --- a/homeassistant/components/cert_expiry/coordinator.py +++ b/homeassistant/components/cert_expiry/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,11 +15,21 @@ from .helper import get_cert_expiry_timestamp _LOGGER = logging.getLogger(__name__) +type CertExpiryConfigEntry = ConfigEntry[CertExpiryDataUpdateCoordinator] + class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): """Class to manage fetching Cert Expiry data from single endpoint.""" - def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + config_entry: CertExpiryConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: CertExpiryConfigEntry, + host: str, + port: int, + ) -> None: """Initialize global Cert Expiry data updater.""" self.host = host self.port = port @@ -31,6 +42,7 @@ class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=name, update_interval=timedelta(hours=12), always_update=False, diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 4fd0846f0f3..a875e664fdd 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -9,9 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import CertExpiryConfigEntry from .const import DOMAIN -from .coordinator import CertExpiryDataUpdateCoordinator +from .coordinator import CertExpiryConfigEntry, CertExpiryDataUpdateCoordinator from .entity import CertExpiryEntity From 88a2b2ab90c72675d2065e5dc5a3c11ca4c3e658 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:22:00 +0100 Subject: [PATCH 0074/1941] Explicitly pass in the config_entry in deluge coordinator (#137733) explicitly pass in the config_entry in coordinator --- homeassistant/components/deluge/__init__.py | 4 +--- homeassistant/components/deluge/coordinator.py | 8 ++++---- homeassistant/components/deluge/sensor.py | 3 +-- homeassistant/components/deluge/switch.py | 3 +-- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index 9b07ae9c875..f9972570df3 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -7,7 +7,6 @@ from ssl import SSLError from deluge_client.client import DelugeRPCClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -19,12 +18,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_WEB_PORT -from .coordinator import DelugeDataUpdateCoordinator +from .coordinator import DelugeConfigEntry, DelugeDataUpdateCoordinator PLATFORMS = [Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> bool: diff --git a/homeassistant/components/deluge/coordinator.py b/homeassistant/components/deluge/coordinator.py index 7f4bf9e884e..c5836243b9d 100644 --- a/homeassistant/components/deluge/coordinator.py +++ b/homeassistant/components/deluge/coordinator.py @@ -4,10 +4,11 @@ from __future__ import annotations from datetime import timedelta from ssl import SSLError -from typing import TYPE_CHECKING, Any +from typing import Any from deluge_client.client import DelugeRPCClient, FailedToReconnectException +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -15,8 +16,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER, DelugeGetSessionStatusKeys -if TYPE_CHECKING: - from . import DelugeConfigEntry +type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator] class DelugeDataUpdateCoordinator( @@ -33,11 +33,11 @@ class DelugeDataUpdateCoordinator( super().__init__( hass=hass, logger=LOGGER, + config_entry=entry, name=entry.title, update_interval=timedelta(seconds=30), ) self.api = api - self.config_entry = entry async def _async_update_data(self) -> dict[Platform, dict[str, Any]]: """Get the latest data from Deluge and updates the state.""" diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 5ebf3d01eeb..24d5ce9ec61 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -17,9 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import DelugeConfigEntry from .const import DelugeGetSessionStatusKeys, DelugeSensorType -from .coordinator import DelugeDataUpdateCoordinator +from .coordinator import DelugeConfigEntry, DelugeDataUpdateCoordinator from .entity import DelugeEntity diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index d81f02eee29..1ec0cd7a7df 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -9,8 +9,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DelugeConfigEntry -from .coordinator import DelugeDataUpdateCoordinator +from .coordinator import DelugeConfigEntry, DelugeDataUpdateCoordinator from .entity import DelugeEntity From 1c7bf9f589006478d66691c944820315b7820ffb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:22:37 +0100 Subject: [PATCH 0075/1941] Explicitly pass in the config_entry in enigma2 coordinator (#137739) explicitly pass in the config_entry in coordinator --- homeassistant/components/enigma2/__init__.py | 7 ++----- homeassistant/components/enigma2/coordinator.py | 11 +++++++++-- homeassistant/components/enigma2/media_player.py | 3 +-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py index da78f3dac5c..16295c7f228 100644 --- a/homeassistant/components/enigma2/__init__.py +++ b/homeassistant/components/enigma2/__init__.py @@ -1,12 +1,9 @@ """Support for Enigma2 devices.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import Enigma2UpdateCoordinator - -type Enigma2ConfigEntry = ConfigEntry[Enigma2UpdateCoordinator] +from .coordinator import Enigma2ConfigEntry, Enigma2UpdateCoordinator PLATFORMS = [Platform.MEDIA_PLAYER] @@ -22,6 +19,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: Enigma2ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Enigma2ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/enigma2/coordinator.py b/homeassistant/components/enigma2/coordinator.py index d5bbf2c0ce5..9710d7f547f 100644 --- a/homeassistant/components/enigma2/coordinator.py +++ b/homeassistant/components/enigma2/coordinator.py @@ -30,18 +30,25 @@ from .const import CONF_SOURCE_BOUQUET, DOMAIN LOGGER = logging.getLogger(__package__) +type Enigma2ConfigEntry = ConfigEntry[Enigma2UpdateCoordinator] + class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]): """The Enigma2 data update coordinator.""" + config_entry: Enigma2ConfigEntry device: OpenWebIfDevice unique_id: str | None - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: Enigma2ConfigEntry) -> None: """Initialize the Enigma2 data update coordinator.""" super().__init__( - hass, logger=LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + logger=LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) base_url = URL.build( diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 1012997ff7f..9a2a4564d1c 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -18,8 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import Enigma2ConfigEntry -from .coordinator import Enigma2UpdateCoordinator +from .coordinator import Enigma2ConfigEntry, Enigma2UpdateCoordinator ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_DESCRIPTION = "media_description" From fae2c94c7407e245e4cdcff75c26b437da9547c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sat, 8 Feb 2025 13:42:43 +0100 Subject: [PATCH 0076/1941] Add snapshot tests for setup of LetPot platforms (#137756) --- tests/components/letpot/__init__.py | 19 +- tests/components/letpot/conftest.py | 18 +- .../letpot/snapshots/test_switch.ambr | 185 ++++++++++++++++++ .../letpot/snapshots/test_time.ambr | 93 +++++++++ tests/components/letpot/test_switch.py | 22 ++- tests/components/letpot/test_time.py | 22 ++- 6 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 tests/components/letpot/snapshots/test_switch.ambr create mode 100644 tests/components/letpot/snapshots/test_time.ambr diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index ac552f907d4..d4570ce44be 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -2,7 +2,12 @@ import datetime -from letpot.models import AuthenticationInfo, LetPotDeviceErrors, LetPotDeviceStatus +from letpot.models import ( + AuthenticationInfo, + LetPotDeviceErrors, + LetPotDeviceStatus, + TemperatureUnit, +) from homeassistant.core import HomeAssistant @@ -26,17 +31,21 @@ AUTHENTICATION = AuthenticationInfo( ) STATUS = LetPotDeviceStatus( - errors=LetPotDeviceErrors(low_water=False), + errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), light_brightness=500, light_mode=1, - light_schedule_end=datetime.time(12, 10), - light_schedule_start=datetime.time(12, 0), + light_schedule_end=datetime.time(18, 0), + light_schedule_start=datetime.time(8, 0), online=True, plant_days=1, pump_mode=1, pump_nutrient=None, pump_status=0, - raw=[77, 0, 1, 18, 98, 1, 0, 0, 1, 1, 1, 0, 1, 12, 0, 12, 10, 1, 244, 0, 0, 0], + raw=[], # Not used by integration, and it requires a real device to get system_on=True, system_sound=False, + temperature_unit=TemperatureUnit.CELSIUS, + temperature_value=18, + water_mode=1, + water_level=100, ) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 3e948ad0ac2..454d4e235db 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from letpot.models import LetPotDevice +from letpot.models import DeviceFeature, LetPotDevice import pytest from homeassistant.components.letpot.const import ( @@ -47,9 +47,9 @@ def mock_client() -> Generator[AsyncMock]: client.refresh_token.return_value = AUTHENTICATION client.get_devices.return_value = [ LetPotDevice( - serial_number="LPH21ABCD", + serial_number="LPH63ABCD", name="Garden", - device_type="LPH21", + device_type="LPH63", is_online=True, is_remote=False, ) @@ -65,8 +65,16 @@ def mock_device_client() -> Generator[AsyncMock]: autospec=True, ) as mock_device_client: device_client = mock_device_client.return_value - device_client.device_model_code = "LPH21" - device_client.device_model_name = "LetPot Air" + device_client.device_features = ( + DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + | DeviceFeature.NUTRIENT_BUTTON + | DeviceFeature.PUMP_AUTO + | DeviceFeature.PUMP_STATUS + | DeviceFeature.TEMPERATURE + | DeviceFeature.WATER_LEVEL + ) + device_client.device_model_code = "LPH63" + device_client.device_model_name = "LetPot Max" subscribe_callbacks: list[Callable] = [] diff --git a/tests/components/letpot/snapshots/test_switch.ambr b/tests/components/letpot/snapshots/test_switch.ambr new file mode 100644 index 00000000000..28ca9603760 --- /dev/null +++ b/tests/components/letpot/snapshots/test_switch.ambr @@ -0,0 +1,185 @@ +# serializer version: 1 +# name: test_all_entities[switch.garden_alarm_sound-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_alarm_sound', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Alarm sound', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_sound', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_alarm_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_alarm_sound-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Alarm sound', + }), + 'context': , + 'entity_id': 'switch.garden_alarm_sound', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.garden_auto_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_auto_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Auto mode', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_mode', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_auto_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_auto_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Auto mode', + }), + 'context': , + 'entity_id': 'switch.garden_auto_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.garden_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_power', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Power', + }), + 'context': , + 'entity_id': 'switch.garden_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[switch.garden_pump_cycling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.garden_pump_cycling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pump cycling', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump_cycling', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump_cycling', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.garden_pump_cycling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Pump cycling', + }), + 'context': , + 'entity_id': 'switch.garden_pump_cycling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/letpot/snapshots/test_time.ambr b/tests/components/letpot/snapshots/test_time.ambr new file mode 100644 index 00000000000..66f6648c202 --- /dev/null +++ b/tests/components/letpot/snapshots/test_time.ambr @@ -0,0 +1,93 @@ +# serializer version: 1 +# name: test_all_entities[time.garden_light_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.garden_light_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light off', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_schedule_end', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_schedule_end', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.garden_light_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light off', + }), + 'context': , + 'entity_id': 'time.garden_light_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18:00:00', + }) +# --- +# name: test_all_entities[time.garden_light_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.garden_light_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light on', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_schedule_start', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_light_schedule_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[time.garden_light_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Light on', + }), + 'context': , + 'entity_id': 'time.garden_light_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index d51721c3348..b166d551adb 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -1,17 +1,35 @@ """Test switch entities for the LetPot integration.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from letpot.exceptions import LetPotConnectionException, LetPotException import pytest +from syrupy import SnapshotAssertion from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test switch entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index 44a03e565c0..82e69979067 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -1,18 +1,36 @@ """Test time entities for the LetPot integration.""" from datetime import time -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from letpot.exceptions import LetPotConnectionException, LetPotException import pytest +from syrupy import SnapshotAssertion from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test time entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.TIME]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( From 9e8f2e81bdd90d399394b3258f65c42adf46eccc Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:43:28 +0100 Subject: [PATCH 0077/1941] Explicitly pass in the config_entry in enphase flexit_bacnet coordinator (#137814) explicitly pass in the config_entry in coordinator --- homeassistant/components/flexit_bacnet/__init__.py | 6 ++---- homeassistant/components/flexit_bacnet/coordinator.py | 5 +++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index 6b42310d181..b0ebc5a40fd 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -21,9 +21,7 @@ PLATFORMS: list[Platform] = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Flexit Nordic (BACnet) from a config entry.""" - device_id = entry.data[CONF_DEVICE_ID] - - coordinator = FlexitCoordinator(hass, device_id) + coordinator = FlexitCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py index 79f3b6a05ad..f723117c9ef 100644 --- a/homeassistant/components/flexit_bacnet/coordinator.py +++ b/homeassistant/components/flexit_bacnet/coordinator.py @@ -23,12 +23,13 @@ class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, device_id: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, - name=f"{DOMAIN}_{device_id}", + config_entry=config_entry, + name=f"{DOMAIN}_{config_entry.data[CONF_DEVICE_ID]}", update_interval=timedelta(seconds=60), ) From ad9d43bc50d74dbe0eade315f9fb8699190c6c01 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:46:59 +0100 Subject: [PATCH 0078/1941] Explicitly pass in the config_entry in duke_energy coordinator (#137741) explicitly pass in the config_entry in coordinator --- homeassistant/components/duke_energy/__init__.py | 2 +- homeassistant/components/duke_energy/coordinator.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/duke_energy/__init__.py b/homeassistant/components/duke_energy/__init__.py index 6eacc15880f..bfa89d81c69 100644 --- a/homeassistant/components/duke_energy/__init__.py +++ b/homeassistant/components/duke_energy/__init__.py @@ -10,7 +10,7 @@ from .coordinator import DukeEnergyConfigEntry, DukeEnergyCoordinator async def async_setup_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool: """Set up Duke Energy from a config entry.""" - coordinator = DukeEnergyCoordinator(hass, entry.data) + coordinator = DukeEnergyCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py index 2b0ae46b405..12a2f5fd6ae 100644 --- a/homeassistant/components/duke_energy/coordinator.py +++ b/homeassistant/components/duke_energy/coordinator.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta import logging -from types import MappingProxyType from typing import Any, cast from aiodukeenergy import DukeEnergy @@ -37,22 +36,21 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]): config_entry: DukeEnergyConfigEntry def __init__( - self, - hass: HomeAssistant, - entry_data: MappingProxyType[str, Any], + self, hass: HomeAssistant, config_entry: DukeEnergyConfigEntry ) -> None: """Initialize the data handler.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Duke Energy", # Data is updated daily on Duke Energy. # Refresh every 12h to be at most 12h behind. update_interval=timedelta(hours=12), ) self.api = DukeEnergy( - entry_data[CONF_USERNAME], - entry_data[CONF_PASSWORD], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], async_get_clientsession(hass), ) self._statistic_ids: set = set() From 074d384d27f862d1b41a338d3f5163aecc2a79b2 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:51:00 +0100 Subject: [PATCH 0079/1941] Bump PyViCare to 2.42.0 (#137804) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 766cf22cb94..489d4accb8a 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.41.0"] + "requirements": ["PyViCare==2.42.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe7ea7fc5ce..ddc55a51ef1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.41.0 +PyViCare==2.42.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a686bbd0633..9df8712d286 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.41.0 +PyViCare==2.42.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From ce66b476533ad9ac9c4f02f24a3bb517a3df9828 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 8 Feb 2025 04:52:28 -0800 Subject: [PATCH 0080/1941] Update fitbit quality scale for runtime-data (#137785) --- homeassistant/components/fitbit/quality_scale.yaml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/fitbit/quality_scale.yaml b/homeassistant/components/fitbit/quality_scale.yaml index abf127cdb98..04ed2817a07 100644 --- a/homeassistant/components/fitbit/quality_scale.yaml +++ b/homeassistant/components/fitbit/quality_scale.yaml @@ -20,11 +20,7 @@ rules: comment: Fitbit is a polling integration that does use async events. entity-unique-id: done has-entity-name: done - runtime-data: - status: todo - comment: | - The integration uses `hass.data` for data associated with a configuration - entry and needs to be updated to use `runtime_data`. + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done From de79fb26db8967e3d6ce4f64dea336db2a024422 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Sat, 8 Feb 2025 12:52:59 +0000 Subject: [PATCH 0081/1941] Bump ohmepy to 1.2.9 (#137695) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 602c53ced7b..100967f819f 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.2.8"] + "requirements": ["ohme==1.2.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index ddc55a51ef1..c63c75271c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1547,7 +1547,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.8 +ohme==1.2.9 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9df8712d286..4e9bc32edf4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1295,7 +1295,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.8 +ohme==1.2.9 # homeassistant.components.ollama ollama==0.4.7 From 78fce5112d639286f7d4c298dab8ac60fa1ff1cd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 14:00:50 +0100 Subject: [PATCH 0082/1941] Explicitly pass in the config_entry in guardian coordinator (#137848) explicitly pass in the config_entry in coordinator --- homeassistant/components/guardian/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/guardian/coordinator.py b/homeassistant/components/guardian/coordinator.py index 849cec8063c..500b7c10784 100644 --- a/homeassistant/components/guardian/coordinator.py +++ b/homeassistant/components/guardian/coordinator.py @@ -42,6 +42,7 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, LOGGER, + config_entry=entry, name=f"{valve_controller_uid}_{api_name}", update_interval=DEFAULT_UPDATE_INTERVAL, ) @@ -50,7 +51,6 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._api_lock = api_lock self._client = client - self.config_entry = entry self.signal_reboot_requested = SIGNAL_REBOOT_REQUESTED.format( self.config_entry.entry_id ) From daccb3e9b36e80e42ffd49120273debe69985851 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 14:06:50 +0100 Subject: [PATCH 0083/1941] Explicitly pass in the config_entry in fitbit coordinator (#137808) explicitly pass in the config_entry in coordinator --- homeassistant/components/fitbit/__init__.py | 8 ++------ homeassistant/components/fitbit/coordinator.py | 17 +++++++++++++++-- homeassistant/components/fitbit/sensor.py | 3 +-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 0c4a37198d6..f2378797d8d 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -1,6 +1,5 @@ """The fitbit component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -8,16 +7,13 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import FitbitScope -from .coordinator import FitbitData, FitbitDeviceCoordinator +from .coordinator import FitbitConfigEntry, FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import config_from_entry_data PLATFORMS: list[Platform] = [Platform.SENSOR] -type FitbitConfigEntry = ConfigEntry[FitbitData] - - async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool: """Set up fitbit from a config entry.""" implementation = ( @@ -39,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bo fitbit_config = config_from_entry_data(entry.data) coordinator: FitbitDeviceCoordinator | None = None if fitbit_config.is_allowed_resource(FitbitScope.DEVICE, "devices/battery"): - coordinator = FitbitDeviceCoordinator(hass, fitbit_api) + coordinator = FitbitDeviceCoordinator(hass, entry, fitbit_api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = FitbitData(api=fitbit_api, device_coordinator=coordinator) diff --git a/homeassistant/components/fitbit/coordinator.py b/homeassistant/components/fitbit/coordinator.py index 2126129d261..867723419dd 100644 --- a/homeassistant/components/fitbit/coordinator.py +++ b/homeassistant/components/fitbit/coordinator.py @@ -6,6 +6,7 @@ import datetime import logging from typing import Final +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,13 +20,25 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) TIMEOUT = 10 +type FitbitConfigEntry = ConfigEntry[FitbitData] + class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]): """Coordinator for fetching fitbit devices from the API.""" - def __init__(self, hass: HomeAssistant, api: FitbitApi) -> None: + config_entry: FitbitConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: FitbitConfigEntry, api: FitbitApi + ) -> None: """Initialize FitbitDeviceCoordinator.""" - super().__init__(hass, _LOGGER, name="Fitbit", update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="Fitbit", + update_interval=UPDATE_INTERVAL, + ) self._api = api async def _async_update_data(self) -> dict[str, FitbitDevice]: diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 4ccbea97a66..bbb3da46e52 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -28,10 +28,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FitbitConfigEntry from .api import FitbitApi from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem -from .coordinator import FitbitDeviceCoordinator +from .coordinator import FitbitConfigEntry, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import FitbitDevice, config_from_entry_data From 37239fca447a1b6cc222a2e85a5cad642e857b1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Buli=C5=84ski?= Date: Sat, 8 Feb 2025 14:13:58 +0100 Subject: [PATCH 0084/1941] Update flexit_bacnet dependecy 2.2.1 -> 2.2.3 (#137730) --- homeassistant/components/flexit_bacnet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json index 40390162ce6..6f6b094c950 100644 --- a/homeassistant/components/flexit_bacnet/manifest.json +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["flexit_bacnet==2.2.1"] + "requirements": ["flexit_bacnet==2.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index c63c75271c4..0fdf048bc63 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -930,7 +930,7 @@ fixerio==1.0.0a0 fjaraskupan==2.3.2 # homeassistant.components.flexit_bacnet -flexit_bacnet==2.2.1 +flexit_bacnet==2.2.3 # homeassistant.components.flipr flipr-api==1.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e9bc32edf4..053acf5bd86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -789,7 +789,7 @@ fivem-api==0.1.2 fjaraskupan==2.3.2 # homeassistant.components.flexit_bacnet -flexit_bacnet==2.2.1 +flexit_bacnet==2.2.3 # homeassistant.components.flipr flipr-api==1.6.1 From 60d966f06f2cf8bb8f33c9dd7bd6324c716a55a1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 8 Feb 2025 14:20:30 +0100 Subject: [PATCH 0085/1941] Limit transmission ConfigEntrySelect to integration domain (#137769) --- homeassistant/components/transmission/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 1a8ffdea0c2..578488dad1a 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -78,7 +78,9 @@ MIGRATION_NAME_TO_KEY = { SERVICE_BASE_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(), + vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector( + {"integration": DOMAIN} + ), } ) From f3aeca5a71007bceb731f3dd42cf062039ba2fbf Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 8 Feb 2025 05:53:15 -0800 Subject: [PATCH 0086/1941] Call backup listener during setup in Google Drive (#137789) --- homeassistant/components/google_drive/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index af93956931a..b30bc2ae1f6 100644 --- a/homeassistant/components/google_drive/__init__.py +++ b/homeassistant/components/google_drive/__init__.py @@ -7,7 +7,7 @@ from collections.abc import Callable from google_drive_api.exceptions import GoogleDriveApiError from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,6 +49,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) except GoogleDriveApiError as err: raise ConfigEntryNotReady from err + _async_notify_backup_listeners_soon(hass) + return True @@ -56,10 +58,15 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleDriveConfigEntry ) -> bool: """Unload a config entry.""" - hass.loop.call_soon(_notify_backup_listeners, hass) + _async_notify_backup_listeners_soon(hass) return True -def _notify_backup_listeners(hass: HomeAssistant) -> None: +def _async_notify_backup_listeners(hass: HomeAssistant) -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() + + +@callback +def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: + hass.loop.call_soon(_async_notify_backup_listeners, hass) From 780e6aa073950cd1148bddf13878fba44c68ab12 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:32:11 +0100 Subject: [PATCH 0087/1941] Explicitly pass in the config_entry in awair coordinator init (#137717) explicitly pass in the config_entry in awair coordinator init --- homeassistant/components/awair/coordinator.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/awair/coordinator.py b/homeassistant/components/awair/coordinator.py index 78f0d9d65f2..62725693522 100644 --- a/homeassistant/components/awair/coordinator.py +++ b/homeassistant/components/awair/coordinator.py @@ -40,17 +40,24 @@ class AwairResult: class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): """Define a wrapper class to update Awair data.""" + config_entry: AwairConfigEntry + def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AwairConfigEntry, update_interval: timedelta | None, ) -> None: """Set up the AwairDataUpdateCoordinator class.""" - self._config_entry = config_entry self.title = config_entry.title - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _fetch_air_data(self, device: AwairBaseDevice) -> AwairResult: """Fetch latest air quality data.""" @@ -64,7 +71,10 @@ class AwairCloudDataUpdateCoordinator(AwairDataUpdateCoordinator): """Define a wrapper class to update Awair data from Cloud API.""" def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + self, + hass: HomeAssistant, + config_entry: AwairConfigEntry, + session: ClientSession, ) -> None: """Set up the AwairCloudDataUpdateCoordinator class.""" access_token = config_entry.data[CONF_ACCESS_TOKEN] @@ -95,7 +105,10 @@ class AwairLocalDataUpdateCoordinator(AwairDataUpdateCoordinator): _device: AwairLocalDevice | None = None def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + self, + hass: HomeAssistant, + config_entry: AwairConfigEntry, + session: ClientSession, ) -> None: """Set up the AwairLocalDataUpdateCoordinator class.""" self._awair = AwairLocal( From de1a503284ce1f3c1c43471bad330ddddd159400 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:32:20 +0100 Subject: [PATCH 0088/1941] Explicitly pass in the config_entry in enphase envoy coordinator (#137806) explicitly pass in the config_entry in coordinator --- homeassistant/components/enphase_envoy/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 8eb2b32ac7b..b8cda03a451 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -55,6 +55,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=entry, name=entry_data[CONF_NAME], update_interval=SCAN_INTERVAL, always_update=False, From b22830260cff4f90daad76ea187957be368a6832 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:32:32 +0100 Subject: [PATCH 0089/1941] Explicitly pass in the config_entry in filesize coordinator (#137807) explicitly pass in the config_entry in coordinator --- homeassistant/components/filesize/__init__.py | 10 +++------- homeassistant/components/filesize/coordinator.py | 10 ++++++++-- homeassistant/components/filesize/sensor.py | 3 +-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 602eac1f24d..b10125de67c 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -2,19 +2,15 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FILE_PATH from homeassistant.core import HomeAssistant from .const import PLATFORMS -from .coordinator import FileSizeCoordinator - -type FileSizeConfigEntry = ConfigEntry[FileSizeCoordinator] +from .coordinator import FileSizeConfigEntry, FileSizeCoordinator async def async_setup_entry(hass: HomeAssistant, entry: FileSizeConfigEntry) -> bool: """Set up from a config entry.""" - coordinator = FileSizeCoordinator(hass, entry.data[CONF_FILE_PATH]) + coordinator = FileSizeCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -22,6 +18,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FileSizeConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FileSizeConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py index 0c2a0277434..87f59f1a53e 100644 --- a/homeassistant/components/filesize/coordinator.py +++ b/homeassistant/components/filesize/coordinator.py @@ -7,6 +7,8 @@ import logging import os import pathlib +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILE_PATH from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -15,22 +17,26 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type FileSizeConfigEntry = ConfigEntry[FileSizeCoordinator] + class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime]]): """Filesize coordinator.""" + config_entry: FileSizeConfigEntry path: pathlib.Path - def __init__(self, hass: HomeAssistant, unresolved_path: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: FileSizeConfigEntry) -> None: """Initialize filesize coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), always_update=False, ) - self._unresolved_path = unresolved_path + self._unresolved_path = self.config_entry.data[CONF_FILE_PATH] def _get_full_path(self) -> pathlib.Path: """Check if path is valid, allowed and return full path.""" diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 2eb170af99d..bd8b9b6c462 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -17,9 +17,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FileSizeConfigEntry from .const import DOMAIN -from .coordinator import FileSizeCoordinator +from .coordinator import FileSizeConfigEntry, FileSizeCoordinator _LOGGER = logging.getLogger(__name__) From d97ef67620c06bc2667cfe447306ef667f9109e2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:32:46 +0100 Subject: [PATCH 0090/1941] Explicitly pass in the config_entry in gios coordinator (#137832) explicitly pass in the config_entry in coordinator --- homeassistant/components/gios/__init__.py | 15 ++--------- homeassistant/components/gios/coordinator.py | 27 ++++++++++++++++++-- homeassistant/components/gios/diagnostics.py | 2 +- homeassistant/components/gios/sensor.py | 3 +-- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index b5a0e9d5371..c76efbcf361 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -2,32 +2,21 @@ from __future__ import annotations -from dataclasses import dataclass import logging from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION_ID, DOMAIN -from .coordinator import GiosDataUpdateCoordinator +from .coordinator import GiosConfigEntry, GiosData, GiosDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type GiosConfigEntry = ConfigEntry[GiosData] - - -@dataclass -class GiosData: - """Data for GIOS integration.""" - - coordinator: GiosDataUpdateCoordinator - async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool: """Set up GIOS as config entry.""" @@ -48,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool websession = async_get_clientsession(hass) - coordinator = GiosDataUpdateCoordinator(hass, websession, station_id) + coordinator = GiosDataUpdateCoordinator(hass, entry, websession, station_id) await coordinator.async_config_entry_first_refresh() entry.runtime_data = GiosData(coordinator) diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index 17b4b89174f..be4b41ca6ee 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass import logging from aiohttp import ClientSession @@ -11,6 +12,7 @@ from gios import Gios from gios.exceptions import GiosError from gios.model import GiosSensors +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,17 +20,38 @@ from .const import API_TIMEOUT, DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type GiosConfigEntry = ConfigEntry[GiosData] + + +@dataclass +class GiosData: + """Data for GIOS integration.""" + + coordinator: GiosDataUpdateCoordinator + class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): """Define an object to hold GIOS data.""" + config_entry: GiosConfigEntry + def __init__( - self, hass: HomeAssistant, session: ClientSession, station_id: int + self, + hass: HomeAssistant, + config_entry: GiosConfigEntry, + session: ClientSession, + station_id: int, ) -> None: """Class to manage fetching GIOS data API.""" self.gios = Gios(station_id, session) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) async def _async_update_data(self) -> GiosSensors: """Update data via library.""" diff --git a/homeassistant/components/gios/diagnostics.py b/homeassistant/components/gios/diagnostics.py index a94a95254de..7e938d5ac6b 100644 --- a/homeassistant/components/gios/diagnostics.py +++ b/homeassistant/components/gios/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import GiosConfigEntry +from .coordinator import GiosConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 69e198d34df..096ea838a41 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -23,7 +23,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GiosConfigEntry from .const import ( ATTR_AQI, ATTR_C6H6, @@ -38,7 +37,7 @@ from .const import ( MANUFACTURER, URL, ) -from .coordinator import GiosDataUpdateCoordinator +from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) From c9ffeb80077998d10c6a459cc07d6e589fbf7ac2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:10 +0100 Subject: [PATCH 0091/1941] Explicitly pass in the config_entry in flipr coordinator (#137818) explicitly pass in the config_entry in coordinator --- homeassistant/components/flipr/__init__.py | 23 +++++++------------ .../components/flipr/binary_sensor.py | 2 +- homeassistant/components/flipr/coordinator.py | 21 +++++++++++++++-- homeassistant/components/flipr/select.py | 2 +- homeassistant/components/flipr/sensor.py | 2 +- homeassistant/components/flipr/switch.py | 2 +- 6 files changed, 31 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 99bddb5a0d0..81e61f2554a 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -1,7 +1,6 @@ """The Flipr integration.""" from collections import Counter -from dataclasses import dataclass import logging from flipr_api import FliprAPIRestClient @@ -13,24 +12,18 @@ from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .coordinator import FliprDataUpdateCoordinator, FliprHubDataUpdateCoordinator +from .coordinator import ( + FliprConfigEntry, + FliprData, + FliprDataUpdateCoordinator, + FliprHubDataUpdateCoordinator, +) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -@dataclass -class FliprData: - """The Flipr data class.""" - - flipr_coordinators: list[FliprDataUpdateCoordinator] - hub_coordinators: list[FliprHubDataUpdateCoordinator] - - -type FliprConfigEntry = ConfigEntry[FliprData] - - async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> bool: """Set up flipr from a config entry.""" @@ -50,13 +43,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> boo flipr_coordinators = [] for flipr_id in ids["flipr"]: - flipr_coordinator = FliprDataUpdateCoordinator(hass, client, flipr_id) + flipr_coordinator = FliprDataUpdateCoordinator(hass, entry, client, flipr_id) await flipr_coordinator.async_config_entry_first_refresh() flipr_coordinators.append(flipr_coordinator) hub_coordinators = [] for hub_id in ids["hub"]: - hub_coordinator = FliprHubDataUpdateCoordinator(hass, client, hub_id) + hub_coordinator = FliprHubDataUpdateCoordinator(hass, entry, client, hub_id) await hub_coordinator.async_config_entry_first_refresh() hub_coordinators.append(hub_coordinator) diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index cc6a9d36abc..07357b81af0 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprConfigEntry +from .coordinator import FliprConfigEntry from .entity import FliprEntity BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( diff --git a/homeassistant/components/flipr/coordinator.py b/homeassistant/components/flipr/coordinator.py index 12fd174fe7d..0d86b43711a 100644 --- a/homeassistant/components/flipr/coordinator.py +++ b/homeassistant/components/flipr/coordinator.py @@ -1,5 +1,6 @@ """DataUpdateCoordinator for flipr integration.""" +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -14,13 +15,28 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +@dataclass +class FliprData: + """The Flipr data class.""" + + flipr_coordinators: list["FliprDataUpdateCoordinator"] + hub_coordinators: list["FliprHubDataUpdateCoordinator"] + + +type FliprConfigEntry = ConfigEntry[FliprData] + + class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Parent class to hold Flipr and Hub data retrieval.""" - config_entry: ConfigEntry + config_entry: FliprConfigEntry def __init__( - self, hass: HomeAssistant, client: FliprAPIRestClient, flipr_or_hub_id: str + self, + hass: HomeAssistant, + config_entry: FliprConfigEntry, + client: FliprAPIRestClient, + flipr_or_hub_id: str, ) -> None: """Initialize.""" self.device_id = flipr_or_hub_id @@ -29,6 +45,7 @@ class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Flipr or Hub data measure for {self.device_id}", update_interval=timedelta(minutes=15), ) diff --git a/homeassistant/components/flipr/select.py b/homeassistant/components/flipr/select.py index b8a8f0db60a..79515be6ed4 100644 --- a/homeassistant/components/flipr/select.py +++ b/homeassistant/components/flipr/select.py @@ -6,7 +6,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprConfigEntry +from .coordinator import FliprConfigEntry from .entity import FliprEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index ba863718182..2594186f24a 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTempe from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprConfigEntry +from .coordinator import FliprConfigEntry from .entity import FliprEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( diff --git a/homeassistant/components/flipr/switch.py b/homeassistant/components/flipr/switch.py index 65e729ec280..03df7f34d12 100644 --- a/homeassistant/components/flipr/switch.py +++ b/homeassistant/components/flipr/switch.py @@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprConfigEntry +from .coordinator import FliprConfigEntry from .entity import FliprEntity _LOGGER = logging.getLogger(__name__) From 846797a3942b4ee91d398858a95c57e773e900e1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:21 +0100 Subject: [PATCH 0092/1941] Explicitly pass in the config_entry in google_tasks coordinator (#137842) explicitly pass in the config_entry in coordinator --- homeassistant/components/google_tasks/__init__.py | 4 ++-- homeassistant/components/google_tasks/coordinator.py | 7 +++++++ homeassistant/components/google_tasks/todo.py | 3 +-- homeassistant/components/google_tasks/types.py | 7 ------- 4 files changed, 10 insertions(+), 11 deletions(-) delete mode 100644 homeassistant/components/google_tasks/types.py diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py index 45ad1777aa0..2d570854ad4 100644 --- a/homeassistant/components/google_tasks/__init__.py +++ b/homeassistant/components/google_tasks/__init__.py @@ -13,9 +13,8 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import DOMAIN -from .coordinator import TaskUpdateCoordinator +from .coordinator import GoogleTasksConfigEntry, TaskUpdateCoordinator from .exceptions import GoogleTasksApiError -from .types import GoogleTasksConfigEntry __all__ = [ "DOMAIN", @@ -52,6 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleTasksConfigEntry) coordinators = [ TaskUpdateCoordinator( hass, + entry, auth, task_list["id"], task_list["title"], diff --git a/homeassistant/components/google_tasks/coordinator.py b/homeassistant/components/google_tasks/coordinator.py index a06faf00a91..b61fe1c30db 100644 --- a/homeassistant/components/google_tasks/coordinator.py +++ b/homeassistant/components/google_tasks/coordinator.py @@ -5,6 +5,7 @@ import datetime import logging from typing import Any, Final +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -15,13 +16,18 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) TIMEOUT = 10 +type GoogleTasksConfigEntry = ConfigEntry[list[TaskUpdateCoordinator]] + class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Coordinator for fetching Google Tasks for a Task List form the API.""" + config_entry: GoogleTasksConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: GoogleTasksConfigEntry, api: AsyncConfigEntryAuth, task_list_id: str, task_list_title: str, @@ -30,6 +36,7 @@ class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Google Tasks {task_list_id}", update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 1df5e5fc2e9..6d1969d9a8a 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -16,8 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .coordinator import TaskUpdateCoordinator -from .types import GoogleTasksConfigEntry +from .coordinator import GoogleTasksConfigEntry, TaskUpdateCoordinator PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/google_tasks/types.py b/homeassistant/components/google_tasks/types.py deleted file mode 100644 index 21500d11eb8..00000000000 --- a/homeassistant/components/google_tasks/types.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Types for the Google Tasks integration.""" - -from homeassistant.config_entries import ConfigEntry - -from .coordinator import TaskUpdateCoordinator - -type GoogleTasksConfigEntry = ConfigEntry[list[TaskUpdateCoordinator]] From e8e4fb296b9fa9ed9ae9f34789b7f0566de7eb92 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:42 +0100 Subject: [PATCH 0093/1941] Explicitly pass in the config_entry in flux_led coordinator (#137823) explicitly pass in the config_entry in coordinator --- homeassistant/components/flux_led/coordinator.py | 4 +++- homeassistant/components/flux_led/entity.py | 4 +++- homeassistant/components/flux_led/select.py | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/flux_led/coordinator.py b/homeassistant/components/flux_led/coordinator.py index a473387a513..a879d894bcc 100644 --- a/homeassistant/components/flux_led/coordinator.py +++ b/homeassistant/components/flux_led/coordinator.py @@ -24,17 +24,19 @@ REQUEST_REFRESH_DELAY: Final = 2.0 class FluxLedUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific flux_led device.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, device: AIOWifiLedBulb, entry: ConfigEntry ) -> None: """Initialize DataUpdateCoordinator to gather data for specific device.""" self.device = device self.title = entry.title - self.entry = entry self.force_next_update = False super().__init__( hass, _LOGGER, + config_entry=entry, name=self.device.ipaddr, update_interval=timedelta(seconds=10), # We don't want an immediate refresh since the device diff --git a/homeassistant/components/flux_led/entity.py b/homeassistant/components/flux_led/entity.py index bcf7bfff9ed..f9b87dbb8c1 100644 --- a/homeassistant/components/flux_led/entity.py +++ b/homeassistant/components/flux_led/entity.py @@ -89,7 +89,9 @@ class FluxEntity(CoordinatorEntity[FluxLedUpdateCoordinator]): self._attr_unique_id = f"{base_unique_id}_{key}" else: self._attr_unique_id = base_unique_id - self._attr_device_info = _async_device_info(self._device, coordinator.entry) + self._attr_device_info = _async_device_info( + self._device, coordinator.config_entry + ) async def _async_ensure_device_on(self) -> None: """Turn the device on if it needs to be turned on before a command.""" diff --git a/homeassistant/components/flux_led/select.py b/homeassistant/components/flux_led/select.py index 3809e73147a..33329ebb3f3 100644 --- a/homeassistant/components/flux_led/select.py +++ b/homeassistant/components/flux_led/select.py @@ -141,7 +141,7 @@ class FluxICTypeSelect(FluxConfigSelect): async def async_select_option(self, option: str) -> None: """Change the ic type.""" await self._device.async_set_device_config(ic_type=option) - await _async_delayed_reload(self.hass, self.coordinator.entry) + await _async_delayed_reload(self.hass, self.coordinator.config_entry) class FluxWiringsSelect(FluxConfigSelect): @@ -184,7 +184,7 @@ class FluxOperatingModesSelect(FluxConfigSelect): async def async_select_option(self, option: str) -> None: """Change the ic type.""" await self._device.async_set_device_config(operating_mode=option) - await _async_delayed_reload(self.hass, self.coordinator.entry) + await _async_delayed_reload(self.hass, self.coordinator.config_entry) class FluxRemoteConfigSelect(FluxConfigSelect): From 04c20b9534256f9bf398040e531b808313c30597 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:52 +0100 Subject: [PATCH 0094/1941] Explicitly pass in the config_entry in fyta coordinator (#137828) explicitly pass in the config_entry in coordinator --- homeassistant/components/fyta/__init__.py | 6 ++---- homeassistant/components/fyta/binary_sensor.py | 2 +- homeassistant/components/fyta/coordinator.py | 12 +++++++----- homeassistant/components/fyta/diagnostics.py | 2 +- homeassistant/components/fyta/entity.py | 3 +-- homeassistant/components/fyta/image.py | 3 +-- homeassistant/components/fyta/sensor.py | 3 +-- 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index ab4a74c627a..1b00afc9c80 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -7,7 +7,6 @@ import logging from fyta_cli.fyta_connector import FytaConnector -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_PASSWORD, @@ -19,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.dt import async_get_time_zone from .const import CONF_EXPIRATION -from .coordinator import FytaCoordinator +from .coordinator import FytaConfigEntry, FytaCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,7 +27,6 @@ PLATFORMS = [ Platform.IMAGE, Platform.SENSOR, ] -type FytaConfigEntry = ConfigEntry[FytaCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool: @@ -46,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FytaConfigEntry) -> bool username, password, access_token, expiration, tz, async_get_clientsession(hass) ) - coordinator = FytaCoordinator(hass, fyta) + coordinator = FytaCoordinator(hass, entry, fyta) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/fyta/binary_sensor.py b/homeassistant/components/fyta/binary_sensor.py index bcef609d01a..66e5b2feeca 100644 --- a/homeassistant/components/fyta/binary_sensor.py +++ b/homeassistant/components/fyta/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FytaConfigEntry +from .coordinator import FytaConfigEntry from .entity import FytaPlantEntity diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index a0c42d449d5..012ed3b2af0 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING from fyta_cli.fyta_connector import FytaConnector from fyta_cli.fyta_exceptions import ( @@ -16,6 +15,7 @@ from fyta_cli.fyta_exceptions import ( ) from fyta_cli.fyta_models import Plant +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -24,22 +24,24 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_EXPIRATION, DOMAIN -if TYPE_CHECKING: - from . import FytaConfigEntry - _LOGGER = logging.getLogger(__name__) +type FytaConfigEntry = ConfigEntry[FytaCoordinator] + class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]): """Fyta custom coordinator.""" config_entry: FytaConfigEntry - def __init__(self, hass: HomeAssistant, fyta: FytaConnector) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: FytaConfigEntry, fyta: FytaConnector + ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="FYTA Coordinator", update_interval=timedelta(minutes=4), ) diff --git a/homeassistant/components/fyta/diagnostics.py b/homeassistant/components/fyta/diagnostics.py index d02f8cacfa3..d6bda70d754 100644 --- a/homeassistant/components/fyta/diagnostics.py +++ b/homeassistant/components/fyta/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import FytaConfigEntry +from .coordinator import FytaConfigEntry TO_REDACT = [ CONF_PASSWORD, diff --git a/homeassistant/components/fyta/entity.py b/homeassistant/components/fyta/entity.py index 0d0ec533c44..02cd73c54f9 100644 --- a/homeassistant/components/fyta/entity.py +++ b/homeassistant/components/fyta/entity.py @@ -6,9 +6,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import FytaConfigEntry from .const import DOMAIN -from .coordinator import FytaCoordinator +from .coordinator import FytaConfigEntry, FytaCoordinator class FytaPlantEntity(CoordinatorEntity[FytaCoordinator]): diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py index f03df969dcc..4a0b32f605b 100644 --- a/homeassistant/components/fyta/image.py +++ b/homeassistant/components/fyta/image.py @@ -9,8 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FytaConfigEntry -from .coordinator import FytaCoordinator +from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 254e4522819..66c96ab697b 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -25,8 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import FytaConfigEntry -from .coordinator import FytaCoordinator +from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity From bc07598f47644ebb63b81c0e876ce1880691b25d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:33:55 +0100 Subject: [PATCH 0095/1941] Explicitly pass in the config_entry in fujitsu_fglair coordinator (#137826) explicitly pass in the config_entry in coordinator --- homeassistant/components/fujitsu_fglair/__init__.py | 7 ++----- homeassistant/components/fujitsu_fglair/climate.py | 3 +-- homeassistant/components/fujitsu_fglair/coordinator.py | 10 +++++++++- homeassistant/components/fujitsu_fglair/sensor.py | 3 +-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index 547545e4feb..699356a2e75 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -7,18 +7,15 @@ from contextlib import suppress from ayla_iot_unofficial import new_ayla_api from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU -from .coordinator import FGLairCoordinator +from .coordinator import FGLairConfigEntry, FGLairCoordinator PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] -type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> bool: """Set up Fujitsu HVAC (based on Ayla IOT) from a config entry.""" @@ -33,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> bo timeout=API_TIMEOUT, ) - coordinator = FGLairCoordinator(hass, api) + coordinator = FGLairCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index c0f5ab7dce4..5df6573e638 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -27,8 +27,7 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemper from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FGLairConfigEntry -from .coordinator import FGLairCoordinator +from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity HA_TO_FUJI_FAN = { diff --git a/homeassistant/components/fujitsu_fglair/coordinator.py b/homeassistant/components/fujitsu_fglair/coordinator.py index d98464e4751..8c66548973b 100644 --- a/homeassistant/components/fujitsu_fglair/coordinator.py +++ b/homeassistant/components/fujitsu_fglair/coordinator.py @@ -5,6 +5,7 @@ import logging from ayla_iot_unofficial import AylaApi, AylaAuthError from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -13,15 +14,22 @@ from .const import API_REFRESH _LOGGER = logging.getLogger(__name__) +type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] + class FGLairCoordinator(DataUpdateCoordinator[dict[str, FujitsuHVAC]]): """Coordinator for Fujitsu HVAC integration.""" - def __init__(self, hass: HomeAssistant, api: AylaApi) -> None: + config_entry: FGLairConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: FGLairConfigEntry, api: AylaApi + ) -> None: """Initialize coordinator for Fujitsu HVAC integration.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Fujitsu HVAC data", update_interval=API_REFRESH, ) diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py index 1426e2349ea..e095a566dcb 100644 --- a/homeassistant/components/fujitsu_fglair/sensor.py +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -11,8 +11,7 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .climate import FGLairConfigEntry -from .coordinator import FGLairCoordinator +from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity From 1179278d5053c40a597c4d4bad7dec65adff36a1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:34:12 +0100 Subject: [PATCH 0096/1941] Explicitly pass in the config_entry in fully_kiosk coordinator (#137827) explicitly pass in the config_entry in coordinator --- homeassistant/components/fully_kiosk/__init__.py | 5 +---- homeassistant/components/fully_kiosk/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index 074ec3feaa0..772e7f79242 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -1,17 +1,14 @@ """The Fully Kiosk Browser integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import FullyKioskDataUpdateCoordinator +from .coordinator import FullyKioskConfigEntry, FullyKioskDataUpdateCoordinator from .services import async_setup_services -type FullyKioskConfigEntry = ConfigEntry[FullyKioskDataUpdateCoordinator] - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, diff --git a/homeassistant/components/fully_kiosk/coordinator.py b/homeassistant/components/fully_kiosk/coordinator.py index 405f0746437..dc3640020be 100644 --- a/homeassistant/components/fully_kiosk/coordinator.py +++ b/homeassistant/components/fully_kiosk/coordinator.py @@ -14,11 +14,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_PORT, LOGGER, UPDATE_INTERVAL +type FullyKioskConfigEntry = ConfigEntry[FullyKioskDataUpdateCoordinator] + class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to hold Fully Kiosk Browser data.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: FullyKioskConfigEntry + + def __init__(self, hass: HomeAssistant, entry: FullyKioskConfigEntry) -> None: """Initialize.""" self.use_ssl = entry.data.get(CONF_SSL, False) self.fully = FullyKiosk( @@ -32,6 +36,7 @@ class FullyKioskDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, LOGGER, + config_entry=entry, name=entry.data[CONF_HOST], update_interval=UPDATE_INTERVAL, ) From 7c9312b3cfe54fea60e7fbc1762cd2f661f6c3c1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:34:34 +0100 Subject: [PATCH 0097/1941] Explicitly pass in the config_entry in goodwe coordinator (#137838) explicitly pass in the config_entry in coordinator --- homeassistant/components/goodwe/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py index a8ee7df6337..914ba3155b4 100644 --- a/homeassistant/components/goodwe/coordinator.py +++ b/homeassistant/components/goodwe/coordinator.py @@ -19,6 +19,8 @@ _LOGGER = logging.getLogger(__name__) class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Gather data for the energy device.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -29,6 +31,7 @@ class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=entry, name=entry.title, update_interval=SCAN_INTERVAL, ) From 70d5685b729b23ae155c42c66687dcc94fd8ee6e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:34:43 +0100 Subject: [PATCH 0098/1941] Explicitly pass in the config_entry in geocaching coordinator (#137831) explicitly pass in the config_entry in coordinator --- homeassistant/components/geocaching/coordinator.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geocaching/coordinator.py b/homeassistant/components/geocaching/coordinator.py index 8f56cd9d846..41b59d049af 100644 --- a/homeassistant/components/geocaching/coordinator.py +++ b/homeassistant/components/geocaching/coordinator.py @@ -18,12 +18,13 @@ from .const import DOMAIN, ENVIRONMENT, LOGGER, UPDATE_INTERVAL class GeocachingDataUpdateCoordinator(DataUpdateCoordinator[GeocachingStatus]): """Class to manage fetching Geocaching data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session ) -> None: """Initialize global Geocaching data updater.""" self.session = session - self.entry = entry async def async_token_refresh() -> str: await session.async_ensure_token_valid() @@ -39,7 +40,13 @@ class GeocachingDataUpdateCoordinator(DataUpdateCoordinator[GeocachingStatus]): token_refresh_method=async_token_refresh, ) - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) async def _async_update_data(self) -> GeocachingStatus: try: From 9e091f7a73ff1a20bf2de17ed9cfed0eed869e49 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:34:53 +0100 Subject: [PATCH 0099/1941] Explicitly pass in the config_entry in google coordinator (#137839) explicitly pass in the config_entry in coordinator --- homeassistant/components/google/calendar.py | 2 ++ homeassistant/components/google/coordinator.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 5ac5dae616c..82208420b8c 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -266,6 +266,7 @@ async def async_setup_entry( if not entity_description.local_sync: coordinator = CalendarQueryUpdateCoordinator( hass, + config_entry, calendar_service, entity_description.name or entity_description.key, calendar_id, @@ -285,6 +286,7 @@ async def async_setup_entry( ) coordinator = CalendarSyncUpdateCoordinator( hass, + config_entry, sync, entity_description.name or entity_description.key, ) diff --git a/homeassistant/components/google/coordinator.py b/homeassistant/components/google/coordinator.py index 19198041c05..4a8a3d9f167 100644 --- a/homeassistant/components/google/coordinator.py +++ b/homeassistant/components/google/coordinator.py @@ -52,6 +52,7 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, sync: CalendarEventSyncManager, name: str, ) -> None: @@ -59,6 +60,7 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=name, update_interval=MIN_TIME_BETWEEN_UPDATES, ) @@ -111,6 +113,7 @@ class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, calendar_service: GoogleCalendarService, name: str, calendar_id: str, @@ -120,6 +123,7 @@ class CalendarQueryUpdateCoordinator(DataUpdateCoordinator[list[Event]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=name, update_interval=MIN_TIME_BETWEEN_UPDATES, ) From 11d5628da735aaf364337134501af16d9e60a9d0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:35:00 +0100 Subject: [PATCH 0100/1941] Explicitly pass in the config_entry in google_photos coordinator (#137840) explicitly pass in the config_entry in coordinator --- homeassistant/components/google_photos/__init__.py | 7 ++++--- .../components/google_photos/coordinator.py | 13 ++++++++++++- .../components/google_photos/media_source.py | 2 +- homeassistant/components/google_photos/services.py | 2 +- homeassistant/components/google_photos/types.py | 7 ------- 5 files changed, 18 insertions(+), 13 deletions(-) delete mode 100644 homeassistant/components/google_photos/types.py diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index 2a7109d8189..40de02554ae 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -12,9 +12,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import api from .const import DOMAIN -from .coordinator import GooglePhotosUpdateCoordinator +from .coordinator import GooglePhotosConfigEntry, GooglePhotosUpdateCoordinator from .services import async_register_services -from .types import GooglePhotosConfigEntry __all__ = [ "DOMAIN", @@ -43,7 +42,9 @@ async def async_setup_entry( raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - coordinator = GooglePhotosUpdateCoordinator(hass, GooglePhotosLibraryApi(auth)) + coordinator = GooglePhotosUpdateCoordinator( + hass, entry, GooglePhotosLibraryApi(auth) + ) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/google_photos/coordinator.py b/homeassistant/components/google_photos/coordinator.py index 3ba5a8124d6..215d40d7864 100644 --- a/homeassistant/components/google_photos/coordinator.py +++ b/homeassistant/components/google_photos/coordinator.py @@ -15,6 +15,7 @@ from google_photos_library_api.api import GooglePhotosLibraryApi from google_photos_library_api.exceptions import GooglePhotosApiError from google_photos_library_api.model import Album, NewAlbum +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,6 +24,8 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL: Final = datetime.timedelta(hours=24) ALBUM_PAGE_SIZE = 50 +type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosUpdateCoordinator] + class GooglePhotosUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): """Coordinator for fetching Google Photos albums. @@ -30,11 +33,19 @@ class GooglePhotosUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): The `data` object is a dict from Album ID to Album title. """ - def __init__(self, hass: HomeAssistant, client: GooglePhotosLibraryApi) -> None: + config_entry: GooglePhotosConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: GooglePhotosConfigEntry, + client: GooglePhotosLibraryApi, + ) -> None: """Initialize TaskUpdateCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Google Photos", update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index 7ee81b51bc0..c0a87e46fbc 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -20,8 +20,8 @@ from homeassistant.components.media_source import ( ) from homeassistant.core import HomeAssistant -from . import GooglePhotosConfigEntry from .const import DOMAIN, READ_SCOPE +from .coordinator import GooglePhotosConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index 22d3cc7deb0..8042df8f811 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from .const import DOMAIN, UPLOAD_SCOPE -from .types import GooglePhotosConfigEntry +from .coordinator import GooglePhotosConfigEntry CONF_CONFIG_ENTRY_ID = "config_entry_id" CONF_ALBUM = "album" diff --git a/homeassistant/components/google_photos/types.py b/homeassistant/components/google_photos/types.py deleted file mode 100644 index 4f4cc1845e4..00000000000 --- a/homeassistant/components/google_photos/types.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Google Photos types.""" - -from homeassistant.config_entries import ConfigEntry - -from .coordinator import GooglePhotosUpdateCoordinator - -type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosUpdateCoordinator] From dbf403a2d32dcba055c078a0d5159c7346a28e86 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Feb 2025 15:39:36 +0100 Subject: [PATCH 0101/1941] Make action descriptions in adguard consistent, remove "true/false" (#137799) * Make action descriptions in adguard consistent Change the remaining two to also use third-person singular for consistency. * Remove "true" and "false" for UI-friendly wording --- homeassistant/components/adguard/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json index 5b6a5a546f7..44f4a388e6e 100644 --- a/homeassistant/components/adguard/strings.json +++ b/homeassistant/components/adguard/strings.json @@ -79,7 +79,7 @@ "services": { "add_url": { "name": "Add URL", - "description": "Add a new filter subscription to AdGuard Home.", + "description": "Adds a new filter subscription to AdGuard Home.", "fields": { "name": { "name": "[%key:common::config_flow::data::name%]", @@ -123,11 +123,11 @@ }, "refresh": { "name": "Refresh", - "description": "Refresh all filter subscriptions in AdGuard Home.", + "description": "Refreshes all filter subscriptions in AdGuard Home.", "fields": { "force": { "name": "Force", - "description": "Force update (bypasses AdGuard Home throttling). \"true\" to force, or \"false\" to omit for a regular refresh." + "description": "Force update (bypasses AdGuard Home throttling), omit for a regular refresh." } } } From 97cde3770229c6a9e3f83576b7fd81d2779cab82 Mon Sep 17 00:00:00 2001 From: Patrick <14628713+patman15@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:40:59 +0100 Subject: [PATCH 0102/1941] Fix manufacturer_id matching for 0 (#137802) fix manufacturer_id matching for 0 --- homeassistant/components/bluetooth/match.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 6307d3ca93b..c37fa4615f6 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -411,7 +411,7 @@ def ble_device_matches( ) and service_data_uuid not in service_info.service_data: return False - if manufacturer_id := matcher.get(MANUFACTURER_ID): + if (manufacturer_id := matcher.get(MANUFACTURER_ID)) is not None: if manufacturer_id not in service_info.manufacturer_data: return False From 21dd6fa53d7b40214681b5fbd59a7f088bb68b28 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:43:03 +0100 Subject: [PATCH 0103/1941] Explicitly pass in the config_entry in flick_electric coordinator (#137816) * explicitly pass in the config_entry in coordinator * Apply suggestions from code review Co-authored-by: Jan Bouwhuis --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/flick_electric/__init__.py | 9 ++++----- .../components/flick_electric/coordinator.py | 12 ++++++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/flick_electric/__init__.py b/homeassistant/components/flick_electric/__init__.py index 3ffddee1c7d..ad772c06b2e 100644 --- a/homeassistant/components/flick_electric/__init__.py +++ b/homeassistant/components/flick_electric/__init__.py @@ -9,7 +9,6 @@ from pyflick import FlickAPI from pyflick.authentication import SimpleFlickAuth from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_CLIENT_ID, @@ -35,9 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> boo """Set up Flick Electric from a config entry.""" auth = HassFlickAuth(hass, entry) - coordinator = FlickElectricDataCoordinator( - hass, FlickAPI(auth), entry.data[CONF_SUPPLY_NODE_REF] - ) + coordinator = FlickElectricDataCoordinator(hass, entry, FlickAPI(auth)) await coordinator.async_config_entry_first_refresh() @@ -53,7 +50,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: FlickConfigEntry) -> bo return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: FlickConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug( "Migrating configuration from version %s.%s", diff --git a/homeassistant/components/flick_electric/coordinator.py b/homeassistant/components/flick_electric/coordinator.py index 474efc5297d..114b364635f 100644 --- a/homeassistant/components/flick_electric/coordinator.py +++ b/homeassistant/components/flick_electric/coordinator.py @@ -13,6 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import CONF_SUPPLY_NODE_REF + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=5) @@ -23,17 +25,23 @@ type FlickConfigEntry = ConfigEntry[FlickElectricDataCoordinator] class FlickElectricDataCoordinator(DataUpdateCoordinator[FlickPrice]): """Coordinator for flick power price.""" + config_entry: FlickConfigEntry + def __init__( - self, hass: HomeAssistant, api: FlickAPI, supply_node_ref: str + self, + hass: HomeAssistant, + config_entry: FlickConfigEntry, + api: FlickAPI, ) -> None: """Initialize FlickElectricDataCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Flick Electric", update_interval=SCAN_INTERVAL, ) - self.supply_node_ref = supply_node_ref + self.supply_node_ref = config_entry.data[CONF_SUPPLY_NODE_REF] self._api = api async def _async_update_data(self) -> FlickPrice: From a542a2e0212cdb2ff22983afd34b8ae1f8e2b61c Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 8 Feb 2025 14:45:48 +0000 Subject: [PATCH 0104/1941] Refactor evohome for major bump of client to 1.0.2 (#135436) * working test_init * update fixtures to be compliant with new schema * test_storage is now working * all tests passing * bump client to 1.0.1b0 * test commit (working tests) * use only id (not e.g. zoneId), use StrEnums * mypy, lint * remove deprecated module * remove waffle * improve typing of asserts * broker is now coordinator * WIP - test failing * rename class * remove unneeded async_dispatcher_send() * restore missing code * harden test * bugfix failing test * don't capture blind except * shrink log messages * doctweak * rationalize asserts * remove unneeded listerner * refactor setup * bump client to 1.0.2b0 * bump client to 1.0.2b1 * refactor extended state attrs * pass UpdateFailed to _async_refresh() * Update homeassistant/components/evohome/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/evohome/entity.py Co-authored-by: Joost Lekkerkerker * not even lint * undo not even lint * remove unused logger * restore old namespace for e_s_a * minimize diff * doctweak * remove unused method * lint * DUC now working * restore old camelCase keynames * tweak * small tweak to _handle_coordinator_update() * Update homeassistant/components/evohome/coordinator.py Co-authored-by: Joost Lekkerkerker * add test of coordinator * bump client to 1.0.2 --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/evohome/__init__.py | 232 ++++----------- homeassistant/components/evohome/climate.py | 272 +++++++++--------- homeassistant/components/evohome/const.py | 23 -- .../components/evohome/coordinator.py | 250 +++++++++------- homeassistant/components/evohome/entity.py | 203 ++++++------- homeassistant/components/evohome/helpers.py | 110 ------- .../components/evohome/manifest.json | 4 +- homeassistant/components/evohome/storage.py | 118 ++++++++ .../components/evohome/water_heater.py | 91 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/evohome/conftest.py | 125 ++++---- .../fixtures/h032585/user_locations.json | 1 + .../fixtures/h099625/user_locations.json | 1 + .../evohome/snapshots/test_climate.ambr | 258 +++++++++-------- .../evohome/snapshots/test_water_heater.ambr | 32 +-- tests/components/evohome/test_climate.py | 84 +++--- tests/components/evohome/test_coordinator.py | 55 ++++ tests/components/evohome/test_init.py | 203 ++++++++----- tests/components/evohome/test_storage.py | 58 ++-- tests/components/evohome/test_water_heater.py | 30 +- 21 files changed, 1065 insertions(+), 1089 deletions(-) delete mode 100644 homeassistant/components/evohome/helpers.py create mode 100644 homeassistant/components/evohome/storage.py create mode 100644 tests/components/evohome/test_coordinator.py diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 97f7c2db54d..e322e266b8a 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -2,22 +2,24 @@ Such systems provide heating/cooling and DHW and include Evohome, Round Thermostat, and others. + +Note that the API used by this integration's client does not support cooling. """ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, Final +from typing import Final -import evohomeasync as ev1 -from evohomeasync.schema import SZ_SESSION_ID -import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_AUTO_WITH_RESET, - SZ_CAN_BE_TEMPORARY, - SZ_SYSTEM_MODE, - SZ_TIMING_MODE, +import evohomeasync as ec1 +import evohomeasync2 as ec2 +from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE +from evohomeasync2.schemas.const import ( + S2_DURATION as SZ_DURATION, + S2_PERIOD as SZ_PERIOD, + SystemMode as EvoSystemMode, ) import voluptuous as vol @@ -34,14 +36,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import verify_domain_control -from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import ( - ACCESS_TOKEN, - ACCESS_TOKEN_EXPIRES, ATTR_DURATION_DAYS, ATTR_DURATION_HOURS, ATTR_DURATION_UNTIL, @@ -49,16 +47,12 @@ from .const import ( ATTR_ZONE_TEMP, CONF_LOCATION_IDX, DOMAIN, - REFRESH_TOKEN, SCAN_INTERVAL_DEFAULT, SCAN_INTERVAL_MINIMUM, - STORAGE_KEY, - STORAGE_VER, - USER_DATA, EvoService, ) -from .coordinator import EvoBroker -from .helpers import dt_aware_to_naive, dt_local_to_aware, handle_evo_exception +from .coordinator import EvoDataUpdateCoordinator +from .storage import TokenManager _LOGGER = logging.getLogger(__name__) @@ -96,177 +90,69 @@ SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( } ) +EVOHOME_KEY: HassKey[EvoData] = HassKey(DOMAIN) -class EvoSession: - """Class for evohome client instantiation & authentication.""" - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the evohome broker and its data structure.""" +@dataclass +class EvoData: + """Dataclass for storing evohome data.""" - self.hass = hass - - self._session = async_get_clientsession(hass) - self._store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY) - - # the main client, which uses the newer API - self.client_v2: evo.EvohomeClient | None = None - self._tokens: dict[str, Any] = {} - - # the older client can be used to obtain high-precision temps (only) - self.client_v1: ev1.EvohomeClient | None = None - self.session_id: str | None = None - - async def authenticate(self, username: str, password: str) -> None: - """Check the user credentials against the web API. - - Will raise evo.AuthenticationFailed if the credentials are invalid. - """ - - if ( - self.client_v2 is None - or username != self.client_v2.username - or password != self.client_v2.password - ): - await self._load_auth_tokens(username) - - client_v2 = evo.EvohomeClient( - username, - password, - **self._tokens, - session=self._session, - ) - - else: # force a re-authentication - client_v2 = self.client_v2 - client_v2._user_account = None # noqa: SLF001 - - await client_v2.login() - self.client_v2 = client_v2 # only set attr if authentication succeeded - - await self.save_auth_tokens() - - self.client_v1 = ev1.EvohomeClient( - username, - password, - session_id=self.session_id, - session=self._session, - ) - - async def _load_auth_tokens(self, username: str) -> None: - """Load access tokens and session_id from the store and validate them. - - Sets self._tokens and self._session_id to the latest values. - """ - - app_storage: dict[str, Any] = dict(await self._store.async_load() or {}) - - if app_storage.pop(CONF_USERNAME, None) != username: - # any tokens won't be valid, and store might be corrupt - await self._store.async_save({}) - - self.session_id = None - self._tokens = {} - - return - - # evohomeasync2 requires naive/local datetimes as strings - if app_storage.get(ACCESS_TOKEN_EXPIRES) is not None and ( - expires := dt_util.parse_datetime(app_storage[ACCESS_TOKEN_EXPIRES]) - ): - app_storage[ACCESS_TOKEN_EXPIRES] = dt_aware_to_naive(expires) - - user_data: dict[str, str] = app_storage.pop(USER_DATA, {}) or {} - - self.session_id = user_data.get(SZ_SESSION_ID) - self._tokens = app_storage - - async def save_auth_tokens(self) -> None: - """Save access tokens and session_id to the store. - - Sets self._tokens and self._session_id to the latest values. - """ - - if self.client_v2 is None: - await self._store.async_save({}) - return - - # evohomeasync2 uses naive/local datetimes - access_token_expires = dt_local_to_aware( - self.client_v2.access_token_expires # type: ignore[arg-type] - ) - - self._tokens = { - CONF_USERNAME: self.client_v2.username, - REFRESH_TOKEN: self.client_v2.refresh_token, - ACCESS_TOKEN: self.client_v2.access_token, - ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), - } - - self.session_id = self.client_v1.broker.session_id if self.client_v1 else None - - app_storage = self._tokens - if self.client_v1: - app_storage[USER_DATA] = {SZ_SESSION_ID: self.session_id} - - await self._store.async_save(app_storage) + coordinator: EvoDataUpdateCoordinator + loc_idx: int + tcs: ec2.ControlSystem async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Evohome integration.""" - sess = EvoSession(hass) - - try: - await sess.authenticate( - config[DOMAIN][CONF_USERNAME], - config[DOMAIN][CONF_PASSWORD], - ) - - except (evo.AuthenticationFailed, evo.RequestFailed) as err: - handle_evo_exception(err) - return False - - finally: - config[DOMAIN][CONF_PASSWORD] = "REDACTED" - - broker = EvoBroker(sess) - - if not broker.validate_location( - config[DOMAIN][CONF_LOCATION_IDX], - ): - return False - - coordinator = DataUpdateCoordinator( + token_manager = TokenManager( + hass, + config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + async_get_clientsession(hass), + ) + coordinator = EvoDataUpdateCoordinator( hass, _LOGGER, - config_entry=None, + ec2.EvohomeClient(token_manager), name=f"{DOMAIN}_coordinator", update_interval=config[DOMAIN][CONF_SCAN_INTERVAL], - update_method=broker.async_update, + location_idx=config[DOMAIN][CONF_LOCATION_IDX], + client_v1=ec1.EvohomeClient(token_manager), ) + await coordinator.async_register_shutdown() + await coordinator.async_first_refresh() - hass.data[DOMAIN] = {"broker": broker, "coordinator": coordinator} + if not coordinator.last_update_success: + _LOGGER.error(f"Failed to fetch initial data: {coordinator.last_exception}") # noqa: G004 + return False - # without a listener, _schedule_refresh() won't be invoked by _async_refresh() - coordinator.async_add_listener(lambda: None) - await coordinator.async_refresh() # get initial state + assert coordinator.tcs is not None # mypy + + hass.data[EVOHOME_KEY] = EvoData( + coordinator=coordinator, + loc_idx=coordinator.loc_idx, + tcs=coordinator.tcs, + ) hass.async_create_task( async_load_platform(hass, Platform.CLIMATE, DOMAIN, {}, config) ) - if broker.tcs.hotwater: + if coordinator.tcs.hotwater: hass.async_create_task( async_load_platform(hass, Platform.WATER_HEATER, DOMAIN, {}, config) ) - setup_service_functions(hass, broker) + setup_service_functions(hass, coordinator) return True @callback -def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: +def setup_service_functions( + hass: HomeAssistant, coordinator: EvoDataUpdateCoordinator +) -> None: """Set up the service handlers for the system/zone operating modes. Not all Honeywell TCC-compatible systems support all operating modes. In addition, @@ -279,13 +165,15 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: @verify_domain_control(hass, DOMAIN) async def force_refresh(call: ServiceCall) -> None: """Obtain the latest state data via the vendor's RESTful API.""" - await broker.async_update() + await coordinator.async_refresh() @verify_domain_control(hass, DOMAIN) async def set_system_mode(call: ServiceCall) -> None: """Set the system mode.""" + assert coordinator.tcs is not None # mypy + payload = { - "unique_id": broker.tcs.systemId, + "unique_id": coordinator.tcs.id, "service": call.service, "data": call.data, } @@ -313,17 +201,23 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: async_dispatcher_send(hass, DOMAIN, payload) + assert coordinator.tcs is not None # mypy + hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh) # Enumerate which operating modes are supported by this system - modes = broker.tcs.allowedSystemModes + modes = list(coordinator.tcs.allowed_system_modes) # Not all systems support "AutoWithReset": register this handler only if required - if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]: + if any( + m[SZ_SYSTEM_MODE] + for m in modes + if m[SZ_SYSTEM_MODE] == EvoSystemMode.AUTO_WITH_RESET + ): hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode) system_mode_schemas = [] - modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET] + modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET] # Permanent-only modes will use this schema perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] @@ -334,7 +228,7 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] # These modes are set for a number of hours (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Duration"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_DURATION] if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( { @@ -348,7 +242,7 @@ def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: system_mode_schemas.append(schema) # These modes are set for a number of days (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Period"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_PERIOD] if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( { diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 64e7367bc32..8a455b300f8 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -4,20 +4,20 @@ from __future__ import annotations from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import Any import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_ACTIVE_FAULTS, +from evohomeasync2.const import ( SZ_SETPOINT_STATUS, - SZ_SYSTEM_ID, SZ_SYSTEM_MODE, SZ_SYSTEM_MODE_STATUS, SZ_TEMPERATURE_STATUS, - SZ_UNTIL, - SZ_ZONE_ID, - ZoneModelType, - ZoneType, +) +from evohomeasync2.schemas.const import ( + SystemMode as EvoSystemMode, + ZoneMode as EvoZoneMode, + ZoneModelType as EvoZoneModelType, + ZoneType as EvoZoneType, ) from homeassistant.components.climate import ( @@ -30,67 +30,46 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util +from . import EVOHOME_KEY from .const import ( ATTR_DURATION_DAYS, ATTR_DURATION_HOURS, ATTR_DURATION_UNTIL, ATTR_SYSTEM_MODE, ATTR_ZONE_TEMP, - DOMAIN, - EVO_AUTO, - EVO_AUTOECO, - EVO_AWAY, - EVO_CUSTOM, - EVO_DAYOFF, - EVO_FOLLOW, - EVO_HEATOFF, - EVO_PERMOVER, - EVO_RESET, - EVO_TEMPOVER, EvoService, ) -from .entity import EvoChild, EvoDevice - -if TYPE_CHECKING: - from . import EvoBroker - +from .coordinator import EvoDataUpdateCoordinator +from .entity import EvoChild, EvoEntity _LOGGER = logging.getLogger(__name__) -PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW +PRESET_RESET = "Reset" # reset all child zones to EvoZoneMode.FOLLOW_SCHEDULE PRESET_CUSTOM = "Custom" TCS_PRESET_TO_HA = { - EVO_AWAY: PRESET_AWAY, - EVO_CUSTOM: PRESET_CUSTOM, - EVO_AUTOECO: PRESET_ECO, - EVO_DAYOFF: PRESET_HOME, - EVO_RESET: PRESET_RESET, -} # EVO_AUTO: None, + EvoSystemMode.AWAY: PRESET_AWAY, + EvoSystemMode.CUSTOM: PRESET_CUSTOM, + EvoSystemMode.AUTO_WITH_ECO: PRESET_ECO, + EvoSystemMode.DAY_OFF: PRESET_HOME, + EvoSystemMode.AUTO_WITH_RESET: PRESET_RESET, +} # EvoSystemMode.AUTO: None, HA_PRESET_TO_TCS = {v: k for k, v in TCS_PRESET_TO_HA.items()} EVO_PRESET_TO_HA = { - EVO_FOLLOW: PRESET_NONE, - EVO_TEMPOVER: "temporary", - EVO_PERMOVER: "permanent", + EvoZoneMode.FOLLOW_SCHEDULE: PRESET_NONE, + EvoZoneMode.TEMPORARY_OVERRIDE: "temporary", + EvoZoneMode.PERMANENT_OVERRIDE: "permanent", } HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} -STATE_ATTRS_TCS = [SZ_SYSTEM_ID, SZ_ACTIVE_FAULTS, SZ_SYSTEM_MODE_STATUS] -STATE_ATTRS_ZONES = [ - SZ_ZONE_ID, - SZ_ACTIVE_FAULTS, - SZ_SETPOINT_STATUS, - SZ_TEMPERATURE_STATUS, -] - async def async_setup_platform( hass: HomeAssistant, @@ -102,32 +81,34 @@ async def async_setup_platform( if discovery_info is None: return - broker: EvoBroker = hass.data[DOMAIN]["broker"] + coordinator = hass.data[EVOHOME_KEY].coordinator + loc_idx = hass.data[EVOHOME_KEY].loc_idx + tcs = hass.data[EVOHOME_KEY].tcs _LOGGER.debug( "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", - broker.tcs.modelType, - broker.tcs.systemId, - broker.loc.name, - broker.loc_idx, + tcs.model, + tcs.id, + tcs.location.name, + loc_idx, ) - entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)] + entities: list[EvoController | EvoZone] = [EvoController(coordinator, tcs)] - for zone in broker.tcs.zones.values(): + for zone in tcs.zones: if ( - zone.modelType == ZoneModelType.HEATING_ZONE - or zone.zoneType == ZoneType.THERMOSTAT + zone.model == EvoZoneModelType.HEATING_ZONE + or zone.type == EvoZoneType.THERMOSTAT ): _LOGGER.debug( "Adding: %s (%s), id=%s, name=%s", - zone.zoneType, - zone.modelType, - zone.zoneId, + zone.type, + zone.model, + zone.id, zone.name, ) - new_entity = EvoZone(broker, zone) + new_entity = EvoZone(coordinator, zone) entities.append(new_entity) else: @@ -136,16 +117,19 @@ async def async_setup_platform( "Ignoring: %s (%s), id=%s, name=%s: unknown/invalid zone type, " "report as an issue if you feel this zone type should be supported" ), - zone.zoneType, - zone.modelType, - zone.zoneId, + zone.type, + zone.model, + zone.id, zone.name, ) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) + + for entity in entities: + await entity.update_attrs() -class EvoClimateEntity(EvoDevice, ClimateEntity): +class EvoClimateEntity(EvoEntity, ClimateEntity): """Base for any evohome-compatible climate entity (controller, zone).""" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] @@ -157,25 +141,29 @@ class EvoZone(EvoChild, EvoClimateEntity): _attr_preset_modes = list(HA_PRESET_TO_EVO) - _evo_device: evo.Zone # mypy hint + _evo_device: evo.Zone + _evo_id_attr = "zone_id" + _evo_state_attr_names = (SZ_SETPOINT_STATUS, SZ_TEMPERATURE_STATUS) - def __init__(self, evo_broker: EvoBroker, evo_device: evo.Zone) -> None: + def __init__( + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.Zone + ) -> None: """Initialize an evohome-compatible heating zone.""" - super().__init__(evo_broker, evo_device) - self._evo_id = evo_device.zoneId + super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id - if evo_device.modelType.startswith("VisionProWifi"): + if evo_device.model.startswith("VisionProWifi"): # this system does not have a distinct ID for the zone - self._attr_unique_id = f"{evo_device.zoneId}z" + self._attr_unique_id = f"{evo_device.id}z" else: - self._attr_unique_id = evo_device.zoneId + self._attr_unique_id = evo_device.id - if evo_broker.client_v1: + if coordinator.client_v1: self._attr_precision = PRECISION_TENTHS else: - self._attr_precision = self._evo_device.setpointCapabilities[ - "valueResolution" + self._attr_precision = self._evo_device.setpoint_capabilities[ + "value_resolution" ] self._attr_supported_features = ( @@ -188,7 +176,7 @@ class EvoZone(EvoChild, EvoClimateEntity): async def async_zone_svc_request(self, service: str, data: dict[str, Any]) -> None: """Process a service request (setpoint override) for a zone.""" if service == EvoService.RESET_ZONE_OVERRIDE: - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) return # otherwise it is EvoService.SET_ZONE_OVERRIDE @@ -198,14 +186,14 @@ class EvoZone(EvoChild, EvoClimateEntity): duration: timedelta = data[ATTR_DURATION_UNTIL] if duration.total_seconds() == 0: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + until = self.setpoints.get("next_sp_from") else: until = dt_util.now() + data[ATTR_DURATION_UNTIL] else: until = None # indefinitely until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) @@ -217,7 +205,7 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" - if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): + if self._evo_tcs.mode in (EvoSystemMode.AWAY, EvoSystemMode.HEATING_OFF): return HVACMode.AUTO if self.target_temperature is None: return None @@ -233,10 +221,8 @@ class EvoZone(EvoChild, EvoClimateEntity): @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): - return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) - if self._evo_device.mode is None: - return None + if self._evo_tcs.mode in (EvoSystemMode.AWAY, EvoSystemMode.HEATING_OFF): + return TCS_PRESET_TO_HA.get(self._evo_tcs.mode) return EVO_PRESET_TO_HA.get(self._evo_device.mode) @property @@ -245,8 +231,6 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 5, but is user-configurable within 5-21 (in Celsius). """ - if self._evo_device.min_heat_setpoint is None: - return 5 return self._evo_device.min_heat_setpoint @property @@ -255,33 +239,27 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 35, but is user-configurable within 21-35 (in Celsius). """ - if self._evo_device.max_heat_setpoint is None: - return 35 return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" - assert self._evo_device.setpointStatus is not None # mypy check - temperature = kwargs["temperature"] if (until := kwargs.get("until")) is None: - if self._evo_device.mode == EVO_FOLLOW: + if self._evo_device.mode == EvoZoneMode.TEMPORARY_OVERRIDE: + until = self._evo_device.until + if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - elif self._evo_device.mode == EVO_TEMPOVER: - until = dt_util.parse_datetime( - self._evo_device.setpointStatus[SZ_UNTIL] - ) + until = self.setpoints.get("next_sp_from") until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set a Zone to one of its native EVO_* operating modes. + """Set a Zone to one of its native operating modes. Zones inherit their _effective_ operating mode from their Controller. @@ -298,41 +276,34 @@ class EvoZone(EvoChild, EvoClimateEntity): and 'Away', Zones to (by default) 12C. """ if hvac_mode == HVACMode.OFF: - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(self.min_temp, until=None) ) else: # HVACMode.HEAT - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode; if None, then revert to following the schedule.""" - evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EVO_FOLLOW) + evo_preset_mode = HA_PRESET_TO_EVO.get(preset_mode, EvoZoneMode.FOLLOW_SCHEDULE) - if evo_preset_mode == EVO_FOLLOW: - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + if evo_preset_mode == EvoZoneMode.FOLLOW_SCHEDULE: + await self.coordinator.call_client_api(self._evo_device.reset()) return - if evo_preset_mode == EVO_TEMPOVER: + if evo_preset_mode == EvoZoneMode.TEMPORARY_OVERRIDE: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - else: # EVO_PERMOVER + until = self.setpoints.get("next_sp_from") + else: # EvoZoneMode.PERMANENT_OVERRIDE until = None temperature = self._evo_device.target_heat_temperature assert temperature is not None # mypy check until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( + await self.coordinator.call_client_api( self._evo_device.set_temperature(temperature, until=until) ) - async def async_update(self) -> None: - """Get the latest state data for a Zone.""" - await super().async_update() - - for attr in STATE_ATTRS_ZONES: - self._device_state_attrs[attr] = getattr(self._evo_device, attr) - class EvoController(EvoClimateEntity): """Base for any evohome-compatible controller. @@ -347,18 +318,22 @@ class EvoController(EvoClimateEntity): _attr_icon = "mdi:thermostat" _attr_precision = PRECISION_TENTHS - _evo_device: evo.ControlSystem # mypy hint + _evo_device: evo.ControlSystem + _evo_id_attr = "system_id" + _evo_state_attr_names = (SZ_SYSTEM_MODE_STATUS,) - def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None: + def __init__( + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.ControlSystem + ) -> None: """Initialize an evohome-compatible controller.""" - super().__init__(evo_broker, evo_device) - self._evo_id = evo_device.systemId + super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id - self._attr_unique_id = evo_device.systemId + self._attr_unique_id = evo_device.id self._attr_name = evo_device.location.name - self._evo_modes = [m[SZ_SYSTEM_MODE] for m in evo_device.allowedSystemModes] + self._evo_modes = [m[SZ_SYSTEM_MODE] for m in evo_device.allowed_system_modes] self._attr_preset_modes = [ TCS_PRESET_TO_HA[m] for m in self._evo_modes if m in list(TCS_PRESET_TO_HA) ] @@ -376,7 +351,7 @@ class EvoController(EvoClimateEntity): if service == EvoService.SET_SYSTEM_MODE: mode = data[ATTR_SYSTEM_MODE] else: # otherwise it is EvoService.RESET_SYSTEM - mode = EVO_RESET + mode = EvoSystemMode.AUTO_WITH_RESET if ATTR_DURATION_DAYS in data: until = dt_util.start_of_local_day() @@ -390,18 +365,24 @@ class EvoController(EvoClimateEntity): await self._set_tcs_mode(mode, until=until) - async def _set_tcs_mode(self, mode: str, until: datetime | None = None) -> None: - """Set a Controller to any of its native EVO_* operating modes.""" + async def _set_tcs_mode( + self, mode: EvoSystemMode, until: datetime | None = None + ) -> None: + """Set a Controller to any of its native operating modes.""" until = dt_util.as_utc(until) if until else None - await self._evo_broker.call_client_api( - self._evo_device.set_mode(mode, until=until) # type: ignore[arg-type] + await self.coordinator.call_client_api( + self._evo_device.set_mode(mode, until=until) ) @property def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" - evo_mode = self._evo_device.system_mode - return HVACMode.OFF if evo_mode in (EVO_HEATOFF, "Off") else HVACMode.HEAT + evo_mode = self._evo_device.mode + return ( + HVACMode.OFF + if evo_mode in (EvoSystemMode.HEATING_OFF, EvoSystemMode.OFF) + else HVACMode.HEAT + ) @property def current_temperature(self) -> float | None: @@ -410,18 +391,14 @@ class EvoController(EvoClimateEntity): Controllers do not have a current temp, but one is expected by HA. """ temps = [ - z.temperature - for z in self._evo_device.zones.values() - if z.temperature is not None + z.temperature for z in self._evo_device.zones if z.temperature is not None ] return round(sum(temps) / len(temps), 1) if temps else None @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if not self._evo_device.system_mode: - return None - return TCS_PRESET_TO_HA.get(self._evo_device.system_mode) + return TCS_PRESET_TO_HA.get(self._evo_device.mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Raise exception as Controllers don't have a target temperature.""" @@ -429,25 +406,40 @@ class EvoController(EvoClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set an operating mode for a Controller.""" + + evo_mode: EvoSystemMode + if hvac_mode == HVACMode.HEAT: - evo_mode = EVO_AUTO if EVO_AUTO in self._evo_modes else "Heat" + evo_mode = ( + EvoSystemMode.AUTO + if EvoSystemMode.AUTO in self._evo_modes + else EvoSystemMode.HEAT + ) elif hvac_mode == HVACMode.OFF: - evo_mode = EVO_HEATOFF if EVO_HEATOFF in self._evo_modes else "Off" + evo_mode = ( + EvoSystemMode.HEATING_OFF + if EvoSystemMode.HEATING_OFF in self._evo_modes + else EvoSystemMode.OFF + ) else: raise HomeAssistantError(f"Invalid hvac_mode: {hvac_mode}") await self._set_tcs_mode(evo_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode; if None, then revert to 'Auto' mode.""" - await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EVO_AUTO)) + await self._set_tcs_mode(HA_PRESET_TO_TCS.get(preset_mode, EvoSystemMode.AUTO)) - async def async_update(self) -> None: - """Get the latest state data for a Controller.""" - self._device_state_attrs = {} + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - attrs = self._device_state_attrs - for attr in STATE_ATTRS_TCS: - if attr == SZ_ACTIVE_FAULTS: - attrs["activeSystemFaults"] = getattr(self._evo_device, attr) - else: - attrs[attr] = getattr(self._evo_device, attr) + self._device_state_attrs = { + "activeSystemFaults": self._evo_device.active_faults + + self._evo_device.gateway.active_faults + } + + super()._handle_coordinator_update() + + async def update_attrs(self) -> None: + """Update the entity's extra state attrs.""" + self._handle_coordinator_update() diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 3ebe6954fea..12642addfa4 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -11,31 +11,8 @@ DOMAIN: Final = "evohome" STORAGE_VER: Final = 1 STORAGE_KEY: Final = DOMAIN -# The Parent's (i.e. TCS, Controller) operating mode is one of: -EVO_RESET: Final = "AutoWithReset" -EVO_AUTO: Final = "Auto" -EVO_AUTOECO: Final = "AutoWithEco" -EVO_AWAY: Final = "Away" -EVO_DAYOFF: Final = "DayOff" -EVO_CUSTOM: Final = "Custom" -EVO_HEATOFF: Final = "HeatingOff" - -# The Children's (i.e. Dhw, Zone) operating mode is one of: -EVO_FOLLOW: Final = "FollowSchedule" # the operating mode is 'inherited' from the TCS -EVO_TEMPOVER: Final = "TemporaryOverride" -EVO_PERMOVER: Final = "PermanentOverride" - -# These two are used only to help prevent E501 (line too long) violations -GWS: Final = "gateways" -TCS: Final = "temperatureControlSystems" - -UTC_OFFSET: Final = "currentOffsetMinutes" - CONF_LOCATION_IDX: Final = "location_idx" -ACCESS_TOKEN: Final = "access_token" -ACCESS_TOKEN_EXPIRES: Final = "access_token_expires" -REFRESH_TOKEN: Final = "refresh_token" USER_DATA: Final = "user_data" SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 943bd6605b4..7b197f1b643 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -4,109 +4,143 @@ from __future__ import annotations from collections.abc import Awaitable from datetime import timedelta +from http import HTTPStatus import logging -from typing import TYPE_CHECKING, Any +from typing import Any -import evohomeasync as ev1 -from evohomeasync.schema import SZ_ID, SZ_TEMP -import evohomeasync2 as evo -from evohomeasync2.schema.const import ( +import evohomeasync as ec1 +import evohomeasync2 as ec2 +from evohomeasync2.const import ( SZ_GATEWAY_ID, SZ_GATEWAY_INFO, + SZ_GATEWAYS, SZ_LOCATION_ID, SZ_LOCATION_INFO, + SZ_TEMPERATURE_CONTROL_SYSTEMS, SZ_TIME_ZONE, + SZ_USE_DAYLIGHT_SAVE_SWITCHING, ) +from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT -from homeassistant.helpers.dispatcher import async_dispatcher_send - -from .const import CONF_LOCATION_IDX, DOMAIN, GWS, TCS, UTC_OFFSET -from .helpers import handle_evo_exception - -if TYPE_CHECKING: - from . import EvoSession - -_LOGGER = logging.getLogger(__name__.rpartition(".")[0]) +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -class EvoBroker: - """Broker for evohome client broker.""" +class EvoDataUpdateCoordinator(DataUpdateCoordinator): + """Coordinator for evohome integration/client.""" - loc_idx: int - loc: evo.Location - loc_utc_offset: timedelta - tcs: evo.ControlSystem + # These will not be None after _async_setup()) + loc: ec2.Location + tcs: ec2.ControlSystem - def __init__(self, sess: EvoSession) -> None: - """Initialize the evohome broker and its data structure.""" + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + client_v2: ec2.EvohomeClient, + *, + name: str, + update_interval: timedelta, + location_idx: int, + client_v1: ec1.EvohomeClient | None = None, + ) -> None: + """Class to manage fetching data.""" - self._sess = sess - self.hass = sess.hass + super().__init__( + hass, + logger, + config_entry=None, + name=name, + update_interval=update_interval, + ) - assert sess.client_v2 is not None # mypy + self.client = client_v2 + self.client_v1 = client_v1 - self.client = sess.client_v2 - self.client_v1 = sess.client_v1 + self.loc_idx = location_idx + self.data: EvoLocStatusResponseT = None # type: ignore[assignment] self.temps: dict[str, float | None] = {} - def validate_location(self, loc_idx: int) -> bool: - """Get the default TCS of the specified location.""" + self._first_refresh_done = False # get schedules only after first refresh - self.loc_idx = loc_idx + # our version of async_config_entry_first_refresh()... + async def async_first_refresh(self) -> None: + """Refresh data for the first time when integration is setup. - assert self.client.installation_info is not None # mypy + This integration does not have config flow, so it is inappropriate to + invoke `async_config_entry_first_refresh()`. + """ + + # can't replicate `if not await self.__wrap_async_setup():` (is mangled), so... + if not await self._DataUpdateCoordinator__wrap_async_setup(): # type: ignore[attr-defined] + return + + await self._async_refresh( + log_failures=False, raise_on_auth_failed=True, raise_on_entry_error=True + ) + + async def _async_setup(self) -> None: + """Set up the coordinator. + + Fetch the user information, and the configuration of their locations. + """ try: - loc_config = self.client.installation_info[loc_idx] - except IndexError: - _LOGGER.error( - ( - "Config error: '%s' = %s, but the valid range is 0-%s. " - "Unable to continue. Fix any configuration errors and restart HA" - ), - CONF_LOCATION_IDX, - loc_idx, - len(self.client.installation_info) - 1, - ) - return False + await self.client.update(dont_update_status=True) # only config for now + except ec2.EvohomeError as err: + raise UpdateFailed(err) from err - self.loc = self.client.locations[loc_idx] - self.loc_utc_offset = timedelta(minutes=self.loc.timeZone[UTC_OFFSET]) - self.tcs = self.loc._gateways[0]._control_systems[0] # noqa: SLF001 + try: + self.loc = self.client.locations[self.loc_idx] + except IndexError as err: + raise UpdateFailed( + f""" + Config error: 'location_idx' = {self.loc_idx}, + but the valid range is 0-{len(self.client.locations) - 1}. + Unable to continue. Fix any configuration errors and restart HA + """ + ) from err - if _LOGGER.isEnabledFor(logging.DEBUG): + self.tcs = self.loc.gateways[0].systems[0] + + if self.logger.isEnabledFor(logging.DEBUG): loc_info = { - SZ_LOCATION_ID: loc_config[SZ_LOCATION_INFO][SZ_LOCATION_ID], - SZ_TIME_ZONE: loc_config[SZ_LOCATION_INFO][SZ_TIME_ZONE], + SZ_LOCATION_ID: self.loc.id, + SZ_TIME_ZONE: self.loc.config[SZ_TIME_ZONE], + SZ_USE_DAYLIGHT_SAVE_SWITCHING: self.loc.config[ + SZ_USE_DAYLIGHT_SAVE_SWITCHING + ], } gwy_info = { - SZ_GATEWAY_ID: loc_config[GWS][0][SZ_GATEWAY_INFO][SZ_GATEWAY_ID], - TCS: loc_config[GWS][0][TCS], + SZ_GATEWAY_ID: self.loc.gateways[0].id, + SZ_TEMPERATURE_CONTROL_SYSTEMS: [ + self.loc.gateways[0].systems[0].config + ], } config = { SZ_LOCATION_INFO: loc_info, - GWS: [{SZ_GATEWAY_INFO: gwy_info}], + SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}], } - _LOGGER.debug("Config = %s", config) - - return True + self.logger.debug("Config = %s", config) async def call_client_api( self, client_api: Awaitable[dict[str, Any] | None], - update_state: bool = True, + request_refresh: bool = True, ) -> dict[str, Any] | None: - """Call a client API and update the broker state if required.""" + """Call a client API and update the Coordinator state if required.""" try: result = await client_api - except evo.RequestFailed as err: - handle_evo_exception(err) + + except ec2.ApiRequestFailedError as err: + self.logger.error(err) return None - if update_state: # wait a moment for system to quiesce before updating state - await self.hass.data[DOMAIN]["coordinator"].async_request_refresh() + if request_refresh: # wait a moment for system to quiesce before updating state + await self.async_request_refresh() # hass.async_create_task() won't help return result @@ -115,80 +149,82 @@ class EvoBroker: assert self.client_v1 is not None # mypy check - old_session_id = self._sess.session_id - try: - temps = await self.client_v1.get_temperatures() + await self.client_v1.update() - except ev1.InvalidSchema as err: - _LOGGER.warning( + except ec1.BadUserCredentialsError as err: + self.logger.warning( ( "Unable to obtain high-precision temperatures. " - "It appears the JSON schema is not as expected, " - "so the high-precision feature will be disabled until next restart." - "Message is: %s" + "The feature will be disabled until next restart: %r" ), err, ) self.client_v1 = None - except ev1.RequestFailed as err: - _LOGGER.warning( + except ec1.EvohomeError as err: + self.logger.warning( ( "Unable to obtain the latest high-precision temperatures. " - "Check your network and the vendor's service status page. " - "Proceeding without high-precision temperatures for now. " - "Message is: %s" + "They will be ignored this refresh cycle: %r" ), err, ) self.temps = {} # high-precision temps now considered stale - except Exception: - self.temps = {} # high-precision temps now considered stale - raise - else: - if str(self.client_v1.location_id) != self.loc.locationId: - _LOGGER.warning( - "The v2 API's configured location doesn't match " - "the v1 API's default location (there is more than one location), " - "so the high-precision feature will be disabled until next restart" - ) - self.client_v1 = None - else: - self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps} + self.temps = await self.client_v1.location_by_id[ + self.loc.id + ].get_temperatures(dont_update_status=True) - finally: - if self.client_v1 and self.client_v1.broker.session_id != old_session_id: - await self._sess.save_auth_tokens() - - _LOGGER.debug("Temperatures = %s", self.temps) + self.logger.debug("Status (high-res temps) = %s", self.temps) async def _update_v2_api_state(self, *args: Any) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" - access_token = self.client.access_token # maybe receive a new token? - try: - status = await self.loc.refresh_status() - except evo.RequestFailed as err: - handle_evo_exception(err) - else: - async_dispatcher_send(self.hass, DOMAIN) - _LOGGER.debug("Status = %s", status) - finally: - if access_token != self.client.access_token: - await self._sess.save_auth_tokens() + status = await self.loc.update() - async def async_update(self, *args: Any) -> None: - """Get the latest state data of an entire Honeywell TCC Location. + except ec2.ApiRequestFailedError as err: + if err.status != HTTPStatus.TOO_MANY_REQUESTS: + raise UpdateFailed(err) from err + + raise UpdateFailed( + f""" + The vendor's API rate limit has been exceeded. + Consider increasing the {CONF_SCAN_INTERVAL} + """ + ) from err + + except ec2.EvohomeError as err: + raise UpdateFailed(err) from err + + self.logger.debug("Status = %s", status) + + async def _update_v2_schedules(self) -> None: + for zone in self.tcs.zones: + await zone.get_schedule() + + if dhw := self.tcs.hotwater: + await dhw.get_schedule() + + async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override] + """Fetch the latest state of an entire TCC Location. This includes state data for a Controller and all its child devices, such as the operating mode of the Controller and the current temp of its children (e.g. Zones, DHW controller). """ - await self._update_v2_api_state() + await self._update_v2_api_state() # may raise UpdateFailed if self.client_v1: - await self._update_v1_api_temps() + await self._update_v1_api_temps() # will never raise UpdateFailed + + # to speed up HA startup, don't update entity schedules during initial + # async_first_refresh(), only during subsequent async_refresh()... + if self._first_refresh_done: + await self._update_v2_schedules() + else: + self._first_refresh_done = True + + return self.loc.status diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index a42d8ef7582..11215dd47b6 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -1,55 +1,49 @@ """Base for evohome entity.""" -from datetime import datetime, timedelta, timezone +from collections.abc import Mapping +from datetime import UTC, datetime import logging from typing import Any import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_HEAT_SETPOINT, - SZ_SETPOINT_STATUS, - SZ_STATE_STATUS, - SZ_SYSTEM_MODE_STATUS, - SZ_TIME_UNTIL, - SZ_UNTIL, -) +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity -from homeassistant.util import dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import EvoBroker, EvoService -from .const import DOMAIN -from .helpers import convert_dict, convert_until +from .const import DOMAIN, EvoService +from .coordinator import EvoDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -class EvoDevice(Entity): +class EvoEntity(CoordinatorEntity[EvoDataUpdateCoordinator]): """Base for any evohome-compatible entity (controller, DHW, zone). This includes the controller, (1 to 12) heating zones and (optionally) a DHW controller. """ - _attr_should_poll = False + _evo_device: evo.ControlSystem | evo.HotWater | evo.Zone + _evo_id_attr: str + _evo_state_attr_names: tuple[str, ...] def __init__( self, - evo_broker: EvoBroker, + coordinator: EvoDataUpdateCoordinator, evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, ) -> None: """Initialize an evohome-compatible entity (TCS, DHW, zone).""" + super().__init__(coordinator, context=evo_device.id) self._evo_device = evo_device - self._evo_broker = evo_broker self._device_state_attrs: dict[str, Any] = {} - async def async_refresh(self, payload: dict | None = None) -> None: + async def process_signal(self, payload: dict | None = None) -> None: """Process any signals.""" + if payload is None: - self.async_schedule_update_ha_state(force_refresh=True) - return + raise NotImplementedError if payload["unique_id"] != self._attr_unique_id: return if payload["service"] in ( @@ -69,40 +63,46 @@ class EvoDevice(Entity): raise NotImplementedError @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the evohome-specific state attributes.""" - status = self._device_state_attrs - if SZ_SYSTEM_MODE_STATUS in status: - convert_until(status[SZ_SYSTEM_MODE_STATUS], SZ_TIME_UNTIL) - if SZ_SETPOINT_STATUS in status: - convert_until(status[SZ_SETPOINT_STATUS], SZ_UNTIL) - if SZ_STATE_STATUS in status: - convert_until(status[SZ_STATE_STATUS], SZ_UNTIL) - - return {"status": convert_dict(status)} + return {"status": self._device_state_attrs} async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" - async_dispatcher_connect(self.hass, DOMAIN, self.async_refresh) + await super().async_added_to_hass() + + async_dispatcher_connect(self.hass, DOMAIN, self.process_signal) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + self._device_state_attrs[self._evo_id_attr] = self._evo_device.id + + for attr in self._evo_state_attr_names: + self._device_state_attrs[attr] = getattr(self._evo_device, attr) + + super()._handle_coordinator_update() -class EvoChild(EvoDevice): +class EvoChild(EvoEntity): """Base for any evohome-compatible child entity (DHW, zone). This includes (1 to 12) heating zones and (optionally) a DHW controller. """ - _evo_id: str # mypy hint + _evo_device: evo.HotWater | evo.Zone + _evo_id: str def __init__( - self, evo_broker: EvoBroker, evo_device: evo.HotWater | evo.Zone + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.HotWater | evo.Zone ) -> None: """Initialize an evohome-compatible child entity (DHW, zone).""" - super().__init__(evo_broker, evo_device) + super().__init__(coordinator, evo_device) self._evo_tcs = evo_device.tcs - self._schedule: dict[str, Any] = {} + self._schedule: dict[str, Any] | None = None self._setpoints: dict[str, Any] = {} @property @@ -111,101 +111,78 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check - if (temp := self._evo_broker.temps.get(self._evo_id)) is not None: + if (temp := self.coordinator.temps.get(self._evo_id)) is not None: # use high-precision temps if available return temp return self._evo_device.temperature @property - def setpoints(self) -> dict[str, Any]: + def setpoints(self) -> Mapping[str, Any]: """Return the current/next setpoints from the schedule. Only Zones & DHW controllers (but not the TCS) can have schedules. """ - def _dt_evo_to_aware(dt_naive: datetime, utc_offset: timedelta) -> datetime: - dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset - return dt_util.as_local(dt_aware) + this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint + next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint - if not (schedule := self._schedule.get("DailySchedules")): - return {} # no scheduled setpoints when {'DailySchedules': []} + key = "temp" if isinstance(self._evo_device, evo.Zone) else "state" - # get dt in the same TZ as the TCS location, so we can compare schedule times - day_time = dt_util.now().astimezone(timezone(self._evo_broker.loc_utc_offset)) - day_of_week = day_time.weekday() # for evohome, 0 is Monday - time_of_day = day_time.strftime("%H:%M:%S") - - try: - # Iterate today's switchpoints until past the current time of day... - day = schedule[day_of_week] - sp_idx = -1 # last switchpoint of the day before - for i, tmp in enumerate(day["Switchpoints"]): - if time_of_day > tmp["TimeOfDay"]: - sp_idx = i # current setpoint - else: - break - - # Did this setpoint start yesterday? Does the next setpoint start tomorrow? - this_sp_day = -1 if sp_idx == -1 else 0 - next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 - - for key, offset, idx in ( - ("this", this_sp_day, sp_idx), - ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), - ): - sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") - day = schedule[(day_of_week + offset) % 7] - switchpoint = day["Switchpoints"][idx] - - switchpoint_time_of_day = dt_util.parse_datetime( - f"{sp_date}T{switchpoint['TimeOfDay']}" - ) - assert switchpoint_time_of_day is not None # mypy check - dt_aware = _dt_evo_to_aware( - switchpoint_time_of_day, self._evo_broker.loc_utc_offset - ) - - self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() - try: - self._setpoints[f"{key}_sp_temp"] = switchpoint[SZ_HEAT_SETPOINT] - except KeyError: - self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"] - - except IndexError: - self._setpoints = {} - _LOGGER.warning( - "Failed to get setpoints, report as an issue if this error persists", - exc_info=True, - ) + self._setpoints = { + "this_sp_from": this_sp_dtm, + f"this_sp_{key}": this_sp_val, + "next_sp_from": next_sp_dtm, + f"next_sp_{key}": next_sp_val, + } return self._setpoints - async def _update_schedule(self) -> None: + async def _update_schedule(self, force_refresh: bool = False) -> None: """Get the latest schedule, if any.""" - assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check + async def get_schedule() -> None: + try: + schedule = await self.coordinator.call_client_api( + self._evo_device.get_schedule(), # type: ignore[arg-type] + request_refresh=False, + ) + except evo.InvalidScheduleError as err: + _LOGGER.warning( + "%s: Unable to retrieve a valid schedule: %s", + self._evo_device, + err, + ) + self._schedule = {} + return + else: + self._schedule = schedule or {} # mypy hint - try: - schedule = await self._evo_broker.call_client_api( - self._evo_device.get_schedule(), update_state=False + _LOGGER.debug("Schedule['%s'] = %s", self.name, schedule) + + if ( + force_refresh + or self._schedule is None + or ( + (until := self._setpoints.get("next_sp_from")) is not None + and until < datetime.now(UTC) ) - except evo.InvalidSchedule as err: - _LOGGER.warning( - "%s: Unable to retrieve a valid schedule: %s", - self._evo_device, - err, - ) - self._schedule = {} - else: - self._schedule = schedule or {} + ): # must use self._setpoints, not self.setpoints + await get_schedule() - _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) + _ = self.setpoints # update the setpoints attr - async def async_update(self) -> None: - """Get the latest state data.""" - next_sp_from = self._setpoints.get("next_sp_from", "2000-01-01T00:00:00+00:00") - next_sp_from_dt = dt_util.parse_datetime(next_sp_from) - if next_sp_from_dt is None or dt_util.now() >= next_sp_from_dt: - await self._update_schedule() # no schedule, or it's out-of-date + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" - self._device_state_attrs = {"setpoints": self.setpoints} + self._device_state_attrs = { + "activeFaults": self._evo_device.active_faults, + "setpoints": self._setpoints, + } + + super()._handle_coordinator_update() + + async def update_attrs(self) -> None: + """Update the entity's extra state attrs.""" + await self._update_schedule() + self._handle_coordinator_update() diff --git a/homeassistant/components/evohome/helpers.py b/homeassistant/components/evohome/helpers.py deleted file mode 100644 index 0e2de36eb47..00000000000 --- a/homeassistant/components/evohome/helpers.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Support for (EMEA/EU-based) Honeywell TCC systems.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -from http import HTTPStatus -import logging -import re -from typing import Any - -import evohomeasync2 as evo - -from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.util import dt as dt_util - -_LOGGER = logging.getLogger(__name__) - - -def dt_local_to_aware(dt_naive: datetime) -> datetime: - """Convert a local/naive datetime to TZ-aware.""" - dt_aware = dt_util.now() + (dt_naive - datetime.now()) - if dt_aware.microsecond >= 500000: - dt_aware += timedelta(seconds=1) - return dt_aware.replace(microsecond=0) - - -def dt_aware_to_naive(dt_aware: datetime) -> datetime: - """Convert a TZ-aware datetime to naive/local.""" - dt_naive = datetime.now() + (dt_aware - dt_util.now()) - if dt_naive.microsecond >= 500000: - dt_naive += timedelta(seconds=1) - return dt_naive.replace(microsecond=0) - - -def convert_until(status_dict: dict, until_key: str) -> None: - """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat.""" - if until_key in status_dict and ( # only present for certain modes - dt_utc_naive := dt_util.parse_datetime(status_dict[until_key]) - ): - status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat() - - -def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: - """Recursively convert a dict's keys to snake_case.""" - - def convert_key(key: str) -> str: - """Convert a string to snake_case.""" - string = re.sub(r"[\-\.\s]", "_", str(key)) - return ( - (string[0]).lower() - + re.sub( - r"[A-Z]", - lambda matched: f"_{matched.group(0).lower()}", # type:ignore[str-bytes-safe] - string[1:], - ) - ) - - return { - (convert_key(k) if isinstance(k, str) else k): ( - convert_dict(v) if isinstance(v, dict) else v - ) - for k, v in dictionary.items() - } - - -def handle_evo_exception(err: evo.RequestFailed) -> None: - """Return False if the exception can't be ignored.""" - - try: - raise err - - except evo.AuthenticationFailed: - _LOGGER.error( - ( - "Failed to authenticate with the vendor's server. Check your username" - " and password. NB: Some special password characters that work" - " correctly via the website will not work via the web API. Message" - " is: %s" - ), - err, - ) - - except evo.RequestFailed: - if err.status is None: - _LOGGER.warning( - ( - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: %s" - ), - err, - ) - - elif err.status == HTTPStatus.SERVICE_UNAVAILABLE: - _LOGGER.warning( - "The vendor says their server is currently unavailable. " - "Check the vendor's service status page" - ) - - elif err.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.warning( - ( - "The vendor's API rate limit has been exceeded. " - "If this message persists, consider increasing the %s" - ), - CONF_SCAN_INTERVAL, - ) - - else: - raise # we don't expect/handle any other Exceptions diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 22edadad7f4..823ad7be5df 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@zxdavb"], "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", - "loggers": ["evohomeasync", "evohomeasync2"], + "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==0.4.21"] + "requirements": ["evohome-async==1.0.2"] } diff --git a/homeassistant/components/evohome/storage.py b/homeassistant/components/evohome/storage.py new file mode 100644 index 00000000000..b078c33b305 --- /dev/null +++ b/homeassistant/components/evohome/storage.py @@ -0,0 +1,118 @@ +"""Support for (EMEA/EU-based) Honeywell TCC systems.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any, NotRequired, TypedDict + +from evohomeasync.auth import ( + SZ_SESSION_ID, + SZ_SESSION_ID_EXPIRES, + AbstractSessionManager, +) +from evohomeasync2.auth import AbstractTokenManager + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import STORAGE_KEY, STORAGE_VER + + +class _SessionIdEntryT(TypedDict): + session_id: str + session_id_expires: NotRequired[str] # dt.isoformat() # TZ-aware + + +class _TokenStoreT(TypedDict): + username: str + refresh_token: str + access_token: str + access_token_expires: str # dt.isoformat() # TZ-aware + session_id: NotRequired[str] + session_id_expires: NotRequired[str] # dt.isoformat() # TZ-aware + + +class TokenManager(AbstractTokenManager, AbstractSessionManager): + """A token manager that uses a cache file to store the tokens.""" + + def __init__( + self, + hass: HomeAssistant, + *args: Any, + **kwargs: Any, + ) -> None: + """Initialise the token manager.""" + super().__init__(*args, **kwargs) + + self._store = Store(hass, STORAGE_VER, STORAGE_KEY) # type: ignore[var-annotated] + self._store_initialized = False # True once cache loaded first time + + async def get_access_token(self) -> str: + """Return a valid access token. + + If the cached entry is not valid, will fetch a new access token. + """ + + if not self._store_initialized: + await self._load_cache_from_store() + + return await super().get_access_token() + + async def get_session_id(self) -> str: + """Return a valid session id. + + If the cached entry is not valid, will fetch a new session id. + """ + + if not self._store_initialized: + await self._load_cache_from_store() + + return await super().get_session_id() + + async def _load_cache_from_store(self) -> None: + """Load the user entry from the cache. + + Assumes single reader/writer. Reads only once, at initialization. + """ + + cache: _TokenStoreT = await self._store.async_load() or {} # type: ignore[assignment] + self._store_initialized = True + + if not cache or cache["username"] != self._client_id: + return + + if SZ_SESSION_ID in cache: + self._import_session_id(cache) # type: ignore[arg-type] + self._import_access_token(cache) + + def _import_session_id(self, session: _SessionIdEntryT) -> None: # type: ignore[override] + """Extract the session id from a (serialized) dictionary.""" + # base class method overridden because session_id_expired is NotRequired here + + self._session_id = session[SZ_SESSION_ID] + + session_id_expires = session.get(SZ_SESSION_ID_EXPIRES) + if session_id_expires is None: + self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15) + else: + self._session_id_expires = datetime.fromisoformat(session_id_expires) + + async def save_access_token(self) -> None: # an abstractmethod + """Save the access token (and expiry dtm, refresh token) to the cache.""" + await self.save_cache_to_store() + + async def save_session_id(self) -> None: # an abstractmethod + """Save the session id (and expiry dtm) to the cache.""" + await self.save_cache_to_store() + + async def save_cache_to_store(self) -> None: + """Save the access token (and session id, if any) to the cache. + + Assumes a single reader/writer. Writes whenever new data has been fetched. + """ + + cache = {"username": self._client_id} | self._export_access_token() + if self._session_id: + cache |= self._export_session_id() + + await self._store.async_save(cache) diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 2c3cf9de12d..7ea0fb3a2d9 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -3,17 +3,11 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import Any import evohomeasync2 as evo -from evohomeasync2.schema.const import ( - SZ_ACTIVE_FAULTS, - SZ_DHW_ID, - SZ_OFF, - SZ_ON, - SZ_STATE_STATUS, - SZ_TEMPERATURE_STATUS, -) +from evohomeasync2.const import SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS +from evohomeasync2.schemas.const import DhwState as EvoDhwState, ZoneMode as EvoZoneMode from homeassistant.components.water_heater import ( WaterHeaterEntity, @@ -31,22 +25,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER +from . import EVOHOME_KEY +from .coordinator import EvoDataUpdateCoordinator from .entity import EvoChild -if TYPE_CHECKING: - from . import EvoBroker - - _LOGGER = logging.getLogger(__name__) STATE_AUTO = "auto" -HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: SZ_ON, STATE_OFF: SZ_OFF} +HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: EvoDhwState.ON, STATE_OFF: EvoDhwState.OFF} EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} -STATE_ATTRS_DHW = [SZ_DHW_ID, SZ_ACTIVE_FAULTS, SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS] - async def async_setup_platform( hass: HomeAssistant, @@ -58,19 +47,22 @@ async def async_setup_platform( if discovery_info is None: return - broker: EvoBroker = hass.data[DOMAIN]["broker"] + coordinator = hass.data[EVOHOME_KEY].coordinator + tcs = hass.data[EVOHOME_KEY].tcs - assert broker.tcs.hotwater is not None # mypy check + assert tcs.hotwater is not None # mypy check _LOGGER.debug( "Adding: DhwController (%s), id=%s", - broker.tcs.hotwater.TYPE, - broker.tcs.hotwater.dhwId, + tcs.hotwater.type, + tcs.hotwater.id, ) - new_entity = EvoDHW(broker, broker.tcs.hotwater) + entity = EvoDHW(coordinator, tcs.hotwater) - async_add_entities([new_entity], update_before_add=True) + async_add_entities([entity]) + + await entity.update_attrs() class EvoDHW(EvoChild, WaterHeaterEntity): @@ -81,19 +73,23 @@ class EvoDHW(EvoChild, WaterHeaterEntity): _attr_operation_list = list(HA_STATE_TO_EVO) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _evo_device: evo.HotWater # mypy hint + _evo_device: evo.HotWater + _evo_id_attr = "dhw_id" + _evo_state_attr_names = (SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS) - def __init__(self, evo_broker: EvoBroker, evo_device: evo.HotWater) -> None: + def __init__( + self, coordinator: EvoDataUpdateCoordinator, evo_device: evo.HotWater + ) -> None: """Initialize an evohome-compatible DHW controller.""" - super().__init__(evo_broker, evo_device) - self._evo_id = evo_device.dhwId + super().__init__(coordinator, evo_device) + self._evo_id = evo_device.id - self._attr_unique_id = evo_device.dhwId + self._attr_unique_id = evo_device.id self._attr_name = evo_device.name # is static self._attr_precision = ( - PRECISION_TENTHS if evo_broker.client_v1 else PRECISION_WHOLE + PRECISION_TENTHS if coordinator.client_v1 else PRECISION_WHOLE ) self._attr_supported_features = ( WaterHeaterEntityFeature.AWAY_MODE | WaterHeaterEntityFeature.OPERATION_MODE @@ -102,19 +98,15 @@ class EvoDHW(EvoChild, WaterHeaterEntity): @property def current_operation(self) -> str | None: """Return the current operating mode (Auto, On, or Off).""" - if self._evo_device.mode == EVO_FOLLOW: + if self._evo_device.mode == EvoZoneMode.FOLLOW_SCHEDULE: return STATE_AUTO - if (device_state := self._evo_device.state) is None: - return None - return EVO_STATE_TO_HA[device_state] + return EVO_STATE_TO_HA[self._evo_device.state] @property def is_away_mode_on(self) -> bool | None: """Return True if away mode is on.""" - if self._evo_device.state is None: - return None is_off = EVO_STATE_TO_HA[self._evo_device.state] == STATE_OFF - is_permanent = self._evo_device.mode == EVO_PERMOVER + is_permanent = self._evo_device.mode == EvoZoneMode.PERMANENT_OVERRIDE return is_off and is_permanent async def async_set_operation_mode(self, operation_mode: str) -> None: @@ -123,40 +115,31 @@ class EvoDHW(EvoChild, WaterHeaterEntity): Except for Auto, the mode is only until the next SetPoint. """ if operation_mode == STATE_AUTO: - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) else: await self._update_schedule() - until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + until = self.setpoints.get("next_sp_from") until = dt_util.as_utc(until) if until else None if operation_mode == STATE_ON: - await self._evo_broker.call_client_api( - self._evo_device.set_on(until=until) - ) + await self.coordinator.call_client_api(self._evo_device.on(until=until)) else: # STATE_OFF - await self._evo_broker.call_client_api( - self._evo_device.set_off(until=until) + await self.coordinator.call_client_api( + self._evo_device.off(until=until) ) async def async_turn_away_mode_on(self) -> None: """Turn away mode on.""" - await self._evo_broker.call_client_api(self._evo_device.set_off()) + await self.coordinator.call_client_api(self._evo_device.off()) async def async_turn_away_mode_off(self) -> None: """Turn away mode off.""" - await self._evo_broker.call_client_api(self._evo_device.reset_mode()) + await self.coordinator.call_client_api(self._evo_device.reset()) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" - await self._evo_broker.call_client_api(self._evo_device.set_on()) + await self.coordinator.call_client_api(self._evo_device.on()) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" - await self._evo_broker.call_client_api(self._evo_device.set_off()) - - async def async_update(self) -> None: - """Get the latest state data for a DHW controller.""" - await super().async_update() - - for attr in STATE_ATTRS_DHW: - self._device_state_attrs[attr] = getattr(self._evo_device, attr) + await self.coordinator.call_client_api(self._evo_device.off()) diff --git a/requirements_all.txt b/requirements_all.txt index 0fdf048bc63..13abe012fcc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -893,7 +893,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.21 +evohome-async==1.0.2 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 053acf5bd86..580efd88992 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -759,7 +759,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==0.4.21 +evohome-async==1.0.2 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 6daab3f32bb..5f60bc418e3 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -3,26 +3,26 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Callable -from datetime import datetime, timedelta, timezone +from datetime import timedelta, timezone from http import HTTPMethod from typing import Any from unittest.mock import MagicMock, patch -from aiohttp import ClientSession from evohomeasync2 import EvohomeClient -from evohomeasync2.broker import Broker -from evohomeasync2.controlsystem import ControlSystem +from evohomeasync2.auth import AbstractTokenManager, Auth +from evohomeasync2.control_system import ControlSystem from evohomeasync2.zone import Zone +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.evohome import CONF_PASSWORD, CONF_USERNAME, DOMAIN -from homeassistant.const import Platform +from homeassistant.components.evohome.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from homeassistant.util.json import JsonArrayType, JsonObjectType -from .const import ACCESS_TOKEN, REFRESH_TOKEN, USERNAME +from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME from tests.common import load_json_array_fixture, load_json_object_fixture @@ -64,44 +64,69 @@ def zone_schedule_fixture(install: str) -> JsonObjectType: return load_json_object_fixture("default/schedule_zone.json", DOMAIN) -def mock_get_factory(install: str) -> Callable: +def mock_post_request(install: str) -> Callable: + """Obtain an access token via a POST to the vendor's web API.""" + + async def post_request( + self: AbstractTokenManager, url: str, /, **kwargs: Any + ) -> JsonArrayType | JsonObjectType: + """Obtain an access token via a POST to the vendor's web API.""" + + if "Token" in url: + return { + "access_token": f"new_{ACCESS_TOKEN}", + "token_type": "bearer", + "expires_in": 1800, + "refresh_token": f"new_{REFRESH_TOKEN}", + # "scope": "EMEA-V1-Basic EMEA-V1-Anonymous", # optional + } + + if "session" in url: + return {"sessionId": f"new_{SESSION_ID}"} + + pytest.fail(f"Unexpected request: {HTTPMethod.POST} {url}") + + return post_request + + +def mock_make_request(install: str) -> Callable: """Return a get method for a specified installation.""" - async def mock_get( - self: Broker, url: str, **kwargs: Any + async def make_request( + self: Auth, method: HTTPMethod, url: str, **kwargs: Any ) -> JsonArrayType | JsonObjectType: """Return the JSON for a HTTP get of a given URL.""" - # a proxy for the behaviour of the real web API - if self.refresh_token is None: - self.refresh_token = f"new_{REFRESH_TOKEN}" + if method != HTTPMethod.GET: + pytest.fail(f"Unmocked method: {method} {url}") - if ( - self.access_token_expires is None - or self.access_token_expires < datetime.now() - ): - self.access_token = f"new_{ACCESS_TOKEN}" - self.access_token_expires = datetime.now() + timedelta(minutes=30) + await self._headers() # assume a valid GET, and return the JSON for that web API - if url == "userAccount": # userAccount + if url == "accountInfo": # /v0/accountInfo + return {} # will throw a KeyError -> BadApiResponseError + + if url.startswith("locations/"): # /v0/locations?userId={id}&allData=True + return [] # user has no locations + + if url == "userAccount": # /v2/userAccount return user_account_config_fixture(install) - if url.startswith("location"): - if "installationInfo" in url: # location/installationInfo?userId={id} + if url.startswith("location/"): + if "installationInfo" in url: # /v2/location/installationInfo?userId={id} return user_locations_config_fixture(install) - if "location" in url: # location/{id}/status + if "status" in url: # /v2/location/{id}/status return location_status_fixture(install) elif "schedule" in url: - if url.startswith("domesticHotWater"): # domesticHotWater/{id}/schedule + if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule return dhw_schedule_fixture(install) - if url.startswith("temperatureZone"): # temperatureZone/{id}/schedule + if url.startswith("temperatureZone"): # /v2/temperatureZone/{id}/schedule return zone_schedule_fixture(install) pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") - return mock_get + return make_request @pytest.fixture @@ -137,9 +162,13 @@ async def setup_evohome( dt_util.set_default_time_zone(timezone(timedelta(minutes=utc_offset))) with ( - patch("homeassistant.components.evohome.evo.EvohomeClient") as mock_client, - patch("homeassistant.components.evohome.ev1.EvohomeClient", return_value=None), - patch("evohomeasync2.broker.Broker.get", mock_get_factory(install)), + # patch("homeassistant.components.evohome.ec1.EvohomeClient", return_value=None), + patch("homeassistant.components.evohome.ec2.EvohomeClient") as mock_client, + patch( + "evohomeasync2.auth.CredentialsManagerBase._post_request", + mock_post_request(install), + ), + patch("evohome.auth.AbstractAuth._make_request", mock_make_request(install)), ): evo: EvohomeClient | None = None @@ -155,12 +184,11 @@ async def setup_evohome( mock_client.assert_called_once() - assert mock_client.call_args.args[0] == config[CONF_USERNAME] - assert mock_client.call_args.args[1] == config[CONF_PASSWORD] + assert isinstance(evo, EvohomeClient) + assert evo._token_manager.client_id == config[CONF_USERNAME] + assert evo._token_manager._secret == config[CONF_PASSWORD] - assert isinstance(mock_client.call_args.kwargs["session"], ClientSession) - - assert evo and evo.account_info is not None + assert evo.user_account mock_client.return_value = evo yield mock_client @@ -170,39 +198,32 @@ async def setup_evohome( async def evohome( hass: HomeAssistant, config: dict[str, str], + freezer: FrozenDateTimeFactory, install: str, ) -> AsyncGenerator[MagicMock]: """Return the mocked evohome client for this install fixture.""" + freezer.move_to("2024-07-10T12:00:00Z") # so schedules are as expected + async for mock_client in setup_evohome(hass, config, install=install): yield mock_client @pytest.fixture -async def ctl_id( - hass: HomeAssistant, - config: dict[str, str], - install: MagicMock, -) -> AsyncGenerator[str]: +def ctl_id(evohome: MagicMock) -> str: """Return the entity_id of the evohome integration's controller.""" - async for mock_client in setup_evohome(hass, config, install=install): - evo: EvohomeClient = mock_client.return_value - ctl: ControlSystem = evo._get_single_tcs() + evo: EvohomeClient = evohome.return_value + ctl: ControlSystem = evo.tcs - yield f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" + return f"{Platform.CLIMATE}.{slugify(ctl.location.name)}" @pytest.fixture -async def zone_id( - hass: HomeAssistant, - config: dict[str, str], - install: MagicMock, -) -> AsyncGenerator[str]: +def zone_id(evohome: MagicMock) -> str: """Return the entity_id of the evohome integration's first zone.""" - async for mock_client in setup_evohome(hass, config, install=install): - evo: EvohomeClient = mock_client.return_value - zone: Zone = list(evo._get_single_tcs().zones.values())[0] + evo: EvohomeClient = evohome.return_value + zone: Zone = evo.tcs.zones[0] - yield f"{Platform.CLIMATE}.{slugify(zone.name)}" + return f"{Platform.CLIMATE}.{slugify(zone.name)}" diff --git a/tests/components/evohome/fixtures/h032585/user_locations.json b/tests/components/evohome/fixtures/h032585/user_locations.json index b4ea2e5c420..c291d591c99 100644 --- a/tests/components/evohome/fixtures/h032585/user_locations.json +++ b/tests/components/evohome/fixtures/h032585/user_locations.json @@ -3,6 +3,7 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "GMTStandardTime", "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", diff --git a/tests/components/evohome/fixtures/h099625/user_locations.json b/tests/components/evohome/fixtures/h099625/user_locations.json index cc32caccc73..31cac00ae9e 100644 --- a/tests/components/evohome/fixtures/h099625/user_locations.json +++ b/tests/components/evohome/fixtures/h099625/user_locations.json @@ -3,6 +3,7 @@ "locationInfo": { "locationId": "111111", "name": "My Home", + "useDaylightSaveSwitching": true, "timeZone": { "timeZoneId": "FLEStandardTime", "displayName": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius", diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index ce7fcf2744e..23a15e3f64f 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -2,120 +2,120 @@ # name: test_ctl_set_hvac_mode[default] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[h032585] list([ tuple( - 'Off', + , ), tuple( - 'Heat', + , ), ]) # --- # name: test_ctl_set_hvac_mode[h099625] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[minimal] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_set_hvac_mode[sys_004] list([ tuple( - 'HeatingOff', + , ), tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_off[default] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[h032585] list([ tuple( - 'Off', + , ), ]) # --- # name: test_ctl_turn_off[h099625] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[minimal] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_off[sys_004] list([ tuple( - 'HeatingOff', + , ), ]) # --- # name: test_ctl_turn_on[default] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[h032585] list([ tuple( - 'Heat', + , ), ]) # --- # name: test_ctl_turn_on[h099625] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[minimal] list([ tuple( - 'Auto', + , ), ]) # --- # name: test_ctl_turn_on[sys_004] list([ tuple( - 'Auto', + , ), ]) # --- @@ -137,16 +137,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -184,16 +184,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -230,21 +230,21 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ + 'activeFaults': tuple( dict({ - 'faultType': 'TempZoneActuatorLowBattery', - 'since': '2022-03-02T04:50:20', + 'fault_type': 'TempZoneActuatorLowBattery', + 'since': '2022-03-02T04:50:20+00:00', }), - ]), + ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T20:00:00+01:00', + 'until': '2022-03-07T19:00:00+00:00', }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -282,16 +282,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -329,16 +329,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -376,16 +376,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -423,20 +423,20 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ + 'activeFaults': tuple( dict({ - 'faultType': 'TempZoneActuatorCommunicationLost', - 'since': '2022-03-02T15:56:01', + 'fault_type': 'TempZoneActuatorCommunicationLost', + 'since': '2022-03-02T15:56:01+00:00', }), - ]), + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -477,8 +477,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -513,16 +513,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -560,16 +560,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -606,17 +606,17 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T20:00:00+01:00', + 'until': '2022-03-07T19:00:00+00:00', }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -654,16 +654,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -701,16 +701,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -748,16 +748,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -795,16 +795,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -845,8 +845,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -881,16 +881,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 14.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -923,8 +923,8 @@ 'max_temp': 35, 'min_temp': 7, 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '416856', 'system_mode_status': dict({ 'is_permanent': True, @@ -959,16 +959,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1006,8 +1006,8 @@ 'away', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '8557535', 'system_mode_status': dict({ 'is_permanent': True, @@ -1042,16 +1042,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+03:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+03:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1089,16 +1089,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+03:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+03:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Kiev')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1136,16 +1136,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'FollowSchedule', 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1186,8 +1186,8 @@ 'Custom', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + ), 'system_id': '3432522', 'system_mode_status': dict({ 'is_permanent': True, @@ -1222,8 +1222,12 @@ 'away', ]), 'status': dict({ - 'active_system_faults': list([ - ]), + 'activeSystemFaults': tuple( + dict({ + 'fault_type': 'GatewayCommunicationLost', + 'since': '2023-05-04T18:47:36.772704+02:00', + }), + ), 'system_id': '4187769', 'system_mode_status': dict({ 'is_permanent': True, @@ -1258,16 +1262,16 @@ 'permanent', ]), 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'setpoint_status': dict({ 'setpoint_mode': 'PermanentOverride', 'target_heat_temperature': 15.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-07-10T22:10:00+02:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), 'next_sp_temp': 18.6, - 'this_sp_from': '2024-07-10T08:00:00+02:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), 'this_sp_temp': 16.0, }), 'temperature_status': dict({ @@ -1331,7 +1335,7 @@ 17.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1344,7 +1348,7 @@ 21.5, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1357,7 +1361,7 @@ 21.5, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1370,7 +1374,7 @@ 17.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -1383,35 +1387,35 @@ 15.0, ), dict({ - 'until': datetime.datetime(2024, 7, 10, 20, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 20, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[default] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[h032585] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[h099625] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 19, 10, tzinfo=datetime.timezone.utc), }), ]) # --- # name: test_zone_set_temperature[minimal] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 21, 10, tzinfo=datetime.timezone.utc), }), ]) # --- diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 4cdeb28f445..771e2c20cba 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -2,10 +2,10 @@ # name: test_set_operation_mode[default] list([ dict({ - 'until': datetime.datetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), }), dict({ - 'until': datetime.datetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -13,11 +13,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'away_mode': 'on', - 'current_temperature': 23, + 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', 'icon': 'mdi:thermometer-lines', - 'max_temp': 60, - 'min_temp': 43, + 'max_temp': 60.0, + 'min_temp': 43.3, 'operation_list': list([ 'auto', 'on', @@ -25,13 +25,13 @@ ]), 'operation_mode': 'off', 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': '2024-07-10T13:00:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_state': 'Off', - 'this_sp_from': '2024-07-10T12:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_state': 'On', }), 'state_status': dict({ @@ -60,11 +60,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'away_mode': 'on', - 'current_temperature': 23, + 'current_temperature': 23.0, 'friendly_name': 'Domestic Hot Water', 'icon': 'mdi:thermometer-lines', - 'max_temp': 60, - 'min_temp': 43, + 'max_temp': 60.0, + 'min_temp': 43.3, 'operation_list': list([ 'auto', 'on', @@ -72,13 +72,13 @@ ]), 'operation_mode': 'off', 'status': dict({ - 'active_faults': list([ - ]), + 'activeFaults': tuple( + ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': '2024-07-10T13:00:00+01:00', + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'next_sp_state': 'Off', - 'this_sp_from': '2024-07-10T12:00:00+01:00', + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), 'this_sp_state': 'On', }), 'state_status': dict({ diff --git a/tests/components/evohome/test_climate.py b/tests/components/evohome/test_climate.py index 325dd914bc0..b1b930c6382 100644 --- a/tests/components/evohome/test_climate.py +++ b/tests/components/evohome/test_climate.py @@ -65,7 +65,7 @@ async def test_ctl_set_hvac_mode( results = [] # SERVICE_SET_HVAC_MODE: HVACMode.OFF - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -76,14 +76,15 @@ async def test_ctl_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Off", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] # SERVICE_SET_HVAC_MODE: HVACMode.HEAT - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -94,11 +95,12 @@ async def test_ctl_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("Auto", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Heat", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -134,7 +136,7 @@ async def test_ctl_turn_off( results = [] # SERVICE_TURN_OFF - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_OFF, @@ -144,11 +146,12 @@ async def test_ctl_turn_off( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'HeatingOff' or 'Off' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("HeatingOff", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Off", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -164,7 +167,7 @@ async def test_ctl_turn_on( results = [] # SERVICE_TURN_ON - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_ON, @@ -174,11 +177,12 @@ async def test_ctl_turn_on( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args != () # 'Auto' or 'Heat' - assert mock_fcn.await_args.kwargs == {"until": None} + try: + mock_fcn.assert_awaited_once_with("Auto", until=None) + except AssertionError: + mock_fcn.assert_awaited_once_with("Heat", until=None) - results.append(mock_fcn.await_args.args) + results.append(mock_fcn.await_args.args) # type: ignore[union-attr] assert results == snapshot @@ -194,7 +198,7 @@ async def test_zone_set_hvac_mode( results = [] # SERVICE_SET_HVAC_MODE: HVACMode.HEAT - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_HVAC_MODE, @@ -205,9 +209,7 @@ async def test_zone_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_HVAC_MODE: HVACMode.OFF with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: @@ -221,7 +223,9 @@ async def test_zone_set_hvac_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # minimum target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -243,7 +247,7 @@ async def test_zone_set_preset_mode( results = [] # SERVICE_SET_PRESET_MODE: none - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_SET_PRESET_MODE, @@ -254,9 +258,7 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_PRESET_MODE: permanent with patch("evohomeasync2.zone.Zone.set_temperature") as mock_fcn: @@ -270,7 +272,9 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # current target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -288,7 +292,9 @@ async def test_zone_set_preset_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # current target temp assert mock_fcn.await_args.kwargs != {} # next setpoint dtm @@ -302,12 +308,10 @@ async def test_zone_set_preset_mode( async def test_zone_set_temperature( hass: HomeAssistant, zone_id: str, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test SERVICE_SET_TEMPERATURE of an evohome heating zone.""" - freezer.move_to("2024-07-10T12:00:00Z") results = [] # SERVICE_SET_TEMPERATURE: temperature @@ -322,7 +326,9 @@ async def test_zone_set_temperature( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == (19.1,) assert mock_fcn.await_args.kwargs != {} # next setpoint dtm @@ -352,7 +358,9 @@ async def test_zone_turn_off( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args != () # minimum target temp assert mock_fcn.await_args.kwargs == {"until": None} @@ -369,7 +377,7 @@ async def test_zone_turn_on( """Test SERVICE_TURN_ON of an evohome heating zone.""" # SERVICE_TURN_ON - with patch("evohomeasync2.zone.Zone.reset_mode") as mock_fcn: + with patch("evohomeasync2.zone.Zone.reset") as mock_fcn: await hass.services.async_call( Platform.CLIMATE, SERVICE_TURN_ON, @@ -379,6 +387,4 @@ async def test_zone_turn_on( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() diff --git a/tests/components/evohome/test_coordinator.py b/tests/components/evohome/test_coordinator.py new file mode 100644 index 00000000000..7fb325d55b9 --- /dev/null +++ b/tests/components/evohome/test_coordinator.py @@ -0,0 +1,55 @@ +"""The tests for the evohome coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from evohomeasync2 import EvohomeClient +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.evohome import EvoData +from homeassistant.components.evohome.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize("install", ["minimal"]) +async def test_setup_platform( + hass: HomeAssistant, + config: dict[str, str], + evohome: EvohomeClient, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities and their states after setup of evohome.""" + + evo_data: EvoData = hass.data.get(DOMAIN) # type: ignore[assignment] + update_interval: timedelta = evo_data.coordinator.update_interval # type: ignore[assignment] + + # confirm initial state after coordinator.async_first_refresh()... + state = hass.states.get("climate.my_home") + assert state is not None and state.state != STATE_UNAVAILABLE + + with patch( + "homeassistant.components.evohome.coordinator.EvoDataUpdateCoordinator._async_update_data", + side_effect=UpdateFailed, + ): + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # confirm appropriate response to loss of state... + state = hass.states.get("climate.my_home") + assert state is not None and state.state == STATE_UNAVAILABLE + + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # if coordinator is working, the state will be restored + state = hass.states.get("climate.my_home") + assert state is not None and state.state != STATE_UNAVAILABLE diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index 9b5fe6ad62d..d327bdf14b4 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -4,80 +4,130 @@ from __future__ import annotations from http import HTTPStatus import logging -from unittest.mock import patch +from unittest.mock import Mock, patch +import aiohttp from evohomeasync2 import EvohomeClient, exceptions as exc -from evohomeasync2.broker import _ERR_MSG_LOOKUP_AUTH, _ERR_MSG_LOOKUP_BASE import pytest -from syrupy import SnapshotAssertion +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.evohome import DOMAIN, EvoService +from homeassistant.components.evohome.const import DOMAIN, EvoService from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .conftest import mock_post_request from .const import TEST_INSTALLS -SETUP_FAILED_ANTICIPATED = ( +_MSG_429 = ( + "You have exceeded the server's API rate limit. Wait a while " + "and try again (consider reducing your polling interval)." +) +_MSG_OTH = ( + "Unable to contact the vendor's server. Check your network " + "and review the vendor's status page, https://status.resideo.com." +) +_MSG_USR = ( + "Failed to authenticate. Check the username/password. Note that some " + "special characters accepted via the vendor's website are not valid here." +) + +LOG_HINT_429_CREDS = ("evohome.credentials", logging.ERROR, _MSG_429) +LOG_HINT_OTH_CREDS = ("evohome.credentials", logging.ERROR, _MSG_OTH) +LOG_HINT_USR_CREDS = ("evohome.credentials", logging.ERROR, _MSG_USR) + +LOG_HINT_429_AUTH = ("evohome.auth", logging.ERROR, _MSG_429) +LOG_HINT_OTH_AUTH = ("evohome.auth", logging.ERROR, _MSG_OTH) +LOG_HINT_USR_AUTH = ("evohome.auth", logging.ERROR, _MSG_USR) + +LOG_FAIL_CONNECTION = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: Authenticator response is invalid: Connection error", +) +LOG_FAIL_CREDENTIALS = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: {'error': 'invalid_grant'}", +) +LOG_FAIL_GATEWAY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: 502 Bad Gateway, response=None", +) +LOG_FAIL_TOO_MANY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "Authenticator response is invalid: 429 Too Many Requests, response=None", +) + +LOG_FGET_CONNECTION = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "Connection error", +) +LOG_FGET_GATEWAY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "502 Bad Gateway, response=None", +) +LOG_FGET_TOO_MANY = ( + "homeassistant.components.evohome", + logging.ERROR, + "Failed to fetch initial data: " + "GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: " + "429 Too Many Requests, response=None", +) + + +LOG_SETUP_FAILED = ( "homeassistant.setup", logging.ERROR, "Setup failed for 'evohome': Integration failed to initialize.", ) -SETUP_FAILED_UNEXPECTED = ( - "homeassistant.setup", - logging.ERROR, - "Error during setup of component evohome: ", + +EXC_BAD_CONNECTION = aiohttp.ClientConnectionError( + "Connection error", ) -AUTHENTICATION_FAILED = ( - "homeassistant.components.evohome.helpers", - logging.ERROR, - "Failed to authenticate with the vendor's server. Check your username" - " and password. NB: Some special password characters that work" - " correctly via the website will not work via the web API. Message" - " is: ", +EXC_BAD_CREDENTIALS = exc.AuthenticationFailedError( + "Authenticator response is invalid: {'error': 'invalid_grant'}", + status=HTTPStatus.BAD_REQUEST, ) -REQUEST_FAILED_NONE = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "Unable to connect with the vendor's server. " - "Check your network and the vendor's service status page. " - "Message is: ", +EXC_TOO_MANY_REQUESTS = aiohttp.ClientResponseError( + Mock(), + (), + status=HTTPStatus.TOO_MANY_REQUESTS, + message=HTTPStatus.TOO_MANY_REQUESTS.phrase, ) -REQUEST_FAILED_503 = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "The vendor says their server is currently unavailable. " - "Check the vendor's service status page", -) -REQUEST_FAILED_429 = ( - "homeassistant.components.evohome.helpers", - logging.WARNING, - "The vendor's API rate limit has been exceeded. " - "If this message persists, consider increasing the scan_interval", +EXC_BAD_GATEWAY = aiohttp.ClientResponseError( + Mock(), (), status=HTTPStatus.BAD_GATEWAY, message=HTTPStatus.BAD_GATEWAY.phrase ) -REQUEST_FAILED_LOOKUP = { - None: [ - REQUEST_FAILED_NONE, - SETUP_FAILED_ANTICIPATED, - ], - HTTPStatus.SERVICE_UNAVAILABLE: [ - REQUEST_FAILED_503, - SETUP_FAILED_ANTICIPATED, - ], - HTTPStatus.TOO_MANY_REQUESTS: [ - REQUEST_FAILED_429, - SETUP_FAILED_ANTICIPATED, - ], +AUTHENTICATION_TESTS: dict[Exception, list] = { + EXC_BAD_CONNECTION: [LOG_HINT_OTH_CREDS, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED], + EXC_BAD_CREDENTIALS: [LOG_HINT_USR_CREDS, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED], + EXC_BAD_GATEWAY: [LOG_HINT_OTH_CREDS, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED], + EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_CREDS, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED], +} + +CLIENT_REQUEST_TESTS: dict[Exception, list] = { + EXC_BAD_CONNECTION: [LOG_HINT_OTH_AUTH, LOG_FGET_CONNECTION, LOG_SETUP_FAILED], + EXC_BAD_GATEWAY: [LOG_HINT_OTH_AUTH, LOG_FGET_GATEWAY, LOG_SETUP_FAILED], + EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_AUTH, LOG_FGET_TOO_MANY, LOG_SETUP_FAILED], } -@pytest.mark.parametrize( - "status", [*sorted([*_ERR_MSG_LOOKUP_AUTH, HTTPStatus.BAD_GATEWAY]), None] -) +@pytest.mark.parametrize("exception", AUTHENTICATION_TESTS) async def test_authentication_failure_v2( hass: HomeAssistant, config: dict[str, str], - status: HTTPStatus, + exception: Exception, caplog: pytest.LogCaptureFixture, ) -> None: """Test failure to setup an evohome-compatible system. @@ -85,27 +135,24 @@ async def test_authentication_failure_v2( In this instance, the failure occurs in the v2 API. """ - with patch("evohomeasync2.broker.Broker.get") as mock_fcn: - mock_fcn.side_effect = exc.AuthenticationFailed("", status=status) - - with caplog.at_level(logging.WARNING): - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + with ( + patch( + "evohome.credentials.CredentialsManagerBase._request", side_effect=exception + ), + caplog.at_level(logging.WARNING), + ): + result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) assert result is False - assert caplog.record_tuples == [ - AUTHENTICATION_FAILED, - SETUP_FAILED_ANTICIPATED, - ] + assert caplog.record_tuples == AUTHENTICATION_TESTS[exception] -@pytest.mark.parametrize( - "status", [*sorted([*_ERR_MSG_LOOKUP_BASE, HTTPStatus.BAD_GATEWAY]), None] -) +@pytest.mark.parametrize("exception", CLIENT_REQUEST_TESTS) async def test_client_request_failure_v2( hass: HomeAssistant, config: dict[str, str], - status: HTTPStatus, + exception: Exception, caplog: pytest.LogCaptureFixture, ) -> None: """Test failure to setup an evohome-compatible system. @@ -113,17 +160,19 @@ async def test_client_request_failure_v2( In this instance, the failure occurs in the v2 API. """ - with patch("evohomeasync2.broker.Broker.get") as mock_fcn: - mock_fcn.side_effect = exc.RequestFailed("", status=status) - - with caplog.at_level(logging.WARNING): - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + with ( + patch( + "evohomeasync2.auth.CredentialsManagerBase._post_request", + mock_post_request("default"), + ), + patch("evohome.auth.AbstractAuth._request", side_effect=exception), + caplog.at_level(logging.WARNING), + ): + result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) assert result is False - assert caplog.record_tuples == REQUEST_FAILED_LOOKUP.get( - status, [SETUP_FAILED_UNEXPECTED] - ) + assert caplog.record_tuples == CLIENT_REQUEST_TESTS[exception] @pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"]) @@ -148,7 +197,7 @@ async def test_service_refresh_system( """Test EvoService.REFRESH_SYSTEM of an evohome system.""" # EvoService.REFRESH_SYSTEM - with patch("evohomeasync2.location.Location.refresh_status") as mock_fcn: + with patch("evohomeasync2.location.Location.update") as mock_fcn: await hass.services.async_call( DOMAIN, EvoService.REFRESH_SYSTEM, @@ -156,9 +205,7 @@ async def test_service_refresh_system( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() @pytest.mark.parametrize("install", ["default"]) @@ -169,7 +216,7 @@ async def test_service_reset_system( """Test EvoService.RESET_SYSTEM of an evohome system.""" # EvoService.RESET_SYSTEM (if SZ_AUTO_WITH_RESET in modes) - with patch("evohomeasync2.controlsystem.ControlSystem.set_mode") as mock_fcn: + with patch("evohomeasync2.control_system.ControlSystem.set_mode") as mock_fcn: await hass.services.async_call( DOMAIN, EvoService.RESET_SYSTEM, @@ -177,6 +224,4 @@ async def test_service_reset_system( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == ("AutoWithReset",) - assert mock_fcn.await_args.kwargs == {"until": None} + mock_fcn.assert_awaited_once_with("AutoWithReset", until=None) diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index b3597352487..4528f1c8590 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -7,13 +7,7 @@ from typing import Any, Final, NotRequired, TypedDict import pytest -from homeassistant.components.evohome import ( - CONF_USERNAME, - DOMAIN, - STORAGE_KEY, - STORAGE_VER, - dt_aware_to_naive, -) +from homeassistant.components.evohome.const import DOMAIN, STORAGE_KEY, STORAGE_VER from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -22,7 +16,8 @@ from .const import ACCESS_TOKEN, REFRESH_TOKEN, SESSION_ID, USERNAME class _SessionDataT(TypedDict): - sessionId: str + session_id: str + session_id_expires: NotRequired[str] # 2024-07-27T23:57:30+01:00 class _TokenStoreT(TypedDict): @@ -65,7 +60,7 @@ _TEST_STORAGE_BASE: Final[_TokenStoreT] = { TEST_STORAGE_DATA: Final[dict[str, _TokenStoreT]] = { "sans_session_id": _TEST_STORAGE_BASE, "null_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: None}, # type: ignore[dict-item] - "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"sessionId": SESSION_ID}}, + "with_session_id": _TEST_STORAGE_BASE | {SZ_USER_DATA: {"session_id": SESSION_ID}}, } TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = { @@ -89,15 +84,12 @@ async def test_auth_tokens_null( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when no cached tokens in the store.""" + """Test credentials manager when cache is empty.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_NULL[idx]} - async for mock_client in setup_evohome(hass, config, install=install): - # Confirm client was instantiated without tokens, as cache was empty... - assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] @@ -120,17 +112,12 @@ async def test_auth_tokens_same( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when matching username.""" + """Test credentials manager when cache contains valid data for this user.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} - async for mock_client in setup_evohome(hass, config, install=install): - # Confirm client was instantiated with the cached tokens... - assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN - assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN - assert mock_client.call_args.kwargs[ - SZ_ACCESS_TOKEN_EXPIRES - ] == dt_aware_to_naive(ACCESS_TOKEN_EXP_DTM) + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] @@ -150,7 +137,7 @@ async def test_auth_tokens_past( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens with matching username, but expired.""" + """Test credentials manager when cache contains expired data for this user.""" dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) @@ -160,19 +147,14 @@ async def test_auth_tokens_past( hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} - async for mock_client in setup_evohome(hass, config, install=install): - # Confirm client was instantiated with the cached tokens... - assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN - assert mock_client.call_args.kwargs[SZ_ACCESS_TOKEN] == ACCESS_TOKEN - assert mock_client.call_args.kwargs[ - SZ_ACCESS_TOKEN_EXPIRES - ] == dt_aware_to_naive(dt_dtm) + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] assert data[SZ_USERNAME] == USERNAME_SAME - assert data[SZ_REFRESH_TOKEN] == REFRESH_TOKEN + assert data[SZ_REFRESH_TOKEN] == f"new_{REFRESH_TOKEN}" assert data[SZ_ACCESS_TOKEN] == f"new_{ACCESS_TOKEN}" assert ( dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES], raise_on_error=True) @@ -189,17 +171,13 @@ async def test_auth_tokens_diff( idx: str, install: str, ) -> None: - """Test loading/saving authentication tokens when unmatched username.""" + """Test credentials manager when cache contains data for a different user.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} + config["username"] = USERNAME_DIFF - async for mock_client in setup_evohome( - hass, config | {CONF_USERNAME: USERNAME_DIFF}, install=install - ): - # Confirm client was instantiated without tokens, as username was different... - assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN not in mock_client.call_args.kwargs - assert SZ_ACCESS_TOKEN_EXPIRES not in mock_client.call_args.kwarg + async for _ in setup_evohome(hass, config, install=install): + pass # Confirm the expected tokens were cached to storage... data: _TokenStoreT = hass_storage[DOMAIN]["data"] diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index 8acfd469b59..a201ff63d1e 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -67,7 +67,7 @@ async def test_set_operation_mode( results = [] # SERVICE_SET_OPERATION_MODE: auto - with patch("evohomeasync2.hotwater.HotWater.reset_mode") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -78,12 +78,10 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # SERVICE_SET_OPERATION_MODE: off (until next scheduled setpoint) - with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -94,14 +92,16 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == () assert mock_fcn.await_args.kwargs != {} results.append(mock_fcn.await_args.kwargs) # SERVICE_SET_OPERATION_MODE: on (until next scheduled setpoint) - with patch("evohomeasync2.hotwater.HotWater.set_on") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.on") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_OPERATION_MODE, @@ -112,7 +112,9 @@ async def test_set_operation_mode( blocking=True, ) - assert mock_fcn.await_count == 1 + mock_fcn.assert_awaited_once() + + assert mock_fcn.await_args is not None # mypy hint assert mock_fcn.await_args.args == () assert mock_fcn.await_args.kwargs != {} @@ -126,7 +128,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non """Test SERVICE_SET_AWAY_MODE of an evohome DHW zone.""" # set_away_mode: off - with patch("evohomeasync2.hotwater.HotWater.reset_mode") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.reset") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_AWAY_MODE, @@ -137,12 +139,10 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() # set_away_mode: on - with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn: + with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn: await hass.services.async_call( Platform.WATER_HEATER, SERVICE_SET_AWAY_MODE, @@ -153,9 +153,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non blocking=True, ) - assert mock_fcn.await_count == 1 - assert mock_fcn.await_args.args == () - assert mock_fcn.await_args.kwargs == {} + mock_fcn.assert_awaited_once_with() @pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) From eab510f4400425b6cfaa1b4b4099a8b5095f2f69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Feb 2025 08:47:01 -0600 Subject: [PATCH 0105/1941] Fix tplink child updates taking up to 60s (#137782) * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Revert "Fix tplink child updates taking up to 60s" This reverts commit 5cd20a120f772b8df96ec32890b071b22135895e. --- homeassistant/components/tplink/coordinator.py | 14 +++++++++++++- homeassistant/components/tplink/entity.py | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index d1b4694779d..fcd1335a77a 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -46,9 +46,11 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): device: Device, update_interval: timedelta, config_entry: TPLinkConfigEntry, + parent_coordinator: TPLinkDataUpdateCoordinator | None = None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device + self.parent_coordinator = parent_coordinator # The iot HS300 allows a limited number of concurrent requests and # fetching the emeter information requires separate ones, so child @@ -95,6 +97,12 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) from ex await self._process_child_devices() + if not self._update_children: + # If the children are not being updated, it means this is an + # IotStrip, and we need to tell the children to write state + # since the power state is provided by the parent. + for child_coordinator in self._child_coordinators.values(): + child_coordinator.async_set_updated_data(None) async def _process_child_devices(self) -> None: """Process child devices and remove stale devices.""" @@ -132,7 +140,11 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): # The child coordinators only update energy data so we can # set a longer update interval to avoid flooding the device child_coordinator = TPLinkDataUpdateCoordinator( - self.hass, child, timedelta(seconds=60), self.config_entry + self.hass, + child, + timedelta(seconds=60), + self.config_entry, + parent_coordinator=self, ) self._child_coordinators[child.device_id] = child_coordinator return child_coordinator diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 15c07655e69..7a0d811b30d 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -151,7 +151,13 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( "exc": str(ex), }, ) from ex - await self.coordinator.async_request_refresh() + coordinator = self.coordinator + if coordinator.parent_coordinator: + # If there is a parent coordinator we need to refresh + # the parent as its what provides the power state data + # for the child entities. + coordinator = coordinator.parent_coordinator + await coordinator.async_request_refresh() return _async_wrap From 7bf81037c1ed1098b5401e3702ac4091c0811d74 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Feb 2025 15:48:31 +0100 Subject: [PATCH 0106/1941] Add Peblar charge switch (#137853) * Add Peblar charge switch * Update snapshots --- .../components/peblar/coordinator.py | 3 + homeassistant/components/peblar/icons.json | 3 + homeassistant/components/peblar/number.py | 150 +++++++++++------- homeassistant/components/peblar/strings.json | 3 + homeassistant/components/peblar/switch.py | 29 +++- .../peblar/snapshots/test_switch.ambr | 46 ++++++ tests/components/peblar/test_number.py | 108 +++++++++++-- tests/components/peblar/test_switch.py | 37 ++++- 8 files changed, 296 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 058f2aefb3b..36708b207c5 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -34,6 +34,7 @@ class PeblarRuntimeData: """Class to hold runtime data.""" data_coordinator: PeblarDataUpdateCoordinator + last_known_charging_limit = 6 system_information: PeblarSystemInformation user_configuration_coordinator: PeblarUserConfigurationDataUpdateCoordinator version_coordinator: PeblarVersionDataUpdateCoordinator @@ -137,6 +138,8 @@ class PeblarVersionDataUpdateCoordinator( class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): """Class to manage fetching Peblar active data.""" + config_entry: PeblarConfigEntry + def __init__( self, hass: HomeAssistant, entry: PeblarConfigEntry, api: PeblarApi ) -> None: diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index 6244945077b..a954d112c4a 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -36,6 +36,9 @@ } }, "switch": { + "charge": { + "default": "mdi:ev-plug-type2" + }, "force_single_phase": { "default": "mdi:power-cycle" } diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py index 1a7cec43295..0e929a63523 100644 --- a/homeassistant/components/peblar/number.py +++ b/homeassistant/components/peblar/number.py @@ -2,58 +2,27 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any - -from peblar import PeblarApi - from homeassistant.components.number import ( NumberDeviceClass, - NumberEntity, NumberEntityDescription, + RestoreNumber, ) -from homeassistant.const import EntityCategory, UnitOfElectricCurrent -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + EntityCategory, + UnitOfElectricCurrent, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import ( - PeblarConfigEntry, - PeblarData, - PeblarDataUpdateCoordinator, - PeblarRuntimeData, -) +from .coordinator import PeblarConfigEntry, PeblarDataUpdateCoordinator from .entity import PeblarEntity from .helpers import peblar_exception_handler PARALLEL_UPDATES = 1 -@dataclass(frozen=True, kw_only=True) -class PeblarNumberEntityDescription(NumberEntityDescription): - """Describe a Peblar number.""" - - native_max_value_fn: Callable[[PeblarRuntimeData], int] - set_value_fn: Callable[[PeblarApi, float], Awaitable[Any]] - value_fn: Callable[[PeblarData], int | None] - - -DESCRIPTIONS = [ - PeblarNumberEntityDescription( - key="charge_current_limit", - translation_key="charge_current_limit", - device_class=NumberDeviceClass.CURRENT, - entity_category=EntityCategory.CONFIG, - native_step=1, - native_min_value=6, - native_max_value_fn=lambda x: x.user_configuration_coordinator.data.user_defined_charge_limit_current, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - set_value_fn=lambda x, v: x.ev_interface(charge_current_limit=int(v) * 1000), - value_fn=lambda x: round(x.ev.charge_current_limit / 1000), - ), -] - - async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, @@ -61,42 +30,101 @@ async def async_setup_entry( ) -> None: """Set up Peblar number based on a config entry.""" async_add_entities( - PeblarNumberEntity( - entry=entry, - coordinator=entry.runtime_data.data_coordinator, - description=description, - ) - for description in DESCRIPTIONS + [ + PeblarChargeCurrentLimitNumberEntity( + entry=entry, + coordinator=entry.runtime_data.data_coordinator, + ) + ] ) -class PeblarNumberEntity( +class PeblarChargeCurrentLimitNumberEntity( PeblarEntity[PeblarDataUpdateCoordinator], - NumberEntity, + RestoreNumber, ): - """Defines a Peblar number.""" + """Defines a Peblar charge current limit number. - entity_description: PeblarNumberEntityDescription + This entity is a little bit different from the other entities, any value + below 6 amps is ignored. It means the Peblar is not charging. + Peblar has assigned a dual functionality to the charge current limit + number, it is used to set the current charging value and to start/stop/pauze + the charging process. + """ + + _attr_device_class = NumberDeviceClass.CURRENT + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value = 6 + _attr_native_step = 1 + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_translation_key = "charge_current_limit" def __init__( self, entry: PeblarConfigEntry, coordinator: PeblarDataUpdateCoordinator, - description: PeblarNumberEntityDescription, ) -> None: - """Initialize the Peblar entity.""" - super().__init__(entry=entry, coordinator=coordinator, description=description) - self._attr_native_max_value = description.native_max_value_fn( - entry.runtime_data + """Initialize the Peblar charge current limit entity.""" + super().__init__( + entry=entry, + coordinator=coordinator, + description=NumberEntityDescription(key="charge_current_limit"), ) + configuration = entry.runtime_data.user_configuration_coordinator.data + self._attr_native_max_value = configuration.user_defined_charge_limit_current - @property - def native_value(self) -> int | None: - """Return the number value.""" - return self.entity_description.value_fn(self.coordinator.data) + async def async_added_to_hass(self) -> None: + """Load the last known state when adding this entity.""" + if ( + (last_state := await self.async_get_last_state()) + and (last_number_data := await self.async_get_last_number_data()) + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and last_number_data.native_value + ): + self._attr_native_value = last_number_data.native_value + # Set the last known charging limit in the runtime data the + # start/stop/pauze functionality needs it in order to restore + # the last known charging limits when charging is resumed. + self.coordinator.config_entry.runtime_data.last_known_charging_limit = int( + last_number_data.native_value + ) + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update. + + Ignore any update that provides a ampere value that is below the + minimum value (6 amps). It means the Peblar is currently not charging. + """ + if ( + current_charge_limit := round( + self.coordinator.data.ev.charge_current_limit / 1000 + ) + ) < 6: + return + self._attr_native_value = current_charge_limit + # Update the last known charging limit in the runtime data the + # start/stop/pauze functionality needs it in order to restore + # the last known charging limits when charging is resumed. + self.coordinator.config_entry.runtime_data.last_known_charging_limit = ( + current_charge_limit + ) + super()._handle_coordinator_update() @peblar_exception_handler async def async_set_native_value(self, value: float) -> None: - """Change to new number value.""" - await self.entity_description.set_value_fn(self.coordinator.api, value) + """Change the current charging value.""" + # If charging is currently disabled (below 6 amps), just set the value + # as the native value and the last known charging limit in the runtime + # data. So we can pick it up once charging gets enabled again. + if self.coordinator.data.ev.charge_current_limit < 6000: + self._attr_native_value = int(value) + self.coordinator.config_entry.runtime_data.last_known_charging_limit = int( + value + ) + self.async_write_ha_state() + return + await self.coordinator.api.ev_interface(charge_current_limit=int(value) * 1000) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index a33667fa533..4a1500e54c5 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -153,6 +153,9 @@ } }, "switch": { + "charge": { + "name": "Charge" + }, "force_single_phase": { "name": "Force single phase" } diff --git a/homeassistant/components/peblar/switch.py b/homeassistant/components/peblar/switch.py index e56c2fcdaec..74a42ddc47d 100644 --- a/homeassistant/components/peblar/switch.py +++ b/homeassistant/components/peblar/switch.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from peblar import PeblarApi +from peblar import PeblarEVInterface from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -31,7 +31,19 @@ class PeblarSwitchEntityDescription(SwitchEntityDescription): has_fn: Callable[[PeblarRuntimeData], bool] = lambda x: True is_on_fn: Callable[[PeblarData], bool] - set_fn: Callable[[PeblarApi, bool], Awaitable[Any]] + set_fn: Callable[[PeblarDataUpdateCoordinator, bool], Awaitable[Any]] + + +def _async_peblar_charge( + coordinator: PeblarDataUpdateCoordinator, on: bool +) -> Awaitable[PeblarEVInterface]: + """Set the charge state.""" + charge_current_limit = 0 + if on: + charge_current_limit = ( + coordinator.config_entry.runtime_data.last_known_charging_limit * 1000 + ) + return coordinator.api.ev_interface(charge_current_limit=charge_current_limit) DESCRIPTIONS = [ @@ -44,7 +56,14 @@ DESCRIPTIONS = [ and x.user_configuration_coordinator.data.connected_phases > 1 ), is_on_fn=lambda x: x.ev.force_single_phase, - set_fn=lambda x, on: x.ev_interface(force_single_phase=on), + set_fn=lambda x, on: x.api.ev_interface(force_single_phase=on), + ), + PeblarSwitchEntityDescription( + key="charge", + translation_key="charge", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda x: (x.ev.charge_current_limit >= 6000), + set_fn=_async_peblar_charge, ), ] @@ -82,11 +101,11 @@ class PeblarSwitchEntity( @peblar_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.set_fn(self.coordinator.api, True) + await self.entity_description.set_fn(self.coordinator, True) await self.coordinator.async_request_refresh() @peblar_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.entity_description.set_fn(self.coordinator.api, False) + await self.entity_description.set_fn(self.coordinator, False) await self.coordinator.async_request_refresh() diff --git a/tests/components/peblar/snapshots/test_switch.ambr b/tests/components/peblar/snapshots/test_switch.ambr index 53829278593..426b48b6838 100644 --- a/tests/components/peblar/snapshots/test_switch.ambr +++ b/tests/components/peblar/snapshots/test_switch.ambr @@ -1,4 +1,50 @@ # serializer version: 1 +# name: test_entities[switch][switch.peblar_ev_charger_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.peblar_ev_charger_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge', + 'unique_id': '23-45-A4O-MOF_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch][switch.peblar_ev_charger_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Peblar EV Charger Charge', + }), + 'context': , + 'entity_id': 'switch.peblar_ev_charger_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entities[switch][switch.peblar_ev_charger_force_single_phase-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/peblar/test_number.py b/tests/components/peblar/test_number.py index 57469fecbc6..fa49b6ab116 100644 --- a/tests/components/peblar/test_number.py +++ b/tests/components/peblar/test_number.py @@ -14,18 +14,19 @@ from homeassistant.components.number import ( from homeassistant.components.peblar.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform - -pytestmark = [ - pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True), - pytest.mark.usefixtures("init_integration"), -] +from tests.common import ( + MockConfigEntry, + mock_restore_cache_with_extra_data, + snapshot_platform, +) +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration") async def test_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -48,7 +49,8 @@ async def test_entities( assert entity_entry.device_id == device_entry.id -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") async def test_number_set_value( hass: HomeAssistant, mock_peblar: MagicMock, @@ -73,6 +75,43 @@ async def test_number_set_value( mocked_method.mock_calls[0].assert_called_with({"charge_current_limit": 10}) +async def test_number_set_value_when_charging_is_suspended( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of setting the charging limit while charging is suspended.""" + entity_id = "number.peblar_ev_charger_charge_limit" + + # Suspend charging + mock_peblar.rest_api.return_value.ev_interface.return_value.charge_current_limit = 0 + + # Setup the config entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mocked_method = mock_peblar.rest_api.return_value.ev_interface + mocked_method.reset_mock() + + # Test normal happy path number value change + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 0 + + # Check the state is reflected + assert (state := hass.states.get(entity_id)) + assert state.state == "10" + + @pytest.mark.parametrize( ("error", "error_match", "translation_key", "translation_placeholders"), [ @@ -96,7 +135,8 @@ async def test_number_set_value( ), ], ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") async def test_number_set_value_communication_error( hass: HomeAssistant, mock_peblar: MagicMock, @@ -128,6 +168,8 @@ async def test_number_set_value_communication_error( assert excinfo.value.translation_placeholders == translation_placeholders +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration") async def test_number_set_value_authentication_error( hass: HomeAssistant, mock_peblar: MagicMock, @@ -175,3 +217,51 @@ async def test_number_set_value_authentication_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == mock_config_entry.entry_id + + +@pytest.mark.parametrize( + ("restore_state", "restore_native_value", "expected_state"), + [ + ("10", 10, "10"), + ("unknown", 10, "unknown"), + ("unavailable", 10, "unknown"), + ("10", None, "unknown"), + ], +) +async def test_restore_state( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, + restore_state: str, + restore_native_value: int, + expected_state: str, +) -> None: + """Test restoring the number state.""" + EXTRA_STORED_DATA = { + "native_max_value": 16, + "native_min_value": 6, + "native_step": 1, + "native_unit_of_measurement": "A", + "native_value": restore_native_value, + } + mock_restore_cache_with_extra_data( + hass, + ( + ( + State("number.peblar_ev_charger_charge_limit", restore_state), + EXTRA_STORED_DATA, + ), + ), + ) + + # Adjust Peblar client to have an ignored value for the charging limit + mock_peblar.rest_api.return_value.ev_interface.return_value.charge_current_limit = 0 + + # Setup the config entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check if state is restored and value is set correctly + assert (state := hass.states.get("number.peblar_ev_charger_charge_limit")) + assert state.state == expected_state diff --git a/tests/components/peblar/test_switch.py b/tests/components/peblar/test_switch.py index 75deeb2d5d3..a7dab51eb3a 100644 --- a/tests/components/peblar/test_switch.py +++ b/tests/components/peblar/test_switch.py @@ -49,10 +49,32 @@ async def test_entities( @pytest.mark.parametrize( - ("service", "force_single_phase"), + ("service", "entity_id", "parameter", "parameter_value"), [ - (SERVICE_TURN_ON, True), - (SERVICE_TURN_OFF, False), + ( + SERVICE_TURN_ON, + "switch.peblar_ev_charger_force_single_phase", + "force_single_phase", + True, + ), + ( + SERVICE_TURN_OFF, + "switch.peblar_ev_charger_force_single_phase", + "force_single_phase", + False, + ), + ( + SERVICE_TURN_ON, + "switch.peblar_ev_charger_charge", + "charge_current_limit", + 16, + ), + ( + SERVICE_TURN_OFF, + "switch.peblar_ev_charger_charge", + "charge_current_limit", + 0, + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -60,10 +82,11 @@ async def test_switch( hass: HomeAssistant, mock_peblar: MagicMock, service: str, - force_single_phase: bool, + entity_id: str, + parameter: str, + parameter_value: bool | int, ) -> None: """Test the Peblar EV charger switches.""" - entity_id = "switch.peblar_ev_charger_force_single_phase" mocked_method = mock_peblar.rest_api.return_value.ev_interface mocked_method.reset_mock() @@ -76,9 +99,7 @@ async def test_switch( ) assert len(mocked_method.mock_calls) == 2 - mocked_method.mock_calls[0].assert_called_with( - {"force_single_phase": force_single_phase} - ) + mocked_method.mock_calls[0].assert_called_with({parameter: parameter_value}) @pytest.mark.parametrize( From 8ddae828f7d440b7b5b6f1baa6522d5924b61386 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:51:05 +0100 Subject: [PATCH 0107/1941] Fix DAB radio in Onkyo (#137852) --- homeassistant/components/onkyo/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 97a82fc8a1a..acb57e594b8 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -92,7 +92,7 @@ SUPPORT_ONKYO = ( DEFAULT_PLAYABLE_SOURCES = ( InputSource.from_meaning("FM"), InputSource.from_meaning("AM"), - InputSource.from_meaning("TUNER"), + InputSource.from_meaning("DAB"), ) ATTR_PRESET = "preset" From 58eb8e15983d8d460ffb5a7e8baddd04cfd12ff1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:52:30 +0100 Subject: [PATCH 0108/1941] Move ForkedDaapdUpdater to separate module (#137654) * Move ForkedDaapdUpdater to separate module * Remove moved constants --- .../components/forked_daapd/coordinator.py | 142 ++++++++++++++++++ .../components/forked_daapd/media_player.py | 124 +-------------- 2 files changed, 143 insertions(+), 123 deletions(-) create mode 100644 homeassistant/components/forked_daapd/coordinator.py diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py new file mode 100644 index 00000000000..7a03a9075ed --- /dev/null +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -0,0 +1,142 @@ +"""Support forked_daapd media player.""" + +from __future__ import annotations + +import asyncio +import logging + +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + SIGNAL_ADD_ZONES, + SIGNAL_UPDATE_DATABASE, + SIGNAL_UPDATE_MASTER, + SIGNAL_UPDATE_OUTPUTS, + SIGNAL_UPDATE_PLAYER, + SIGNAL_UPDATE_QUEUE, +) + +_LOGGER = logging.getLogger(__name__) + +WS_NOTIFY_EVENT_TYPES = ["player", "outputs", "volume", "options", "queue", "database"] +WEBSOCKET_RECONNECT_TIME = 30 # seconds + + +class ForkedDaapdUpdater: + """Manage updates for the forked-daapd device.""" + + def __init__(self, hass, api, entry_id): + """Initialize.""" + self.hass = hass + self._api = api + self.websocket_handler = None + self._all_output_ids = set() + self._entry_id = entry_id + + async def async_init(self): + """Perform async portion of class initialization.""" + if not (server_config := await self._api.get_request("config")): + raise PlatformNotReady + if websocket_port := server_config.get("websocket_port"): + self.websocket_handler = asyncio.create_task( + self._api.start_websocket_handler( + websocket_port, + WS_NOTIFY_EVENT_TYPES, + self._update, + WEBSOCKET_RECONNECT_TIME, + self._disconnected_callback, + ) + ) + else: + _LOGGER.error("Invalid websocket port") + + async def _disconnected_callback(self): + """Send update signals when the websocket gets disconnected.""" + async_dispatcher_send( + self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), False + ) + async_dispatcher_send( + self.hass, SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), [] + ) + + async def _update(self, update_types): + """Private update method.""" + update_types = set(update_types) + update_events = {} + _LOGGER.debug("Updating %s", update_types) + if ( + "queue" in update_types + ): # update queue, queue before player for async_play_media + if queue := await self._api.get_request("queue"): + update_events["queue"] = asyncio.Event() + async_dispatcher_send( + self.hass, + SIGNAL_UPDATE_QUEUE.format(self._entry_id), + queue, + update_events["queue"], + ) + # order of below don't matter + if not {"outputs", "volume"}.isdisjoint(update_types): # update outputs + if outputs := await self._api.get_request("outputs"): + outputs = outputs["outputs"] + update_events["outputs"] = ( + asyncio.Event() + ) # only for master, zones should ignore + async_dispatcher_send( + self.hass, + SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), + outputs, + update_events["outputs"], + ) + self._add_zones(outputs) + if not {"database"}.isdisjoint(update_types): + pipes, playlists = await asyncio.gather( + self._api.get_pipes(), self._api.get_playlists() + ) + update_events["database"] = asyncio.Event() + async_dispatcher_send( + self.hass, + SIGNAL_UPDATE_DATABASE.format(self._entry_id), + pipes, + playlists, + update_events["database"], + ) + if not {"update", "config"}.isdisjoint(update_types): # not supported + _LOGGER.debug("update/config notifications neither requested nor supported") + if not {"player", "options", "volume"}.isdisjoint( + update_types + ): # update player + if player := await self._api.get_request("player"): + update_events["player"] = asyncio.Event() + if update_events.get("queue"): + await update_events[ + "queue" + ].wait() # make sure queue done before player for async_play_media + async_dispatcher_send( + self.hass, + SIGNAL_UPDATE_PLAYER.format(self._entry_id), + player, + update_events["player"], + ) + if update_events: + await asyncio.wait( + [asyncio.create_task(event.wait()) for event in update_events.values()] + ) # make sure callbacks done before update + async_dispatcher_send( + self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), True + ) + + def _add_zones(self, outputs): + outputs_to_add = [] + for output in outputs: + if output["id"] not in self._all_output_ids: + self._all_output_ids.add(output["id"]) + outputs_to_add.append(output) + if outputs_to_add: + async_dispatcher_send( + self.hass, + SIGNAL_ADD_ZONES.format(self._entry_id), + self._api, + outputs_to_add, + ) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 0116cc57e7b..8e61df3de45 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -31,7 +31,6 @@ from homeassistant.components.spotify import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -75,12 +74,10 @@ from .const import ( SUPPORTED_FEATURES_ZONE, TTS_TIMEOUT, ) +from .coordinator import ForkedDaapdUpdater _LOGGER = logging.getLogger(__name__) -WS_NOTIFY_EVENT_TYPES = ["player", "outputs", "volume", "options", "queue", "database"] -WEBSOCKET_RECONNECT_TIME = 30 # seconds - async def async_setup_entry( hass: HomeAssistant, @@ -897,122 +894,3 @@ class ForkedDaapdMaster(MediaPlayerEntity): if url := result.get("artwork_url"): return await self._async_fetch_image(self.api.full_url(url)) return None, None - - -class ForkedDaapdUpdater: - """Manage updates for the forked-daapd device.""" - - def __init__(self, hass, api, entry_id): - """Initialize.""" - self.hass = hass - self._api = api - self.websocket_handler = None - self._all_output_ids = set() - self._entry_id = entry_id - - async def async_init(self): - """Perform async portion of class initialization.""" - if not (server_config := await self._api.get_request("config")): - raise PlatformNotReady - if websocket_port := server_config.get("websocket_port"): - self.websocket_handler = asyncio.create_task( - self._api.start_websocket_handler( - websocket_port, - WS_NOTIFY_EVENT_TYPES, - self._update, - WEBSOCKET_RECONNECT_TIME, - self._disconnected_callback, - ) - ) - else: - _LOGGER.error("Invalid websocket port") - - async def _disconnected_callback(self): - """Send update signals when the websocket gets disconnected.""" - async_dispatcher_send( - self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), False - ) - async_dispatcher_send( - self.hass, SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), [] - ) - - async def _update(self, update_types): - """Private update method.""" - update_types = set(update_types) - update_events = {} - _LOGGER.debug("Updating %s", update_types) - if ( - "queue" in update_types - ): # update queue, queue before player for async_play_media - if queue := await self._api.get_request("queue"): - update_events["queue"] = asyncio.Event() - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_QUEUE.format(self._entry_id), - queue, - update_events["queue"], - ) - # order of below don't matter - if not {"outputs", "volume"}.isdisjoint(update_types): # update outputs - if outputs := await self._api.get_request("outputs"): - outputs = outputs["outputs"] - update_events["outputs"] = ( - asyncio.Event() - ) # only for master, zones should ignore - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), - outputs, - update_events["outputs"], - ) - self._add_zones(outputs) - if not {"database"}.isdisjoint(update_types): - pipes, playlists = await asyncio.gather( - self._api.get_pipes(), self._api.get_playlists() - ) - update_events["database"] = asyncio.Event() - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_DATABASE.format(self._entry_id), - pipes, - playlists, - update_events["database"], - ) - if not {"update", "config"}.isdisjoint(update_types): # not supported - _LOGGER.debug("update/config notifications neither requested nor supported") - if not {"player", "options", "volume"}.isdisjoint( - update_types - ): # update player - if player := await self._api.get_request("player"): - update_events["player"] = asyncio.Event() - if update_events.get("queue"): - await update_events[ - "queue" - ].wait() # make sure queue done before player for async_play_media - async_dispatcher_send( - self.hass, - SIGNAL_UPDATE_PLAYER.format(self._entry_id), - player, - update_events["player"], - ) - if update_events: - await asyncio.wait( - [asyncio.create_task(event.wait()) for event in update_events.values()] - ) # make sure callbacks done before update - async_dispatcher_send( - self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), True - ) - - def _add_zones(self, outputs): - outputs_to_add = [] - for output in outputs: - if output["id"] not in self._all_output_ids: - self._all_output_ids.add(output["id"]) - outputs_to_add.append(output) - if outputs_to_add: - async_dispatcher_send( - self.hass, - SIGNAL_ADD_ZONES.format(self._entry_id), - self._api, - outputs_to_add, - ) From 303ab750ab6f20c0497b6f62e96bca7702d334e4 Mon Sep 17 00:00:00 2001 From: jdelaney72 <20731268+jdelaney72@users.noreply.github.com> Date: Sat, 8 Feb 2025 06:56:54 -0800 Subject: [PATCH 0109/1941] Bump noaa-coops to version 0.4.0 (#137777) Bump noaa-coops 0.4.0 https://github.com/GClunies/noaa_coops/compare/2cd2fca..0972373 --- homeassistant/components/noaa_tides/manifest.json | 2 +- homeassistant/components/noaa_tides/sensor.py | 4 ++-- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/noaa_tides/manifest.json b/homeassistant/components/noaa_tides/manifest.json index 8cc81857770..02a189883bc 100644 --- a/homeassistant/components/noaa_tides/manifest.json +++ b/homeassistant/components/noaa_tides/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["noaa_coops"], "quality_scale": "legacy", - "requirements": ["noaa-coops==0.1.9"] + "requirements": ["noaa-coops==0.4.0"] } diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index f6ec9dc4bf2..0af2c340960 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -169,8 +169,8 @@ class NOAATidesAndCurrentsSensor(SensorEntity): api_data = df_predictions.head() self.data = NOAATidesData( time_stamp=list(api_data.index), - hi_lo=list(api_data["hi_lo"].values), - predicted_wl=list(api_data["predicted_wl"].values), + hi_lo=list(api_data["type"].values), + predicted_wl=list(api_data["v"].values), ) _LOGGER.debug("Data = %s", api_data) _LOGGER.debug( diff --git a/requirements_all.txt b/requirements_all.txt index 13abe012fcc..b00c07f01b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1501,7 +1501,7 @@ nice-go==1.0.1 niluclient==0.1.2 # homeassistant.components.noaa_tides -noaa-coops==0.1.9 +noaa-coops==0.4.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 7f6855045a8a1ca921f82f66a396a23d04e44000 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:02:24 +0100 Subject: [PATCH 0110/1941] Bump plugwise to v1.7.1 and adapt (#137599) * Bump plugwise v1.7.1 * Refresh test-fixtures * Adapt integration code * Adapt test code * Fixes * Save updated snapshot * Ruff fixes * More ruff fixes --- homeassistant/components/plugwise/__init__.py | 2 +- .../components/plugwise/binary_sensor.py | 9 +- homeassistant/components/plugwise/button.py | 7 +- homeassistant/components/plugwise/climate.py | 20 +- homeassistant/components/plugwise/const.py | 1 - .../components/plugwise/coordinator.py | 30 +- .../components/plugwise/diagnostics.py | 5 +- homeassistant/components/plugwise/entity.py | 12 +- .../components/plugwise/manifest.json | 2 +- homeassistant/components/plugwise/number.py | 2 +- homeassistant/components/plugwise/select.py | 2 +- homeassistant/components/plugwise/sensor.py | 2 +- homeassistant/components/plugwise/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plugwise/conftest.py | 124 +- .../fixtures/anna_heatpump_heating/data.json | 97 ++ .../plugwise/fixtures/legacy_anna/data.json | 60 + .../fixtures/m_adam_cooling/data.json | 203 +++ .../fixtures/m_adam_heating/data.json | 202 +++ .../plugwise/fixtures/m_adam_jip/data.json | 370 +++++ .../data.json | 584 ++++++++ .../m_anna_heatpump_cooling/data.json | 97 ++ .../fixtures/m_anna_heatpump_idle/data.json | 97 ++ .../fixtures/p1v4_442_single/data.json | 43 + .../fixtures/p1v4_442_triple/data.json | 56 + .../plugwise/fixtures/smile_p1_v2/data.json | 34 + .../plugwise/fixtures/stretch_v31/data.json | 136 ++ .../plugwise/snapshots/test_diagnostics.ambr | 1222 ++++++++--------- .../components/plugwise/test_binary_sensor.py | 2 + tests/components/plugwise/test_climate.py | 40 +- tests/components/plugwise/test_init.py | 12 +- tests/components/plugwise/test_number.py | 3 + tests/components/plugwise/test_select.py | 2 + tests/components/plugwise/test_sensor.py | 1 + 35 files changed, 2738 insertions(+), 747 deletions(-) create mode 100644 tests/components/plugwise/fixtures/anna_heatpump_heating/data.json create mode 100644 tests/components/plugwise/fixtures/legacy_anna/data.json create mode 100644 tests/components/plugwise/fixtures/m_adam_cooling/data.json create mode 100644 tests/components/plugwise/fixtures/m_adam_heating/data.json create mode 100644 tests/components/plugwise/fixtures/m_adam_jip/data.json create mode 100644 tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json create mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json create mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json create mode 100644 tests/components/plugwise/fixtures/p1v4_442_single/data.json create mode 100644 tests/components/plugwise/fixtures/p1v4_442_triple/data.json create mode 100644 tests/components/plugwise/fixtures/smile_p1_v2/data.json create mode 100644 tests/components/plugwise/fixtures/stretch_v31/data.json diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index a100103b029..f1cc7c6c11d 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -82,7 +82,7 @@ def migrate_sensor_entities( # Migrating opentherm_outdoor_temperature # to opentherm_outdoor_air_temperature sensor - for device_id, device in coordinator.data.devices.items(): + for device_id, device in coordinator.data.items(): if device["dev_class"] != "heater_central": continue diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 539fa243d6c..a4c6e051c78 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -100,11 +100,7 @@ async def async_setup_entry( async_add_entities( PlugwiseBinarySensorEntity(coordinator, device_id, description) for device_id in coordinator.new_devices - if ( - binary_sensors := coordinator.data.devices[device_id].get( - "binary_sensors" - ) - ) + if (binary_sensors := coordinator.data[device_id].get("binary_sensors")) for description in BINARY_SENSORS if description.key in binary_sensors ) @@ -141,7 +137,8 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): return None attrs: dict[str, list[str]] = {f"{severity}_msg": [] for severity in SEVERITIES} - if notify := self.coordinator.data.gateway["notifications"]: + gateway_id = self.coordinator.api.gateway_id + if notify := self.coordinator.data[gateway_id]["notifications"]: for details in notify.values(): for msg_type, msg in details.items(): msg_type = msg_type.lower() diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py index 8a05ede3496..139b358162c 100644 --- a/homeassistant/components/plugwise/button.py +++ b/homeassistant/components/plugwise/button.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import PlugwiseConfigEntry -from .const import GATEWAY_ID, REBOOT +from .const import REBOOT from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command @@ -24,11 +24,10 @@ async def async_setup_entry( """Set up the Plugwise buttons from a ConfigEntry.""" coordinator = entry.runtime_data - gateway = coordinator.data.gateway async_add_entities( PlugwiseButtonEntity(coordinator, device_id) - for device_id in coordinator.data.devices - if device_id == gateway[GATEWAY_ID] and REBOOT in gateway + for device_id in coordinator.data + if device_id == coordinator.api.gateway_id and coordinator.api.reboot ) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 3caed1e7bc2..7abdfcfde54 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -41,18 +41,17 @@ async def async_setup_entry( if not coordinator.new_devices: return - if coordinator.data.gateway["smile_name"] == "Adam": + if coordinator.api.smile_name == "Adam": async_add_entities( PlugwiseClimateEntity(coordinator, device_id) for device_id in coordinator.new_devices - if coordinator.data.devices[device_id]["dev_class"] == "climate" + if coordinator.data[device_id]["dev_class"] == "climate" ) else: async_add_entities( PlugwiseClimateEntity(coordinator, device_id) for device_id in coordinator.new_devices - if coordinator.data.devices[device_id]["dev_class"] - in MASTER_THERMOSTATS + if coordinator.data[device_id]["dev_class"] in MASTER_THERMOSTATS ) _add_entities() @@ -77,10 +76,8 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): super().__init__(coordinator, device_id) self._attr_unique_id = f"{device_id}-climate" - self._devices = coordinator.data.devices - self._gateway = coordinator.data.gateway - gateway_id: str = self._gateway["gateway_id"] - self._gateway_data = self._devices[gateway_id] + gateway_id: str = coordinator.api.gateway_id + self._gateway_data = coordinator.data[gateway_id] self._location = device_id if (location := self.device.get("location")) is not None: @@ -88,7 +85,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): # Determine supported features self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if self._gateway["cooling_present"] and self._gateway["smile_name"] != "Adam": + if ( + self.coordinator.api.cooling_present + and coordinator.api.smile_name != "Adam" + ): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) @@ -170,7 +170,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): if "available_schedules" in self.device: hvac_modes.append(HVACMode.AUTO) - if self._gateway["cooling_present"]: + if self.coordinator.api.cooling_present: if "regulation_modes" in self._gateway_data: if self._gateway_data["select_regulation_mode"] == "cooling": hvac_modes.append(HVACMode.COOL) diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index 5e4dea5586b..176ae39b1ba 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -17,7 +17,6 @@ FLOW_SMILE: Final = "smile (Adam/Anna/P1)" FLOW_STRETCH: Final = "stretch (Stretch)" FLOW_TYPE: Final = "flow_type" GATEWAY: Final = "gateway" -GATEWAY_ID: Final = "gateway_id" LOCATION: Final = "location" PW_TYPE: Final = "plugwise_type" REBOOT: Final = "reboot" diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 7ac0cc21c51..9a85ae2a5df 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta from packaging.version import Version -from plugwise import PlugwiseData, Smile +from plugwise import GwEntityData, Smile from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, @@ -22,10 +22,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, GATEWAY_ID, LOGGER +from .const import DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, LOGGER -class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): +class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData]]): """Class to manage fetching Plugwise data from single endpoint.""" _connected: bool = False @@ -63,10 +63,8 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): """Connect to the Plugwise Smile.""" version = await self.api.connect() self._connected = isinstance(version, Version) - if self._connected: - self.api.get_all_gateway_entities() - async def _async_update_data(self) -> PlugwiseData: + async def _async_update_data(self) -> dict[str, GwEntityData]: """Fetch data from Plugwise.""" try: if not self._connected: @@ -101,26 +99,28 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): self._async_add_remove_devices(data, self.config_entry) return data - def _async_add_remove_devices(self, data: PlugwiseData, entry: ConfigEntry) -> None: + def _async_add_remove_devices( + self, data: dict[str, GwEntityData], entry: ConfigEntry + ) -> None: """Add new Plugwise devices, remove non-existing devices.""" # Check for new or removed devices - self.new_devices = set(data.devices) - self._current_devices - removed_devices = self._current_devices - set(data.devices) - self._current_devices = set(data.devices) + self.new_devices = set(data) - self._current_devices + removed_devices = self._current_devices - set(data) + self._current_devices = set(data) if removed_devices: self._async_remove_devices(data, entry) - def _async_remove_devices(self, data: PlugwiseData, entry: ConfigEntry) -> None: + def _async_remove_devices( + self, data: dict[str, GwEntityData], entry: ConfigEntry + ) -> None: """Clean registries when removed devices found.""" device_reg = dr.async_get(self.hass) device_list = dr.async_entries_for_config_entry( device_reg, self.config_entry.entry_id ) # First find the Plugwise via_device - gateway_device = device_reg.async_get_device( - {(DOMAIN, data.gateway[GATEWAY_ID])} - ) + gateway_device = device_reg.async_get_device({(DOMAIN, self.api.gateway_id)}) assert gateway_device is not None via_device_id = gateway_device.id @@ -130,7 +130,7 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): if identifier[0] == DOMAIN: if ( device_entry.via_device_id == via_device_id - and identifier[1] not in data.devices + and identifier[1] not in data ): device_reg.async_update_device( device_entry.id, remove_config_entry_id=entry.entry_id diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py index 47ff7d1a9fb..a576e60dbe1 100644 --- a/homeassistant/components/plugwise/diagnostics.py +++ b/homeassistant/components/plugwise/diagnostics.py @@ -14,7 +14,4 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator = entry.runtime_data - return { - "devices": coordinator.data.devices, - "gateway": coordinator.data.gateway, - } + return coordinator.data diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index 3f63abaff43..39838c38fde 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -34,7 +34,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): if entry := self.coordinator.config_entry: configuration_url = f"http://{entry.data[CONF_HOST]}" - data = coordinator.data.devices[device_id] + data = coordinator.data[device_id] connections = set() if mac := data.get("mac_address"): connections.add((CONNECTION_NETWORK_MAC, mac)) @@ -48,18 +48,18 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): manufacturer=data.get("vendor"), model=data.get("model"), model_id=data.get("model_id"), - name=coordinator.data.gateway["smile_name"], + name=coordinator.api.smile_name, sw_version=data.get("firmware"), hw_version=data.get("hardware"), ) - if device_id != coordinator.data.gateway["gateway_id"]: + if device_id != coordinator.api.gateway_id: self._attr_device_info.update( { ATTR_NAME: data.get("name"), ATTR_VIA_DEVICE: ( DOMAIN, - str(self.coordinator.data.gateway["gateway_id"]), + str(self.coordinator.api.gateway_id), ), } ) @@ -68,7 +68,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): def available(self) -> bool: """Return if entity is available.""" return ( - self._dev_id in self.coordinator.data.devices + self._dev_id in self.coordinator.data and ("available" not in self.device or self.device["available"] is True) and super().available ) @@ -76,4 +76,4 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): @property def device(self) -> GwEntityData: """Return data for this device.""" - return self.coordinator.data.devices[self._dev_id] + return self.coordinator.data[self._dev_id] diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index f7bd646f801..983ff10b0a6 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.6.4"], + "requirements": ["plugwise==1.7.1"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 1d0b1382c24..2de49f17d4a 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -73,7 +73,7 @@ async def async_setup_entry( PlugwiseNumberEntity(coordinator, device_id, description) for device_id in coordinator.new_devices for description in NUMBER_TYPES - if description.key in coordinator.data.devices[device_id] + if description.key in coordinator.data[device_id] ) _add_entities() diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index ff268d8eded..307091f0ff9 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -71,7 +71,7 @@ async def async_setup_entry( PlugwiseSelectEntity(coordinator, device_id, description) for device_id in coordinator.new_devices for description in SELECT_TYPES - if description.options_key in coordinator.data.devices[device_id] + if description.options_key in coordinator.data[device_id] ) _add_entities() diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 14b42682376..8b630c39878 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -420,7 +420,7 @@ async def async_setup_entry( async_add_entities( PlugwiseSensorEntity(coordinator, device_id, description) for device_id in coordinator.new_devices - if (sensors := coordinator.data.devices[device_id].get("sensors")) + if (sensors := coordinator.data[device_id].get("sensors")) for description in SENSORS if description.key in sensors ) diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index ea6d6f18b7f..86496a4311e 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -72,7 +72,7 @@ async def async_setup_entry( async_add_entities( PlugwiseSwitchEntity(coordinator, device_id, description) for device_id in coordinator.new_devices - if (switches := coordinator.data.devices[device_id].get("switches")) + if (switches := coordinator.data[device_id].get("switches")) for description in SWITCHES if description.key in switches ) diff --git a/requirements_all.txt b/requirements_all.txt index b00c07f01b7..2d07a3aa061 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1663,7 +1663,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.6.4 +plugwise==1.7.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 580efd88992..c18706cbef0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1376,7 +1376,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.6.4 +plugwise==1.7.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index 92ed42aa03a..e0a61106101 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -8,7 +8,6 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from packaging.version import Version -from plugwise import PlugwiseData import pytest from homeassistant.components.plugwise.const import DOMAIN @@ -30,6 +29,15 @@ def _read_json(environment: str, call: str) -> dict[str, Any]: return json.loads(fixture) +@pytest.fixture +def cooling_present(request: pytest.FixtureRequest) -> str: + """Pass the cooling_present boolean. + + Used with fixtures that require parametrization of the cooling capability. + """ + return request.param + + @pytest.fixture def chosen_env(request: pytest.FixtureRequest) -> str: """Pass the chosen_env string. @@ -48,6 +56,24 @@ def gateway_id(request: pytest.FixtureRequest) -> str: return request.param +@pytest.fixture +def heater_id(request: pytest.FixtureRequest) -> str: + """Pass the heater_idstring. + + Used with fixtures that require parametrization of the heater_id. + """ + return request.param + + +@pytest.fixture +def reboot(request: pytest.FixtureRequest) -> str: + """Pass the reboot boolean. + + Used with fixtures that require parametrization of the reboot capability. + """ + return request.param + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -82,11 +108,14 @@ def mock_smile_config_flow() -> Generator[MagicMock]: autospec=True, ) as smile_mock: smile = smile_mock.return_value + + smile.connect.return_value = Version("4.3.2") smile.smile_hostname = "smile12345" smile.smile_model = "Test Model" smile.smile_model_id = "Test Model ID" smile.smile_name = "Test Smile Name" - smile.connect.return_value = Version("4.3.2") + smile.smile_version = "4.3.2" + yield smile @@ -94,7 +123,7 @@ def mock_smile_config_flow() -> Generator[MagicMock]: def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" chosen_env = "m_adam_multiple_devices_per_zone" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with ( patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True @@ -106,43 +135,45 @@ def mock_smile_adam() -> Generator[MagicMock]: ): smile = smile_mock.return_value + smile.async_update.return_value = data + smile.cooling_present = False + smile.connect.return_value = Version("3.0.15") smile.gateway_id = "fe799307f1624099878210aa0b9f1475" smile.heater_id = "90986d591dcd426cae3ec3e8111ff730" - smile.smile_version = "3.0.15" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" - smile.connect.return_value = Version("3.0.15") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "thermostat" + smile.smile_version = "3.0.15" yield smile @pytest.fixture -def mock_smile_adam_heat_cool(chosen_env: str) -> Generator[MagicMock]: +def mock_smile_adam_heat_cool( + chosen_env: str, cooling_present: bool +) -> Generator[MagicMock]: """Create a special base Mock Adam type for testing with different datasets.""" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.async_update.return_value = data smile.connect.return_value = Version("3.6.4") + smile.cooling_present = cooling_present smile.gateway_id = "da224107914542988a88561b4452b0f6" smile.heater_id = "056ee145a816487eaa69243c3280f8bf" - smile.smile_version = "3.6.4" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" + smile.smile_type = "thermostat" + smile.smile_version = "3.6.4" yield smile @@ -151,49 +182,49 @@ def mock_smile_adam_heat_cool(chosen_env: str) -> Generator[MagicMock]: def mock_smile_adam_jip() -> Generator[MagicMock]: """Create a Mock adam-jip type for testing exceptions.""" chosen_env = "m_adam_jip" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("3.2.8") + smile.cooling_present = False smile.gateway_id = "b5c2386c6f6342669e50fe49dd05b188" smile.heater_id = "e4684553153b44afbef2200885f379dc" - smile.smile_version = "3.2.8" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" - smile.connect.return_value = Version("3.2.8") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "thermostat" + smile.smile_version = "3.2.8" yield smile @pytest.fixture -def mock_smile_anna(chosen_env: str) -> Generator[MagicMock]: +def mock_smile_anna(chosen_env: str, cooling_present: bool) -> Generator[MagicMock]: """Create a Mock Anna type for testing.""" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.async_update.return_value = data smile.connect.return_value = Version("4.0.15") + smile.cooling_present = cooling_present smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.smile_version = "4.0.15" - smile.smile_type = "thermostat" + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile_thermo" smile.smile_name = "Smile Anna" + smile.smile_type = "thermostat" + smile.smile_version = "4.0.15" yield smile @@ -201,18 +232,17 @@ def mock_smile_anna(chosen_env: str) -> Generator[MagicMock]: @pytest.fixture def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: """Create a base Mock P1 type for testing with different datasets and gateway-ids.""" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.async_update.return_value = data smile.connect.return_value = Version("4.4.2") smile.gateway_id = gateway_id smile.heater_id = None + smile.reboot = True smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = "smile" @@ -227,24 +257,23 @@ def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: def mock_smile_legacy_anna() -> Generator[MagicMock]: """Create a Mock legacy Anna environment for testing exceptions.""" chosen_env = "legacy_anna" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("1.8.22") smile.gateway_id = "0000aaaa0000aaaa0000aaaa0000aa00" smile.heater_id = "04e4cbfe7f4340f090f85ec3b9e6a950" - smile.smile_version = "1.8.22" - smile.smile_type = "thermostat" + smile.reboot = False smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" smile.smile_model_id = None smile.smile_name = "Smile Anna" - smile.connect.return_value = Version("1.8.22") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "thermostat" + smile.smile_version = "1.8.22" yield smile @@ -253,24 +282,23 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: def mock_stretch() -> Generator[MagicMock]: """Create a Mock Stretch environment for testing exceptions.""" chosen_env = "stretch_v31" - all_data = _read_json(chosen_env, "all_data") + data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True ) as smile_mock: smile = smile_mock.return_value + smile.async_update.return_value = data + smile.connect.return_value = Version("3.1.11") smile.gateway_id = "259882df3c05415b99c2d962534ce820" smile.heater_id = None - smile.smile_version = "3.1.11" - smile.smile_type = "stretch" + smile.reboot = False smile.smile_hostname = "stretch98765" smile.smile_model = "Gateway" smile.smile_model_id = None smile.smile_name = "Stretch" - smile.connect.return_value = Version("3.1.11") - smile.async_update.return_value = PlugwiseData( - all_data["devices"], all_data["gateway"] - ) + smile.smile_type = "stretch" + smile.smile_version = "3.1.11" yield smile diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json new file mode 100644 index 00000000000..ab6bdf08e95 --- /dev/null +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/data.json @@ -0,0 +1,97 @@ +{ + "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.0.15", + "hardware": "AME Smile 2.0 board", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna", + "notifications": {}, + "sensors": { + "outdoor_temperature": 20.2 + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": true, + "cooling_enabled": false, + "cooling_state": false, + "dhw_state": false, + "flame_state": false, + "heating_state": true, + "secondary_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 46.3, + "intended_boiler_temperature": 35.0, + "modulation_level": 52, + "outdoor_air_temperature": 3.0, + "return_temperature": 25.1, + "water_pressure": 1.57, + "water_temperature": 29.1 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" + }, + "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard", "off"], + "climate_mode": "auto", + "control_state": "heating", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-5002", + "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], + "select_schedule": "standaard", + "sensors": { + "cooling_activation_outdoor_temperature": 21.0, + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "temperature": 19.3 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/legacy_anna/data.json b/tests/components/plugwise/fixtures/legacy_anna/data.json new file mode 100644 index 00000000000..cc7e66fb174 --- /dev/null +++ b/tests/components/plugwise/fixtures/legacy_anna/data.json @@ -0,0 +1,60 @@ +{ + "0000aaaa0000aaaa0000aaaa0000aa00": { + "dev_class": "gateway", + "firmware": "1.8.22", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "mac_address": "01:23:45:67:89:AB", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise" + }, + "04e4cbfe7f4340f090f85ec3b9e6a950": { + "binary_sensors": { + "flame_state": true, + "heating_state": true + }, + "dev_class": "heater_central", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "maximum_boiler_temperature": { + "lower_bound": 50.0, + "resolution": 1.0, + "setpoint": 50.0, + "upper_bound": 90.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 51.2, + "intended_boiler_temperature": 17.0, + "modulation_level": 0.0, + "return_temperature": 21.7, + "water_pressure": 1.2, + "water_temperature": 23.6 + }, + "vendor": "Bosch Thermotechniek B.V." + }, + "0d266432d64443e283b5d708ae98b455": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "heating", + "dev_class": "thermostat", + "firmware": "2017-03-13T11:54:58+01:00", + "hardware": "6539-1301-500", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], + "sensors": { + "illuminance": 150.8, + "setpoint": 20.5, + "temperature": 20.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/data.json b/tests/components/plugwise/fixtures/m_adam_cooling/data.json new file mode 100644 index 00000000000..51f19ca3c03 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_cooling/data.json @@ -0,0 +1,203 @@ +{ + "056ee145a816487eaa69243c3280f8bf": { + "available": true, + "binary_sensors": { + "cooling_state": true, + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "maximum_boiler_temperature": { + "lower_bound": 25.0, + "resolution": 0.01, + "setpoint": 50.0, + "upper_bound": 95.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 17.5, + "water_temperature": 19.0 + }, + "switches": { + "dhw_cm_switch": false + } + }, + "1772a4ea304041adb83f357b751341ff": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "setpoint": 18.0, + "temperature": 21.6, + "temperature_difference": -0.2, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C8FF5EE" + }, + "ad4838d7d35c4d6ea796ee12ae5aedf8": { + "available": true, + "dev_class": "thermostat", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "model": "ThermoTouch", + "model_id": "143.1", + "name": "Anna", + "sensors": { + "setpoint": 23.5, + "temperature": 25.8 + }, + "vendor": "Plugwise" + }, + "da224107914542988a88561b4452b0f6": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "mac_address": "012345679891", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": [ + "bleeding_hot", + "bleeding_cold", + "off", + "heating", + "cooling" + ], + "select_gateway_mode": "full", + "select_regulation_mode": "cooling", + "sensors": { + "outdoor_temperature": 29.65 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000D5A168D" + }, + "e2f4322d57924fa090fbbc48b3a140dc": { + "available": true, + "binary_sensors": { + "low_battery": true + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-10T02:00:00+02:00", + "hardware": "255", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Lisa", + "model_id": "158-01", + "name": "Lisa Badkamer", + "sensors": { + "battery": 14, + "setpoint": 23.5, + "temperature": 23.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C869B61" + }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "2568cc4b9c1e401495d4741a5f89bee1", + "29542b2b6a6a4169acecc15c72a599b8" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "cool", + "control_state": "cooling", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 25.8 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 23.5, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "auto", + "control_state": "cooling", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 23.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 25.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_heating/data.json b/tests/components/plugwise/fixtures/m_adam_heating/data.json new file mode 100644 index 00000000000..b10ff8ec2a8 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_heating/data.json @@ -0,0 +1,202 @@ +{ + "056ee145a816487eaa69243c3280f8bf": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": true + }, + "dev_class": "heater_central", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "max_dhw_temperature": { + "lower_bound": 40.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 25.0, + "resolution": 0.01, + "setpoint": 50.0, + "upper_bound": 95.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 38.1, + "water_temperature": 37.0 + }, + "switches": { + "dhw_cm_switch": false + } + }, + "1772a4ea304041adb83f357b751341ff": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Badkamer", + "sensors": { + "battery": 99, + "setpoint": 18.0, + "temperature": 18.6, + "temperature_difference": -0.2, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C8FF5EE" + }, + "ad4838d7d35c4d6ea796ee12ae5aedf8": { + "available": true, + "dev_class": "thermostat", + "location": "f2bf9048bef64cc5b6d5110154e33c81", + "model": "ThermoTouch", + "model_id": "143.1", + "name": "Anna", + "sensors": { + "setpoint": 20.0, + "temperature": 19.1 + }, + "vendor": "Plugwise" + }, + "da224107914542988a88561b4452b0f6": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.7.8", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "bc93488efab249e5bc54fd7e175a6f91", + "mac_address": "012345679891", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"], + "select_gateway_mode": "full", + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": -1.25 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000D5A168D" + }, + "e2f4322d57924fa090fbbc48b3a140dc": { + "available": true, + "binary_sensors": { + "low_battery": true + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-10T02:00:00+02:00", + "hardware": "255", + "location": "f871b8c4d63549319221e294e4f88074", + "model": "Lisa", + "model_id": "158-01", + "name": "Lisa Badkamer", + "sensors": { + "battery": 14, + "setpoint": 15.0, + "temperature": 17.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "000D6F000C869B61" + }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "2568cc4b9c1e401495d4741a5f89bee1", + "29542b2b6a6a4169acecc15c72a599b8" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "f2bf9048bef64cc5b6d5110154e33c81": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "heat", + "control_state": "preheating", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Living room", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 149.9, + "electricity_produced": 0.0, + "temperature": 19.1 + }, + "thermostat": { + "lower_bound": 1.0, + "resolution": 0.01, + "setpoint": 20.0, + "upper_bound": 35.0 + }, + "thermostats": { + "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "f871b8c4d63549319221e294e4f88074": { + "active_preset": "home", + "available_schedules": [ + "Badkamer", + "Test", + "Vakantie", + "Weekschema", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bathroom", + "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], + "select_schedule": "Badkamer", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 17.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], + "secondary": ["1772a4ea304041adb83f357b751341ff"] + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_jip/data.json b/tests/components/plugwise/fixtures/m_adam_jip/data.json new file mode 100644 index 00000000000..8de57910f66 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_jip/data.json @@ -0,0 +1,370 @@ +{ + "06aecb3d00354375924f50c47af36bd2": { + "active_preset": "no_frost", + "climate_mode": "off", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Slaapkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 24.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["1346fbd8498d4dbcab7e18d51b771f3d"], + "secondary": ["356b65335e274d769c338223e7af9c33"] + }, + "vendor": "Plugwise" + }, + "13228dab8ce04617af318a2888b3c548": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 27.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.01, + "setpoint": 9.0, + "upper_bound": 30.0 + }, + "thermostats": { + "primary": ["f61f1a2535f54f52ad006a3d18e459ca"], + "secondary": ["833de10f269c4deab58fb9df69901b4e"] + }, + "vendor": "Plugwise" + }, + "1346fbd8498d4dbcab7e18d51b771f3d": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "06aecb3d00354375924f50c47af36bd2", + "model": "Lisa", + "model_id": "158-01", + "name": "Slaapkamer", + "sensors": { + "battery": 92, + "setpoint": 13.0, + "temperature": 24.2 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03" + }, + "1da4d325838e4ad8aac12177214505c9": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "d58fec52899f4f1c92e4f8fad6d8c48c", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Logeerkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 28.8, + "temperature_difference": 2.0, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "356b65335e274d769c338223e7af9c33": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "06aecb3d00354375924f50c47af36bd2", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Slaapkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 24.2, + "temperature_difference": 1.7, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05" + }, + "457ce8414de24596a2d5e7dbc9c7682f": { + "available": true, + "dev_class": "zz_misc_plug", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "model": "Aqara Smart Plug", + "model_id": "lumi.plug.maeu01", + "name": "Plug", + "sensors": { + "electricity_consumed_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": false + }, + "vendor": "LUMI", + "zigbee_mac_address": "ABCD012345670A06" + }, + "6f3e9d7084214c21b9dfa46f6eeb8700": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "d27aede973b54be484f6842d1b2802ad", + "model": "Lisa", + "model_id": "158-01", + "name": "Kinderkamer", + "sensors": { + "battery": 79, + "setpoint": 13.0, + "temperature": 30.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "833de10f269c4deab58fb9df69901b4e": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "13228dab8ce04617af318a2888b3c548", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Woonkamer", + "sensors": { + "setpoint": 9.0, + "temperature": 24.0, + "temperature_difference": 1.8, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09" + }, + "a6abc6a129ee499c88a4d420cc413b47": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "d58fec52899f4f1c92e4f8fad6d8c48c", + "model": "Lisa", + "model_id": "158-01", + "name": "Logeerkamer", + "sensors": { + "battery": 80, + "setpoint": 13.0, + "temperature": 30.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "b5c2386c6f6342669e50fe49dd05b188": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "3.2.8", + "gateway_modes": ["away", "full", "vacation"], + "hardware": "AME Smile 2.0 board", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": {}, + "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], + "select_gateway_mode": "full", + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": 24.9 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + }, + "d27aede973b54be484f6842d1b2802ad": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Kinderkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["6f3e9d7084214c21b9dfa46f6eeb8700"], + "secondary": ["d4496250d0e942cfa7aea3476e9070d5"] + }, + "vendor": "Plugwise" + }, + "d4496250d0e942cfa7aea3476e9070d5": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2020-11-04T01:00:00+01:00", + "hardware": "1", + "location": "d27aede973b54be484f6842d1b2802ad", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Tom Kinderkamer", + "sensors": { + "setpoint": 13.0, + "temperature": 28.7, + "temperature_difference": 1.9, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" + }, + "d58fec52899f4f1c92e4f8fad6d8c48c": { + "active_preset": "home", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Logeerkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 30.0 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9 + }, + "thermostats": { + "primary": ["a6abc6a129ee499c88a4d420cc413b47"], + "secondary": ["1da4d325838e4ad8aac12177214505c9"] + }, + "vendor": "Plugwise" + }, + "e4684553153b44afbef2200885f379dc": { + "available": true, + "binary_sensors": { + "dhw_state": false, + "flame_state": false, + "heating_state": false + }, + "dev_class": "heater_central", + "location": "9e4433a9d69f40b3aefd15e74395eaec", + "max_dhw_temperature": { + "lower_bound": 40.0, + "resolution": 0.01, + "setpoint": 60.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 20.0, + "resolution": 0.01, + "setpoint": 90.0, + "upper_bound": 90.0 + }, + "model": "Generic heater", + "model_id": "10.20", + "name": "OpenTherm", + "sensors": { + "intended_boiler_temperature": 0.0, + "modulation_level": 0.0, + "return_temperature": 37.1, + "water_pressure": 1.4, + "water_temperature": 37.3 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Remeha B.V." + }, + "f61f1a2535f54f52ad006a3d18e459ca": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermometer", + "firmware": "2020-09-01T02:00:00+02:00", + "hardware": "1", + "location": "13228dab8ce04617af318a2888b3c548", + "model": "Jip", + "model_id": "168-01", + "name": "Woonkamer", + "sensors": { + "battery": 100, + "humidity": 56.2, + "setpoint": 9.0, + "temperature": 27.4 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08" + } +} diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json new file mode 100644 index 00000000000..7c38b1b2197 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json @@ -0,0 +1,584 @@ +{ + "02cf28bfec924855854c544690a609ef": { + "available": true, + "dev_class": "vcr_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "NVR", + "sensors": { + "electricity_consumed": 34.0, + "electricity_consumed_interval": 9.15, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A15" + }, + "08963fec7c53423ca5680aa4cb502c63": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer Schema", + "sensors": { + "temperature": 18.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 14.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": [ + "f1fee6043d3642a9b0a65297455f008e", + "680423ff840043738f42cc7f1ff97a36" + ], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "12493538af164a409c6a1c79e38afe1c": { + "active_preset": "away", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Bios", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "off", + "sensors": { + "electricity_consumed": 0.0, + "electricity_produced": 0.0, + "temperature": 16.5 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["df4a4a8169904cdb9c03d61a21f42140"], + "secondary": ["a2c3583e0a6349358998b760cea82d2a"] + }, + "vendor": "Plugwise" + }, + "21f2b542c49845e6bb416884c55778d6": { + "available": true, + "dev_class": "game_console_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "Playstation Smart Plug", + "sensors": { + "electricity_consumed": 84.1, + "electricity_consumed_interval": 8.6, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A12" + }, + "446ac08dd04d4eff8ac57489757b7314": { + "active_preset": "no_frost", + "climate_mode": "heat", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Garage", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "sensors": { + "temperature": 15.6 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 5.5, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["e7693eb9582644e5b865dba8d4447cf1"], + "secondary": [] + }, + "vendor": "Plugwise" + }, + "4a810418d5394b3f82727340b91ba740": { + "available": true, + "dev_class": "router_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "USG Smart Plug", + "sensors": { + "electricity_consumed": 8.5, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A16" + }, + "675416a629f343c495449970e2ca37b5": { + "available": true, + "dev_class": "router_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "Ziggo Modem", + "sensors": { + "electricity_consumed": 12.2, + "electricity_consumed_interval": 2.97, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "680423ff840043738f42cc7f1ff97a36": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "08963fec7c53423ca5680aa4cb502c63", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Thermostatic Radiator Badkamer 1", + "sensors": { + "battery": 51, + "setpoint": 14.0, + "temperature": 19.1, + "temperature_difference": -0.4, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A17" + }, + "6a3bf693d05e48e0b460c815a4fdd09d": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "82fa13f017d240daa0d0ea1775420f24", + "model": "Lisa", + "model_id": "158-01", + "name": "Zone Thermostat Jessie", + "sensors": { + "battery": 37, + "setpoint": 15.0, + "temperature": 17.2 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03" + }, + "78d1126fc4c743db81b61c20e88342a7": { + "available": true, + "dev_class": "central_heating_pump_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Plug", + "model_id": "160-01", + "name": "CV Pomp", + "sensors": { + "electricity_consumed": 35.6, + "electricity_consumed_interval": 7.37, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05" + }, + "82fa13f017d240daa0d0ea1775420f24": { + "active_preset": "asleep", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Jessie", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "CV Jessie", + "sensors": { + "temperature": 17.2 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 15.0, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["6a3bf693d05e48e0b460c815a4fdd09d"], + "secondary": ["d3da73bde12a47d5a6b8f9dad971f2ec"] + }, + "vendor": "Plugwise" + }, + "90986d591dcd426cae3ec3e8111ff730": { + "binary_sensors": { + "heating_state": true + }, + "dev_class": "heater_central", + "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", + "model": "Unknown", + "name": "OnOff", + "sensors": { + "intended_boiler_temperature": 70.0, + "modulation_level": 1, + "water_temperature": 70.0 + } + }, + "a28f588dc4a049a483fd03a30361ad3a": { + "available": true, + "dev_class": "settop_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "Fibaro HC2", + "sensors": { + "electricity_consumed": 12.5, + "electricity_consumed_interval": 3.8, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A13" + }, + "a2c3583e0a6349358998b760cea82d2a": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "12493538af164a409c6a1c79e38afe1c", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Bios Cv Thermostatic Radiator ", + "sensors": { + "battery": 62, + "setpoint": 13.0, + "temperature": 17.2, + "temperature_difference": -0.2, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09" + }, + "b310b72a0e354bfab43089919b9a88bf": { + "available": true, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Floor kraan", + "sensors": { + "setpoint": 21.5, + "temperature": 26.0, + "temperature_difference": 3.5, + "valve_position": 100 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "b59bcebaf94b499ea7d46e4a66fb62d8": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-08-02T02:00:00+02:00", + "hardware": "255", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Lisa", + "model_id": "158-01", + "name": "Zone Lisa WK", + "sensors": { + "battery": 34, + "setpoint": 21.5, + "temperature": 20.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "c50f167537524366a5af7aa3942feb1e": { + "active_preset": "home", + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + "off" + ], + "climate_mode": "auto", + "control_state": "heating", + "dev_class": "climate", + "model": "ThermoZone", + "name": "Woonkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "GF7 Woonkamer", + "sensors": { + "electricity_consumed": 35.6, + "electricity_produced": 0.0, + "temperature": 20.9 + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 21.5, + "upper_bound": 100.0 + }, + "thermostats": { + "primary": ["b59bcebaf94b499ea7d46e4a66fb62d8"], + "secondary": ["b310b72a0e354bfab43089919b9a88bf"] + }, + "vendor": "Plugwise" + }, + "cd0ddb54ef694e11ac18ed1cbce5dbbd": { + "available": true, + "dev_class": "vcr_plug", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "model_id": "160-01", + "name": "NAS", + "sensors": { + "electricity_consumed": 16.5, + "electricity_consumed_interval": 0.5, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A14" + }, + "d3da73bde12a47d5a6b8f9dad971f2ec": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "82fa13f017d240daa0d0ea1775420f24", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "Thermostatic Radiator Jessie", + "sensors": { + "battery": 62, + "setpoint": 15.0, + "temperature": 17.1, + "temperature_difference": 0.1, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A10" + }, + "df4a4a8169904cdb9c03d61a21f42140": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "12493538af164a409c6a1c79e38afe1c", + "model": "Lisa", + "model_id": "158-01", + "name": "Zone Lisa Bios", + "sensors": { + "battery": 67, + "setpoint": 13.0, + "temperature": 16.5 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A06" + }, + "e7693eb9582644e5b865dba8d4447cf1": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "446ac08dd04d4eff8ac57489757b7314", + "model": "Tom/Floor", + "model_id": "106-03", + "name": "CV Kraan Garage", + "sensors": { + "battery": 68, + "setpoint": 5.5, + "temperature": 15.6, + "temperature_difference": 0.0, + "valve_position": 0.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A11" + }, + "f1fee6043d3642a9b0a65297455f008e": { + "available": true, + "binary_sensors": { + "low_battery": false + }, + "dev_class": "thermostatic_radiator_valve", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "location": "08963fec7c53423ca5680aa4cb502c63", + "model": "Lisa", + "model_id": "158-01", + "name": "Thermostatic Radiator Badkamer 2", + "sensors": { + "battery": 92, + "setpoint": 14.0, + "temperature": 18.9 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08" + }, + "fe799307f1624099878210aa0b9f1475": { + "binary_sensors": { + "plugwise_notification": true + }, + "dev_class": "gateway", + "firmware": "3.0.15", + "hardware": "AME Smile 2.0 board", + "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_open_therm", + "name": "Adam", + "notifications": { + "af82e4ccf9c548528166d38e560662a4": { + "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." + } + }, + "select_regulation_mode": "heating", + "sensors": { + "outdoor_temperature": 7.81 + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + } +} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json new file mode 100644 index 00000000000..ccfd816ff63 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/data.json @@ -0,0 +1,97 @@ +{ + "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.0.15", + "hardware": "AME Smile 2.0 board", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna", + "notifications": {}, + "sensors": { + "outdoor_temperature": 28.2 + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": true, + "cooling_enabled": true, + "cooling_state": true, + "dhw_state": false, + "flame_state": false, + "heating_state": false, + "secondary_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 41.5, + "intended_boiler_temperature": 0.0, + "modulation_level": 40, + "outdoor_air_temperature": 28.0, + "return_temperature": 23.8, + "water_pressure": 1.57, + "water_temperature": 22.7 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" + }, + "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard", "off"], + "climate_mode": "auto", + "control_state": "cooling", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-5002", + "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], + "select_schedule": "standaard", + "sensors": { + "cooling_activation_outdoor_temperature": 21.0, + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "temperature": 26.3 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json new file mode 100644 index 00000000000..5a1cdebd380 --- /dev/null +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/data.json @@ -0,0 +1,97 @@ +{ + "015ae9ea3f964e668e490fa39da3870b": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.0.15", + "hardware": "AME Smile 2.0 board", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile_thermo", + "name": "Smile Anna", + "notifications": {}, + "sensors": { + "outdoor_temperature": 28.2 + }, + "vendor": "Plugwise" + }, + "1cbf783bb11e4a7c8a6843dee3a86927": { + "available": true, + "binary_sensors": { + "compressor_state": false, + "cooling_enabled": true, + "cooling_state": false, + "dhw_state": false, + "flame_state": false, + "heating_state": false, + "secondary_boiler_state": false + }, + "dev_class": "heater_central", + "location": "a57efe5f145f498c9be62a9b63626fbf", + "max_dhw_temperature": { + "lower_bound": 35.0, + "resolution": 0.01, + "setpoint": 53.0, + "upper_bound": 60.0 + }, + "maximum_boiler_temperature": { + "lower_bound": 0.0, + "resolution": 1.0, + "setpoint": 60.0, + "upper_bound": 100.0 + }, + "model": "Generic heater/cooler", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 46.3, + "intended_boiler_temperature": 18.0, + "modulation_level": 0, + "outdoor_air_temperature": 28.2, + "return_temperature": 22.0, + "water_pressure": 1.57, + "water_temperature": 19.1 + }, + "switches": { + "dhw_cm_switch": false + }, + "vendor": "Techneco" + }, + "3cb70739631c4d17a86b8b12e8a5161b": { + "active_preset": "home", + "available_schedules": ["standaard", "off"], + "climate_mode": "auto", + "control_state": "idle", + "dev_class": "thermostat", + "firmware": "2018-02-08T11:15:53+01:00", + "hardware": "6539-1301-5002", + "location": "c784ee9fdab44e1395b8dee7d7a497d5", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], + "select_schedule": "standaard", + "sensors": { + "cooling_activation_outdoor_temperature": 25.0, + "cooling_deactivation_threshold": 4.0, + "illuminance": 86.0, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "temperature": 23.0 + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint_high": 30.0, + "setpoint_low": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/p1v4_442_single/data.json b/tests/components/plugwise/fixtures/p1v4_442_single/data.json new file mode 100644 index 00000000000..6dfcd7ee033 --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_single/data.json @@ -0,0 +1,43 @@ +{ + "a455b61e52394b2db5081ce025a430f3": { + "binary_sensors": { + "plugwise_notification": false + }, + "dev_class": "gateway", + "firmware": "4.4.2", + "hardware": "AME Smile 2.0 board", + "location": "a455b61e52394b2db5081ce025a430f3", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile", + "name": "Smile P1", + "notifications": {}, + "vendor": "Plugwise" + }, + "ba4de7613517478da82dd9b6abea36af": { + "available": true, + "dev_class": "smartmeter", + "location": "a455b61e52394b2db5081ce025a430f3", + "model": "KFM5KAIFA-METER", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 17643.423, + "electricity_consumed_off_peak_interval": 15, + "electricity_consumed_off_peak_point": 486, + "electricity_consumed_peak_cumulative": 13966.608, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_phase_one_consumed": 486, + "electricity_phase_one_produced": 0, + "electricity_produced_off_peak_cumulative": 0.0, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_off_peak_point": 0, + "electricity_produced_peak_cumulative": 0.0, + "electricity_produced_peak_interval": 0, + "electricity_produced_peak_point": 0, + "net_electricity_cumulative": 31610.031, + "net_electricity_point": 486 + }, + "vendor": "SHENZHEN KAIFA TECHNOLOGY \uff08CHENGDU\uff09 CO., LTD." + } +} diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/data.json new file mode 100644 index 00000000000..943325d1415 --- /dev/null +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/data.json @@ -0,0 +1,56 @@ +{ + "03e65b16e4b247a29ae0d75a78cb492e": { + "binary_sensors": { + "plugwise_notification": true + }, + "dev_class": "gateway", + "firmware": "4.4.2", + "hardware": "AME Smile 2.0 board", + "location": "03e65b16e4b247a29ae0d75a78cb492e", + "mac_address": "012345670001", + "model": "Gateway", + "model_id": "smile", + "name": "Smile P1", + "notifications": { + "97a04c0c263049b29350a660b4cdd01e": { + "warning": "The Smile P1 is not connected to a smart meter." + } + }, + "vendor": "Plugwise" + }, + "b82b6b3322484f2ea4e25e0bd5f3d61f": { + "available": true, + "dev_class": "smartmeter", + "location": "03e65b16e4b247a29ae0d75a78cb492e", + "model": "XMX5LGF0010453051839", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 70537.898, + "electricity_consumed_off_peak_interval": 314, + "electricity_consumed_off_peak_point": 5553, + "electricity_consumed_peak_cumulative": 161328.641, + "electricity_consumed_peak_interval": 0, + "electricity_consumed_peak_point": 0, + "electricity_phase_one_consumed": 1763, + "electricity_phase_one_produced": 0, + "electricity_phase_three_consumed": 2080, + "electricity_phase_three_produced": 0, + "electricity_phase_two_consumed": 1703, + "electricity_phase_two_produced": 0, + "electricity_produced_off_peak_cumulative": 0.0, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_off_peak_point": 0, + "electricity_produced_peak_cumulative": 0.0, + "electricity_produced_peak_interval": 0, + "electricity_produced_peak_point": 0, + "gas_consumed_cumulative": 16811.37, + "gas_consumed_interval": 0.06, + "net_electricity_cumulative": 231866.539, + "net_electricity_point": 5553, + "voltage_phase_one": 233.2, + "voltage_phase_three": 234.7, + "voltage_phase_two": 234.4 + }, + "vendor": "XEMEX NV" + } +} diff --git a/tests/components/plugwise/fixtures/smile_p1_v2/data.json b/tests/components/plugwise/fixtures/smile_p1_v2/data.json new file mode 100644 index 00000000000..768dd2c2334 --- /dev/null +++ b/tests/components/plugwise/fixtures/smile_p1_v2/data.json @@ -0,0 +1,34 @@ +{ + "938696c4bcdb4b8a9a595cb38ed43913": { + "dev_class": "smartmeter", + "location": "938696c4bcdb4b8a9a595cb38ed43913", + "model": "Ene5\\T210-DESMR5.0", + "name": "P1", + "sensors": { + "electricity_consumed_off_peak_cumulative": 1642.74, + "electricity_consumed_off_peak_interval": 0, + "electricity_consumed_peak_cumulative": 1155.195, + "electricity_consumed_peak_interval": 250, + "electricity_consumed_point": 458, + "electricity_produced_off_peak_cumulative": 482.598, + "electricity_produced_off_peak_interval": 0, + "electricity_produced_peak_cumulative": 1296.136, + "electricity_produced_peak_interval": 0, + "electricity_produced_point": 0, + "gas_consumed_cumulative": 584.433, + "gas_consumed_interval": 0.016, + "net_electricity_cumulative": 1019.201, + "net_electricity_point": 458 + }, + "vendor": "Ene5\\T210-DESMR5.0" + }, + "aaaa0000aaaa0000aaaa0000aaaa00aa": { + "dev_class": "gateway", + "firmware": "2.5.9", + "location": "938696c4bcdb4b8a9a595cb38ed43913", + "mac_address": "012345670001", + "model": "Gateway", + "name": "Smile P1", + "vendor": "Plugwise" + } +} diff --git a/tests/components/plugwise/fixtures/stretch_v31/data.json b/tests/components/plugwise/fixtures/stretch_v31/data.json new file mode 100644 index 00000000000..250839d08a8 --- /dev/null +++ b/tests/components/plugwise/fixtures/stretch_v31/data.json @@ -0,0 +1,136 @@ +{ + "0000aaaa0000aaaa0000aaaa0000aa00": { + "dev_class": "gateway", + "firmware": "3.1.11", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "mac_address": "01:23:45:67:89:AB", + "model": "Gateway", + "name": "Stretch", + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + }, + "059e4d03c7a34d278add5c7a4a781d19": { + "dev_class": "washingmachine", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Wasmachine (52AC1)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "5871317346d045bc9f6b987ef25ee638": { + "dev_class": "water_heater_vessel", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4028", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Boiler (1EB31)", + "sensors": { + "electricity_consumed": 1.19, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "aac7b735042c4832ac9ff33aae4f453b": { + "dev_class": "dishwasher", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4022", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Vaatwasser (2a1ab)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.71, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "cfe95cf3de1948c0b8955125bf754614": { + "dev_class": "dryer", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Droger (52559)", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" + }, + "d03738edfcc947f7b8f4573571d90d2d": { + "dev_class": "switching", + "members": [ + "059e4d03c7a34d278add5c7a4a781d19", + "cfe95cf3de1948c0b8955125bf754614" + ], + "model": "Switchgroup", + "name": "Schakel", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "d950b314e9d8499f968e6db8d82ef78c": { + "dev_class": "report", + "members": [ + "059e4d03c7a34d278add5c7a4a781d19", + "5871317346d045bc9f6b987ef25ee638", + "aac7b735042c4832ac9ff33aae4f453b", + "cfe95cf3de1948c0b8955125bf754614", + "e1c884e7dede431dadee09506ec4f859" + ], + "model": "Switchgroup", + "name": "Stroomvreters", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, + "e1c884e7dede431dadee09506ec4f859": { + "dev_class": "refrigerator", + "firmware": "2011-06-27T10:47:37+02:00", + "hardware": "6539-0700-7330", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle+ type F", + "name": "Koelkast (92C4A)", + "sensors": { + "electricity_consumed": 50.5, + "electricity_consumed_interval": 0.08, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "0123456789AB" + } +} diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 806c92fe7cb..92ed327b841 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -1,643 +1,633 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'devices': dict({ - '02cf28bfec924855854c544690a609ef': dict({ - 'available': True, - 'dev_class': 'vcr_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'NVR', - 'sensors': dict({ - 'electricity_consumed': 34.0, - 'electricity_consumed_interval': 9.15, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A15', + '02cf28bfec924855854c544690a609ef': dict({ + 'available': True, + 'dev_class': 'vcr_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'NVR', + 'sensors': dict({ + 'electricity_consumed': 34.0, + 'electricity_consumed_interval': 9.15, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, }), - '08963fec7c53423ca5680aa4cb502c63': dict({ - 'active_preset': 'away', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A15', + }), + '08963fec7c53423ca5680aa4cb502c63': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Badkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'Badkamer Schema', + 'sensors': dict({ + 'temperature': 18.9, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 14.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'f1fee6043d3642a9b0a65297455f008e', + '680423ff840043738f42cc7f1ff97a36', ]), - 'climate_mode': 'auto', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Badkamer', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + 'secondary': list([ ]), - 'select_schedule': 'Badkamer Schema', - 'sensors': dict({ - 'temperature': 18.9, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 14.0, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'f1fee6043d3642a9b0a65297455f008e', - '680423ff840043738f42cc7f1ff97a36', - ]), - 'secondary': list([ - ]), - }), - 'vendor': 'Plugwise', }), - '12493538af164a409c6a1c79e38afe1c': dict({ - 'active_preset': 'away', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'vendor': 'Plugwise', + }), + '12493538af164a409c6a1c79e38afe1c': dict({ + 'active_preset': 'away', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'heat', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Bios', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'off', + 'sensors': dict({ + 'electricity_consumed': 0.0, + 'electricity_produced': 0.0, + 'temperature': 16.5, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 13.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'df4a4a8169904cdb9c03d61a21f42140', ]), - 'climate_mode': 'heat', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Bios', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + 'secondary': list([ + 'a2c3583e0a6349358998b760cea82d2a', ]), - 'select_schedule': 'off', - 'sensors': dict({ - 'electricity_consumed': 0.0, - 'electricity_produced': 0.0, - 'temperature': 16.5, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 13.0, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'df4a4a8169904cdb9c03d61a21f42140', - ]), - 'secondary': list([ - 'a2c3583e0a6349358998b760cea82d2a', - ]), - }), - 'vendor': 'Plugwise', }), - '21f2b542c49845e6bb416884c55778d6': dict({ - 'available': True, - 'dev_class': 'game_console_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'Playstation Smart Plug', - 'sensors': dict({ - 'electricity_consumed': 84.1, - 'electricity_consumed_interval': 8.6, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': False, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A12', + 'vendor': 'Plugwise', + }), + '21f2b542c49845e6bb416884c55778d6': dict({ + 'available': True, + 'dev_class': 'game_console_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'Playstation Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 84.1, + 'electricity_consumed_interval': 8.6, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, }), - '446ac08dd04d4eff8ac57489757b7314': dict({ - 'active_preset': 'no_frost', - 'climate_mode': 'heat', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Garage', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + 'switches': dict({ + 'lock': False, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A12', + }), + '446ac08dd04d4eff8ac57489757b7314': dict({ + 'active_preset': 'no_frost', + 'climate_mode': 'heat', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Garage', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'sensors': dict({ + 'temperature': 15.6, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 5.5, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'e7693eb9582644e5b865dba8d4447cf1', ]), - 'sensors': dict({ - 'temperature': 15.6, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 5.5, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'e7693eb9582644e5b865dba8d4447cf1', - ]), - 'secondary': list([ - ]), - }), - 'vendor': 'Plugwise', - }), - '4a810418d5394b3f82727340b91ba740': dict({ - 'available': True, - 'dev_class': 'router_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'USG Smart Plug', - 'sensors': dict({ - 'electricity_consumed': 8.5, - 'electricity_consumed_interval': 0.0, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A16', - }), - '675416a629f343c495449970e2ca37b5': dict({ - 'available': True, - 'dev_class': 'router_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'Ziggo Modem', - 'sensors': dict({ - 'electricity_consumed': 12.2, - 'electricity_consumed_interval': 2.97, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A01', - }), - '680423ff840043738f42cc7f1ff97a36': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '08963fec7c53423ca5680aa4cb502c63', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Thermostatic Radiator Badkamer 1', - 'sensors': dict({ - 'battery': 51, - 'setpoint': 14.0, - 'temperature': 19.1, - 'temperature_difference': -0.4, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A17', - }), - '6a3bf693d05e48e0b460c815a4fdd09d': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'zone_thermostat', - 'firmware': '2016-10-27T02:00:00+02:00', - 'hardware': '255', - 'location': '82fa13f017d240daa0d0ea1775420f24', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Zone Thermostat Jessie', - 'sensors': dict({ - 'battery': 37, - 'setpoint': 15.0, - 'temperature': 17.2, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A03', - }), - '78d1126fc4c743db81b61c20e88342a7': dict({ - 'available': True, - 'dev_class': 'central_heating_pump_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'c50f167537524366a5af7aa3942feb1e', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'CV Pomp', - 'sensors': dict({ - 'electricity_consumed': 35.6, - 'electricity_consumed_interval': 7.37, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A05', - }), - '82fa13f017d240daa0d0ea1775420f24': dict({ - 'active_preset': 'asleep', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'secondary': list([ ]), - 'climate_mode': 'auto', - 'control_state': 'idle', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Jessie', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', + }), + 'vendor': 'Plugwise', + }), + '4a810418d5394b3f82727340b91ba740': dict({ + 'available': True, + 'dev_class': 'router_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'USG Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 8.5, + 'electricity_consumed_interval': 0.0, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A16', + }), + '675416a629f343c495449970e2ca37b5': dict({ + 'available': True, + 'dev_class': 'router_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'Ziggo Modem', + 'sensors': dict({ + 'electricity_consumed': 12.2, + 'electricity_consumed_interval': 2.97, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A01', + }), + '680423ff840043738f42cc7f1ff97a36': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Thermostatic Radiator Badkamer 1', + 'sensors': dict({ + 'battery': 51, + 'setpoint': 14.0, + 'temperature': 19.1, + 'temperature_difference': -0.4, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A17', + }), + '6a3bf693d05e48e0b460c815a4fdd09d': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Zone Thermostat Jessie', + 'sensors': dict({ + 'battery': 37, + 'setpoint': 15.0, + 'temperature': 17.2, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A03', + }), + '78d1126fc4c743db81b61c20e88342a7': dict({ + 'available': True, + 'dev_class': 'central_heating_pump_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'CV Pomp', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_consumed_interval': 7.37, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A05', + }), + '82fa13f017d240daa0d0ea1775420f24': dict({ + 'active_preset': 'asleep', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'idle', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Jessie', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'CV Jessie', + 'sensors': dict({ + 'temperature': 17.2, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 15.0, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + '6a3bf693d05e48e0b460c815a4fdd09d', ]), - 'select_schedule': 'CV Jessie', - 'sensors': dict({ - 'temperature': 17.2, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 15.0, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - '6a3bf693d05e48e0b460c815a4fdd09d', - ]), - 'secondary': list([ - 'd3da73bde12a47d5a6b8f9dad971f2ec', - ]), - }), - 'vendor': 'Plugwise', - }), - '90986d591dcd426cae3ec3e8111ff730': dict({ - 'binary_sensors': dict({ - 'heating_state': True, - }), - 'dev_class': 'heater_central', - 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', - 'model': 'Unknown', - 'name': 'OnOff', - 'sensors': dict({ - 'intended_boiler_temperature': 70.0, - 'modulation_level': 1, - 'water_temperature': 70.0, - }), - }), - 'a28f588dc4a049a483fd03a30361ad3a': dict({ - 'available': True, - 'dev_class': 'settop_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'Fibaro HC2', - 'sensors': dict({ - 'electricity_consumed': 12.5, - 'electricity_consumed_interval': 3.8, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A13', - }), - 'a2c3583e0a6349358998b760cea82d2a': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '12493538af164a409c6a1c79e38afe1c', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Bios Cv Thermostatic Radiator ', - 'sensors': dict({ - 'battery': 62, - 'setpoint': 13.0, - 'temperature': 17.2, - 'temperature_difference': -0.2, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A09', - }), - 'b310b72a0e354bfab43089919b9a88bf': dict({ - 'available': True, - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': 'c50f167537524366a5af7aa3942feb1e', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Floor kraan', - 'sensors': dict({ - 'setpoint': 21.5, - 'temperature': 26.0, - 'temperature_difference': 3.5, - 'valve_position': 100, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A02', - }), - 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'zone_thermostat', - 'firmware': '2016-08-02T02:00:00+02:00', - 'hardware': '255', - 'location': 'c50f167537524366a5af7aa3942feb1e', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Zone Lisa WK', - 'sensors': dict({ - 'battery': 34, - 'setpoint': 21.5, - 'temperature': 20.9, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A07', - }), - 'c50f167537524366a5af7aa3942feb1e': dict({ - 'active_preset': 'home', - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', + 'secondary': list([ + 'd3da73bde12a47d5a6b8f9dad971f2ec', ]), - 'climate_mode': 'auto', - 'control_state': 'heating', - 'dev_class': 'climate', - 'model': 'ThermoZone', - 'name': 'Woonkamer', - 'preset_modes': list([ - 'home', - 'asleep', - 'away', - 'vacation', - 'no_frost', - ]), - 'select_schedule': 'GF7 Woonkamer', - 'sensors': dict({ - 'electricity_consumed': 35.6, - 'electricity_produced': 0.0, - 'temperature': 20.9, - }), - 'thermostat': dict({ - 'lower_bound': 0.0, - 'resolution': 0.01, - 'setpoint': 21.5, - 'upper_bound': 100.0, - }), - 'thermostats': dict({ - 'primary': list([ - 'b59bcebaf94b499ea7d46e4a66fb62d8', - ]), - 'secondary': list([ - 'b310b72a0e354bfab43089919b9a88bf', - ]), - }), - 'vendor': 'Plugwise', }), - 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ - 'available': True, - 'dev_class': 'vcr_plug', - 'firmware': '2019-06-21T02:00:00+02:00', - 'location': 'cd143c07248f491493cea0533bc3d669', - 'model': 'Plug', - 'model_id': '160-01', - 'name': 'NAS', - 'sensors': dict({ - 'electricity_consumed': 16.5, - 'electricity_consumed_interval': 0.5, - 'electricity_produced': 0.0, - 'electricity_produced_interval': 0.0, - }), - 'switches': dict({ - 'lock': True, - 'relay': True, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A14', + 'vendor': 'Plugwise', + }), + '90986d591dcd426cae3ec3e8111ff730': dict({ + 'binary_sensors': dict({ + 'heating_state': True, }), - 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '82fa13f017d240daa0d0ea1775420f24', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'Thermostatic Radiator Jessie', - 'sensors': dict({ - 'battery': 62, - 'setpoint': 15.0, - 'temperature': 17.1, - 'temperature_difference': 0.1, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A10', - }), - 'df4a4a8169904cdb9c03d61a21f42140': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'zone_thermostat', - 'firmware': '2016-10-27T02:00:00+02:00', - 'hardware': '255', - 'location': '12493538af164a409c6a1c79e38afe1c', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Zone Lisa Bios', - 'sensors': dict({ - 'battery': 67, - 'setpoint': 13.0, - 'temperature': 16.5, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A06', - }), - 'e7693eb9582644e5b865dba8d4447cf1': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2019-03-27T01:00:00+01:00', - 'hardware': '1', - 'location': '446ac08dd04d4eff8ac57489757b7314', - 'model': 'Tom/Floor', - 'model_id': '106-03', - 'name': 'CV Kraan Garage', - 'sensors': dict({ - 'battery': 68, - 'setpoint': 5.5, - 'temperature': 15.6, - 'temperature_difference': 0.0, - 'valve_position': 0.0, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A11', - }), - 'f1fee6043d3642a9b0a65297455f008e': dict({ - 'available': True, - 'binary_sensors': dict({ - 'low_battery': False, - }), - 'dev_class': 'thermostatic_radiator_valve', - 'firmware': '2016-10-27T02:00:00+02:00', - 'hardware': '255', - 'location': '08963fec7c53423ca5680aa4cb502c63', - 'model': 'Lisa', - 'model_id': '158-01', - 'name': 'Thermostatic Radiator Badkamer 2', - 'sensors': dict({ - 'battery': 92, - 'setpoint': 14.0, - 'temperature': 18.9, - }), - 'temperature_offset': dict({ - 'lower_bound': -2.0, - 'resolution': 0.1, - 'setpoint': 0.0, - 'upper_bound': 2.0, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670A08', - }), - 'fe799307f1624099878210aa0b9f1475': dict({ - 'binary_sensors': dict({ - 'plugwise_notification': True, - }), - 'dev_class': 'gateway', - 'firmware': '3.0.15', - 'hardware': 'AME Smile 2.0 board', - 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', - 'mac_address': '012345670001', - 'model': 'Gateway', - 'model_id': 'smile_open_therm', - 'name': 'Adam', - 'select_regulation_mode': 'heating', - 'sensors': dict({ - 'outdoor_temperature': 7.81, - }), - 'vendor': 'Plugwise', - 'zigbee_mac_address': 'ABCD012345670101', + 'dev_class': 'heater_central', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'model': 'Unknown', + 'name': 'OnOff', + 'sensors': dict({ + 'intended_boiler_temperature': 70.0, + 'modulation_level': 1, + 'water_temperature': 70.0, }), }), - 'gateway': dict({ - 'cooling_present': False, - 'gateway_id': 'fe799307f1624099878210aa0b9f1475', - 'heater_id': '90986d591dcd426cae3ec3e8111ff730', - 'item_count': 369, + 'a28f588dc4a049a483fd03a30361ad3a': dict({ + 'available': True, + 'dev_class': 'settop_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'Fibaro HC2', + 'sensors': dict({ + 'electricity_consumed': 12.5, + 'electricity_consumed_interval': 3.8, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A13', + }), + 'a2c3583e0a6349358998b760cea82d2a': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Bios Cv Thermostatic Radiator ', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 13.0, + 'temperature': 17.2, + 'temperature_difference': -0.2, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A09', + }), + 'b310b72a0e354bfab43089919b9a88bf': dict({ + 'available': True, + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Floor kraan', + 'sensors': dict({ + 'setpoint': 21.5, + 'temperature': 26.0, + 'temperature_difference': 3.5, + 'valve_position': 100, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A02', + }), + 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-08-02T02:00:00+02:00', + 'hardware': '255', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Zone Lisa WK', + 'sensors': dict({ + 'battery': 34, + 'setpoint': 21.5, + 'temperature': 20.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A07', + }), + 'c50f167537524366a5af7aa3942feb1e': dict({ + 'active_preset': 'home', + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + 'off', + ]), + 'climate_mode': 'auto', + 'control_state': 'heating', + 'dev_class': 'climate', + 'model': 'ThermoZone', + 'name': 'Woonkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'GF7 Woonkamer', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_produced': 0.0, + 'temperature': 20.9, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 21.5, + 'upper_bound': 100.0, + }), + 'thermostats': dict({ + 'primary': list([ + 'b59bcebaf94b499ea7d46e4a66fb62d8', + ]), + 'secondary': list([ + 'b310b72a0e354bfab43089919b9a88bf', + ]), + }), + 'vendor': 'Plugwise', + }), + 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ + 'available': True, + 'dev_class': 'vcr_plug', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'model_id': '160-01', + 'name': 'NAS', + 'sensors': dict({ + 'electricity_consumed': 16.5, + 'electricity_consumed_interval': 0.5, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A14', + }), + 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'Thermostatic Radiator Jessie', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 15.0, + 'temperature': 17.1, + 'temperature_difference': 0.1, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A10', + }), + 'df4a4a8169904cdb9c03d61a21f42140': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Zone Lisa Bios', + 'sensors': dict({ + 'battery': 67, + 'setpoint': 13.0, + 'temperature': 16.5, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A06', + }), + 'e7693eb9582644e5b865dba8d4447cf1': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '446ac08dd04d4eff8ac57489757b7314', + 'model': 'Tom/Floor', + 'model_id': '106-03', + 'name': 'CV Kraan Garage', + 'sensors': dict({ + 'battery': 68, + 'setpoint': 5.5, + 'temperature': 15.6, + 'temperature_difference': 0.0, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A11', + }), + 'f1fee6043d3642a9b0a65297455f008e': dict({ + 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), + 'dev_class': 'thermostatic_radiator_valve', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'model': 'Lisa', + 'model_id': '158-01', + 'name': 'Thermostatic Radiator Badkamer 2', + 'sensors': dict({ + 'battery': 92, + 'setpoint': 14.0, + 'temperature': 18.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A08', + }), + 'fe799307f1624099878210aa0b9f1475': dict({ + 'binary_sensors': dict({ + 'plugwise_notification': True, + }), + 'dev_class': 'gateway', + 'firmware': '3.0.15', + 'hardware': 'AME Smile 2.0 board', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'mac_address': '012345670001', + 'model': 'Gateway', + 'model_id': 'smile_open_therm', + 'name': 'Adam', 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", }), }), - 'reboot': True, - 'smile_name': 'Adam', + 'select_regulation_mode': 'heating', + 'sensors': dict({ + 'outdoor_temperature': 7.81, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670101', }), }) # --- diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index 554326a72b1..7bf475086af 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -12,6 +12,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize( ("entity_id", "expected_state"), [ @@ -35,6 +36,7 @@ async def test_anna_climate_binary_sensor_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_binary_sensor_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index ab6bd3d4f29..7a481285be0 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -80,6 +80,7 @@ async def test_adam_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) async def test_adam_2_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, @@ -108,6 +109,7 @@ async def test_adam_2_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_3_climate_entity_attributes( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, @@ -125,18 +127,10 @@ async def test_adam_3_climate_entity_attributes( HVACMode.COOL, ] data = mock_smile_adam_heat_cool.async_update.return_value - data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( - "heating" - ) - data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( - HVACAction.HEATING - ) - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "cooling_state" - ] = False - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "heating_state" - ] = True + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -153,18 +147,10 @@ async def test_adam_3_climate_entity_attributes( ] data = mock_smile_adam_heat_cool.async_update.return_value - data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( - "cooling" - ) - data.devices["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = ( - HVACAction.COOLING - ) - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "cooling_state" - ] = True - data.devices["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"][ - "heating_state" - ] = False + data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling" + data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True + data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) @@ -323,6 +309,7 @@ async def test_adam_climate_off_mode_change( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -349,6 +336,7 @@ async def test_anna_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_2_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -369,6 +357,7 @@ async def test_anna_2_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_3_climate_entity_attributes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -386,6 +375,7 @@ async def test_anna_3_climate_entity_attributes( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_anna: MagicMock, @@ -441,7 +431,7 @@ async def test_anna_climate_entity_climate_changes( ) data = mock_smile_anna.async_update.return_value - data.devices["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") + data["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 874c4b61a47..5f1f065fa90 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -62,6 +62,7 @@ TOM = { @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -82,6 +83,7 @@ async def test_load_unload_config_entry( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize( ("side_effect", "entry_state"), [ @@ -138,6 +140,7 @@ async def test_device_in_dr( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) @pytest.mark.parametrize( ("entitydata", "old_unique_id", "new_unique_id"), [ @@ -232,6 +235,7 @@ async def test_migrate_unique_id_relay( @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_update_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -265,8 +269,8 @@ async def test_update_device( ) # Add a 2nd Tom/Floor - data.devices.update(TOM) - data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + data.update(TOM) + data["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( { "secondary": [ "01234567890abcdefghijklmnopqrstu", @@ -301,10 +305,10 @@ async def test_update_device( assert "01234567890abcdefghijklmnopqrstu" in item_list # Remove the existing Tom/Floor - data.devices["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( + data["f871b8c4d63549319221e294e4f88074"]["thermostats"].update( {"secondary": ["01234567890abcdefghijklmnopqrstu"]} ) - data.devices.pop("1772a4ea304041adb83f357b751341ff") + data.pop("1772a4ea304041adb83f357b751341ff") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index c5361433388..4ae461d96c8 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -17,6 +17,7 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_number_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -27,6 +28,7 @@ async def test_anna_number_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_max_boiler_temp_change( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: @@ -48,6 +50,7 @@ async def test_anna_max_boiler_temp_change( @pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [False], indirect=True) async def test_adam_dhw_setpoint_change( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index f06d07767f3..f6c4205b756 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -51,6 +51,7 @@ async def test_adam_change_select_entity( @pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_select_regulation_mode( hass: HomeAssistant, mock_smile_adam_heat_cool: MagicMock, @@ -95,6 +96,7 @@ async def test_legacy_anna_select_entities( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_adam_select_unavailable_regulation_mode( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index 11aa68bded7..c6c6c6cc284 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -95,6 +95,7 @@ async def test_unique_id_migration_humidity( @pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True) +@pytest.mark.parametrize("cooling_present", [True], indirect=True) async def test_anna_as_smt_climate_sensor_entities( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry ) -> None: From 91dbe3092fda376ef5aa2052628acf42907d6a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 8 Feb 2025 16:29:00 +0100 Subject: [PATCH 0111/1941] Only allow single Home Connect config entry (#137088) * Make Home Connect config entry unique * Use unique ID for Home connect config entry * Remove unnecessary code * Revert "Use unique ID for Home connect config entry" This reverts commit 424131746990dfbbba05b578f61fd49f4f7cb8d7. * Added tests --- .../components/home_connect/manifest.json | 3 ++- homeassistant/generated/integrations.json | 3 ++- .../components/home_connect/test_config_flow.py | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 41d359446fa..94085af2fc3 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.12.3"] + "requirements": ["aiohomeconnect==0.12.3"], + "single_config_entry": true } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 026eab30f8f..6c688e07f5c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2609,7 +2609,8 @@ "name": "Home Connect", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_push" + "iot_class": "cloud_push", + "single_config_entry": true }, "home_plus_control": { "name": "Legrand Home+ Control", diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index c015a881343..343d648e543 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -77,3 +78,18 @@ async def test_full_flow( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_prevent_multiple_config_entries( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we only allow one config entry.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "home_connect", context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" From 8976e7f4ea044a74140bf98a01a35b0a4e2609ac Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:33:46 +0100 Subject: [PATCH 0112/1941] Explicitly pass in the config_entry in zamg coordinator (#137858) explicitly pass in the config_entry in coordinator --- homeassistant/components/zamg/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zamg/coordinator.py b/homeassistant/components/zamg/coordinator.py index d53c743f500..a88c97ad267 100644 --- a/homeassistant/components/zamg/coordinator.py +++ b/homeassistant/components/zamg/coordinator.py @@ -32,6 +32,7 @@ class ZamgDataUpdateCoordinator(DataUpdateCoordinator[ZamgDevice]): super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=MIN_TIME_BETWEEN_UPDATES, ) From 367dafdfe70724e8d81e505fff909701f6ee283a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:45:23 +0100 Subject: [PATCH 0113/1941] Explicitly pass in the config_entry in zeversolar coordinator (#137857) explicitly pass in the config_entry in coordinator --- homeassistant/components/zeversolar/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/zeversolar/coordinator.py b/homeassistant/components/zeversolar/coordinator.py index 9f6ff49eaf8..ec68cf4b56f 100644 --- a/homeassistant/components/zeversolar/coordinator.py +++ b/homeassistant/components/zeversolar/coordinator.py @@ -20,11 +20,14 @@ _LOGGER = logging.getLogger(__name__) class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]): """Data update coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(minutes=1), ) From d07c2b8226c111f6cdffe6b4f5ed36ef29bc40d3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:45:36 +0100 Subject: [PATCH 0114/1941] Explicitly pass in the config_entry in youtube coordinator (#137859) explicitly pass in the config_entry in coordinator --- homeassistant/components/youtube/__init__.py | 2 +- homeassistant/components/youtube/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py index aee4b83508c..ec8a3f325cb 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - coordinator = YouTubeDataUpdateCoordinator(hass, auth) + coordinator = YouTubeDataUpdateCoordinator(hass, entry, auth) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 0da480f1169..476e5bb4022 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -35,12 +35,15 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, auth: AsyncConfigEntryAuth) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, auth: AsyncConfigEntryAuth + ) -> None: """Initialize the YouTube data coordinator.""" self._auth = auth super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=15), ) From 5ade026b87edecdae41e007507adb267d1e3ca35 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 16:45:57 +0100 Subject: [PATCH 0115/1941] Explicitly pass in the config_entry in ws66i coordinator (#137865) explicitly pass in the config_entry in coordinator --- homeassistant/components/ws66i/__init__.py | 1 + homeassistant/components/ws66i/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 83ad7bbf070..32c6a11f25c 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -78,6 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create the coordinator for the WS66i coordinator: Ws66iDataUpdateCoordinator = Ws66iDataUpdateCoordinator( hass, + entry, ws66i, zones, ) diff --git a/homeassistant/components/ws66i/coordinator.py b/homeassistant/components/ws66i/coordinator.py index 013e4d02b15..1b2b43963fc 100644 --- a/homeassistant/components/ws66i/coordinator.py +++ b/homeassistant/components/ws66i/coordinator.py @@ -6,6 +6,7 @@ import logging from pyws66i import WS66i, ZoneStatus +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,9 +18,12 @@ _LOGGER = logging.getLogger(__name__) class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]): """DataUpdateCoordinator to gather data for WS66i Zones.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, my_api: WS66i, zones: list[int], ) -> None: @@ -27,6 +31,7 @@ class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name="WS66i", update_interval=POLL_INTERVAL, ) From 3cce2d679c2226bf8a5ba0355593c7d4a0f51bd4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 17:21:09 +0100 Subject: [PATCH 0116/1941] Explicitly pass in the config_entry in waqi coordinator (#137873) explicitly pass in the config_entry in coordinator --- homeassistant/components/waqi/__init__.py | 2 +- homeassistant/components/waqi/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index e9feca75ee7..9821b5435d9 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -21,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = WAQIClient(session=async_get_clientsession(hass)) client.authenticate(entry.data[CONF_API_KEY]) - waqi_coordinator = WAQIDataUpdateCoordinator(hass, client) + waqi_coordinator = WAQIDataUpdateCoordinator(hass, entry, client) await waqi_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index d1a44e9f5b8..86f553a86cd 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -18,11 +18,14 @@ class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, client: WAQIClient) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, client: WAQIClient + ) -> None: """Initialize the WAQI data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=5), ) From 219b5324e954c8beb0bbc63056bea893f8a5554a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 17:21:17 +0100 Subject: [PATCH 0117/1941] Explicitly pass in the config_entry in watergate coordinator (#137872) explicitly pass in the config_entry in coordinator --- homeassistant/components/watergate/__init__.py | 7 ++----- homeassistant/components/watergate/coordinator.py | 14 +++++++++++++- homeassistant/components/watergate/sensor.py | 7 +++++-- homeassistant/components/watergate/valve.py | 3 +-- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py index fa761110339..c1747af1f11 100644 --- a/homeassistant/components/watergate/__init__.py +++ b/homeassistant/components/watergate/__init__.py @@ -16,12 +16,11 @@ from homeassistant.components.webhook import ( async_generate_url, async_register, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -from .coordinator import WatergateDataCoordinator +from .coordinator import WatergateConfigEntry, WatergateDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -35,8 +34,6 @@ PLATFORMS: list[Platform] = [ Platform.VALVE, ] -type WatergateConfigEntry = ConfigEntry[WatergateDataCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool: """Set up Watergate from a config entry.""" @@ -52,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> sonic_address if sonic_address.startswith("http") else f"http://{sonic_address}" ) - coordinator = WatergateDataCoordinator(hass, watergate_client) + coordinator = WatergateDataCoordinator(hass, entry, watergate_client) entry.runtime_data = coordinator async_register( diff --git a/homeassistant/components/watergate/coordinator.py b/homeassistant/components/watergate/coordinator.py index 1d83b7a3ccb..e3f198c144d 100644 --- a/homeassistant/components/watergate/coordinator.py +++ b/homeassistant/components/watergate/coordinator.py @@ -7,6 +7,7 @@ import logging from watergate_local_api import WatergateApiException, WatergateLocalApiClient from watergate_local_api.models import DeviceState, NetworkingData, TelemetryData +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,14 +25,25 @@ class WatergateAgregatedRequests: networking: NetworkingData +type WatergateConfigEntry = ConfigEntry[WatergateDataCoordinator] + + class WatergateDataCoordinator(DataUpdateCoordinator[WatergateAgregatedRequests]): """Class to manage fetching watergate data.""" - def __init__(self, hass: HomeAssistant, api: WatergateLocalApiClient) -> None: + config_entry: WatergateConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: WatergateConfigEntry, + api: WatergateLocalApiClient, + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=2), ) diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py index 6782a93541b..44630d2f587 100644 --- a/homeassistant/components/watergate/sensor.py +++ b/homeassistant/components/watergate/sensor.py @@ -26,8 +26,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from . import WatergateConfigEntry -from .coordinator import WatergateAgregatedRequests, WatergateDataCoordinator +from .coordinator import ( + WatergateAgregatedRequests, + WatergateConfigEntry, + WatergateDataCoordinator, +) from .entity import WatergateEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/watergate/valve.py b/homeassistant/components/watergate/valve.py index 556b53e1d3c..ce914ebbb55 100644 --- a/homeassistant/components/watergate/valve.py +++ b/homeassistant/components/watergate/valve.py @@ -10,8 +10,7 @@ from homeassistant.components.valve import ( from homeassistant.core import callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import WatergateConfigEntry -from .coordinator import WatergateDataCoordinator +from .coordinator import WatergateConfigEntry, WatergateDataCoordinator from .entity import WatergateEntity ENTITY_NAME = "valve" From bd32a6ab83a87476c946351138a6307e085c8268 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 8 Feb 2025 17:54:48 +0100 Subject: [PATCH 0118/1941] Prolong ondilo ico update interval (#137888) --- homeassistant/components/ondilo_ico/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index bc092ad0b9a..ff1502a89fd 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -34,7 +34,7 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]): hass, logger=_LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=20), + update_interval=timedelta(hours=1), ) self.api = api From 907826e909823a0eb6970d3c3d166784a0c8d580 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 8 Feb 2025 11:56:23 -0500 Subject: [PATCH 0119/1941] Fix heos migration (#137887) * Fix heos migration * Fix for loop --- homeassistant/components/heos/__init__.py | 36 +++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 0c268b612ea..7bbd3765602 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -37,24 +37,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool for device in device_registry.devices.get_devices_for_config_entry_id( entry.entry_id ): - for domain, player_id in device.identifiers: - if domain == DOMAIN and not isinstance(player_id, str): - # Create set of identifiers excluding this integration - identifiers = { # type: ignore[unreachable] - (domain, identifier) - for domain, identifier in device.identifiers - if domain != DOMAIN - } - migrated_identifiers = {(DOMAIN, str(player_id))} - # Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded - if not device_registry.async_get_device(migrated_identifiers): - identifiers.update(migrated_identifiers) - if len(identifiers) > 0: - device_registry.async_update_device( - device.id, new_identifiers=identifiers - ) - else: - device_registry.async_remove_device(device.id) + for ident in device.identifiers: + if ident[0] != DOMAIN or isinstance(ident[1], str): + continue + + player_id = int(ident[1]) # type: ignore[unreachable] + + # Create set of identifiers excluding this integration + identifiers = {ident for ident in device.identifiers if ident[0] != DOMAIN} + migrated_identifiers = {(DOMAIN, str(player_id))} + # Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded + if not device_registry.async_get_device(migrated_identifiers): + identifiers.update(migrated_identifiers) + if len(identifiers) > 0: + device_registry.async_update_device( + device.id, new_identifiers=identifiers + ) + else: + device_registry.async_remove_device(device.id) break coordinator = HeosCoordinator(hass, entry) From 8fcc1f7e10f55aeb951d79d72e6bbf235dfd8025 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 18:51:39 +0100 Subject: [PATCH 0120/1941] Explicitly pass in the config_entry in v2c coordinator (#137882) explicitly pass in the config_entry in coordinator --- homeassistant/components/v2c/__init__.py | 13 ++++--------- homeassistant/components/v2c/binary_sensor.py | 3 +-- homeassistant/components/v2c/coordinator.py | 12 +++++++++--- homeassistant/components/v2c/diagnostics.py | 2 +- homeassistant/components/v2c/number.py | 3 +-- homeassistant/components/v2c/sensor.py | 3 +-- homeassistant/components/v2c/switch.py | 3 +-- 7 files changed, 18 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 0c07891df72..7cd5e71f3ae 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations from pytrydan import Trydan -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -19,15 +18,11 @@ PLATFORMS: list[Platform] = [ ] -type V2CConfigEntry = ConfigEntry[V2CUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: """Set up V2C from a config entry.""" - host = entry.data[CONF_HOST] - trydan = Trydan(host, get_async_client(hass, verify_ssl=False)) - coordinator = V2CUpdateCoordinator(hass, trydan, host) + trydan = Trydan(entry.data[CONF_HOST], get_async_client(hass, verify_ssl=False)) + coordinator = V2CUpdateCoordinator(hass, entry, trydan) await coordinator.async_config_entry_first_refresh() @@ -41,6 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: V2CConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py index 28ad3665996..18724a4eada 100644 --- a/homeassistant/components/v2c/binary_sensor.py +++ b/homeassistant/components/v2c/binary_sensor.py @@ -15,8 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity diff --git a/homeassistant/components/v2c/coordinator.py b/homeassistant/components/v2c/coordinator.py index b121c84563c..de8015985f9 100644 --- a/homeassistant/components/v2c/coordinator.py +++ b/homeassistant/components/v2c/coordinator.py @@ -9,6 +9,7 @@ from pytrydan import Trydan, TrydanData from pytrydan.exceptions import TrydanError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -16,19 +17,24 @@ SCAN_INTERVAL = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +type V2CConfigEntry = ConfigEntry[V2CUpdateCoordinator] + class V2CUpdateCoordinator(DataUpdateCoordinator[TrydanData]): """DataUpdateCoordinator to gather data from any v2c.""" - config_entry: ConfigEntry + config_entry: V2CConfigEntry - def __init__(self, hass: HomeAssistant, evse: Trydan, host: str) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: V2CConfigEntry, evse: Trydan + ) -> None: """Initialize DataUpdateCoordinator for a v2c evse.""" self.evse = evse super().__init__( hass, _LOGGER, - name=f"EVSE {host}", + config_entry=config_entry, + name=f"EVSE {config_entry.data[CONF_HOST]}", update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/v2c/diagnostics.py b/homeassistant/components/v2c/diagnostics.py index 289d585b164..994f702a7bd 100644 --- a/homeassistant/components/v2c/diagnostics.py +++ b/homeassistant/components/v2c/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import V2CConfigEntry +from .coordinator import V2CConfigEntry TO_REDACT = {CONF_HOST, "title"} diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index 1540b098cf1..0d6401d194f 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -17,8 +17,7 @@ from homeassistant.const import EntityCategory, UnitOfElectricCurrent from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity MIN_INTENSITY = 6 diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 97853740e9d..5b02928385b 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -26,8 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index cca7da70e48..d6ba6a3b13e 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -20,8 +20,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import V2CConfigEntry -from .coordinator import V2CUpdateCoordinator +from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) From ed8ee34a2803574754653506bd0325c53018b65c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Feb 2025 19:24:14 +0100 Subject: [PATCH 0121/1941] Fix sentence-casing and description of homekit.reload action (#137894) --- homeassistant/components/homekit/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 92b836d5ec6..dcdf6892dc2 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -2,13 +2,13 @@ "options": { "step": { "yaml": { - "title": "Adjust HomeKit Options", + "title": "Adjust HomeKit options", "description": "This entry is controlled via YAML" }, "init": { "data": { - "mode": "HomeKit Mode", - "include_exclude_mode": "Inclusion Mode", + "mode": "HomeKit mode", + "include_exclude_mode": "Inclusion mode", "domains": "[%key:component::homekit::config::step::user::data::include_domains%]" }, "description": "HomeKit can be configured expose a bridge or a single accessory. In accessory mode, only a single entity can be used. Accessory mode is required for media players with the TV or RECEIVER device class to function properly. Entities in the \u201cDomains to include\u201d will be included to HomeKit. You will be able to select which entities to include or exclude from this list on the next screen.", @@ -40,14 +40,14 @@ "camera_audio": "Cameras that support audio" }, "description": "Check all cameras that support native H.264 streams. If the camera does not output a H.264 stream, the system will transcode the video to H.264 for HomeKit. Transcoding requires a performant CPU and is unlikely to work on single board computers.", - "title": "Camera Configuration" + "title": "Camera configuration" }, "advanced": { "data": { "devices": "Devices (Triggers)" }, "description": "Programmable switches are created for each selected device. When a device trigger fires, HomeKit can be configured to run an automation or scene.", - "title": "Advanced Configuration" + "title": "Advanced configuration" } } }, @@ -72,7 +72,7 @@ "services": { "reload": { "name": "[%key:common::action::reload%]", - "description": "Reloads homekit and re-process YAML-configuration." + "description": "Reloads HomeKit and re-processes the YAML-configuration." }, "reset_accessory": { "name": "Reset accessory", From 32d13f3356df921fcd68629684496c230c4b13eb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:24:38 +0100 Subject: [PATCH 0122/1941] Explicitly pass in the config_entry in steamist coordinator (#137930) explicitly pass in the config_entry in coordinator --- homeassistant/components/steamist/__init__.py | 5 ++--- homeassistant/components/steamist/coordinator.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py index 8d8401ec6fd..380f25ea8da 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -8,7 +8,7 @@ from typing import Any from aiosteamist import Steamist from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -52,9 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = entry.data[CONF_HOST] coordinator = SteamistDataUpdateCoordinator( hass, + entry, Steamist(host, async_get_clientsession(hass)), - host, - entry.data.get(CONF_NAME), # Only found from discovery ) await coordinator.async_config_entry_first_refresh() if not async_get_discovery(hass, host): diff --git a/homeassistant/components/steamist/coordinator.py b/homeassistant/components/steamist/coordinator.py index c5aa7be7ddc..3f864364be7 100644 --- a/homeassistant/components/steamist/coordinator.py +++ b/homeassistant/components/steamist/coordinator.py @@ -7,6 +7,8 @@ import logging from aiosteamist import Steamist, SteamistStatus +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -16,20 +18,22 @@ _LOGGER = logging.getLogger(__name__) class SteamistDataUpdateCoordinator(DataUpdateCoordinator[SteamistStatus]): """DataUpdateCoordinator to gather data from a steamist steam shower.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: Steamist, - host: str, - device_name: str | None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific steamist.""" self.client = client - self.device_name = device_name + self.device_name = config_entry.data.get(CONF_NAME) # Only found from discovery super().__init__( hass, _LOGGER, - name=f"Steamist {host}", + config_entry=config_entry, + name=f"Steamist {config_entry.data[CONF_HOST]}", update_interval=timedelta(seconds=5), always_update=False, ) From e698e436d63254930342edac12fc7485b284c524 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:25:14 +0100 Subject: [PATCH 0123/1941] Explicitly pass in the config_entry in uptimerobot coordinator (#137883) explicitly pass in the config_entry in coordinator --- homeassistant/components/uptimerobot/__init__.py | 5 +---- homeassistant/components/uptimerobot/coordinator.py | 11 +++++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index afff0c8fe03..b8619b1fe39 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -8,7 +8,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS @@ -24,12 +23,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Wrong API key type detected, use the 'main' API key" ) uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass)) - dev_reg = dr.async_get(hass) hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator( hass, - config_entry_id=entry.entry_id, - dev_reg=dev_reg, + entry, api=uptime_robot_api, ) diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index 3069884eb99..fbadc237965 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -26,19 +26,18 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon def __init__( self, hass: HomeAssistant, - config_entry_id: str, - dev_reg: dr.DeviceRegistry, + config_entry: ConfigEntry, api: UptimeRobot, ) -> None: """Initialize coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=COORDINATOR_UPDATE_INTERVAL, ) - self._config_entry_id = config_entry_id - self._device_registry = dev_reg + self._device_registry = dr.async_get(hass) self.api = api async def _async_update_data(self) -> list[UptimeRobotMonitor]: @@ -58,7 +57,7 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon current_monitors = { list(device.identifiers)[0][1] for device in dr.async_entries_for_config_entry( - self._device_registry, self._config_entry_id + self._device_registry, self.config_entry.entry_id ) } new_monitors = {str(monitor.id) for monitor in monitors} @@ -73,7 +72,7 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon # create new devices and entities. if self.data and new_monitors - {str(monitor.id) for monitor in self.data}: self.hass.async_create_task( - self.hass.config_entries.async_reload(self._config_entry_id) + self.hass.config_entries.async_reload(self.config_entry.entry_id) ) return monitors From cfb062a5a48e9e0ff16ede3204201740851c48a1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:33:50 +0100 Subject: [PATCH 0124/1941] Explicitly pass in the config_entry in skybell coordinator (#137947) explicitly pass in the config_entry in coordinator --- homeassistant/components/skybell/__init__.py | 2 +- homeassistant/components/skybell/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 0282ad40254..5baa4ad83ad 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Unable to connect to Skybell service: {ex}") from ex device_coordinators: list[SkybellDataUpdateCoordinator] = [ - SkybellDataUpdateCoordinator(hass, device) for device in devices + SkybellDataUpdateCoordinator(hass, entry, device) for device in devices ] await asyncio.gather( *[ diff --git a/homeassistant/components/skybell/coordinator.py b/homeassistant/components/skybell/coordinator.py index 55e34df5c63..48e67c63ac9 100644 --- a/homeassistant/components/skybell/coordinator.py +++ b/homeassistant/components/skybell/coordinator.py @@ -16,11 +16,14 @@ class SkybellDataUpdateCoordinator(DataUpdateCoordinator[None]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, device: SkybellDevice) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: SkybellDevice + ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=device.name, update_interval=timedelta(seconds=30), ) From 074500dc8a0cfdbd816671227e66045f144e5590 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:35:41 +0100 Subject: [PATCH 0125/1941] Bump bring-api to version 1.0.2 (#137925) --- homeassistant/components/bring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 16767b7b0d6..b846cb1c5ca 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["bring_api"], - "requirements": ["bring-api==1.0.1"] + "requirements": ["bring-api==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2d07a3aa061..84a31529aed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -650,7 +650,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.1 +bring-api==1.0.2 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c18706cbef0..fe7fdc68099 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -570,7 +570,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.1 +bring-api==1.0.2 # homeassistant.components.broadlink broadlink==0.19.0 From 2db6860b6024e3c1849f2a9d923b5cf50c3799dc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Feb 2025 20:36:37 +0100 Subject: [PATCH 0126/1941] Fix three action descriptions in xiaomi_miio (#137918) * Update strings.json * Change "a robot" to "the robot" Co-authored-by: Sebastian Muszynski --------- Co-authored-by: Sebastian Muszynski --- homeassistant/components/xiaomi_miio/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index bafc1ec543b..dd49ba502f0 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -461,7 +461,7 @@ }, "switch_set_wifi_led_on": { "name": "Switch set Wi-Fi LED on", - "description": "Turns the wifi led on.", + "description": "Turns the Wi-Fi LED on.", "fields": { "entity_id": { "name": "Entity ID", @@ -471,7 +471,7 @@ }, "switch_set_wifi_led_off": { "name": "Switch set Wi-Fi LED off", - "description": "Turn the Wi-Fi led off.", + "description": "Turns the Wi-Fi LED off.", "fields": { "entity_id": { "name": "Entity ID", @@ -567,7 +567,7 @@ }, "vacuum_goto": { "name": "Vacuum go to", - "description": "Go to the specified coordinates.", + "description": "Sends the robot to the specified coordinates.", "fields": { "x_coord": { "name": "X coordinate", From 743873b2e9943f5bc8f151a00714621279fd3b51 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Feb 2025 20:38:28 +0100 Subject: [PATCH 0127/1941] Fix spelling of "Wi-Fi" in keenetic_ndms2 integration (#137920) --- homeassistant/components/keenetic_ndms2/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 765a3fc4d47..739846de0a8 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -21,7 +21,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "no_udn": "SSDP discovery info has no UDN", - "not_keenetic_ndms2": "Discovered item is not a Keenetic router" + "not_keenetic_ndms2": "Discovered device is not a Keenetic router" } }, "options": { @@ -33,7 +33,7 @@ "interfaces": "Choose interfaces to scan", "try_hotspot": "Use 'ip hotspot' data (most accurate)", "include_arp": "Use ARP data (ignored if hotspot data used)", - "include_associated": "Use WiFi AP associations data (ignored if hotspot data used)" + "include_associated": "Use Wi-Fi AP associations data (ignored if hotspot data used)" } } } From 22d6913bc59dc8a9d1c036576caf7e4f659a4978 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:40:51 +0100 Subject: [PATCH 0128/1941] Explicitly pass in the config_entry in simplefin coordinator (#137948) explicitly pass in the config_entry in coordinator --- homeassistant/components/simplefin/__init__.py | 10 +++------- homeassistant/components/simplefin/binary_sensor.py | 2 +- homeassistant/components/simplefin/coordinator.py | 9 +++++++-- homeassistant/components/simplefin/sensor.py | 2 +- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/simplefin/__init__.py b/homeassistant/components/simplefin/__init__.py index c47b3118415..1fe2f2a6189 100644 --- a/homeassistant/components/simplefin/__init__.py +++ b/homeassistant/components/simplefin/__init__.py @@ -4,12 +4,11 @@ from __future__ import annotations from simplefin4py import SimpleFin -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import CONF_ACCESS_URL -from .coordinator import SimpleFinDataUpdateCoordinator +from .coordinator import SimpleFinConfigEntry, SimpleFinDataUpdateCoordinator PLATFORMS: list[str] = [ Platform.BINARY_SENSOR, @@ -17,20 +16,17 @@ PLATFORMS: list[str] = [ ] -type SimpleFinConfigEntry = ConfigEntry[SimpleFinDataUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: SimpleFinConfigEntry) -> bool: """Set up from a config entry.""" access_url = entry.data[CONF_ACCESS_URL] sf_client = SimpleFin(access_url) - sf_coordinator = SimpleFinDataUpdateCoordinator(hass, sf_client) + sf_coordinator = SimpleFinDataUpdateCoordinator(hass, entry, sf_client) await sf_coordinator.async_config_entry_first_refresh() entry.runtime_data = sf_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SimpleFinConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/simplefin/binary_sensor.py b/homeassistant/components/simplefin/binary_sensor.py index 5805fc370b6..66d920fb309 100644 --- a/homeassistant/components/simplefin/binary_sensor.py +++ b/homeassistant/components/simplefin/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpleFinConfigEntry +from .coordinator import SimpleFinConfigEntry from .entity import SimpleFinEntity diff --git a/homeassistant/components/simplefin/coordinator.py b/homeassistant/components/simplefin/coordinator.py index 7fa5aedb7a1..08e9732c6b7 100644 --- a/homeassistant/components/simplefin/coordinator.py +++ b/homeassistant/components/simplefin/coordinator.py @@ -15,17 +15,22 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER +type SimpleFinConfigEntry = ConfigEntry[SimpleFinDataUpdateCoordinator] + class SimpleFinDataUpdateCoordinator(DataUpdateCoordinator[FinancialData]): """Data update coordinator for the SimpleFIN integration.""" - config_entry: ConfigEntry + config_entry: SimpleFinConfigEntry - def __init__(self, hass: HomeAssistant, client: SimpleFin) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: SimpleFinConfigEntry, client: SimpleFin + ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name="simplefin", update_interval=timedelta(hours=4), ) diff --git a/homeassistant/components/simplefin/sensor.py b/homeassistant/components/simplefin/sensor.py index b2167a2c014..51a96bae2be 100644 --- a/homeassistant/components/simplefin/sensor.py +++ b/homeassistant/components/simplefin/sensor.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import SimpleFinConfigEntry +from .coordinator import SimpleFinConfigEntry from .entity import SimpleFinEntity From 1374fb23db34777400333f192f92c13c299a0ba3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 20:42:44 +0100 Subject: [PATCH 0129/1941] Explicitly pass in the config_entry in sleepiq coordinator (#137946) explicitly pass in the config_entry in coordinator --- homeassistant/components/sleepiq/__init__.py | 4 ++-- homeassistant/components/sleepiq/coordinator.py | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 4f54b4cd305..565611fe169 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -94,8 +94,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _async_migrate_unique_ids(hass, entry, gateway) - coordinator = SleepIQDataUpdateCoordinator(hass, gateway, email) - pause_coordinator = SleepIQPauseUpdateCoordinator(hass, gateway, email) + coordinator = SleepIQDataUpdateCoordinator(hass, entry, gateway) + pause_coordinator = SleepIQPauseUpdateCoordinator(hass, entry, gateway) # Call the SleepIQ API to refresh data await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/sleepiq/coordinator.py b/homeassistant/components/sleepiq/coordinator.py index 7fe4f964b36..46b754976e5 100644 --- a/homeassistant/components/sleepiq/coordinator.py +++ b/homeassistant/components/sleepiq/coordinator.py @@ -7,6 +7,8 @@ import logging from asyncsleepiq import AsyncSleepIQ +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -19,17 +21,20 @@ LONGER_UPDATE_INTERVAL = timedelta(minutes=5) class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): """SleepIQ data update coordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: AsyncSleepIQ, - username: str, ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, - name=f"{username}@SleepIQ", + config_entry=config_entry, + name=f"{config_entry.data[CONF_USERNAME]}@SleepIQ", update_interval=UPDATE_INTERVAL, ) self.client = client @@ -45,17 +50,20 @@ class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]): """SleepIQ data update coordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: AsyncSleepIQ, - username: str, ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, - name=f"{username}@SleepIQPause", + config_entry=config_entry, + name=f"{config_entry.data[CONF_USERNAME]}@SleepIQPause", update_interval=LONGER_UPDATE_INTERVAL, ) self.client = client From 848ee762a732039958f69955ad9c2208b33860fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Buli=C5=84ski?= Date: Sat, 8 Feb 2025 20:54:13 +0100 Subject: [PATCH 0130/1941] Add support for fireplace mode control for flexit_bacnet integration (#137594) --- .../components/flexit_bacnet/icons.json | 9 +++ .../components/flexit_bacnet/number.py | 17 +++++- .../components/flexit_bacnet/strings.json | 6 ++ .../components/flexit_bacnet/switch.py | 7 +++ tests/components/flexit_bacnet/conftest.py | 1 + .../flexit_bacnet/snapshots/test_number.ambr | 57 +++++++++++++++++++ .../flexit_bacnet/snapshots/test_switch.ambr | 47 +++++++++++++++ 7 files changed, 143 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/icons.json b/homeassistant/components/flexit_bacnet/icons.json index 7ce8b116a27..a0c5ccd5a6e 100644 --- a/homeassistant/components/flexit_bacnet/icons.json +++ b/homeassistant/components/flexit_bacnet/icons.json @@ -30,6 +30,9 @@ }, "home_supply_fan_setpoint": { "default": "mdi:fan-plus" + }, + "fireplace_mode_runtime": { + "default": "mdi:fireplace" } }, "switch": { @@ -38,6 +41,12 @@ "state": { "off": "mdi:radiator-off" } + }, + "fireplace_mode": { + "default": "mdi:fireplace", + "state": { + "off": "mdi:fireplace-off" + } } } } diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index 6e405e8e8ac..30df5370868 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -26,6 +26,9 @@ from .entity import FlexitEntity _MAX_FAN_SETPOINT = 100 _MIN_FAN_SETPOINT = 30 +_MAX_RUNTIME_DURATION = 360 +_MIN_RUNTIME_DURATION = 1 + @dataclass(kw_only=True, frozen=True) class FlexitNumberEntityDescription(NumberEntityDescription): @@ -176,6 +179,18 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = ( native_max_value_fn=lambda _: _MAX_FAN_SETPOINT, native_min_value_fn=lambda device: int(device.fan_setpoint_supply_air_away), ), + FlexitNumberEntityDescription( + key="fireplace_mode_runtime", + translation_key="fireplace_mode_runtime", + device_class=NumberDeviceClass.DURATION, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fireplace_mode_runtime, + set_native_value_fn=lambda device: device.set_fireplace_mode_runtime, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_max_value_fn=lambda _: _MAX_RUNTIME_DURATION, + native_min_value_fn=lambda _: _MIN_RUNTIME_DURATION, + ), ) diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index 7f763674d00..8888b02a3ef 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -52,6 +52,9 @@ }, "home_supply_fan_setpoint": { "name": "Home supply fan setpoint" + }, + "fireplace_mode_runtime": { + "name": "Fireplace mode runtime" } }, "sensor": { @@ -104,6 +107,9 @@ "switch": { "electric_heater": { "name": "Electric heater" + }, + "fireplace_mode": { + "name": "Fireplace mode" } } } diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index c58e35cda75..7f12a7524b6 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -40,6 +40,13 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( turn_on_fn=lambda data: data.enable_electric_heater(), turn_off_fn=lambda data: data.disable_electric_heater(), ), + FlexitSwitchEntityDescription( + key="fireplace_mode", + translation_key="fireplace_mode", + is_on_fn=lambda data: data.fireplace_ventilation_status, + turn_on_fn=lambda data: data.trigger_fireplace_mode(), + turn_off_fn=lambda data: data.trigger_fireplace_mode(), + ), ) diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py index 6ce17261bfc..09957fe496f 100644 --- a/tests/components/flexit_bacnet/conftest.py +++ b/tests/components/flexit_bacnet/conftest.py @@ -67,6 +67,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock]: flexit_bacnet.air_filter_polluted = False flexit_bacnet.air_filter_exchange_interval = 8784 flexit_bacnet.electric_heater = True + flexit_bacnet.fireplace_mode_runtime = 10 # Mock fan setpoints flexit_bacnet.fan_setpoint_extract_air_fire = 56 diff --git a/tests/components/flexit_bacnet/snapshots/test_number.ambr b/tests/components/flexit_bacnet/snapshots/test_number.ambr index 78eefd08345..e2875c140cc 100644 --- a/tests/components/flexit_bacnet/snapshots/test_number.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_number.ambr @@ -284,6 +284,63 @@ 'state': '56', }) # --- +# name: test_numbers[number.device_name_fireplace_mode_runtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 360, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.device_name_fireplace_mode_runtime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fireplace mode runtime', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fireplace_mode_runtime', + 'unique_id': '0000-0001-fireplace_mode_runtime', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.device_name_fireplace_mode_runtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Device Name Fireplace mode runtime', + 'max': 360, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.device_name_fireplace_mode_runtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- # name: test_numbers[number.device_name_fireplace_supply_fan_setpoint-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/flexit_bacnet/snapshots/test_switch.ambr b/tests/components/flexit_bacnet/snapshots/test_switch.ambr index d054608f1f7..1df1c12e791 100644 --- a/tests/components/flexit_bacnet/snapshots/test_switch.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_switch.ambr @@ -46,6 +46,53 @@ 'state': 'on', }) # --- +# name: test_switches[switch.device_name_fireplace_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device_name_fireplace_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fireplace mode', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fireplace_mode', + 'unique_id': '0000-0001-fireplace_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.device_name_fireplace_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Fireplace mode', + }), + 'context': , + 'entity_id': 'switch.device_name_fireplace_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches_implementation[switch.device_name_electric_heater-state] StateSnapshot({ 'attributes': ReadOnlyDict({ From 6eea232a2382b0f0cdac5a31013df916061b5448 Mon Sep 17 00:00:00 2001 From: "Glenn Vandeuren (aka Iondependent)" Date: Sat, 8 Feb 2025 20:57:56 +0100 Subject: [PATCH 0131/1941] Bump nhc to 0.4.10 (#137903) --- homeassistant/components/niko_home_control/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index b50410cd7de..83fca0ca2d6 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.4.4"] + "requirements": ["nhc==0.4.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 84a31529aed..c2934a232c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1489,7 +1489,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.4.4 +nhc==0.4.10 # homeassistant.components.nibe_heatpump nibe==2.14.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe7fdc68099..83b20bbfb94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1252,7 +1252,7 @@ nextcord==2.6.0 nextdns==4.0.0 # homeassistant.components.niko_home_control -nhc==0.4.4 +nhc==0.4.10 # homeassistant.components.nibe_heatpump nibe==2.14.0 From 5871ece4df5400f1e48ad5ff6533c0e24a7a4c56 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:11:30 +0100 Subject: [PATCH 0132/1941] Explicitly pass in the config_entry in shelly coordinator (#137951) explicitly pass in the config_entry in coordinator --- .../components/shelly/bluetooth/__init__.py | 2 +- .../components/shelly/coordinator.py | 75 ++++++++++--------- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 366d5c51d25..d7eb020d671 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -25,7 +25,7 @@ async def async_connect_scanner( ) -> CALLBACK_TYPE: """Connect scanner.""" device = coordinator.device - entry = coordinator.entry + entry = coordinator.config_entry source = format_mac(coordinator.mac).upper() scanner = create_scanner(source, entry.title) unload_callbacks = [ diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f2a01240f70..ad35ec32299 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -95,6 +95,8 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( ): """Coordinator for a Shelly device.""" + config_entry: ShellyConfigEntry + def __init__( self, hass: HomeAssistant, @@ -103,7 +105,6 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( update_interval: float, ) -> None: """Initialize the Shelly device coordinator.""" - self.entry = entry self.device = device self.device_id: str | None = None self._pending_platforms: list[Platform] | None = None @@ -112,7 +113,13 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( # The device has come online at least once. In the case of a sleeping RPC # device, this means that the device has connected to the WS server at least once. self._came_online_once = False - super().__init__(hass, LOGGER, name=device_name, update_interval=interval_td) + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=device_name, + update_interval=interval_td, + ) self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( hass, @@ -130,12 +137,12 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @cached_property def model(self) -> str: """Model of the device.""" - return cast(str, self.entry.data["model"]) + return cast(str, self.config_entry.data["model"]) @cached_property def mac(self) -> str: """Mac address of the device.""" - return cast(str, self.entry.unique_id) + return cast(str, self.config_entry.unique_id) @property def sw_version(self) -> str: @@ -145,14 +152,14 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @property def sleep_period(self) -> int: """Sleep period of the device.""" - return self.entry.data.get(CONF_SLEEP_PERIOD, 0) + return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) def async_setup(self, pending_platforms: list[Platform] | None = None) -> None: """Set up the coordinator.""" self._pending_platforms = pending_platforms dev_reg = dr.async_get(self.hass) device_entry = dev_reg.async_get_or_create( - config_entry_id=self.entry.entry_id, + config_entry_id=self.config_entry.entry_id, name=self.name, connections={(CONNECTION_NETWORK_MAC, self.mac)}, identifiers={(DOMAIN, self.mac)}, @@ -160,8 +167,8 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( model=MODEL_NAMES.get(self.model), model_id=self.model, sw_version=self.sw_version, - hw_version=f"gen{get_device_entry_gen(self.entry)}", - configuration_url=f"http://{get_host(self.entry.data[CONF_HOST])}:{get_http_port(self.entry.data)}", + hw_version=f"gen{get_device_entry_gen(self.config_entry)}", + configuration_url=f"http://{get_host(self.config_entry.data[CONF_HOST])}:{get_http_port(self.config_entry.data)}", ) self.device_id = device_entry.id @@ -179,18 +186,18 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( LOGGER.debug("Connecting to Shelly Device - %s", self.name) try: await self.device.initialize() - update_device_fw_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.config_entry) except (DeviceConnectionError, MacAddressMismatchError) as err: LOGGER.debug( "Error connecting to Shelly device %s, error: %r", self.name, err ) return False except InvalidAuthError: - self.entry.async_start_reauth(self.hass) + self.config_entry.async_start_reauth(self.hass) return False if not self.device.firmware_supported: - async_create_issue_unsupported_firmware(self.hass, self.entry) + async_create_issue_unsupported_firmware(self.hass, self.config_entry) return False if not self._pending_platforms: @@ -200,7 +207,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( platforms = self._pending_platforms self._pending_platforms = None - data = {**self.entry.data} + data = {**self.config_entry.data} # Update sleep_period old_sleep_period = data[CONF_SLEEP_PERIOD] @@ -211,10 +218,12 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( if new_sleep_period != old_sleep_period: data[CONF_SLEEP_PERIOD] = new_sleep_period - self.hass.config_entries.async_update_entry(self.entry, data=data) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) # Resume platform setup - await self.hass.config_entries.async_forward_entry_setups(self.entry, platforms) + await self.hass.config_entries.async_forward_entry_setups( + self.config_entry, platforms + ) return True @@ -222,7 +231,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( """Reload entry.""" self._debounced_reload.async_cancel() LOGGER.debug("Reloading entry %s", self.name) - await self.hass.config_entries.async_reload(self.entry.entry_id) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) async def async_shutdown_device_and_start_reauth(self) -> None: """Shutdown Shelly device and start reauth flow.""" @@ -230,7 +239,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( # and won't be able to send commands to the device self.last_update_success = False await self.shutdown() - self.entry.async_start_reauth(self.hass) + self.config_entry.async_start_reauth(self.hass) class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): @@ -240,9 +249,8 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): self, hass: HomeAssistant, entry: ShellyConfigEntry, device: BlockDevice ) -> None: """Initialize the Shelly block device coordinator.""" - self.entry = entry - if self.sleep_period: - update_interval = UPDATE_PERIOD_MULTIPLIER * self.sleep_period + if sleep_period := entry.data.get(CONF_SLEEP_PERIOD, 0): + update_interval = UPDATE_PERIOD_MULTIPLIER * sleep_period else: update_interval = ( UPDATE_PERIOD_MULTIPLIER * device.settings["coiot"]["update_period"] @@ -385,7 +393,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): LOGGER.debug("Shelly %s handle update, type: %s", self.name, update_type) if update_type is BlockUpdateType.ONLINE: self._came_online_once = True - self.entry.async_create_background_task( + self.config_entry.async_create_background_task( self.hass, self._async_device_connect_task(), "block device online", @@ -415,7 +423,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): learn_more_url="https://www.home-assistant.io/integrations/shelly/#shelly-device-configuration-generation-1", translation_key="push_update_failure", translation_placeholders={ - "device_name": self.entry.title, + "device_name": self.config_entry.title, "ip_address": self.device.ip_address, }, ) @@ -462,7 +470,7 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): except InvalidAuthError: await self.async_shutdown_device_and_start_reauth() else: - update_device_fw_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.config_entry) class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): @@ -472,9 +480,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self, hass: HomeAssistant, entry: ShellyConfigEntry, device: RpcDevice ) -> None: """Initialize the Shelly RPC device coordinator.""" - self.entry = entry - if self.sleep_period: - update_interval = UPDATE_PERIOD_MULTIPLIER * self.sleep_period + if sleep_period := entry.data.get(CONF_SLEEP_PERIOD, 0): + update_interval = UPDATE_PERIOD_MULTIPLIER * sleep_period else: update_interval = RPC_RECONNECT_INTERVAL super().__init__(hass, entry, device, update_interval) @@ -514,9 +521,9 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ): return False - data = {**self.entry.data} + data = {**self.config_entry.data} data[CONF_SLEEP_PERIOD] = wakeup_period - self.hass.config_entries.async_update_entry(self.entry, data=data) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) update_interval = UPDATE_PERIOD_MULTIPLIER * wakeup_period self.update_interval = timedelta(seconds=update_interval) @@ -693,7 +700,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): async def _async_connect_ble_scanner(self) -> None: """Connect BLE scanner.""" - ble_scanner_mode = self.entry.options.get( + ble_scanner_mode = self.config_entry.options.get( CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED ) if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: @@ -719,7 +726,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ): LOGGER.debug("Device %s already connected/connecting", self.name) return - self._connect_task = self.entry.async_create_background_task( + self._connect_task = self.config_entry.async_create_background_task( self.hass, self._async_device_connect_task(), "rpc device online", @@ -736,13 +743,13 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self._came_online_once = True self._async_handle_rpc_device_online() elif update_type is RpcUpdateType.INITIALIZED: - self.entry.async_create_background_task( + self.config_entry.async_create_background_task( self.hass, self._async_connected(), "rpc device init", eager_start=True ) # Make sure entities are marked available self.async_set_updated_data(None) elif update_type is RpcUpdateType.DISCONNECTED: - self.entry.async_create_background_task( + self.config_entry.async_create_background_task( self.hass, self._async_disconnected(True), "rpc device disconnected", @@ -753,7 +760,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) if self.sleep_period: - update_device_fw_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.config_entry) elif update_type is RpcUpdateType.EVENT and (event := self.device.event): self._async_device_event_handler(event) @@ -763,7 +770,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self.device.subscribe_updates(self._async_handle_update) if self.device.initialized: # If we are already initialized, we are connected - self.entry.async_create_task( + self.config_entry.async_create_task( self.hass, self._async_connected(), eager_start=True ) @@ -775,7 +782,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): await async_stop_scanner(self.device) await super().shutdown() except InvalidAuthError: - self.entry.async_start_reauth(self.hass) + self.config_entry.async_start_reauth(self.hass) return except DeviceConnectionError as err: # If the device is restarting or has gone offline before From 022119e74f70ddc3104d96227c8b2e8db31e1ad9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:12:10 +0100 Subject: [PATCH 0133/1941] Explicitly pass in the config_entry in surepetcare coordinator (#137926) explicitly pass in the config_entry in coordinator --- homeassistant/components/surepetcare/__init__.py | 2 +- homeassistant/components/surepetcare/coordinator.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index e1f846d63a7..130242b7742 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -38,8 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: hass.data[DOMAIN][entry.entry_id] = coordinator = SurePetcareDataCoordinator( - entry, hass, + entry, ) except SurePetcareAuthenticationError as error: _LOGGER.error("Unable to connect to surepetcare.io: Wrong credentials!") diff --git a/homeassistant/components/surepetcare/coordinator.py b/homeassistant/components/surepetcare/coordinator.py index a80e96ad185..d8112cebc90 100644 --- a/homeassistant/components/surepetcare/coordinator.py +++ b/homeassistant/components/surepetcare/coordinator.py @@ -33,7 +33,9 @@ SCAN_INTERVAL = timedelta(minutes=3) class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]): """Handle Surepetcare data.""" - def __init__(self, entry: ConfigEntry, hass: HomeAssistant) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the data handler.""" self.surepy = Surepy( entry.data[CONF_USERNAME], @@ -51,6 +53,7 @@ class SurePetcareDataCoordinator(DataUpdateCoordinator[dict[int, SurepyEntity]]) super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) From a7dbcf72c28a3fe1bea51703eded6f0b9cd96f47 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:14:47 +0100 Subject: [PATCH 0134/1941] Explicitly pass in the config_entry in swiss_public_transport coordinator (#137924) explicitly pass in the config_entry in coordinator --- homeassistant/components/swiss_public_transport/__init__.py | 4 +++- .../components/swiss_public_transport/coordinator.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index 628f6e95c2a..0d0c4dc6169 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -96,7 +96,9 @@ async def async_setup_entry( }, ) from e - coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata, time_offset) + coordinator = SwissPublicTransportDataUpdateCoordinator( + hass, entry, opendata, time_offset + ) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py index 81322117a6f..32b52122c7d 100644 --- a/homeassistant/components/swiss_public_transport/coordinator.py +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -61,6 +61,7 @@ class SwissPublicTransportDataUpdateCoordinator( def __init__( self, hass: HomeAssistant, + config_entry: SwissPublicTransportConfigEntry, opendata: OpendataTransport, time_offset: dict[str, int] | None, ) -> None: @@ -68,6 +69,7 @@ class SwissPublicTransportDataUpdateCoordinator( super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_UPDATE_TIME), ) From 43569df53785ac9450e09370105c973eed5e098f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:19:08 +0100 Subject: [PATCH 0135/1941] Explicitly pass in the config_entry in switchbee coordinator (#137923) explicitly pass in the config_entry in coordinator --- homeassistant/components/switchbee/__init__.py | 5 +---- homeassistant/components/switchbee/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index a2a3ecf0df9..6e4bf004a3d 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -63,10 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websession = async_get_clientsession(hass, verify_ssl=False) api = await get_api_object(central_unit, user, password, websession) - coordinator = SwitchBeeCoordinator( - hass, - api, - ) + coordinator = SwitchBeeCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index 49400e3c28d..b0ea1707be8 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -10,6 +10,7 @@ from switchbee.api import CentralUnitPolling, CentralUnitWsRPC from switchbee.api.central_unit import SwitchBeeError from switchbee.device import DeviceType, SwitchBeeBaseDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,9 +23,12 @@ _LOGGER = logging.getLogger(__name__) class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevice]]): """Class to manage fetching SwitchBee data API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, swb_api: CentralUnitPolling | CentralUnitWsRPC, ) -> None: """Initialize.""" @@ -39,6 +43,7 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=SCAN_INTERVAL_SEC[type(self.api)]), ) From bcc3e6d31c4acb56e84ba19b5e0306387a28d5bd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:19:38 +0100 Subject: [PATCH 0136/1941] Explicitly pass in the config_entry in streamlabswater coordinator (#137927) explicitly pass in the config_entry in coordinator --- homeassistant/components/streamlabswater/__init__.py | 2 +- homeassistant/components/streamlabswater/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 313fc1f24c5..1c1357a9b2b 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_key = entry.data[CONF_API_KEY] client = StreamlabsClient(api_key) - coordinator = StreamlabsCoordinator(hass, client) + coordinator = StreamlabsCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py index 56e67abe222..df4a6056b36 100644 --- a/homeassistant/components/streamlabswater/coordinator.py +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -5,6 +5,7 @@ from datetime import timedelta from streamlabswater.streamlabswater import StreamlabsClient +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -25,15 +26,19 @@ class StreamlabsData: class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): """Coordinator for Streamlabs.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: StreamlabsClient, ) -> None: """Coordinator for Streamlabs.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Streamlabs", update_interval=timedelta(seconds=60), ) From 3ec872fbfeaa234ea18ff99b38d7db5d0fae84e0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:25:10 +0100 Subject: [PATCH 0137/1941] Explicitly pass in the config_entry in yardian coordinator (#137862) explicitly pass in the config_entry in coordinator --- homeassistant/components/yardian/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index b0c8a882474..da016ca4ec4 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -28,6 +28,8 @@ SCAN_INTERVAL = datetime.timedelta(seconds=30) class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): """Coordinator for Yardian API calls.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -38,6 +40,7 @@ class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): super().__init__( hass, _LOGGER, + config_entry=entry, name=entry.title, update_interval=SCAN_INTERVAL, always_update=False, From c17007e17bdb1b24069a61ea3f5caf314597d3a5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:25:48 +0100 Subject: [PATCH 0138/1941] Explicitly pass in the config_entry in xbox coordinator (#137864) explicitly pass in the config_entry in coordinator --- homeassistant/components/xbox/__init__.py | 2 +- homeassistant/components/xbox/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xbox/__init__.py b/homeassistant/components/xbox/__init__.py index ab0d510a709..30bc7d59417 100644 --- a/homeassistant/components/xbox/__init__.py +++ b/homeassistant/components/xbox/__init__.py @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: consoles.dict(), ) - coordinator = XboxUpdateCoordinator(hass, client, consoles) + coordinator = XboxUpdateCoordinator(hass, entry, client, consoles) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { diff --git a/homeassistant/components/xbox/coordinator.py b/homeassistant/components/xbox/coordinator.py index 4012820c43c..62c7a35e88b 100644 --- a/homeassistant/components/xbox/coordinator.py +++ b/homeassistant/components/xbox/coordinator.py @@ -20,6 +20,7 @@ from xbox.webapi.api.provider.smartglass.models import ( SmartglassConsoleStatus, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -64,9 +65,12 @@ class XboxData: class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): """Store Xbox Console Status.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: XboxLiveClient, consoles: SmartglassConsoleList, ) -> None: @@ -74,6 +78,7 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) From beb5c3b83875a391771f96646912b192653f87b8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:27:20 +0100 Subject: [PATCH 0139/1941] Explicitly pass in the config_entry in slide_local coordinator (#137945) explicitly pass in the config_entry in coordinator --- .../components/slide_local/__init__.py | 4 +-- .../components/slide_local/button.py | 3 +-- .../components/slide_local/config_flow.py | 2 +- .../components/slide_local/coordinator.py | 26 ++++++++++++------- homeassistant/components/slide_local/cover.py | 3 +-- .../components/slide_local/diagnostics.py | 2 +- .../components/slide_local/switch.py | 3 +-- 7 files changed, 23 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py index 5b4867bf337..4690fe8016c 100644 --- a/homeassistant/components/slide_local/__init__.py +++ b/homeassistant/components/slide_local/__init__.py @@ -2,14 +2,12 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SWITCH] -type SlideConfigEntry = ConfigEntry[SlideCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py index faca7cb3f2b..12474969ca6 100644 --- a/homeassistant/components/slide_local/button.py +++ b/homeassistant/components/slide_local/button.py @@ -15,9 +15,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SlideConfigEntry from .const import DOMAIN -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator from .entity import SlideEntity PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 4ceb347568f..96aac1a135c 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -20,8 +20,8 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import SlideConfigEntry from .const import CONF_INVERT_POSITION, DOMAIN +from .coordinator import SlideConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/slide_local/coordinator.py b/homeassistant/components/slide_local/coordinator.py index e5311967198..cbc3e653739 100644 --- a/homeassistant/components/slide_local/coordinator.py +++ b/homeassistant/components/slide_local/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import TYPE_CHECKING, Any +from typing import Any from goslideapi.goslideapi import ( AuthenticationFailed, @@ -14,6 +14,7 @@ from goslideapi.goslideapi import ( GoSlideLocal as SlideLocalApi, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_VERSION, CONF_HOST, @@ -31,23 +32,30 @@ from .const import DEFAULT_OFFSET, DOMAIN _LOGGER = logging.getLogger(__name__) -if TYPE_CHECKING: - from . import SlideConfigEntry +type SlideConfigEntry = ConfigEntry[SlideCoordinator] class SlideCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get and update the latest data.""" - def __init__(self, hass: HomeAssistant, entry: SlideConfigEntry) -> None: + config_entry: SlideConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: SlideConfigEntry) -> None: """Initialize the data object.""" super().__init__( - hass, _LOGGER, name="Slide", update_interval=timedelta(seconds=15) + hass, + _LOGGER, + config_entry=config_entry, + name="Slide", + update_interval=timedelta(seconds=15), ) self.slide = SlideLocalApi() - self.api_version = entry.data[CONF_API_VERSION] - self.mac = entry.data[CONF_MAC] - self.host = entry.data[CONF_HOST] - self.password = entry.data[CONF_PASSWORD] if self.api_version == 1 else "" + self.api_version = config_entry.data[CONF_API_VERSION] + self.mac = config_entry.data[CONF_MAC] + self.host = config_entry.data[CONF_HOST] + self.password = ( + config_entry.data[CONF_PASSWORD] if self.api_version == 1 else "" + ) async def _async_setup(self) -> None: """Do initialization logic for Slide coordinator.""" diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py index cf04f46d139..0e5e647dea8 100644 --- a/homeassistant/components/slide_local/cover.py +++ b/homeassistant/components/slide_local/cover.py @@ -10,9 +10,8 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SlideConfigEntry from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator from .entity import SlideEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/slide_local/diagnostics.py b/homeassistant/components/slide_local/diagnostics.py index 2655cb5fada..6a70720a14a 100644 --- a/homeassistant/components/slide_local/diagnostics.py +++ b/homeassistant/components/slide_local/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from . import SlideConfigEntry +from .coordinator import SlideConfigEntry TO_REDACT = [ CONF_PASSWORD, diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py index 0471dfcc4e6..8de608b7fc0 100644 --- a/homeassistant/components/slide_local/switch.py +++ b/homeassistant/components/slide_local/switch.py @@ -17,9 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SlideConfigEntry from .const import DOMAIN -from .coordinator import SlideCoordinator +from .coordinator import SlideConfigEntry, SlideCoordinator from .entity import SlideEntity PARALLEL_UPDATES = 1 From 12072b625c25f6e262306e940389780ff8f50d09 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:28:48 +0100 Subject: [PATCH 0140/1941] Explicitly pass in the config_entry in solaredge coordinator (#137941) explicitly pass in the config_entry in coordinator --- .../components/solaredge/coordinator.py | 35 +++++++++++++++---- homeassistant/components/solaredge/sensor.py | 20 +++++++---- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index d37cf355fce..44f015eedeb 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import date, datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from aiosolaredge import SolarEdge from stringcase import snakecase @@ -21,13 +21,22 @@ from .const import ( POWER_FLOW_UPDATE_DELAY, ) +if TYPE_CHECKING: + from .types import SolarEdgeConfigEntry + class SolarEdgeDataService(ABC): """Get and update the latest data.""" coordinator: DataUpdateCoordinator[None] - def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + api: SolarEdge, + site_id: str, + ) -> None: """Initialize the data object.""" self.api = api self.site_id = site_id @@ -36,6 +45,7 @@ class SolarEdgeDataService(ABC): self.attributes: dict[str, Any] = {} self.hass = hass + self.config_entry = config_entry @callback def async_setup(self) -> None: @@ -43,6 +53,7 @@ class SolarEdgeDataService(ABC): self.coordinator = DataUpdateCoordinator( self.hass, LOGGER, + config_entry=self.config_entry, name=str(self), update_method=self.async_update_data, update_interval=self.update_interval, @@ -174,9 +185,15 @@ class SolarEdgeInventoryDataService(SolarEdgeDataService): class SolarEdgeEnergyDetailsService(SolarEdgeDataService): """Get and update the latest power flow data.""" - def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + api: SolarEdge, + site_id: str, + ) -> None: """Initialize the power flow data service.""" - super().__init__(hass, api, site_id) + super().__init__(hass, config_entry, api, site_id) self.unit = None @@ -234,9 +251,15 @@ class SolarEdgeEnergyDetailsService(SolarEdgeDataService): class SolarEdgePowerFlowDataService(SolarEdgeDataService): """Get and update the latest power flow data.""" - def __init__(self, hass: HomeAssistant, api: SolarEdge, site_id: str) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + api: SolarEdge, + site_id: str, + ) -> None: """Initialize the power flow data service.""" - super().__init__(hass, api, site_id) + super().__init__(hass, config_entry, api, site_id) self.unit = None diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 4b2398d15c2..004335b644b 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -206,7 +206,7 @@ async def async_setup_entry( """Add an solarEdge entry.""" # Add the needed sensors to hass api = entry.runtime_data[DATA_API_CLIENT] - sensor_factory = SolarEdgeSensorFactory(hass, entry.data[CONF_SITE_ID], api) + sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api) for service in sensor_factory.all_services: service.async_setup() await service.coordinator.async_refresh() @@ -222,14 +222,20 @@ async def async_setup_entry( class SolarEdgeSensorFactory: """Factory which creates sensors based on the sensor_key.""" - def __init__(self, hass: HomeAssistant, site_id: str, api: SolarEdge) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarEdgeConfigEntry, + site_id: str, + api: SolarEdge, + ) -> None: """Initialize the factory.""" - details = SolarEdgeDetailsDataService(hass, api, site_id) - overview = SolarEdgeOverviewDataService(hass, api, site_id) - inventory = SolarEdgeInventoryDataService(hass, api, site_id) - flow = SolarEdgePowerFlowDataService(hass, api, site_id) - energy = SolarEdgeEnergyDetailsService(hass, api, site_id) + details = SolarEdgeDetailsDataService(hass, config_entry, api, site_id) + overview = SolarEdgeOverviewDataService(hass, config_entry, api, site_id) + inventory = SolarEdgeInventoryDataService(hass, config_entry, api, site_id) + flow = SolarEdgePowerFlowDataService(hass, config_entry, api, site_id) + energy = SolarEdgeEnergyDetailsService(hass, config_entry, api, site_id) self.all_services = (details, overview, inventory, flow, energy) From 61ce1fc009a32f065985f0bc03e89220b3cfde0c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:33:36 +0100 Subject: [PATCH 0141/1941] Explicitly pass in the config_entry in samsungtv coordinator (#137962) explicitly pass in the config_entry in coordinator --- homeassistant/components/samsungtv/__init__.py | 7 ++----- homeassistant/components/samsungtv/coordinator.py | 12 ++++++++++-- homeassistant/components/samsungtv/diagnostics.py | 2 +- homeassistant/components/samsungtv/helpers.py | 2 +- homeassistant/components/samsungtv/media_player.py | 3 +-- homeassistant/components/samsungtv/remote.py | 2 +- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 6d4e491b839..e416cd35765 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -44,14 +44,11 @@ from .const import ( UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, ) -from .coordinator import SamsungTVDataUpdateCoordinator +from .coordinator import SamsungTVConfigEntry, SamsungTVDataUpdateCoordinator PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] -SamsungTVConfigEntry = ConfigEntry[SamsungTVDataUpdateCoordinator] - - @callback def _async_get_device_bridge( hass: HomeAssistant, data: dict[str, Any] @@ -165,7 +162,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -> entry.async_on_unload(debounced_reloader.async_shutdown) entry.async_on_unload(entry.add_update_listener(debounced_reloader.async_call)) - coordinator = SamsungTVDataUpdateCoordinator(hass, bridge) + coordinator = SamsungTVDataUpdateCoordinator(hass, entry, bridge) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py index 92d8dc8fa84..443e62b13fb 100644 --- a/homeassistant/components/samsungtv/coordinator.py +++ b/homeassistant/components/samsungtv/coordinator.py @@ -15,17 +15,25 @@ from .const import DOMAIN, LOGGER SCAN_INTERVAL = 10 +type SamsungTVConfigEntry = ConfigEntry[SamsungTVDataUpdateCoordinator] + class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): """Coordinator for the SamsungTV integration.""" - config_entry: ConfigEntry + config_entry: SamsungTVConfigEntry - def __init__(self, hass: HomeAssistant, bridge: SamsungTVBridge) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SamsungTVConfigEntry, + bridge: SamsungTVBridge, + ) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=SCAN_INTERVAL), ) diff --git a/homeassistant/components/samsungtv/diagnostics.py b/homeassistant/components/samsungtv/diagnostics.py index ebca8d2543b..667d23ba631 100644 --- a/homeassistant/components/samsungtv/diagnostics.py +++ b/homeassistant/components/samsungtv/diagnostics.py @@ -8,8 +8,8 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -from . import SamsungTVConfigEntry from .const import CONF_SESSION_ID +from .coordinator import SamsungTVConfigEntry TO_REDACT = {CONF_TOKEN, CONF_SESSION_ID} diff --git a/homeassistant/components/samsungtv/helpers.py b/homeassistant/components/samsungtv/helpers.py index 4e8dd00d486..b4075b8117f 100644 --- a/homeassistant/components/samsungtv/helpers.py +++ b/homeassistant/components/samsungtv/helpers.py @@ -7,9 +7,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from . import SamsungTVConfigEntry from .bridge import SamsungTVBridge from .const import DOMAIN +from .coordinator import SamsungTVConfigEntry @callback diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 7180e8a0c1a..9db9916c24a 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -34,10 +34,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.async_ import create_eager_task -from . import SamsungTVConfigEntry from .bridge import SamsungTVWSBridge from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, LOGGER -from .coordinator import SamsungTVDataUpdateCoordinator +from .coordinator import SamsungTVConfigEntry, SamsungTVDataUpdateCoordinator from .entity import SamsungTVEntity SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index 401a5d383f0..3d2529153be 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -9,8 +9,8 @@ from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SamsungTVConfigEntry from .const import LOGGER +from .coordinator import SamsungTVConfigEntry from .entity import SamsungTVEntity From 93dad987f89e453629201ef40acdfd068efba8b3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:35:19 +0100 Subject: [PATCH 0142/1941] Explicitly pass in the config_entry in sanix coordinator (#137960) explicitly pass in the config_entry in coordinator --- homeassistant/components/sanix/__init__.py | 2 +- homeassistant/components/sanix/coordinator.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sanix/__init__.py b/homeassistant/components/sanix/__init__.py index c8c5567eedc..60cc5b56f2e 100644 --- a/homeassistant/components/sanix/__init__.py +++ b/homeassistant/components/sanix/__init__.py @@ -19,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: token = entry.data[CONF_TOKEN] sanix_api = Sanix(serial_no, token) - coordinator = SanixCoordinator(hass, sanix_api) + coordinator = SanixCoordinator(hass, entry, sanix_api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/sanix/coordinator.py b/homeassistant/components/sanix/coordinator.py index d6362337a38..64d28fa9191 100644 --- a/homeassistant/components/sanix/coordinator.py +++ b/homeassistant/components/sanix/coordinator.py @@ -21,10 +21,16 @@ class SanixCoordinator(DataUpdateCoordinator[Measurement]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, sanix_api: Sanix) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, sanix_api: Sanix + ) -> None: """Initialize coordinator.""" super().__init__( - hass, _LOGGER, name=MANUFACTURER, update_interval=timedelta(hours=1) + hass, + _LOGGER, + config_entry=config_entry, + name=MANUFACTURER, + update_interval=timedelta(hours=1), ) self._sanix_api = sanix_api From 071b46055b11eae28b9c31fdc3c650927828b76b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:36:14 +0100 Subject: [PATCH 0143/1941] Explicitly pass in the config_entry in steam_online coordinator (#137929) explicitly pass in the config_entry in coordinator --- homeassistant/components/steam_online/__init__.py | 6 ++---- homeassistant/components/steam_online/config_flow.py | 2 +- homeassistant/components/steam_online/coordinator.py | 8 ++++---- homeassistant/components/steam_online/sensor.py | 3 +-- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 6e45758fb94..7a2c32cb4d5 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -2,19 +2,17 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import SteamDataUpdateCoordinator +from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> bool: """Set up Steam from a config entry.""" - coordinator = SteamDataUpdateCoordinator(hass) + coordinator = SteamDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 69009fca8c4..57c75f0a704 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -18,8 +18,8 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from . import SteamConfigEntry from .const import CONF_ACCOUNT, CONF_ACCOUNTS, DOMAIN, LOGGER, PLACEHOLDERS +from .coordinator import SteamConfigEntry # To avoid too long request URIs, the amount of ids to request is limited MAX_IDS_TO_REQUEST = 275 diff --git a/homeassistant/components/steam_online/coordinator.py b/homeassistant/components/steam_online/coordinator.py index 81a3bb0d898..731154183ed 100644 --- a/homeassistant/components/steam_online/coordinator.py +++ b/homeassistant/components/steam_online/coordinator.py @@ -3,11 +3,11 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING import steam from steam.api import _interface_method as INTMethod +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -15,8 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_ACCOUNTS, DOMAIN, LOGGER -if TYPE_CHECKING: - from . import SteamConfigEntry +type SteamConfigEntry = ConfigEntry[SteamDataUpdateCoordinator] class SteamDataUpdateCoordinator( @@ -26,11 +25,12 @@ class SteamDataUpdateCoordinator( config_entry: SteamConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SteamConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 058bb386383..625a8b95979 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp -from . import SteamConfigEntry from .const import ( CONF_ACCOUNTS, STEAM_API_URL, @@ -21,7 +20,7 @@ from .const import ( STEAM_MAIN_IMAGE_FILE, STEAM_STATUSES, ) -from .coordinator import SteamDataUpdateCoordinator +from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator from .entity import SteamEntity PARALLEL_UPDATES = 1 From 13dbeed5c21c95d5eb9cf444c6758d8d22a3c495 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:36:59 +0100 Subject: [PATCH 0144/1941] Explicitly pass in the config_entry in stookwijzer coordinator (#137928) explicitly pass in the config_entry in coordinator --- homeassistant/components/stookwijzer/coordinator.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/coordinator.py b/homeassistant/components/stookwijzer/coordinator.py index 23092bed66e..8f81494b7d5 100644 --- a/homeassistant/components/stookwijzer/coordinator.py +++ b/homeassistant/components/stookwijzer/coordinator.py @@ -20,18 +20,23 @@ type StookwijzerConfigEntry = ConfigEntry[StookwijzerCoordinator] class StookwijzerCoordinator(DataUpdateCoordinator[None]): """Stookwijzer update coordinator.""" - def __init__(self, hass: HomeAssistant, entry: StookwijzerConfigEntry) -> None: + config_entry: StookwijzerConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: StookwijzerConfigEntry + ) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) self.client = Stookwijzer( async_get_clientsession(hass), - entry.data[CONF_LATITUDE], - entry.data[CONF_LONGITUDE], + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], ) async def _async_update_data(self) -> None: From b512838d1e3e7849d841bef25ca1aa948ca42c85 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:39:15 +0100 Subject: [PATCH 0145/1941] Explicitly pass in the config_entry in smarty coordinator (#137944) explicitly pass in the config_entry in coordinator --- homeassistant/components/smarty/__init__.py | 2 +- homeassistant/components/smarty/coordinator.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 0e1e99aa444..aab8c6ab3c7 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -89,7 +89,7 @@ async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool: """Set up the Smarty environment from a config entry.""" - coordinator = SmartyCoordinator(hass) + coordinator = SmartyCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/smarty/coordinator.py b/homeassistant/components/smarty/coordinator.py index d7f3e2452d1..a55c9f2e78f 100644 --- a/homeassistant/components/smarty/coordinator.py +++ b/homeassistant/components/smarty/coordinator.py @@ -22,15 +22,16 @@ class SmartyCoordinator(DataUpdateCoordinator[None]): software_version: str configuration_version: str - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: SmartyConfigEntry) -> None: """Initialize.""" super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name="Smarty", update_interval=timedelta(seconds=30), ) - self.client = Smarty(host=self.config_entry.data[CONF_HOST]) + self.client = Smarty(host=config_entry.data[CONF_HOST]) async def _async_setup(self) -> None: if not await self.hass.async_add_executor_job(self.client.update): From a59d829e6a311443fd1799849889cdd9bd6eb0e3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:40:51 +0100 Subject: [PATCH 0146/1941] Explicitly pass in the config_entry in seventeentrack coordinator (#137956) explicitly pass in the config_entry in coordinator --- homeassistant/components/seventeentrack/__init__.py | 2 +- homeassistant/components/seventeentrack/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index 695ca179966..235a5338cb6 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SeventeenTrackError as err: raise ConfigEntryNotReady from err - seventeen_coordinator = SeventeenTrackCoordinator(hass, client) + seventeen_coordinator = SeventeenTrackCoordinator(hass, entry, client) await seventeen_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/seventeentrack/coordinator.py b/homeassistant/components/seventeentrack/coordinator.py index 3e27f9f0369..107f1d48a21 100644 --- a/homeassistant/components/seventeentrack/coordinator.py +++ b/homeassistant/components/seventeentrack/coordinator.py @@ -34,11 +34,17 @@ class SeventeenTrackCoordinator(DataUpdateCoordinator[SeventeenTrackData]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, client: SeventeenTrackClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + client: SeventeenTrackClient, + ) -> None: """Initialize.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) From 07e9d806078342cd001b1ed459a1e7778648f6d3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:46:05 +0100 Subject: [PATCH 0147/1941] Explicitly pass in the config_entry in weheat coordinator (#137868) explicitly pass in the config_entry in coordinator --- homeassistant/components/weheat/__init__.py | 7 ++----- homeassistant/components/weheat/binary_sensor.py | 3 +-- homeassistant/components/weheat/coordinator.py | 7 +++++++ homeassistant/components/weheat/sensor.py | 3 +-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index d8d8616c867..b67c3540dc5 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -8,7 +8,6 @@ import aiohttp from weheat.abstractions.discovery import HeatPumpDiscovery from weheat.exceptions import UnauthorizedException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -19,12 +18,10 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from .const import API_URL, LOGGER -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bool: """Set up Weheat from a config entry.""" @@ -58,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo for pump_info in discovered_heat_pumps: LOGGER.debug("Adding %s", pump_info) # for each pump, add a coordinator - new_coordinator = WeheatDataUpdateCoordinator(hass, session, pump_info) + new_coordinator = WeheatDataUpdateCoordinator(hass, entry, session, pump_info) await new_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index 1fb8f614a40..0ffa876ad0f 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -14,8 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import WeheatConfigEntry -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity # Coordinator is used to centralize the data updates diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index 4a85380e4a3..d7e53258e9b 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -13,6 +13,7 @@ from weheat.exceptions import ( UnauthorizedException, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -30,13 +31,18 @@ EXCEPTIONS = ( ApiException, ) +type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] + class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): """A custom coordinator for the Weheat heatpump integration.""" + config_entry: WeheatConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: WeheatConfigEntry, session: OAuth2Session, heat_pump: HeatPumpDiscovery.HeatPumpInfo, ) -> None: @@ -44,6 +50,7 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 2d840aec86a..5d948c6d565 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -22,13 +22,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import WeheatConfigEntry from .const import ( DISPLAY_PRECISION_COP, DISPLAY_PRECISION_WATER_TEMP, DISPLAY_PRECISION_WATTS, ) -from .coordinator import WeheatDataUpdateCoordinator +from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator from .entity import WeheatEntity # Coordinator is used to centralize the data updates From 2ef4e75014aea82353de92e921f0f07dbd5c7b90 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:51:28 +0100 Subject: [PATCH 0148/1941] Explicitly pass in the config_entry in yolink coordinator (#137861) explicitly pass in the config_entry in coordinator --- homeassistant/components/yolink/__init__.py | 2 +- homeassistant/components/yolink/coordinator.py | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 004c5a70cc1..0c92aa696ca 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -152,7 +152,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: paried_device_id := device_pairing_mapping.get(device.device_id) ) is not None: paried_device = yolink_home.get_device(paried_device_id) - device_coordinator = YoLinkCoordinator(hass, device, paried_device) + device_coordinator = YoLinkCoordinator(hass, entry, device, paried_device) try: await device_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index b7db36541b1..d18a37bd276 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -9,6 +9,7 @@ import logging from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,9 +22,12 @@ _LOGGER = logging.getLogger(__name__) class YoLinkCoordinator(DataUpdateCoordinator[dict]): """YoLink DataUpdateCoordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, device: YoLinkDevice, paired_device: YoLinkDevice | None = None, ) -> None: @@ -34,7 +38,11 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): data at first update """ super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=30), ) self.device = device self.paired_device = paired_device From c47a97a4f043e582c3e2e5ca6f01d14002fd000c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:54:12 +0100 Subject: [PATCH 0149/1941] Explicitly pass in the config_entry in romy coordinator (#137967) explicitly pass in the config_entry in coordinator --- homeassistant/components/romy/__init__.py | 2 +- homeassistant/components/romy/coordinator.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/romy/__init__.py b/homeassistant/components/romy/__init__.py index 352f5f3715a..be227645122 100644 --- a/homeassistant/components/romy/__init__.py +++ b/homeassistant/components/romy/__init__.py @@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.data[CONF_HOST], config_entry.data.get(CONF_PASSWORD, "") ) - coordinator = RomyVacuumCoordinator(hass, new_romy) + coordinator = RomyVacuumCoordinator(hass, config_entry, new_romy) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator diff --git a/homeassistant/components/romy/coordinator.py b/homeassistant/components/romy/coordinator.py index 5868eae70e2..d666ec44f80 100644 --- a/homeassistant/components/romy/coordinator.py +++ b/homeassistant/components/romy/coordinator.py @@ -2,6 +2,7 @@ from romy import RomyRobot +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -11,9 +12,19 @@ from .const import DOMAIN, LOGGER, UPDATE_INTERVAL class RomyVacuumCoordinator(DataUpdateCoordinator[None]): """ROMY Vacuum Coordinator.""" - def __init__(self, hass: HomeAssistant, romy: RomyRobot) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, romy: RomyRobot + ) -> None: """Initialize.""" - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) self.hass = hass self.romy = romy From b338de9a308d8d3c3b8ab70ee41b4841e03672eb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:57:03 +0100 Subject: [PATCH 0150/1941] Explicitly pass in the config_entry in tado coordinator (#137916) explicitly pass in the config_entry in coordinator --- homeassistant/components/tado/__init__.py | 4 +++- homeassistant/components/tado/coordinator.py | 24 ++++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 3e42e33489f..4087183bfe5 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -87,7 +87,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool @callback -def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): +def _async_import_options_from_data_if_missing( + hass: HomeAssistant, entry: TadoConfigEntry +): options = dict(entry.options) if CONF_FALLBACK not in options: options[CONF_FALLBACK] = entry.data.get( diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index ddec9e7f292..6e932c8ccfc 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -4,18 +4,20 @@ from __future__ import annotations from datetime import datetime, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from PyTado.interface import Tado from requests import RequestException from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import TadoConfigEntry + from .const import ( CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT, @@ -31,8 +33,6 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) SCAN_INTERVAL = timedelta(minutes=5) SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) -type TadoConfigEntry = ConfigEntry[TadoDataUpdateCoordinator] - class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage API calls from and to Tado via PyTado.""" @@ -45,7 +45,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + config_entry: TadoConfigEntry, tado: Tado, debug: bool = False, ) -> None: @@ -53,13 +53,16 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) self._tado = tado - self._username = entry.data[CONF_USERNAME] - self._password = entry.data[CONF_PASSWORD] - self._fallback = entry.options.get(CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT) + self._username = config_entry.data[CONF_USERNAME] + self._password = config_entry.data[CONF_PASSWORD] + self._fallback = config_entry.options.get( + CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT + ) self._debug = debug self.home_id: int @@ -343,16 +346,19 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage the mobile devices from Tado via PyTado.""" + config_entry: TadoConfigEntry + def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + config_entry: TadoConfigEntry, tado: Tado, ) -> None: """Initialize the Tado data update coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_MOBILE_DEVICE_INTERVAL, ) From a2a55d9ff0ad0053a9dd556ced6b18a5743d56e3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:57:41 +0100 Subject: [PATCH 0151/1941] Explicitly pass in the config_entry in weatherkit coordinator (#137869) explicitly pass in the config_entry in coordinator --- homeassistant/components/weatherkit/__init__.py | 1 + homeassistant/components/weatherkit/coordinator.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py index 49158182696..4cbac2b32d8 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -32,6 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) coordinator = WeatherKitDataUpdateCoordinator( hass=hass, + config_entry=entry, client=WeatherKitApiClient( key_id=entry.data[CONF_KEY_ID], service_id=entry.data[CONF_SERVICE_ID], diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index 6438d7503db..6c7119d6fb0 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -33,6 +33,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: WeatherKitApiClient, ) -> None: """Initialize.""" @@ -40,6 +41,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=5), ) From 52fb99f9672dd43d3b8adebe299f7bfc7c8cf13d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 21:58:33 +0100 Subject: [PATCH 0152/1941] Explicitly pass in the config_entry in weatherflow_cloud coordinator (#137871) explicitly pass in the config_entry in coordinator --- .../components/weatherflow_cloud/__init__.py | 7 ++----- .../components/weatherflow_cloud/coordinator.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py index 8dc26f9b9c6..94c65b7c0a1 100644 --- a/homeassistant/components/weatherflow_cloud/__init__.py +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -15,10 +15,7 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WeatherFlowCloud from a config entry.""" - data_coordinator = WeatherFlowCloudDataUpdateCoordinator( - hass=hass, - api_token=entry.data[CONF_API_TOKEN], - ) + data_coordinator = WeatherFlowCloudDataUpdateCoordinator(hass, entry) await data_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index 8b8a916262f..b6d2bfd5af2 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -6,6 +6,8 @@ from aiohttp import ClientResponseError from weatherflow4py.api import WeatherFlowRestAPI from weatherflow4py.models.rest.unified import WeatherFlowDataREST +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,12 +20,17 @@ class WeatherFlowCloudDataUpdateCoordinator( ): """Class to manage fetching REST Based WeatherFlow Forecast data.""" - def __init__(self, hass: HomeAssistant, api_token: str) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize global WeatherFlow forecast data updater.""" - self.weather_api = WeatherFlowRestAPI(api_token=api_token) + self.weather_api = WeatherFlowRestAPI( + api_token=config_entry.data[CONF_API_TOKEN] + ) super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), ) From dfa2c218e4afdf21ae332b67df1c77caff9ca507 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:00:36 +0100 Subject: [PATCH 0153/1941] Explicitly pass in the config_entry in verisure coordinator (#137879) explicitly pass in the config_entry in coordinator --- .../components/verisure/alarm_control_panel.py | 4 ++-- homeassistant/components/verisure/binary_sensor.py | 6 +++--- homeassistant/components/verisure/camera.py | 2 +- homeassistant/components/verisure/coordinator.py | 11 ++++++++--- homeassistant/components/verisure/lock.py | 4 ++-- homeassistant/components/verisure/sensor.py | 4 ++-- homeassistant/components/verisure/switch.py | 2 +- 7 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 5f34b587163..2b9ae7b60b6 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -49,14 +49,14 @@ class VerisureAlarm( name="Verisure Alarm", manufacturer="Verisure", model="VBox", - identifiers={(DOMAIN, self.coordinator.entry.data[CONF_GIID])}, + identifiers={(DOMAIN, self.coordinator.config_entry.data[CONF_GIID])}, configuration_url="https://mypages.verisure.com", ) @property def unique_id(self) -> str: """Return the unique ID for this entity.""" - return self.coordinator.entry.data[CONF_GIID] + return self.coordinator.config_entry.data[CONF_GIID] async def _async_set_arm_state( self, state: str, command_data: dict[str, str | dict[str, str]] diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 542ee3485ce..94a44550d47 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -62,7 +62,7 @@ class VerisureDoorWindowSensor( manufacturer="Verisure", model="Shock Sensor Detector", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) @@ -104,7 +104,7 @@ class VerisureEthernetStatus( @property def unique_id(self) -> str: """Return the unique ID for this entity.""" - return f"{self.coordinator.entry.data[CONF_GIID]}_ethernet" + return f"{self.coordinator.config_entry.data[CONF_GIID]}_ethernet" @property def device_info(self) -> DeviceInfo: @@ -113,7 +113,7 @@ class VerisureEthernetStatus( name="Verisure Alarm", manufacturer="Verisure", model="VBox", - identifiers={(DOMAIN, self.coordinator.entry.data[CONF_GIID])}, + identifiers={(DOMAIN, self.coordinator.config_entry.data[CONF_GIID])}, configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 70cd436d24c..7f49f917d83 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -75,7 +75,7 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) manufacturer="Verisure", model="SmartCam", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 930d862257b..5165ddc6d3d 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -25,10 +25,11 @@ from .const import CONF_GIID, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER class VerisureDataUpdateCoordinator(DataUpdateCoordinator): """A Verisure Data Update Coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the Verisure hub.""" self.imageseries: list[dict[str, str]] = [] - self.entry = entry self._overview: list[dict] = [] self.verisure = Verisure( @@ -40,7 +41,11 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): ) super().__init__( - hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) async def async_login(self) -> bool: @@ -55,7 +60,7 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): return False await self.hass.async_add_executor_job( - self.verisure.set_giid, self.entry.data[CONF_GIID] + self.verisure.set_giid, self.config_entry.data[CONF_GIID] ) return True diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 87f5c53880e..16c69ecf2e2 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -81,7 +81,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt manufacturer="Verisure", model="Lockguard Smartlock", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) @@ -109,7 +109,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def code_format(self) -> str: """Return the configured code format.""" - digits = self.coordinator.entry.options.get( + digits = self.coordinator.config_entry.options.get( CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS ) return f"^\\d{{{digits}}}$" diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 4f6e6b3d3c5..77a576caad8 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -72,7 +72,7 @@ class VerisureThermometer( manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) @@ -122,7 +122,7 @@ class VerisureHygrometer( manufacturer="Verisure", model=DEVICE_TYPE_NAME.get(device_type, device_type), identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index e0238097e01..838e0222087 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -57,7 +57,7 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch manufacturer="Verisure", model="SmartPlug", identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), + via_device=(DOMAIN, self.coordinator.config_entry.data[CONF_GIID]), configuration_url="https://mypages.verisure.com", ) From 999badf6757cbd2e4a3568fc9ab4b8e621b76c1b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:02:30 +0100 Subject: [PATCH 0154/1941] Explicitly pass in the config_entry in toon coordinator (#137899) explicitly pass in the config_entry in coordinator --- homeassistant/components/toon/__init__.py | 2 +- homeassistant/components/toon/coordinator.py | 45 +++++++++++--------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 43c787b2301..1c56b780c0f 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -89,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - coordinator = ToonDataUpdateCoordinator(hass, entry=entry, session=session) + coordinator = ToonDataUpdateCoordinator(hass, entry, session) await coordinator.toon.activate_agreement( agreement_id=entry.data[CONF_AGREEMENT_ID] ) diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 586eca34959..894b4c91334 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -28,12 +28,13 @@ _LOGGER = logging.getLogger(__name__) class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): """Class to manage fetching Toon data from single endpoint.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session + self, hass: HomeAssistant, entry: ConfigEntry, session: OAuth2Session ) -> None: """Initialize global Toon data updater.""" self.session = session - self.entry = entry async def async_token_refresh() -> str: await session.async_ensure_token_valid() @@ -46,49 +47,55 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): ) super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, ) async def register_webhook(self, event: Event | None = None) -> None: """Register a webhook with Toon to get live updates.""" - if CONF_WEBHOOK_ID not in self.entry.data: - data = {**self.entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} - self.hass.config_entries.async_update_entry(self.entry, data=data) + if CONF_WEBHOOK_ID not in self.config_entry.data: + data = {**self.config_entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} + self.hass.config_entries.async_update_entry(self.config_entry, data=data) if cloud.async_active_subscription(self.hass): - if CONF_CLOUDHOOK_URL not in self.entry.data: + if CONF_CLOUDHOOK_URL not in self.config_entry.data: try: webhook_url = await cloud.async_create_cloudhook( - self.hass, self.entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) except cloud.CloudNotConnected: webhook_url = webhook.async_generate_url( - self.hass, self.entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) else: - data = {**self.entry.data, CONF_CLOUDHOOK_URL: webhook_url} - self.hass.config_entries.async_update_entry(self.entry, data=data) + data = {**self.config_entry.data, CONF_CLOUDHOOK_URL: webhook_url} + self.hass.config_entries.async_update_entry( + self.config_entry, data=data + ) else: - webhook_url = self.entry.data[CONF_CLOUDHOOK_URL] + webhook_url = self.config_entry.data[CONF_CLOUDHOOK_URL] else: webhook_url = webhook.async_generate_url( - self.hass, self.entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) # Ensure the webhook is not registered already - webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(self.hass, self.config_entry.data[CONF_WEBHOOK_ID]) webhook_register( self.hass, DOMAIN, "Toon", - self.entry.data[CONF_WEBHOOK_ID], + self.config_entry.data[CONF_WEBHOOK_ID], self.handle_webhook, ) try: await self.toon.subscribe_webhook( - application_id=self.entry.entry_id, url=webhook_url + application_id=self.config_entry.entry_id, url=webhook_url ) _LOGGER.debug("Registered Toon webhook: %s", webhook_url) except ToonError as err: @@ -131,14 +138,14 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): async def unregister_webhook(self, event: Event | None = None) -> None: """Remove / Unregister webhook for toon.""" _LOGGER.debug( - "Unregistering Toon webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] + "Unregistering Toon webhook (%s)", self.config_entry.data[CONF_WEBHOOK_ID] ) try: - await self.toon.unsubscribe_webhook(self.entry.entry_id) + await self.toon.unsubscribe_webhook(self.config_entry.entry_id) except ToonError as err: _LOGGER.error("Failed unregistering Toon webhook - %s", err) - webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(self.hass, self.config_entry.data[CONF_WEBHOOK_ID]) async def _async_update_data(self) -> Status: """Fetch data from Toon.""" From f643f76f1f39041a31e8f3290c1ec1a87df50add Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:03:40 +0100 Subject: [PATCH 0155/1941] Explicitly pass in the config_entry in upnp coordinator (#137885) explicitly pass in the config_entry in coordinator --- homeassistant/components/upnp/__init__.py | 9 +++------ homeassistant/components/upnp/binary_sensor.py | 2 +- homeassistant/components/upnp/coordinator.py | 7 +++++++ homeassistant/components/upnp/sensor.py | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index aacb7538b61..757cad221b5 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -8,7 +8,6 @@ from datetime import timedelta from async_upnp_client.exceptions import UpnpConnectionError from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -28,7 +27,7 @@ from .const import ( IDENTIFIER_SERIAL_NUMBER, LOGGER, ) -from .coordinator import UpnpDataUpdateCoordinator +from .coordinator import UpnpConfigEntry, UpnpDataUpdateCoordinator from .device import async_create_device, get_preferred_location NOTIFICATION_ID = "upnp_notification" @@ -37,9 +36,6 @@ NOTIFICATION_TITLE = "UPnP/IGD Setup" PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -type UpnpConfigEntry = ConfigEntry[UpnpDataUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" LOGGER.debug("Setting up config entry: %s", entry.entry_id) @@ -176,6 +172,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) coordinator = UpnpDataUpdateCoordinator( hass, + config_entry=entry, device=device, device_entry=device_entry, update_interval=update_interval, @@ -193,7 +190,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool: """Unload a UPnP/IGD device from a config entry.""" LOGGER.debug("Unloading config entry: %s", entry.entry_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index fb32946bf7d..1576cccac6a 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -13,8 +13,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpConfigEntry, UpnpDataUpdateCoordinator from .const import LOGGER, WAN_STATUS +from .coordinator import UpnpConfigEntry, UpnpDataUpdateCoordinator from .entity import UpnpEntity, UpnpEntityDescription diff --git a/homeassistant/components/upnp/coordinator.py b/homeassistant/components/upnp/coordinator.py index 37ff700bfe2..03e4c53f143 100644 --- a/homeassistant/components/upnp/coordinator.py +++ b/homeassistant/components/upnp/coordinator.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from async_upnp_client.exceptions import UpnpCommunicationError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,15 +14,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import LOGGER from .device import Device +type UpnpConfigEntry = ConfigEntry[UpnpDataUpdateCoordinator] + class UpnpDataUpdateCoordinator( DataUpdateCoordinator[dict[str, str | datetime | int | float | None]] ): """Define an object to update data from UPNP device.""" + config_entry: UpnpConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: UpnpConfigEntry, device: Device, device_entry: DeviceEntry, update_interval: timedelta, @@ -34,6 +40,7 @@ class UpnpDataUpdateCoordinator( super().__init__( hass, LOGGER, + config_entry=config_entry, name=device.name, update_interval=update_interval, ) diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index aae2f8308c1..c0e77315f77 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -20,7 +20,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpnpConfigEntry from .const import ( BYTES_RECEIVED, BYTES_SENT, @@ -38,6 +37,7 @@ from .const import ( ROUTER_UPTIME, WAN_STATUS, ) +from .coordinator import UpnpConfigEntry from .entity import UpnpEntity, UpnpEntityDescription From 361933091c53626ec7a04f0c8577e9924a681a2c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:04:38 +0100 Subject: [PATCH 0156/1941] Explicitly pass in the config_entry in rova coordinator (#137966) explicitly pass in the config_entry in coordinator --- homeassistant/components/rova/__init__.py | 2 +- homeassistant/components/rova/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rova/__init__.py b/homeassistant/components/rova/__init__.py index 64f0e787a4b..ecde0578772 100644 --- a/homeassistant/components/rova/__init__.py +++ b/homeassistant/components/rova/__init__.py @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) raise ConfigEntryError("Rova does not collect garbage in this area") - coordinator = RovaCoordinator(hass, api) + coordinator = RovaCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/rova/coordinator.py b/homeassistant/components/rova/coordinator.py index ecd91cad823..a48048d32c3 100644 --- a/homeassistant/components/rova/coordinator.py +++ b/homeassistant/components/rova/coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from rova.rova import Rova +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import get_time_zone @@ -16,11 +17,16 @@ EUROPE_AMSTERDAM_ZONE_INFO = get_time_zone("Europe/Amsterdam") class RovaCoordinator(DataUpdateCoordinator[dict[str, datetime]]): """Class to manage fetching Rova data.""" - def __init__(self, hass: HomeAssistant, api: Rova) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: Rova + ) -> None: """Initialize.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(hours=12), ) From 20707b94b55b162bfc5dd282efa48e1a29a6f8a7 Mon Sep 17 00:00:00 2001 From: Steve Sinchak Date: Sat, 8 Feb 2025 15:12:51 -0600 Subject: [PATCH 0157/1941] Improve emulated_hue logging to identify bad devices (#137919) * Improve emulated_hue logging to identify bad devices * Updated per @bdraco request --- homeassistant/components/emulated_hue/hue_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 464d2bcb7e7..9ccb8a64367 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -322,8 +322,10 @@ class HueOneLightStateView(HomeAssistantView): if hass_entity_id is None: _LOGGER.error( - "Unknown entity number: %s not found in emulated_hue_ids.json", + "Unknown entity number: %s not found in emulated_hue_ids.json, " + "state request from %s", entity_id, + request.remote, ) return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) From bdcf2a1e56b9d6de32f214d2a9314ede31d0d88c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:18:55 +0100 Subject: [PATCH 0158/1941] Explicitly pass in the config_entry in ridwell coordinator (#137973) explicitly pass in the config_entry in coordinator --- homeassistant/components/ridwell/__init__.py | 2 +- homeassistant/components/ridwell/coordinator.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 71e80086833..9c9104258a8 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -17,7 +17,7 @@ PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.SWITCH async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ridwell from a config entry.""" - coordinator = RidwellDataUpdateCoordinator(hass, name=entry.title) + coordinator = RidwellDataUpdateCoordinator(hass, entry) await coordinator.async_initialize() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/ridwell/coordinator.py b/homeassistant/components/ridwell/coordinator.py index 28190522c76..336a71bc67f 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -29,7 +29,7 @@ class RidwellDataUpdateCoordinator( config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, *, name: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize.""" # These will be filled in by async_initialize; we give them these defaults to # avoid arduous typing checks down the line: @@ -37,7 +37,13 @@ class RidwellDataUpdateCoordinator( self.dashboard_url = "" self.user_id = "" - super().__init__(hass, LOGGER, name=name, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=UPDATE_INTERVAL, + ) async def _async_update_data(self) -> dict[str, list[RidwellPickupEvent]]: """Fetch the latest data from the source.""" From 50c15f305606c3f2a35b3267e9334c4293b91382 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 8 Feb 2025 16:29:18 -0500 Subject: [PATCH 0159/1941] Bump pydrawise to 2025.2.0 (#137961) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index de45eb061d5..73423882e4a 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.1.0"] + "requirements": ["pydrawise==2025.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c2934a232c6..27b6b091960 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1900,7 +1900,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.1.0 +pydrawise==2025.2.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83b20bbfb94..7b6fd7f011f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1550,7 +1550,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.1.0 +pydrawise==2025.2.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 17569d8186340d0988d3d65f3dd522f588e3e663 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 00:39:11 +0100 Subject: [PATCH 0160/1941] Explicitly pass in the config_entry in wallbox coordinator (#137874) explicitly pass in the config_entry in coordinator --- homeassistant/components/wallbox/__init__.py | 8 ++------ homeassistant/components/wallbox/coordinator.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index b2f8ac7fd5d..fc8c6e00e84 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import CONF_STATION, DOMAIN, UPDATE_INTERVAL +from .const import DOMAIN, UPDATE_INTERVAL from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input PLATFORMS = [Platform.LOCK, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] @@ -27,11 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except InvalidAuth as ex: raise ConfigEntryAuthFailed from ex - wallbox_coordinator = WallboxCoordinator( - entry.data[CONF_STATION], - wallbox, - hass, - ) + wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox) await wallbox_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = wallbox_coordinator diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 99c565d9c0c..4f20f5c406d 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -11,6 +11,7 @@ from typing import Any, Concatenate import requests from wallbox import Wallbox +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -28,6 +29,7 @@ from .const import ( CHARGER_STATUS_DESCRIPTION_KEY, CHARGER_STATUS_ID_KEY, CODE_KEY, + CONF_STATION, DOMAIN, UPDATE_INTERVAL, ChargerStatus, @@ -107,14 +109,19 @@ async def async_validate_input(hass: HomeAssistant, wallbox: Wallbox) -> None: class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" - def __init__(self, station: str, wallbox: Wallbox, hass: HomeAssistant) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, wallbox: Wallbox + ) -> None: """Initialize.""" - self._station = station + self._station = config_entry.data[CONF_STATION] self._wallbox = wallbox super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) From 7ec7badef637d109397bfe7be1d0d3a044704976 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 00:39:42 +0100 Subject: [PATCH 0161/1941] Explicitly pass in the config_entry in volvooncall coordinator (#137875) explicitly pass in the config_entry in coordinator --- homeassistant/components/volvooncall/__init__.py | 2 +- homeassistant/components/volvooncall/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 9fc07dd92b0..1a53f9a5dc4 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: volvo_data = VolvoData(hass, connection, entry) - coordinator = VolvoUpdateCoordinator(hass, volvo_data) + coordinator = VolvoUpdateCoordinator(hass, entry, volvo_data) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/volvooncall/coordinator.py b/homeassistant/components/volvooncall/coordinator.py index 5ac6a58acb0..2c3e2ba365f 100644 --- a/homeassistant/components/volvooncall/coordinator.py +++ b/homeassistant/components/volvooncall/coordinator.py @@ -3,6 +3,7 @@ import asyncio import logging +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -15,12 +16,17 @@ _LOGGER = logging.getLogger(__name__) class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): """Volvo coordinator.""" - def __init__(self, hass: HomeAssistant, volvo_data: VolvoData) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, volvo_data: VolvoData + ) -> None: """Initialize the data update coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="volvooncall", update_interval=DEFAULT_UPDATE_INTERVAL, ) From 54c4ee7838abb06a5629cc03567333251453c489 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 00:40:14 +0100 Subject: [PATCH 0162/1941] Explicitly pass in the config_entry in vizio coordinator (#137876) explicitly pass in the config_entry in coordinator --- homeassistant/components/vizio/__init__.py | 2 +- homeassistant/components/vizio/coordinator.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 4af42d76b62..27a7fa2cd97 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV ): store: Store[list[dict[str, Any]]] = Store(hass, 1, DOMAIN) - coordinator = VizioAppsDataUpdateCoordinator(hass, store) + coordinator = VizioAppsDataUpdateCoordinator(hass, entry, store) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][CONF_APPS] = coordinator diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index a7ca7d7f9ed..0f95c8a53b7 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -9,6 +9,7 @@ from typing import Any from pyvizio.const import APPS from pyvizio.util import gen_apps_list_from_url +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.storage import Store @@ -22,11 +23,19 @@ _LOGGER = logging.getLogger(__name__) class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Define an object to hold Vizio app config data.""" - def __init__(self, hass: HomeAssistant, store: Store[list[dict[str, Any]]]) -> None: + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + store: Store[list[dict[str, Any]]], + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(days=1), ) From 932c2f794eaf481577de2a4074bccd719908b30a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 04:29:36 +0100 Subject: [PATCH 0163/1941] Explicitly pass in the config_entry in rainbird coordinator (#137982) explicitly pass in the config_entry in coordinator --- homeassistant/components/rainbird/__init__.py | 14 ++------------ homeassistant/components/rainbird/coordinator.py | 15 +++++++++------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index d8b71e2df0b..f9cd751a81e 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -101,18 +101,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) -> data = RainbirdData( controller, model_info, - coordinator=RainbirdUpdateCoordinator( - hass, - name=entry.title, - controller=controller, - unique_id=entry.unique_id, - model_info=model_info, - ), - schedule_coordinator=RainbirdScheduleUpdateCoordinator( - hass, - name=f"{entry.title} Schedule", - controller=controller, - ), + coordinator=RainbirdUpdateCoordinator(hass, entry, controller, model_info), + schedule_coordinator=RainbirdScheduleUpdateCoordinator(hass, entry, controller), ) await data.coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 2ccfa0af62a..426df625697 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -58,26 +58,28 @@ def async_create_clientsession() -> aiohttp.ClientSession: class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): """Coordinator for rainbird API calls.""" + config_entry: RainbirdConfigEntry + def __init__( self, hass: HomeAssistant, - name: str, + config_entry: RainbirdConfigEntry, controller: AsyncRainbirdController, - unique_id: str | None, model_info: ModelAndVersion, ) -> None: """Initialize RainbirdUpdateCoordinator.""" super().__init__( hass, _LOGGER, - name=name, + config_entry=config_entry, + name=config_entry.title, update_interval=UPDATE_INTERVAL, request_refresh_debouncer=Debouncer( hass, _LOGGER, cooldown=DEBOUNCER_COOLDOWN, immediate=False ), ) self._controller = controller - self._unique_id = unique_id + self._unique_id = config_entry.unique_id self._zones: set[int] | None = None self._model_info = model_info @@ -145,14 +147,15 @@ class RainbirdScheduleUpdateCoordinator(DataUpdateCoordinator[Schedule]): def __init__( self, hass: HomeAssistant, - name: str, + config_entry: RainbirdConfigEntry, controller: AsyncRainbirdController, ) -> None: """Initialize ZoneStateUpdateCoordinator.""" super().__init__( hass, _LOGGER, - name=name, + config_entry=config_entry, + name=f"{config_entry.title} Schedule", update_method=self._async_update_data, update_interval=CALENDAR_UPDATE_INTERVAL, ) From 6e84280e3c5715b281cdcf93a6eb8b2ee55a6d29 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 9 Feb 2025 04:37:49 +0100 Subject: [PATCH 0164/1941] Small typing improvements (#137994) --- homeassistant/components/anova/coordinator.py | 4 +++- homeassistant/components/aosmith/coordinator.py | 6 ++++-- homeassistant/components/braviatv/coordinator.py | 2 +- homeassistant/components/conversation/chat_log.py | 2 +- homeassistant/components/flipr/coordinator.py | 6 ++++-- homeassistant/components/home_connect/coordinator.py | 4 +++- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py index 811c32c97b5..61d118ed0a5 100644 --- a/homeassistant/components/anova/coordinator.py +++ b/homeassistant/components/anova/coordinator.py @@ -1,5 +1,7 @@ """Support for Anova Coordinators.""" +from __future__ import annotations + from dataclasses import dataclass import logging @@ -20,7 +22,7 @@ class AnovaData: """Data for the Anova integration.""" api_jwt: str - coordinators: list["AnovaCoordinator"] + coordinators: list[AnovaCoordinator] api: AnovaApi diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py index 26029fee750..16cacfcbc10 100644 --- a/homeassistant/components/aosmith/coordinator.py +++ b/homeassistant/components/aosmith/coordinator.py @@ -1,5 +1,7 @@ """The data update coordinator for the A. O. Smith integration.""" +from __future__ import annotations + from dataclasses import dataclass import logging @@ -27,8 +29,8 @@ class AOSmithData: """Data for the A. O. Smith integration.""" client: AOSmithAPIClient - status_coordinator: "AOSmithStatusCoordinator" - energy_coordinator: "AOSmithEnergyCoordinator" + status_coordinator: AOSmithStatusCoordinator + energy_coordinator: AOSmithEnergyCoordinator class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, AOSmithDevice]]): diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 1cc306bd5cf..039726de94d 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -39,7 +39,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=10) -type BraviaTVConfigEntry = ConfigEntry["BraviaTVCoordinator"] +type BraviaTVConfigEntry = ConfigEntry[BraviaTVCoordinator] def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P]( diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index e4ff1904e7c..086e1374c1a 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -142,7 +142,7 @@ class ToolResultContent: tool_result: JsonObjectType -Content = SystemContent | UserContent | AssistantContent | ToolResultContent +type Content = SystemContent | UserContent | AssistantContent | ToolResultContent @dataclass diff --git a/homeassistant/components/flipr/coordinator.py b/homeassistant/components/flipr/coordinator.py index 0d86b43711a..82de5ae34d5 100644 --- a/homeassistant/components/flipr/coordinator.py +++ b/homeassistant/components/flipr/coordinator.py @@ -1,5 +1,7 @@ """DataUpdateCoordinator for flipr integration.""" +from __future__ import annotations + from dataclasses import dataclass from datetime import timedelta import logging @@ -19,8 +21,8 @@ _LOGGER = logging.getLogger(__name__) class FliprData: """The Flipr data class.""" - flipr_coordinators: list["FliprDataUpdateCoordinator"] - hub_coordinators: list["FliprHubDataUpdateCoordinator"] + flipr_coordinators: list[FliprDataUpdateCoordinator] + hub_coordinators: list[FliprHubDataUpdateCoordinator] type FliprConfigEntry = ConfigEntry[FliprData] diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 68652936872..16584bfd586 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -1,5 +1,7 @@ """Coordinator for Home Connect.""" +from __future__ import annotations + import asyncio from collections import defaultdict from collections.abc import Callable @@ -53,7 +55,7 @@ class HomeConnectApplianceData: settings: dict[SettingKey, GetSetting] status: dict[StatusKey, Status] - def update(self, other: "HomeConnectApplianceData") -> None: + def update(self, other: HomeConnectApplianceData) -> None: """Update data with data from other instance.""" self.events.update(other.events) self.info.connected = other.info.connected From a526baa8316e8bfce5c158749ef64640f556c30a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 05:53:44 +0100 Subject: [PATCH 0165/1941] Explicitly pass in the config_entry in roborock coordinator (#137970) explicitly pass in the config_entry in coordinator --- homeassistant/components/roborock/__init__.py | 48 +++++++++---------- .../components/roborock/binary_sensor.py | 3 +- homeassistant/components/roborock/button.py | 3 +- .../components/roborock/coordinator.py | 40 ++++++++++++++-- .../components/roborock/diagnostics.py | 2 +- homeassistant/components/roborock/image.py | 3 +- homeassistant/components/roborock/number.py | 4 +- homeassistant/components/roborock/select.py | 3 +- homeassistant/components/roborock/sensor.py | 7 ++- homeassistant/components/roborock/switch.py | 4 +- homeassistant/components/roborock/time.py | 4 +- homeassistant/components/roborock/vacuum.py | 3 +- 12 files changed, 77 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index b383c1acfd7..764518df636 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine -from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -21,35 +20,23 @@ from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 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, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS -from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 +from .coordinator import ( + RoborockConfigEntry, + RoborockCoordinators, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, +) from .roborock_storage import async_remove_map_storage SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -type RoborockConfigEntry = ConfigEntry[RoborockCoordinators] - - -@dataclass -class RoborockCoordinators: - """Roborock coordinators type.""" - - v1: list[RoborockDataUpdateCoordinator] - a01: list[RoborockDataUpdateCoordinatorA01] - - def values( - self, - ) -> list[RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01]: - """Return all coordinators.""" - return self.v1 + self.a01 - async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: """Set up roborock from a config entry.""" @@ -95,7 +82,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, device_map, user_data, product_info, home_data.rooms + hass, entry, device_map, user_data, product_info, home_data.rooms ), return_exceptions=True, ) @@ -142,6 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> def build_setup_functions( hass: HomeAssistant, + entry: RoborockConfigEntry, device_map: dict[str, HomeDataDevice], user_data: UserData, product_info: dict[str, HomeDataProduct], @@ -156,7 +144,12 @@ def build_setup_functions( """Create a list of setup functions that can later be called asynchronously.""" return [ setup_device( - hass, user_data, device, product_info[device.product_id], home_data_rooms + hass, + entry, + user_data, + device, + product_info[device.product_id], + home_data_rooms, ) for device in device_map.values() ] @@ -164,6 +157,7 @@ def build_setup_functions( async def setup_device( hass: HomeAssistant, + entry: RoborockConfigEntry, user_data: UserData, device: HomeDataDevice, product_info: HomeDataProduct, @@ -172,10 +166,10 @@ async def setup_device( """Set up a coordinator for a given device.""" if device.pv == "1.0": return await setup_device_v1( - hass, user_data, device, product_info, home_data_rooms + hass, entry, user_data, device, product_info, home_data_rooms ) if device.pv == "A01": - return await setup_device_a01(hass, user_data, device, product_info) + return await setup_device_a01(hass, entry, user_data, device, product_info) _LOGGER.warning( "Not adding device %s because its protocol version %s or category %s is not supported", device.duid, @@ -187,6 +181,7 @@ async def setup_device( async def setup_device_v1( hass: HomeAssistant, + entry: RoborockConfigEntry, user_data: UserData, device: HomeDataDevice, product_info: HomeDataProduct, @@ -212,7 +207,7 @@ async def setup_device_v1( await mqtt_client.async_release() raise coordinator = RoborockDataUpdateCoordinator( - hass, device, networking, product_info, mqtt_client, home_data_rooms + hass, entry, device, networking, product_info, mqtt_client, home_data_rooms ) try: await coordinator.async_config_entry_first_refresh() @@ -246,6 +241,7 @@ async def setup_device_v1( async def setup_device_a01( hass: HomeAssistant, + entry: RoborockConfigEntry, user_data: UserData, device: HomeDataDevice, product_info: HomeDataProduct, @@ -254,7 +250,9 @@ async def setup_device_a01( mqtt_client = RoborockMqttClientA01( user_data, DeviceData(device, product_info.name), product_info.category ) - coord = RoborockDataUpdateCoordinatorA01(hass, device, product_info, mqtt_client) + coord = RoborockDataUpdateCoordinatorA01( + hass, entry, device, product_info, mqtt_client + ) await coord.async_config_entry_first_refresh() return coord diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index b88556ea857..c734eaf5ce8 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -16,8 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 2f214c7c51c..038f224f726 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -11,8 +11,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 8860a5c1f43..918c7159ee3 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import timedelta import logging @@ -35,14 +36,32 @@ SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) +@dataclass +class RoborockCoordinators: + """Roborock coordinators type.""" + + v1: list[RoborockDataUpdateCoordinator] + a01: list[RoborockDataUpdateCoordinatorA01] + + def values( + self, + ) -> list[RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01]: + """Return all coordinators.""" + return self.v1 + self.a01 + + +type RoborockConfigEntry = ConfigEntry[RoborockCoordinators] + + class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Class to manage fetching data from the API.""" - config_entry: ConfigEntry + config_entry: RoborockConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: RoborockConfigEntry, device: HomeDataDevice, device_networking: NetworkInfo, product_info: HomeDataProduct, @@ -50,7 +69,13 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): home_data_rooms: list[HomeDataRoom], ) -> None: """Initialize.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) self.roborock_device_info = RoborockHassDeviceInfo( device, device_networking, @@ -186,15 +211,24 @@ class RoborockDataUpdateCoordinatorA01( ): """Class to manage fetching data from the API for A01 devices.""" + config_entry: RoborockConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: RoborockConfigEntry, device: HomeDataDevice, product_info: HomeDataProduct, api: RoborockClientA01, ) -> None: """Initialize.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) self.api = api self.device_info = DeviceInfo( name=device.name, diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py index e784e4ce837..4602b4bd02a 100644 --- a/homeassistant/components/roborock/diagnostics.py +++ b/homeassistant/components/roborock/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from . import RoborockConfigEntry +from .coordinator import RoborockConfigEntry TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b4776c27164..ff1c94957e0 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -19,7 +19,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import RoborockConfigEntry from .const import ( DEFAULT_DRAWABLES, DOMAIN, @@ -28,7 +27,7 @@ from .const import ( MAP_FILE_FORMAT, MAP_SLEEP, ) -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 7f568ae824b..97aa8c2ffd4 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 73cb95d2d7c..826af3e24e8 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -13,9 +13,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RoborockConfigEntry from .const import MAP_SLEEP -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index e01a03d7720..0d376debcbf 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -31,8 +31,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 +from .coordinator import ( + RoborockConfigEntry, + RoborockDataUpdateCoordinator, + RoborockDataUpdateCoordinatorA01, +) from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index b0c8c880188..ebf8225b4f5 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -18,8 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 1dd681dff1f..76f20bc6607 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -18,8 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, RoborockConfigEntry -from .coordinator import RoborockDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 7582dadad16..e604ab6a209 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -18,14 +18,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RoborockConfigEntry from .const import ( DOMAIN, GET_MAPS_SERVICE_NAME, GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, SET_VACUUM_GOTO_POSITION_SERVICE_NAME, ) -from .coordinator import RoborockDataUpdateCoordinator +from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 from .image import ColorsPalette, ImageConfig, RoborockMapDataParser, Sizes From df307aeb6d5930bcc2f62d5e849a30afa355b429 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 9 Feb 2025 00:01:24 -0500 Subject: [PATCH 0166/1941] Stream OpenAI messages into the chat log (#137400) --- .../components/conversation/__init__.py | 2 + .../components/conversation/chat_log.py | 87 ++- .../openai_conversation/conversation.py | 130 ++-- homeassistant/helpers/chat_session.py | 4 + .../conversation/snapshots/test_chat_log.ambr | 150 ++++ .../components/conversation/test_chat_log.py | 264 +++++-- .../snapshots/test_conversation.ambr | 90 ++- .../openai_conversation/test_conversation.py | 670 ++++++++++-------- 8 files changed, 964 insertions(+), 433 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 69e738205c5..11de75801ba 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -32,6 +32,7 @@ from .agent_manager import ( ) from .chat_log import ( AssistantContent, + AssistantContentDeltaDict, ChatLog, Content, ConverseError, @@ -65,6 +66,7 @@ __all__ = [ "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", "AssistantContent", + "AssistantContentDeltaDict", "ChatLog", "Content", "ConversationEntity", diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 086e1374c1a..5dbd19ba275 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Generator +from collections.abc import AsyncGenerator, AsyncIterable, Generator from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass, field, replace import logging +from typing import Literal, TypedDict import voluptuous as vol @@ -145,6 +146,14 @@ class ToolResultContent: type Content = SystemContent | UserContent | AssistantContent | ToolResultContent +class AssistantContentDeltaDict(TypedDict, total=False): + """Partial content to define an AssistantContent.""" + + role: Literal["assistant"] + content: str | None + tool_calls: list[llm.ToolInput] | None + + @dataclass class ChatLog: """Class holding the chat history of a specific conversation.""" @@ -155,6 +164,11 @@ class ChatLog: extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None + @property + def unresponded_tool_results(self) -> bool: + """Return if there are unresponded tool results.""" + return self.content[-1].role == "tool_result" + @callback def async_add_user_content(self, content: UserContent) -> None: """Add user content to the log.""" @@ -223,6 +237,77 @@ class ChatLog: self.content.append(response_content) yield response_content + async def async_add_delta_content_stream( + self, agent_id: str, stream: AsyncIterable[AssistantContentDeltaDict] + ) -> AsyncGenerator[AssistantContent | ToolResultContent]: + """Stream content into the chat log. + + Returns a generator with all content that was added to the chat log. + + stream iterates over dictionaries with optional keys role, content and tool_calls. + + When a delta contains a role key, the current message is considered complete and + a new message is started. + + The keys content and tool_calls will be concatenated if they appear multiple times. + """ + current_content = "" + current_tool_calls: list[llm.ToolInput] = [] + tool_call_tasks: dict[str, asyncio.Task] = {} + + async for delta in stream: + LOGGER.debug("Received delta: %s", delta) + + # Indicates update to current message + if "role" not in delta: + if delta_content := delta.get("content"): + current_content += delta_content + if delta_tool_calls := delta.get("tool_calls"): + if self.llm_api is None: + raise ValueError("No LLM API configured") + current_tool_calls += delta_tool_calls + + # Start processing the tool calls as soon as we know about them + for tool_call in delta_tool_calls: + tool_call_tasks[tool_call.id] = self.hass.async_create_task( + self.llm_api.async_call_tool(tool_call), + name=f"llm_tool_{tool_call.id}", + ) + continue + + # Starting a new message + + if delta["role"] != "assistant": + raise ValueError(f"Only assistant role expected. Got {delta['role']}") + + # Yield the previous message if it has content + if current_content or current_tool_calls: + content = AssistantContent( + agent_id=agent_id, + content=current_content or None, + tool_calls=current_tool_calls or None, + ) + yield content + async for tool_result in self.async_add_assistant_content( + content, tool_call_tasks=tool_call_tasks + ): + yield tool_result + + current_content = delta.get("content") or "" + current_tool_calls = delta.get("tool_calls") or [] + + if current_content or current_tool_calls: + content = AssistantContent( + agent_id=agent_id, + content=current_content or None, + tool_calls=current_tool_calls or None, + ) + yield content + async for tool_result in self.async_add_assistant_content( + content, tool_call_tasks=tool_call_tasks + ): + yield tool_result + async def async_update_llm_data( self, conversing_domain: str, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index eaa62bd1adc..4dee1d4b167 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -1,14 +1,15 @@ """Conversation support for OpenAI.""" -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable import json from typing import Any, Literal, cast import openai +from openai._streaming import AsyncStream from openai._types import NOT_GIVEN from openai.types.chat import ( ChatCompletionAssistantMessageParam, - ChatCompletionMessage, + ChatCompletionChunk, ChatCompletionMessageParam, ChatCompletionMessageToolCallParam, ChatCompletionToolMessageParam, @@ -70,32 +71,6 @@ def _format_tool( return ChatCompletionToolParam(type="function", function=tool_spec) -def _convert_message_to_param( - message: ChatCompletionMessage, -) -> ChatCompletionMessageParam: - """Convert from class to TypedDict.""" - tool_calls: list[ChatCompletionMessageToolCallParam] = [] - if message.tool_calls: - tool_calls = [ - ChatCompletionMessageToolCallParam( - id=tool_call.id, - function=Function( - arguments=tool_call.function.arguments, - name=tool_call.function.name, - ), - type=tool_call.type, - ) - for tool_call in message.tool_calls - ] - param = ChatCompletionAssistantMessageParam( - role=message.role, - content=message.content, - ) - if tool_calls: - param["tool_calls"] = tool_calls - return param - - def _convert_content_to_param( content: conversation.Content, ) -> ChatCompletionMessageParam: @@ -135,6 +110,74 @@ def _convert_content_to_param( ) +async def _transform_stream( + result: AsyncStream[ChatCompletionChunk], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform an OpenAI delta stream into HA format.""" + current_tool_call: dict | None = None + + async for chunk in result: + LOGGER.debug("Received chunk: %s", chunk) + choice = chunk.choices[0] + + if choice.finish_reason: + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=json.loads(current_tool_call["tool_args"]), + ) + ] + } + + break + + delta = chunk.choices[0].delta + + # We can yield delta messages not continuing or starting tool calls + if current_tool_call is None and not delta.tool_calls: + yield { # type: ignore[misc] + key: value + for key in ("role", "content") + if (value := getattr(delta, key)) is not None + } + continue + + # When doing tool calls, we should always have a tool call + # object or we have gotten stopped above with a finish_reason set. + if ( + not delta.tool_calls + or not (delta_tool_call := delta.tool_calls[0]) + or not delta_tool_call.function + ): + raise ValueError("Expected delta with tool call") + + if current_tool_call and delta_tool_call.index == current_tool_call["index"]: + current_tool_call["tool_args"] += delta_tool_call.function.arguments or "" + continue + + # We got tool call with new index, so we need to yield the previous + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["tool_name"], + tool_args=json.loads(current_tool_call["tool_args"]), + ) + ] + } + + current_tool_call = { + "index": delta_tool_call.index, + "id": delta_tool_call.id, + "tool_name": delta_tool_call.function.name, + "tool_args": delta_tool_call.function.arguments or "", + } + + class OpenAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -234,6 +277,7 @@ class OpenAIConversationEntity( "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), "user": chat_log.conversation_id, + "stream": True, } if model.startswith("o"): @@ -247,39 +291,21 @@ class OpenAIConversationEntity( LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err - LOGGER.debug("Response %s", result) - response = result.choices[0].message - messages.append(_convert_message_to_param(response)) - - tool_calls: list[llm.ToolInput] | None = None - if response.tool_calls: - tool_calls = [ - llm.ToolInput( - id=tool_call.id, - tool_name=tool_call.function.name, - tool_args=json.loads(tool_call.function.arguments), - ) - for tool_call in response.tool_calls - ] - messages.extend( [ - _convert_content_to_param(tool_response) - async for tool_response in chat_log.async_add_assistant_content( - conversation.AssistantContent( - agent_id=user_input.agent_id, - content=response.content or "", - tool_calls=tool_calls, - ) + _convert_content_to_param(content) + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(result) ) ] ) - if not tool_calls: + if not chat_log.unresponded_tool_results: break intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response.content or "") + assert type(chat_log.content[-1]) is conversation.AssistantContent + intent_response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( response=intent_response, conversation_id=chat_log.conversation_id ) diff --git a/homeassistant/helpers/chat_session.py b/homeassistant/helpers/chat_session.py index 686272dd834..e7a4ecd2ca9 100644 --- a/homeassistant/helpers/chat_session.py +++ b/homeassistant/helpers/chat_session.py @@ -7,6 +7,7 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass, field from datetime import datetime, timedelta +import logging from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( @@ -27,6 +28,7 @@ DATA_CHAT_SESSION: HassKey[dict[str, ChatSession]] = HassKey("chat_session") DATA_CHAT_SESSION_CLEANUP: HassKey[SessionCleanup] = HassKey("chat_session_cleanup") CONVERSATION_TIMEOUT = timedelta(minutes=5) +LOGGER = logging.getLogger(__name__) current_session: ContextVar[ChatSession | None] = ContextVar( "current_session", default=None @@ -100,6 +102,7 @@ class SessionCleanup: # yielding session based on it. for conversation_id, session in list(all_sessions.items()): if session.last_updated + CONVERSATION_TIMEOUT < now: + LOGGER.debug("Cleaning up session %s", conversation_id) del all_sessions[conversation_id] session.async_cleanup() @@ -150,6 +153,7 @@ def async_get_chat_session( pass if session is None: + LOGGER.debug("Creating new session %s", conversation_id) session = ChatSession(conversation_id) current_session.set(session) diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index 4e94157c601..1ddbf68bb84 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -1,4 +1,154 @@ # serializer version: 1 +# name: test_add_delta_content_stream[deltas0] + list([ + ]) +# --- +# name: test_add_delta_content_stream[deltas1] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas2] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'role': 'assistant', + 'tool_calls': None, + }), + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test 2', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas3] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 1', + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas4] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 1', + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas5] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test', + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 1', + }), + dict({ + 'agent_id': 'mock-agent-id', + 'content': 'Test 2', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_add_delta_content_stream[deltas6] + list([ + dict({ + 'agent_id': 'mock-agent-id', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'mock-tool-call-id', + 'tool_args': dict({ + 'param1': 'Test Param 1', + }), + 'tool_name': 'test_tool', + }), + dict({ + 'id': 'mock-tool-call-id-2', + 'tool_args': dict({ + 'param1': 'Test Param 2', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 1', + }), + dict({ + 'agent_id': 'mock-agent-id', + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id-2', + 'tool_name': 'test_tool', + 'tool_result': 'Test Param 2', + }), + ]) +# --- # name: test_template_error dict({ 'conversation_id': , diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 1f659b8005e..090904c7063 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -282,7 +282,7 @@ async def test_extra_systen_prompt( @pytest.mark.parametrize( "prerun_tool_tasks", [ - None, + (), ("mock-tool-call-id",), ("mock-tool-call-id", "mock-tool-call-id-2"), ], @@ -290,7 +290,7 @@ async def test_extra_systen_prompt( async def test_tool_call( hass: HomeAssistant, mock_conversation_input: ConversationInput, - prerun_tool_tasks: tuple[str] | None, + prerun_tool_tasks: tuple[str], ) -> None: """Test using the session tool calling API.""" @@ -334,15 +334,13 @@ async def test_tool_call( ], ) - tool_call_tasks = None - if prerun_tool_tasks: - tool_call_tasks = { - tool_call_id: hass.async_create_task( - chat_log.llm_api.async_call_tool(content.tool_calls[0]), - tool_call_id, - ) - for tool_call_id in prerun_tool_tasks - } + tool_call_tasks = { + tool_call_id: hass.async_create_task( + chat_log.llm_api.async_call_tool(content.tool_calls[0]), + tool_call_id, + ) + for tool_call_id in prerun_tool_tasks + } with pytest.raises(ValueError): chat_log.async_add_assistant_content_without_tools(content) @@ -350,7 +348,7 @@ async def test_tool_call( results = [ tool_result_content async for tool_result_content in chat_log.async_add_assistant_content( - content, tool_call_tasks=tool_call_tasks + content, tool_call_tasks=tool_call_tasks or None ) ] @@ -382,37 +380,36 @@ async def test_tool_call_exception( ) mock_tool.async_call.side_effect = HomeAssistantError("Test error") - with patch( - "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] - ) as mock_get_tools: + with ( + patch( + "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] + ) as mock_get_tools, + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): mock_get_tools.return_value = [mock_tool] - - with ( - chat_session.async_get_chat_session(hass) as session, - async_get_chat_log(hass, session, mock_conversation_input) as chat_log, - ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, - user_llm_hass_api="assist", - user_llm_prompt=None, + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + result = None + async for tool_result_content in chat_log.async_add_assistant_content( + AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ], ) - result = None - async for tool_result_content in chat_log.async_add_assistant_content( - AssistantContent( - agent_id=mock_conversation_input.agent_id, - content="", - tool_calls=[ - llm.ToolInput( - id="mock-tool-call-id", - tool_name="test_tool", - tool_args={"param1": "Test Param"}, - ) - ], - ) - ): - assert result is None - result = tool_result_content + ): + assert result is None + result = tool_result_content assert result == ToolResultContent( agent_id=mock_conversation_input.agent_id, @@ -420,3 +417,188 @@ async def test_tool_call_exception( tool_result={"error": "HomeAssistantError", "error_text": "Test error"}, tool_name="test_tool", ) + + +@pytest.mark.parametrize( + "deltas", + [ + [], + # With content + [ + {"role": "assistant"}, + {"content": "Test"}, + ], + # With 2 content + [ + {"role": "assistant"}, + {"content": "Test"}, + {"role": "assistant"}, + {"content": "Test 2"}, + ], + # With 1 tool call + [ + {"role": "assistant"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + ) + ] + }, + ], + # With content and 1 tool call + [ + {"role": "assistant"}, + {"content": "Test"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + ) + ] + }, + ], + # With 2 contents and 1 tool call + [ + {"role": "assistant"}, + {"content": "Test"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + ) + ] + }, + {"role": "assistant"}, + {"content": "Test 2"}, + ], + # With 2 tool calls + [ + {"role": "assistant"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param 1"}, + ) + ] + }, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="test_tool", + tool_args={"param1": "Test Param 2"}, + ) + ] + }, + ], + ], +) +async def test_add_delta_content_stream( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, + snapshot: SnapshotAssertion, + deltas: list[dict], +) -> None: + """Test streaming deltas.""" + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + + async def tool_call( + hass: HomeAssistant, tool_input: llm.ToolInput, llm_context: llm.LLMContext + ) -> str: + """Call the tool.""" + return tool_input.tool_args["param1"] + + mock_tool.async_call.side_effect = tool_call + + async def stream(): + """Yield deltas.""" + for d in deltas: + yield d + + with ( + patch( + "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] + ) as mock_get_tools, + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + mock_get_tools.return_value = [mock_tool] + await chat_log.async_update_llm_data( + conversing_domain="test", + user_input=mock_conversation_input, + user_llm_hass_api="assist", + user_llm_prompt=None, + ) + + results = [ + tool_result_content + async for tool_result_content in chat_log.async_add_delta_content_stream( + "mock-agent-id", stream() + ) + ] + + assert results == snapshot + assert chat_log.content[2:] == results + + +async def test_add_delta_content_stream_errors( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test streaming deltas error handling.""" + + async def stream(deltas): + """Yield deltas.""" + for d in deltas: + yield d + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + # Stream content without LLM API set + with pytest.raises(ValueError): # noqa: PT012 + async for _tool_result_content in chat_log.async_add_delta_content_stream( + "mock-agent-id", + stream( + [ + {"role": "assistant"}, + { + "tool_calls": [ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={}, + ) + ] + }, + ] + ), + ): + pass + + # Non assistant role + for role in "system", "user": + with pytest.raises(ValueError): # noqa: PT012 + async for ( + _tool_result_content + ) in chat_log.async_add_delta_content_stream( + "mock-agent-id", + stream([{"role": role}]), + ): + pass diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 4ef8b8655ee..2db5be706ef 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,34 +1,64 @@ # serializer version: 1 -# name: test_unknown_hass_api - dict({ - 'conversation_id': 'my-conversation-id', - 'response': IntentResponse( - card=dict({ - }), - error_code=, - failed_results=list([ - ]), - intent=None, - intent_targets=list([ - ]), - language='en', - matched_states=list([ - ]), - reprompt=dict({ - }), - response_type=, - speech=dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Error preparing LLM API', +# name: test_function_call + list([ + dict({ + 'content': ''' + Current time is 16:00:00. Today's date is 2024-06-03. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + ''', + 'role': 'system', + }), + dict({ + 'content': 'hello', + 'role': 'user', + }), + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.openai', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + dict({ + 'id': 'call_call_2', + 'tool_args': dict({ + 'param1': 'call2', + }), + 'tool_name': 'test_tool', }), - }), - speech_slots=dict({ - }), - success_results=list([ ]), - unmatched_states=list([ - ]), - ), - }) + }), + dict({ + 'agent_id': 'conversation.openai', + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.openai', + 'role': 'tool_result', + 'tool_call_id': 'call_call_2', + 'tool_name': 'test_tool', + 'tool_result': 'value2', + }), + dict({ + 'agent_id': 'conversation.openai', + 'content': 'Cool', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) # --- diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 39ca1b53e28..9afdfc6a5a2 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,29 +1,130 @@ """Tests for the OpenAI integration.""" +from collections.abc import Generator +from dataclasses import dataclass, field from unittest.mock import AsyncMock, patch from freezegun import freeze_time from httpx import Response from openai import RateLimitError -from openai.types.chat.chat_completion import ChatCompletion, Choice -from openai.types.chat.chat_completion_message import ChatCompletionMessage -from openai.types.chat.chat_completion_message_tool_call import ( - ChatCompletionMessageToolCall, - Function, +from openai.types.chat.chat_completion_chunk import ( + ChatCompletionChunk, + Choice, + ChoiceDelta, + ChoiceDeltaToolCall, + ChoiceDeltaToolCallFunction, ) -from openai.types.completion_usage import CompletionUsage -import voluptuous as vol +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation -from homeassistant.components.conversation import trace +from homeassistant.components.conversation import chat_log +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import chat_session, intent from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +ASSIST_RESPONSE_FINISH = ( + # Assistant message + ChatCompletionChunk( + id="chatcmpl-B", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], + ), + # Finish stream + ChatCompletionChunk( + id="chatcmpl-B", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[Choice(index=0, finish_reason="stop", delta=ChoiceDelta())], + ), +) + + +@pytest.fixture +def mock_create_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(stream): + for value in stream: + yield value + + with patch( + "openai.resources.chat.completions.AsyncCompletions.create", + AsyncMock(), + ) as mock_create: + mock_create.side_effect = lambda **kwargs: mock_generator( + mock_create.return_value.pop(0) + ) + + yield mock_create + + +@dataclass +class MockChatLog(chat_log.ChatLog): + """Mock chat log.""" + + _mock_tool_results: dict = field(default_factory=dict) + + def mock_tool_results(self, results: dict) -> None: + """Set tool results.""" + self._mock_tool_results = results + + @property + def llm_api(self): + """Return LLM API.""" + return self._llm_api + + @llm_api.setter + def llm_api(self, value): + """Set LLM API.""" + self._llm_api = value + + if not value: + return + + async def async_call_tool(tool_input): + """Call tool.""" + if tool_input.id not in self._mock_tool_results: + raise ValueError(f"Tool {tool_input.id} not found") + return self._mock_tool_results[tool_input.id] + + self._llm_api.async_call_tool = async_call_tool + + def latest_content(self) -> list[conversation.Content]: + """Return content from latest version chat log. + + The chat log makes copies until it's committed. Helper to get latest content. + """ + with ( + chat_session.async_get_chat_session( + self.hass, self.conversation_id + ) as session, + conversation.async_get_chat_log(self.hass, session) as chat_log, + ): + return chat_log.content + + +@pytest.fixture +async def mock_chat_log(hass: HomeAssistant) -> MockChatLog: + """Return mock chat logs.""" + with ( + patch( + "homeassistant.components.conversation.chat_log.ChatLog", + MockChatLog, + ), + chat_session.async_get_chat_session(hass, "mock-conversation-id") as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + chat_log.async_add_user_content(conversation.UserContent("hello")) + return chat_log + async def test_entity( hass: HomeAssistant, @@ -83,348 +184,299 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -@patch( - "homeassistant.components.openai_conversation.conversation.llm.AssistAPI._async_get_tools" -) async def test_function_call( - mock_get_tools, hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, mock_init_component, + mock_create_stream: AsyncMock, + mock_chat_log: MockChatLog, + snapshot: SnapshotAssertion, ) -> None: """Test function call from the assistant.""" - agent_id = mock_config_entry_with_assist.entry_id - context = Context() - - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema( - {vol.Optional("param1", description="Test parameters"): str} + mock_create_stream.return_value = [ + # Initial conversation + ( + # First tool call + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + id="call_call_1", + index=0, + function=ChoiceDeltaToolCallFunction( + name="test_tool", + arguments=None, + ), + ) + ] + ), + ) + ], + ), + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + index=0, + function=ChoiceDeltaToolCallFunction( + name=None, + arguments='{"para', + ), + ) + ] + ), + ) + ], + ), + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + index=0, + function=ChoiceDeltaToolCallFunction( + name=None, + arguments='m1":"call1"}', + ), + ) + ] + ), + ) + ], + ), + # Second tool call + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + id="call_call_2", + index=1, + function=ChoiceDeltaToolCallFunction( + name="test_tool", + arguments='{"param1":"call2"}', + ), + ) + ] + ), + ) + ], + ), + # Finish stream + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice(index=0, finish_reason="tool_calls", delta=ChoiceDelta()) + ], + ), + ), + # Response after tool responses + ASSIST_RESPONSE_FINISH, + ] + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } ) - mock_tool.async_call.return_value = "Test response" - mock_get_tools.return_value = [mock_tool] + with freeze_time("2024-06-03 23:00:00"): + result = await conversation.async_converse( + hass, + "Please call the test function", + "mock-conversation-id", + Context(), + agent_id="conversation.openai", + ) - def completion_result(*args, messages, **kwargs): - for message in messages: - role = message["role"] if isinstance(message, dict) else message.role - if role == "tool": - return ChatCompletion( - id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat_log.latest_content() == snapshot + + +@pytest.mark.parametrize( + ("description", "messages"), + [ + ( + "Test function call started with missing arguments", + ( + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", choices=[ Choice( - finish_reason="stop", index=0, - message=ChatCompletionMessage( - content="I have successfully called the function", - role="assistant", - function_call=None, - tool_calls=None, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + id="call_call_1", + index=0, + function=ChoiceDeltaToolCallFunction( + name="test_tool", + arguments=None, + ), + ) + ] ), ) ], + ), + ChatCompletionChunk( + id="chatcmpl-B", created=1700000000, model="gpt-4-1106-preview", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 - ), - ) - - return ChatCompletion( - id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", - choices=[ - Choice( - finish_reason="tool_calls", - index=0, - message=ChatCompletionMessage( - content=None, - role="assistant", - function_call=None, - tool_calls=[ - ChatCompletionMessageToolCall( - id="call_AbCdEfGhIjKlMnOpQrStUvWx", - function=Function( - arguments='{"param1":"test_value"}', - name="test_tool", - ), - type="function", - ) - ], - ), - ) - ], - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 + object="chat.completion.chunk", + choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], + ), ), - ) - - with ( - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - side_effect=completion_result, - ) as mock_create, - freeze_time("2024-06-03 23:00:00"), - ): - result = await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - ) - - assert ( - "Today's date is 2024-06-03." - in mock_create.mock_calls[1][2]["messages"][0]["content"] - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert mock_create.mock_calls[1][2]["messages"][3] == { - "role": "tool", - "tool_call_id": "call_AbCdEfGhIjKlMnOpQrStUvWx", - "content": '"Test response"', - } - mock_tool.async_call.assert_awaited_once_with( - hass, - llm.ToolInput( - id="call_AbCdEfGhIjKlMnOpQrStUvWx", - tool_name="test_tool", - tool_args={"param1": "test_value"}, ), - llm.LLMContext( - platform="openai_conversation", - context=context, - user_prompt="Please call the test function", - language="en", - assistant="conversation", - device_id=None, + ( + "Test invalid JSON", + ( + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + id="call_call_1", + index=0, + function=ChoiceDeltaToolCallFunction( + name="test_tool", + arguments=None, + ), + ) + ] + ), + ) + ], + ), + ChatCompletionChunk( + id="chatcmpl-A", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta( + tool_calls=[ + ChoiceDeltaToolCall( + index=0, + function=ChoiceDeltaToolCallFunction( + name=None, + arguments='{"para', + ), + ) + ] + ), + ) + ], + ), + ChatCompletionChunk( + id="chatcmpl-B", + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion.chunk", + choices=[ + Choice( + index=0, + delta=ChoiceDelta(content="Cool"), + finish_reason="tool_calls", + ) + ], + ), + ), ), - ) - - # Test Conversation tracing - traces = trace.async_get_traces() - assert traces - last_trace = traces[-1].as_dict() - trace_events = last_trace.get("events", []) - assert [event["event_type"] for event in trace_events] == [ - trace.ConversationTraceEventType.ASYNC_PROCESS, - trace.ConversationTraceEventType.AGENT_DETAIL, - trace.ConversationTraceEventType.TOOL_CALL, - ] - # AGENT_DETAIL event contains the raw prompt passed to the model - detail_event = trace_events[1] - assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] - assert ( - "Today's date is 2024-06-03." - in trace_events[1]["data"]["messages"][0]["content"] - ) - assert [t.name for t in detail_event["data"]["tools"]] == ["test_tool"] - - # Call it again, make sure we have updated prompt - with ( - patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - side_effect=completion_result, - ) as mock_create, - freeze_time("2024-06-04 23:00:00"), - ): - result = await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - ) - - assert ( - "Today's date is 2024-06-04." - in mock_create.mock_calls[1][2]["messages"][0]["content"] - ) - # Test old assert message not updated - assert ( - "Today's date is 2024-06-03." - in trace_events[1]["data"]["messages"][0]["content"] - ) - - -@patch( - "homeassistant.components.openai_conversation.conversation.llm.AssistAPI._async_get_tools" + ], ) -async def test_function_exception( - mock_get_tools, +async def test_function_call_invalid( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, mock_init_component, + mock_create_stream: AsyncMock, + mock_chat_log: MockChatLog, + description: str, + messages: tuple[ChatCompletionChunk], ) -> None: - """Test function call with exception.""" - agent_id = mock_config_entry_with_assist.entry_id - context = Context() + """Test function call containing invalid data.""" + mock_create_stream.return_value = [messages] - mock_tool = AsyncMock() - mock_tool.name = "test_tool" - mock_tool.description = "Test function" - mock_tool.parameters = vol.Schema( - {vol.Optional("param1", description="Test parameters"): str} - ) - mock_tool.async_call.side_effect = HomeAssistantError("Test tool exception") - - mock_get_tools.return_value = [mock_tool] - - def completion_result(*args, messages, **kwargs): - for message in messages: - role = message["role"] if isinstance(message, dict) else message.role - if role == "tool": - return ChatCompletion( - id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", - choices=[ - Choice( - finish_reason="stop", - index=0, - message=ChatCompletionMessage( - content="There was an error calling the function", - role="assistant", - function_call=None, - tool_calls=None, - ), - ) - ], - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 - ), - ) - - return ChatCompletion( - id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", - choices=[ - Choice( - finish_reason="tool_calls", - index=0, - message=ChatCompletionMessage( - content=None, - role="assistant", - function_call=None, - tool_calls=[ - ChatCompletionMessageToolCall( - id="call_AbCdEfGhIjKlMnOpQrStUvWx", - function=Function( - arguments='{"param1":"test_value"}', - name="test_tool", - ), - type="function", - ) - ], - ), - ) - ], - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 - ), - ) - - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - side_effect=completion_result, - ) as mock_create: - result = await conversation.async_converse( + with pytest.raises(ValueError): + await conversation.async_converse( hass, "Please call the test function", - None, - context, - agent_id=agent_id, + "mock-conversation-id", + Context(), + agent_id="conversation.openai", ) - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert mock_create.mock_calls[1][2]["messages"][3] == { - "role": "tool", - "tool_call_id": "call_AbCdEfGhIjKlMnOpQrStUvWx", - "content": '{"error": "HomeAssistantError", "error_text": "Test tool exception"}', - } - mock_tool.async_call.assert_awaited_once_with( - hass, - llm.ToolInput( - id="call_AbCdEfGhIjKlMnOpQrStUvWx", - tool_name="test_tool", - tool_args={"param1": "test_value"}, - ), - llm.LLMContext( - platform="openai_conversation", - context=context, - user_prompt="Please call the test function", - language="en", - assistant="conversation", - device_id=None, - ), - ) - async def test_assist_api_tools_conversion( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, mock_init_component, + mock_create_stream, ) -> None: """Test that we are able to convert actual tools from Assist API.""" for component in ( - "intent", - "todo", - "light", - "shopping_list", - "humidifier", + "calendar", "climate", - "media_player", - "vacuum", "cover", + "humidifier", + "intent", + "light", + "media_player", + "script", + "shopping_list", + "todo", + "vacuum", "weather", ): assert await async_setup_component(hass, component, {}) + hass.states.async_set(f"{component}.test", "on") + async_expose_entity(hass, "conversation", f"{component}.test", True) - agent_id = mock_config_entry_with_assist.entry_id - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - return_value=ChatCompletion( - id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", - choices=[ - Choice( - finish_reason="stop", - index=0, - message=ChatCompletionMessage( - content="Hello, how can I help you?", - role="assistant", - function_call=None, - tool_calls=None, - ), - ) - ], - created=1700000000, - model="gpt-3.5-turbo-0613", - object="chat.completion", - system_fingerprint=None, - usage=CompletionUsage( - completion_tokens=9, prompt_tokens=8, total_tokens=17 - ), - ), - ) as mock_create: - await conversation.async_converse( - hass, "hello", None, Context(), agent_id=agent_id - ) + mock_create_stream.return_value = [ASSIST_RESPONSE_FINISH] - tools = mock_create.mock_calls[0][2]["tools"] + await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.openai" + ) + + tools = mock_create_stream.mock_calls[0][2]["tools"] assert tools From 80cff85c1489e49a94abd122c82e7f0df09094ae Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 9 Feb 2025 10:55:27 +0100 Subject: [PATCH 0167/1941] Fix sentence-casing in user-facing strings of screenlogic (#138015) --- .../components/screenlogic/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index 09e64808dfe..97e12277eb6 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -3,9 +3,9 @@ "service_config_entry_name": "Config entry", "service_config_entry_description": "The config entry to use for this action.", "climate_preset_solar": "Solar", - "climate_preset_solar_preferred": "Solar Preferred", + "climate_preset_solar_preferred": "Solar preferred", "climate_preset_heater": "Heater", - "climate_preset_dont_change": "Don't Change" + "climate_preset_dont_change": "Don't change" }, "config": { "flow_title": "{name}", @@ -15,7 +15,7 @@ "step": { "gateway_entry": { "title": "ScreenLogic", - "description": "Enter your ScreenLogic Gateway information.", + "description": "Enter your ScreenLogic gateway information.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]" @@ -46,7 +46,7 @@ }, "services": { "set_color_mode": { - "name": "Set Color Mode", + "name": "Set color mode", "description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.", "fields": { "config_entry": { @@ -54,13 +54,13 @@ "description": "[%key:component::screenlogic::common::service_config_entry_description%]" }, "color_mode": { - "name": "Color Mode", + "name": "Color mode", "description": "The ScreenLogic color mode to set." } } }, "start_super_chlorination": { - "name": "Start Super Chlorination", + "name": "Start super chlorination", "description": "Begins super chlorination, running for the specified period or 24 hours if none is specified.", "fields": { "config_entry": { @@ -68,13 +68,13 @@ "description": "[%key:component::screenlogic::common::service_config_entry_description%]" }, "runtime": { - "name": "Run Time", + "name": "Run time", "description": "Number of hours for super chlorination to run." } } }, "stop_super_chlorination": { - "name": "Stop Super Chlorination", + "name": "Stop super chlorination", "description": "Stops super chlorination.", "fields": { "config_entry": { From 4a8c96471b68114b357bb4d1b89ca8feaf635521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 9 Feb 2025 12:36:08 +0100 Subject: [PATCH 0168/1941] Raise `ConfigEntryAuthFailed` at Home Connect update auth error (#136953) * Raise `ConfigEntryAuthFailed` on `UnauthorizedError` handling * Implement reauth flow * Add tests * Remove unnecessary code from tests --- .../components/home_connect/config_flow.py | 31 ++++++++++ .../components/home_connect/coordinator.py | 8 +++ .../components/home_connect/strings.json | 8 +++ .../home_connect/test_config_flow.py | 57 ++++++++++++++++++- tests/components/home_connect/test_init.py | 15 ++++- 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/config_flow.py b/homeassistant/components/home_connect/config_flow.py index 444ea24cb6b..02a3ca29335 100644 --- a/homeassistant/components/home_connect/config_flow.py +++ b/homeassistant/components/home_connect/config_flow.py @@ -1,7 +1,12 @@ """Config flow for Home Connect.""" +from collections.abc import Mapping import logging +from typing import Any +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN @@ -20,3 +25,29 @@ class OAuth2FlowHandler( def logger(self) -> logging.Logger: """Return logger.""" return logging.getLogger(__name__) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({}), + ) + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an oauth config entry or update existing entry for reauth.""" + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=data, + ) + return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 16584bfd586..da47d8ec91c 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -26,12 +26,14 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, HomeConnectRequestError, + UnauthorizedError, ) from aiohomeconnect.model.program import EnumerateProgram from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -270,6 +272,12 @@ class HomeConnectCoordinator( """Fetch data from Home Connect.""" try: appliances = await self.client.get_home_appliances() + except UnauthorizedError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders=get_dict_from_home_connect_error(error), + ) from error except HomeConnectError as error: raise UpdateFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index d163d04a6f7..d07cfcdf854 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -7,9 +7,14 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Home Connect integration needs to re-authenticate your account" } }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", @@ -22,6 +27,9 @@ } }, "exceptions": { + "auth_error": { + "message": "Authentication error: {error}. Please, re-authenticate your account" + }, "appliance_not_found": { "message": "Appliance for device ID {device_id} not found" }, diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 343d648e543..c35678e4e5f 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -1,7 +1,8 @@ """Test the Home Connect config flow.""" +from collections.abc import Awaitable, Callable from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import MagicMock, patch from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN import pytest @@ -93,3 +94,57 @@ async def test_prevent_multiple_config_entries( assert result["type"] == "abort" assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauth flow.""" + result = await 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() + 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.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.home_connect.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index b3d1c4e531f..009c40b662d 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError import pytest import requests_mock import respx @@ -216,7 +216,16 @@ async def test_token_refresh_success( ) +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (HomeConnectError(), ConfigEntryState.SETUP_RETRY), + (UnauthorizedError("error.key"), ConfigEntryState.SETUP_ERROR), + ], +) async def test_client_error( + exception: HomeConnectError, + expected_state: ConfigEntryState, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, @@ -224,10 +233,10 @@ async def test_client_error( ) -> None: """Test client errors during setup integration.""" client_with_exception.get_home_appliances.return_value = None - client_with_exception.get_home_appliances.side_effect = HomeConnectError() + client_with_exception.get_home_appliances.side_effect = exception assert config_entry.state == ConfigEntryState.NOT_LOADED assert not await integration_setup(client_with_exception) - assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert config_entry.state == expected_state assert client_with_exception.get_home_appliances.call_count == 1 From 6cebc0e25f3c8689ca4de620d18c16c98cb52bfd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 12:57:48 +0100 Subject: [PATCH 0169/1941] Explicitly pass in the config_entry in pvpc_hourly_pricing coordinator (#138032) explicitly pass in the config_entry in coordinator --- .../components/pvpc_hourly_pricing/coordinator.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/coordinator.py b/homeassistant/components/pvpc_hourly_pricing/coordinator.py index 171e516abdc..28e676d37ed 100644 --- a/homeassistant/components/pvpc_hourly_pricing/coordinator.py +++ b/homeassistant/components/pvpc_hourly_pricing/coordinator.py @@ -21,6 +21,8 @@ _LOGGER = logging.getLogger(__name__) class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): """Class to manage fetching Electricity prices data from API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] ) -> None: @@ -35,14 +37,17 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): sensor_keys=tuple(sensor_keys), ) super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(minutes=30), ) - self._entry = entry @property def entry_id(self) -> str: """Return entry ID.""" - return self._entry.entry_id + return self.config_entry.entry_id async def _async_update_data(self) -> EsiosApiData: """Update electricity prices from the ESIOS API.""" From 62f9d9e6d37b3d67815dd3aa7a463fef544ef86e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 9 Feb 2025 13:51:02 +0100 Subject: [PATCH 0170/1941] Bump aioshelly to version 12.4.2 (#137986) --- 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 4cfb49b680f..4c9927f515a 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.1"], + "requirements": ["aioshelly==12.4.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 27b6b091960..d6b95256f37 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.1 +aioshelly==12.4.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b6fd7f011f..f37799d1a56 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.1 +aioshelly==12.4.2 # homeassistant.components.skybell aioskybell==22.7.0 From 28f83cefda3d3e7d293ef066a05445618b24d85b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 13:55:55 +0100 Subject: [PATCH 0171/1941] Explicitly pass in the config_entry in openexchangerates coordinator (#138053) explicitly pass in the config_entry in coordinator --- homeassistant/components/openexchangerates/__init__.py | 1 + .../components/openexchangerates/coordinator.py | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openexchangerates/__init__.py b/homeassistant/components/openexchangerates/__init__.py index 65005235c6b..ed704a61fed 100644 --- a/homeassistant/components/openexchangerates/__init__.py +++ b/homeassistant/components/openexchangerates/__init__.py @@ -33,6 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval = BASE_UPDATE_INTERVAL * (len(existing_coordinator_for_api_key) + 1) coordinator = OpenexchangeratesCoordinator( hass, + entry, async_get_clientsession(hass), api_key, base, diff --git a/homeassistant/components/openexchangerates/coordinator.py b/homeassistant/components/openexchangerates/coordinator.py index 627e0d92e32..6245877ddbd 100644 --- a/homeassistant/components/openexchangerates/coordinator.py +++ b/homeassistant/components/openexchangerates/coordinator.py @@ -13,6 +13,7 @@ from aioopenexchangerates import ( OpenExchangeRatesClientError, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,9 +24,12 @@ from .const import CLIENT_TIMEOUT, DOMAIN, LOGGER class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): """Represent a coordinator for Open Exchange Rates API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, session: ClientSession, api_key: str, base: str, @@ -33,7 +37,11 @@ class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): ) -> None: """Initialize the coordinator.""" super().__init__( - hass, LOGGER, name=f"{DOMAIN} base {base}", update_interval=update_interval + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN} base {base}", + update_interval=update_interval, ) self.base = base self.client = Client(api_key, session) From 4d5987fa804ebbd5781c82d8009794dc8f9159ea Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 13:56:35 +0100 Subject: [PATCH 0172/1941] Explicitly pass in the config_entry in philips_js coordinator (#138042) explicitly pass in the config_entry in coordinator --- homeassistant/components/philips_js/__init__.py | 7 ++----- .../components/philips_js/binary_sensor.py | 3 +-- .../components/philips_js/coordinator.py | 15 +++++++++------ .../components/philips_js/diagnostics.py | 2 +- homeassistant/components/philips_js/light.py | 3 +-- .../components/philips_js/media_player.py | 4 ++-- homeassistant/components/philips_js/remote.py | 4 ++-- homeassistant/components/philips_js/switch.py | 3 +-- 8 files changed, 19 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 93f869e849d..9ff101915b8 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -7,7 +7,6 @@ import logging from haphilipsjs import PhilipsTV from haphilipsjs.typing import SystemType -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_VERSION, CONF_HOST, @@ -18,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .const import CONF_SYSTEM -from .coordinator import PhilipsTVDataUpdateCoordinator +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -30,8 +29,6 @@ PLATFORMS = [ LOGGER = logging.getLogger(__name__) -PhilipsTVConfigEntry = ConfigEntry[PhilipsTVDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PhilipsTVConfigEntry) -> bool: """Set up Philips TV from a config entry.""" @@ -44,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PhilipsTVConfigEntry) -> password=entry.data.get(CONF_PASSWORD), system=system, ) - coordinator = PhilipsTVDataUpdateCoordinator(hass, tvapi, entry.options) + coordinator = PhilipsTVDataUpdateCoordinator(hass, entry, tvapi) await coordinator.async_refresh() diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 6de814efd97..eef91513efe 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -13,8 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity diff --git a/homeassistant/components/philips_js/coordinator.py b/homeassistant/components/philips_js/coordinator.py index cae59fa5123..f450e971093 100644 --- a/homeassistant/components/philips_js/coordinator.py +++ b/homeassistant/components/philips_js/coordinator.py @@ -3,10 +3,8 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping from datetime import timedelta import logging -from typing import Any from haphilipsjs import ( AutenticationFailure, @@ -27,23 +25,28 @@ from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN _LOGGER = logging.getLogger(__name__) +type PhilipsTVConfigEntry = ConfigEntry[PhilipsTVDataUpdateCoordinator] + class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): """Coordinator to update data.""" - config_entry: ConfigEntry + config_entry: PhilipsTVConfigEntry def __init__( - self, hass: HomeAssistant, api: PhilipsTV, options: Mapping[str, Any] + self, + hass: HomeAssistant, + config_entry: PhilipsTVConfigEntry, + api: PhilipsTV, ) -> None: """Set up the coordinator.""" self.api = api - self.options = options self._notify_future: asyncio.Task | None = None super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), request_refresh_debouncer=Debouncer( @@ -91,7 +94,7 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): self.api.on and self.api.powerstate == "On" and self.api.notify_change_supported - and self.options.get(CONF_ALLOW_NOTIFY, False) + and self.config_entry.options.get(CONF_ALLOW_NOTIFY, False) ) async def _notify_task(self): diff --git a/homeassistant/components/philips_js/diagnostics.py b/homeassistant/components/philips_js/diagnostics.py index 625b77f6c25..99b27b2c85a 100644 --- a/homeassistant/components/philips_js/diagnostics.py +++ b/homeassistant/components/philips_js/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import PhilipsTVConfigEntry +from .coordinator import PhilipsTVConfigEntry TO_REDACT = { "serialnumber_encrypted", diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index 1d63b2062e6..5c4f629aea4 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -21,8 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv -from . import PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity EFFECT_PARTITION = ": " diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index bd8727ae9c1..a1ed3e4c168 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -21,8 +21,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from . import LOGGER as _LOGGER, PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from . import LOGGER as _LOGGER +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity from .helpers import async_get_turn_on_trigger diff --git a/homeassistant/components/philips_js/remote.py b/homeassistant/components/philips_js/remote.py index f8d9cb0885d..a573a2946fe 100644 --- a/homeassistant/components/philips_js/remote.py +++ b/homeassistant/components/philips_js/remote.py @@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from . import LOGGER, PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from . import LOGGER +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity from .helpers import async_get_turn_on_trigger diff --git a/homeassistant/components/philips_js/switch.py b/homeassistant/components/philips_js/switch.py index b35b2ad4ff1..fd7add5122d 100644 --- a/homeassistant/components/philips_js/switch.py +++ b/homeassistant/components/philips_js/switch.py @@ -8,8 +8,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PhilipsTVConfigEntry -from .coordinator import PhilipsTVDataUpdateCoordinator +from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity HUE_POWER_OFF = "Off" From 0ecff272f2179b41a0f7bbf7281430f2b607d5f5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:04:14 +0100 Subject: [PATCH 0173/1941] Explicitly pass in the config_entry in withings coordinator (#137866) explicitly pass in the config_entry in coordinator --- homeassistant/components/withings/__init__.py | 18 ++++--- .../components/withings/coordinator.py | 53 +++++++++++++++---- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 59c3ed8433f..1392b72f16b 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -120,13 +120,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> client.refresh_token_function = _refresh_token withings_data = WithingsData( client=client, - measurement_coordinator=WithingsMeasurementDataUpdateCoordinator(hass, client), - sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), - bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), - goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), - activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), - workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client), - device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, client), + measurement_coordinator=WithingsMeasurementDataUpdateCoordinator( + hass, entry, client + ), + sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, entry, client), + bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator( + hass, entry, client + ), + goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, entry, client), + activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, entry, client), + workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, entry, client), + device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, entry, client), ) for coordinator in withings_data.coordinators: diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 79419ae23ff..13789816d85 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -44,11 +44,17 @@ class WithingsDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): webhooks_connected: bool = False coordinator_name: str = "" - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="", update_interval=self._default_update_interval, ) @@ -95,9 +101,14 @@ class WithingsMeasurementDataUpdateCoordinator( coordinator_name: str = "measurements" - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.WEIGHT, NotificationCategory.PRESSURE, @@ -133,9 +144,14 @@ class WithingsSleepDataUpdateCoordinator( coordinator_name: str = "sleep" - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.SLEEP, } @@ -184,9 +200,14 @@ class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[Non in_bed: bool | None = None _default_update_interval = None - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.IN_BED, NotificationCategory.OUT_BED, @@ -226,9 +247,14 @@ class WithingsActivityDataUpdateCoordinator( coordinator_name: str = "activity" _previous_data: Activity | None = None - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.ACTIVITY, } @@ -265,9 +291,14 @@ class WithingsWorkoutDataUpdateCoordinator( coordinator_name: str = "workout" _previous_data: Workout | None = None - def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: WithingsConfigEntry, + client: WithingsClient, + ) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, client) + super().__init__(hass, config_entry, client) self.notification_categories = { NotificationCategory.ACTIVITY, } From f464aee33a60b4084194e9e1eb0842caf6fc813b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:04:34 +0100 Subject: [PATCH 0174/1941] Explicitly pass in the config_entry in wemo coordinator (#137867) explicitly pass in the config_entry in coordinator --- homeassistant/components/wemo/coordinator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index 1f25c12f7ca..0aaedf598d2 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -88,13 +88,17 @@ class Options: class DeviceCoordinator(DataUpdateCoordinator[None]): """Home Assistant wrapper for a pyWeMo device.""" + config_entry: ConfigEntry options: Options | None = None - def __init__(self, hass: HomeAssistant, wemo: WeMoDevice) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, wemo: WeMoDevice + ) -> None: """Initialize DeviceCoordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=wemo.name, update_interval=timedelta(seconds=30), ) @@ -285,7 +289,7 @@ async def async_register_device( hass: HomeAssistant, config_entry: ConfigEntry, wemo: WeMoDevice ) -> DeviceCoordinator: """Register a device with home assistant and enable pywemo event callbacks.""" - device = DeviceCoordinator(hass, wemo) + device = DeviceCoordinator(hass, config_entry, wemo) await device.async_refresh() if not device.last_update_success and device.last_exception: raise device.last_exception From 282c2c6a29e7f68ad947118e9868e81e347e14ae Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:24:43 +0100 Subject: [PATCH 0175/1941] Explicitly pass in the config_entry in octoprint coordinator (#138056) explicitly pass in the config_entry in coordinator --- homeassistant/components/octoprint/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/octoprint/coordinator.py b/homeassistant/components/octoprint/coordinator.py index d4f8f652b80..bb006329ff1 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -39,10 +39,10 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"octoprint-{config_entry.entry_id}", update_interval=timedelta(seconds=interval), ) - self.config_entry = config_entry self._octoprint = octoprint self._printer_offline = False self.data = {"printer": None, "job": None, "last_read_time": None} From a2f150194328007aa2671002aa37cc9d541cf6de Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:24:55 +0100 Subject: [PATCH 0176/1941] Explicitly pass in the config_entry in yamaha_musiccast coordinator (#137863) explicitly pass in the config_entry in coordinator --- .../components/yamaha_musiccast/__init__.py | 2 +- .../components/yamaha_musiccast/coordinator.py | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index a2ce98dde56..3e890c8b943 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_get_clientsession(hass), entry.data[CONF_UPNP_DESC], ) - coordinator = MusicCastDataUpdateCoordinator(hass, client=client) + coordinator = MusicCastDataUpdateCoordinator(hass, entry, client=client) await coordinator.async_config_entry_first_refresh() coordinator.musiccast.build_capabilities() diff --git a/homeassistant/components/yamaha_musiccast/coordinator.py b/homeassistant/components/yamaha_musiccast/coordinator.py index d5e0c67310a..13afbe3aa5e 100644 --- a/homeassistant/components/yamaha_musiccast/coordinator.py +++ b/homeassistant/components/yamaha_musiccast/coordinator.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from aiomusiccast import MusicCastConnectionException from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,11 +26,21 @@ SCAN_INTERVAL = timedelta(seconds=60) class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): """Class to manage fetching data from the API.""" - def __init__(self, hass: HomeAssistant, client: MusicCastDevice) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, client: MusicCastDevice + ) -> None: """Initialize.""" self.musiccast = client - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) self.entities: list[MusicCastDeviceEntity] = [] async def _async_update_data(self) -> MusicCastData: From e092937c004e196f4afddf6ce9100b73e3455059 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:25:06 +0100 Subject: [PATCH 0177/1941] Explicitly pass in the config_entry in version coordinator (#137877) explicitly pass in the config_entry in coordinator --- homeassistant/components/version/__init__.py | 6 ++---- homeassistant/components/version/binary_sensor.py | 2 +- homeassistant/components/version/coordinator.py | 6 +++++- homeassistant/components/version/diagnostics.py | 2 +- homeassistant/components/version/sensor.py | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/version/__init__.py b/homeassistant/components/version/__init__.py index cf13821dc8a..6fabf97c8dd 100644 --- a/homeassistant/components/version/__init__.py +++ b/homeassistant/components/version/__init__.py @@ -6,7 +6,6 @@ import logging from pyhaversion import HaVersion -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -18,12 +17,10 @@ from .const import ( CONF_SOURCE, PLATFORMS, ) -from .coordinator import VersionDataUpdateCoordinator +from .coordinator import VersionConfigEntry, VersionDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -type VersionConfigEntry = ConfigEntry[VersionDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: VersionConfigEntry) -> bool: """Set up the version integration from a config entry.""" @@ -40,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VersionConfigEntry) -> b coordinator = VersionDataUpdateCoordinator( hass=hass, + config_entry=entry, api=HaVersion( session=async_get_clientsession(hass), source=entry.data[CONF_SOURCE], diff --git a/homeassistant/components/version/binary_sensor.py b/homeassistant/components/version/binary_sensor.py index 827029e1d8c..6fafd219417 100644 --- a/homeassistant/components/version/binary_sensor.py +++ b/homeassistant/components/version/binary_sensor.py @@ -13,8 +13,8 @@ from homeassistant.const import CONF_NAME, EntityCategory, __version__ as HA_VER from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VersionConfigEntry from .const import CONF_SOURCE, DEFAULT_NAME +from .coordinator import VersionConfigEntry from .entity import VersionEntity HA_VERSION_OBJECT = AwesomeVersion(HA_VERSION) diff --git a/homeassistant/components/version/coordinator.py b/homeassistant/components/version/coordinator.py index 05adf07642b..349ede53d33 100644 --- a/homeassistant/components/version/coordinator.py +++ b/homeassistant/components/version/coordinator.py @@ -14,21 +14,25 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, UPDATE_COORDINATOR_UPDATE_INTERVAL +type VersionConfigEntry = ConfigEntry[VersionDataUpdateCoordinator] + class VersionDataUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for Version entities.""" - config_entry: ConfigEntry + config_entry: VersionConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: VersionConfigEntry, api: HaVersion, ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=UPDATE_COORDINATOR_UPDATE_INTERVAL, ) diff --git a/homeassistant/components/version/diagnostics.py b/homeassistant/components/version/diagnostics.py index ca7318f468b..bcc94bd8da4 100644 --- a/homeassistant/components/version/diagnostics.py +++ b/homeassistant/components/version/diagnostics.py @@ -9,7 +9,7 @@ from attr import asdict from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import VersionConfigEntry +from .coordinator import VersionConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index e1d552bcd36..d44625d38f8 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import VersionConfigEntry from .const import CONF_SOURCE, DEFAULT_NAME +from .coordinator import VersionConfigEntry from .entity import VersionEntity From 0cbec3c4bb71bc73a0b806faa20055f36681a5c7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:25:27 +0100 Subject: [PATCH 0178/1941] Explicitly pass in the config_entry in vallox coordinator (#137881) explicitly pass in the config_entry in coordinator --- homeassistant/components/vallox/__init__.py | 2 +- homeassistant/components/vallox/coordinator.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index ceb34bc6ff9..785ecd09fb1 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -111,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = Vallox(host) - coordinator = ValloxDataUpdateCoordinator(hass, name, client) + coordinator = ValloxDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/vallox/coordinator.py b/homeassistant/components/vallox/coordinator.py index c2485c7b4fd..2fe7fa533db 100644 --- a/homeassistant/components/vallox/coordinator.py +++ b/homeassistant/components/vallox/coordinator.py @@ -6,6 +6,8 @@ import logging from vallox_websocket_api import MetricData, Vallox, ValloxApiException +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,17 +19,20 @@ _LOGGER = logging.getLogger(__name__) class ValloxDataUpdateCoordinator(DataUpdateCoordinator[MetricData]): """The DataUpdateCoordinator for Vallox.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - name: str, + config_entry: ConfigEntry, client: Vallox, ) -> None: """Initialize Vallox data coordinator.""" super().__init__( hass, _LOGGER, - name=f"{name} DataUpdateCoordinator", + config_entry=config_entry, + name=f"{config_entry.data[CONF_NAME]} DataUpdateCoordinator", update_interval=STATE_SCAN_INTERVAL, ) self.client = client From b54b90a60452b58c247f0e9bbac719dbea99b2d3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:25:43 +0100 Subject: [PATCH 0179/1941] Explicitly pass in the config_entry in ukraine_alarm coordinator (#137886) explicitly pass in the config_entry in coordinator --- .../components/ukraine_alarm/__init__.py | 5 +---- .../components/ukraine_alarm/coordinator.py | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index d850ed6eba8..3658b821625 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_REGION from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -13,11 +12,9 @@ from .coordinator import UkraineAlarmDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ukraine Alarm as config entry.""" - region_id = entry.data[CONF_REGION] - websession = async_get_clientsession(hass) - coordinator = UkraineAlarmDataUpdateCoordinator(hass, websession, region_id) + coordinator = UkraineAlarmDataUpdateCoordinator(hass, entry, websession) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/ukraine_alarm/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py index fbf7c9f81c2..267358e4aa6 100644 --- a/homeassistant/components/ukraine_alarm/coordinator.py +++ b/homeassistant/components/ukraine_alarm/coordinator.py @@ -10,6 +10,8 @@ import aiohttp from aiohttp import ClientSession from uasiren.client import Client +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_REGION from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,17 +25,25 @@ UPDATE_INTERVAL = timedelta(seconds=10) class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Ukraine Alarm API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, session: ClientSession, - region_id: str, ) -> None: """Initialize.""" - self.region_id = region_id + self.region_id = config_entry.data[CONF_REGION] self.uasiren = Client(session) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" From 3153c54d1ab8f7d17a602a5a9e68c899119b29e6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:25:55 +0100 Subject: [PATCH 0180/1941] Explicitly pass in the config_entry in twinkly coordinator (#137889) explicitly pass in the config_entry in coordinator --- homeassistant/components/twinkly/__init__.py | 8 ++------ homeassistant/components/twinkly/coordinator.py | 9 ++++++++- homeassistant/components/twinkly/diagnostics.py | 2 +- homeassistant/components/twinkly/light.py | 2 +- homeassistant/components/twinkly/select.py | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index cd29ffaf423..e3b53bba6c9 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -5,23 +5,19 @@ import logging from aiohttp import ClientError from ttls.client import Twinkly -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -from .coordinator import TwinklyCoordinator +from .coordinator import TwinklyConfigEntry, TwinklyCoordinator PLATFORMS = [Platform.LIGHT, Platform.SELECT] _LOGGER = logging.getLogger(__name__) -type TwinklyConfigEntry = ConfigEntry[TwinklyCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool: """Set up entries from config flow.""" # We setup the client here so if at some point we add any other entity for this device, @@ -30,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> b client = Twinkly(host, async_get_clientsession(hass)) - coordinator = TwinklyCoordinator(hass, client) + coordinator = TwinklyCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/twinkly/coordinator.py b/homeassistant/components/twinkly/coordinator.py index 627fb0b39ba..2c2fc2a41d4 100644 --- a/homeassistant/components/twinkly/coordinator.py +++ b/homeassistant/components/twinkly/coordinator.py @@ -9,6 +9,7 @@ from aiohttp import ClientError from awesomeversion import AwesomeVersion from ttls.client import Twinkly, TwinklyError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,6 +18,8 @@ from .const import DEV_NAME, DOMAIN, MIN_EFFECT_VERSION _LOGGER = logging.getLogger(__name__) +type TwinklyConfigEntry = ConfigEntry[TwinklyCoordinator] + @dataclass class TwinklyData: @@ -33,15 +36,19 @@ class TwinklyData: class TwinklyCoordinator(DataUpdateCoordinator[TwinklyData]): """Class to manage fetching Twinkly data from API.""" + config_entry: TwinklyConfigEntry software_version: str supports_effects: bool device_name: str - def __init__(self, hass: HomeAssistant, client: Twinkly) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: TwinklyConfigEntry, client: Twinkly + ) -> None: """Initialize global Twinkly data updater.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py index d732ce14929..2bf46a208e8 100644 --- a/homeassistant/components/twinkly/diagnostics.py +++ b/homeassistant/components/twinkly/diagnostics.py @@ -10,8 +10,8 @@ from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, CONF_IP_ADDRESS, CON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import TwinklyConfigEntry from .const import DOMAIN +from .coordinator import TwinklyConfigEntry TO_REDACT = [CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 31e95d70fc0..5ce731d158f 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -17,8 +17,8 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TwinklyConfigEntry, TwinklyCoordinator from .const import DEV_LED_PROFILE, DEV_PROFILE_RGB, DEV_PROFILE_RGBW +from .coordinator import TwinklyConfigEntry, TwinklyCoordinator from .entity import TwinklyEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index 38e5c9a6fc7..a97424b4b8b 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -10,7 +10,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TwinklyConfigEntry, TwinklyCoordinator +from .coordinator import TwinklyConfigEntry, TwinklyCoordinator from .entity import TwinklyEntity _LOGGER = logging.getLogger(__name__) From cce03d2ee75028984864f214de17007ae8bc01f9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:26:09 +0100 Subject: [PATCH 0181/1941] Explicitly pass in the config_entry in transmission coordinator (#137891) explicitly pass in the config_entry in coordinator --- homeassistant/components/transmission/__init__.py | 14 ++++++++------ .../components/transmission/coordinator.py | 11 ++++++++--- homeassistant/components/transmission/sensor.py | 3 +-- homeassistant/components/transmission/switch.py | 3 +-- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 578488dad1a..6d23017ab75 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -15,7 +15,7 @@ from transmission_rpc.error import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_ID, @@ -54,7 +54,7 @@ from .const import ( SERVICE_START_TORRENT, SERVICE_STOP_TORRENT, ) -from .coordinator import TransmissionDataUpdateCoordinator +from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator from .errors import AuthenticationError, CannotConnect, UnknownError _LOGGER = logging.getLogger(__name__) @@ -117,8 +117,6 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All( CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Transmission component.""" @@ -167,12 +165,16 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: TransmissionConfigEntry +) -> bool: """Unload Transmission Entry from config_entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: TransmissionConfigEntry +) -> bool: """Migrate an old config entry.""" _LOGGER.debug( "Migrating from version %s.%s", diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index b998ab6fbdd..afe2660e711 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -27,17 +27,21 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator] + class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): """Transmission dataupdate coordinator class.""" - config_entry: ConfigEntry + config_entry: TransmissionConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, api: transmission_rpc.Client + self, + hass: HomeAssistant, + entry: TransmissionConfigEntry, + api: transmission_rpc.Client, ) -> None: """Initialize the Transmission RPC API.""" - self.config_entry = entry self.api = api self.host = entry.data[CONF_HOST] self._session: transmission_rpc.Session | None = None @@ -47,6 +51,7 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): self.torrents: list[transmission_rpc.Torrent] = [] super().__init__( hass, + config_entry=entry, name=f"{DOMAIN} - {self.host}", logger=_LOGGER, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 652f5d51fbb..bae9e7f3cc7 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -21,7 +21,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TransmissionConfigEntry from .const import ( DOMAIN, STATE_ATTR_TORRENT_INFO, @@ -30,7 +29,7 @@ from .const import ( STATE_UP_DOWN, SUPPORTED_ORDER_MODES, ) -from .coordinator import TransmissionDataUpdateCoordinator +from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator MODES: dict[str, list[str] | None] = { "started_torrents": ["downloading"], diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index d88f794cb10..d06932ff862 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -10,9 +10,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TransmissionConfigEntry from .const import DOMAIN -from .coordinator import TransmissionDataUpdateCoordinator +from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) From 7986e0fec1fe178a02339f7d55334e7a1365e23e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:26:27 +0100 Subject: [PATCH 0182/1941] Explicitly pass in the config_entry in tradfri coordinator (#137892) explicitly pass in the config_entry in coordinator --- homeassistant/components/tradfri/__init__.py | 2 +- homeassistant/components/tradfri/coordinator.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 92ed2ea8b82..2073829e021 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -106,7 +106,7 @@ async def async_setup_entry( for device in devices: coordinator = TradfriDeviceDataUpdateCoordinator( - hass=hass, api=api, device=device + hass=hass, config_entry=entry, api=api, device=device ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tradfri/coordinator.py b/homeassistant/components/tradfri/coordinator.py index 5246545ae65..4c5c186626e 100644 --- a/homeassistant/components/tradfri/coordinator.py +++ b/homeassistant/components/tradfri/coordinator.py @@ -10,6 +10,7 @@ from pytradfri.command import Command from pytradfri.device import Device from pytradfri.error import RequestError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,10 +22,12 @@ SCAN_INTERVAL = 60 # Interval for updating the coordinator class TradfriDeviceDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Coordinator to manage data for a specific Tradfri device.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, + config_entry: ConfigEntry, api: Callable[[Command | list[Command]], Any], device: Device, ) -> None: @@ -36,6 +39,7 @@ class TradfriDeviceDataUpdateCoordinator(DataUpdateCoordinator[Device]): super().__init__( hass, LOGGER, + config_entry=config_entry, name=f"Update coordinator for {device}", update_interval=timedelta(seconds=SCAN_INTERVAL), ) From 8a7ee039d1276bbc8b6b59d1325b197b5def3c1b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:26:39 +0100 Subject: [PATCH 0183/1941] Explicitly pass in the config_entry in traccar_server coordinator (#137893) explicitly pass in the config_entry in coordinator --- .../components/traccar_server/__init__.py | 13 ++------- .../components/traccar_server/coordinator.py | 27 ++++++++++++------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/traccar_server/__init__.py b/homeassistant/components/traccar_server/__init__.py index c7a65d2d4a8..44aeedc3376 100644 --- a/homeassistant/components/traccar_server/__init__.py +++ b/homeassistant/components/traccar_server/__init__.py @@ -21,13 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.event import async_track_time_interval -from .const import ( - CONF_CUSTOM_ATTRIBUTES, - CONF_EVENTS, - CONF_MAX_ACCURACY, - CONF_SKIP_ACCURACY_FILTER_FOR, - DOMAIN, -) +from .const import CONF_EVENTS, DOMAIN from .coordinator import TraccarServerCoordinator PLATFORMS: list[Platform] = [ @@ -47,6 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = TraccarServerCoordinator( hass=hass, + config_entry=entry, client=ApiClient( client_session=client_session, host=entry.data[CONF_HOST], @@ -56,10 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ssl=entry.data[CONF_SSL], verify_ssl=entry.data[CONF_VERIFY_SSL], ), - events=entry.options.get(CONF_EVENTS, []), - max_accuracy=entry.options.get(CONF_MAX_ACCURACY, 0.0), - skip_accuracy_filter_for=entry.options.get(CONF_SKIP_ACCURACY_FILTER_FOR, []), - custom_attributes=entry.options.get(CONF_CUSTOM_ATTRIBUTES, []), ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 95ce42469f1..2c878856cc2 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -22,7 +22,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN, EVENTS, LOGGER +from .const import ( + CONF_CUSTOM_ATTRIBUTES, + CONF_EVENTS, + CONF_MAX_ACCURACY, + CONF_SKIP_ACCURACY_FILTER_FOR, + DOMAIN, + EVENTS, + LOGGER, +) from .helpers import get_device, get_first_geofence @@ -46,25 +54,24 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, client: ApiClient, - *, - events: list[str], - max_accuracy: float, - skip_accuracy_filter_for: list[str], - custom_attributes: list[str], ) -> None: """Initialize global Traccar Server data updater.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=None, ) self.client = client - self.custom_attributes = custom_attributes - self.events = events - self.max_accuracy = max_accuracy - self.skip_accuracy_filter_for = skip_accuracy_filter_for + self.custom_attributes = config_entry.options.get(CONF_CUSTOM_ATTRIBUTES, []) + self.events = config_entry.options.get(CONF_EVENTS, []) + self.max_accuracy = config_entry.options.get(CONF_MAX_ACCURACY, 0.0) + self.skip_accuracy_filter_for = config_entry.options.get( + CONF_SKIP_ACCURACY_FILTER_FOR, [] + ) self._geofences: list[GeofenceModel] = [] self._last_event_import: datetime | None = None self._should_log_subscription_error: bool = True From d71a539fbc0457cb576151229b32a96c8c9de15e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:27:04 +0100 Subject: [PATCH 0184/1941] Explicitly pass in the config_entry in touchline_sl coordinator (#137897) explicitly pass in the config_entry in coordinator --- homeassistant/components/touchline_sl/__init__.py | 8 +++----- homeassistant/components/touchline_sl/climate.py | 3 +-- homeassistant/components/touchline_sl/coordinator.py | 11 ++++++++++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/touchline_sl/__init__.py b/homeassistant/components/touchline_sl/__init__.py index 45a85185673..ba1da06ed5a 100644 --- a/homeassistant/components/touchline_sl/__init__.py +++ b/homeassistant/components/touchline_sl/__init__.py @@ -6,18 +6,15 @@ import asyncio from pytouchlinesl import TouchlineSL -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import TouchlineSLModuleCoordinator +from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator PLATFORMS: list[Platform] = [Platform.CLIMATE] -type TouchlineSLConfigEntry = ConfigEntry[list[TouchlineSLModuleCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: TouchlineSLConfigEntry) -> bool: """Set up Roth Touchline SL from a config entry.""" @@ -26,7 +23,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TouchlineSLConfigEntry) ) coordinators: list[TouchlineSLModuleCoordinator] = [ - TouchlineSLModuleCoordinator(hass, module) for module in await account.modules() + TouchlineSLModuleCoordinator(hass, entry, module) + for module in await account.modules() ] await asyncio.gather( diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py index 8a0ffc4cd86..e7bb33311d0 100644 --- a/homeassistant/components/touchline_sl/climate.py +++ b/homeassistant/components/touchline_sl/climate.py @@ -12,8 +12,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TouchlineSLConfigEntry -from .coordinator import TouchlineSLModuleCoordinator +from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator from .entity import TouchlineSLZoneEntity diff --git a/homeassistant/components/touchline_sl/coordinator.py b/homeassistant/components/touchline_sl/coordinator.py index cd74ba6130f..dce616a81b3 100644 --- a/homeassistant/components/touchline_sl/coordinator.py +++ b/homeassistant/components/touchline_sl/coordinator.py @@ -10,6 +10,7 @@ from pytouchlinesl import Module, Zone from pytouchlinesl.client import RothAPIError from pytouchlinesl.client.models import GlobalScheduleModel +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,14 +27,22 @@ class TouchlineSLModuleData: schedules: dict[str, GlobalScheduleModel] +type TouchlineSLConfigEntry = ConfigEntry[list[TouchlineSLModuleCoordinator]] + + class TouchlineSLModuleCoordinator(DataUpdateCoordinator[TouchlineSLModuleData]): """A coordinator to manage the fetching of Touchline SL data.""" - def __init__(self, hass: HomeAssistant, module: Module) -> None: + config_entry: TouchlineSLConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: TouchlineSLConfigEntry, module: Module + ) -> None: """Initialize coordinator.""" super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=f"Touchline SL ({module.name})", update_interval=timedelta(seconds=30), ) From 89e29dd14f7b199926abcca840cb6d578c113901 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:32:36 +0100 Subject: [PATCH 0185/1941] Explicitly pass in the config_entry in tplink_omada coordinator (#137895) explicitly pass in the config_entry in coordinator --- .../components/tplink_omada/__init__.py | 2 +- .../components/tplink_omada/controller.py | 21 +++++++++--- .../components/tplink_omada/coordinator.py | 32 ++++++++++++++++--- .../components/tplink_omada/update.py | 4 ++- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 2d33a890510..06df118463b 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo ) from ex site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE])) - controller = OmadaSiteController(hass, site_client) + controller = OmadaSiteController(hass, entry, site_client) await controller.initialize_first_refresh() entry.runtime_data = controller diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index 658286981f9..60a07f76b23 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,10 +1,17 @@ """Controller for sharing Omada API coordinators between platforms.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from tplink_omada_client import OmadaSiteClient from tplink_omada_client.devices import OmadaSwitch from homeassistant.core import HomeAssistant +if TYPE_CHECKING: + from . import OmadaConfigEntry + from .coordinator import ( OmadaClientsCoordinator, OmadaDevicesCoordinator, @@ -21,15 +28,21 @@ class OmadaSiteController: def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, ) -> None: """Create the controller.""" self._hass = hass + self._config_entry = config_entry self._omada_client = omada_client self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {} - self._devices_coordinator = OmadaDevicesCoordinator(hass, omada_client) - self._clients_coordinator = OmadaClientsCoordinator(hass, omada_client) + self._devices_coordinator = OmadaDevicesCoordinator( + hass, config_entry, omada_client + ) + self._clients_coordinator = OmadaClientsCoordinator( + hass, config_entry, omada_client + ) async def initialize_first_refresh(self) -> None: """Initialize the all coordinators, and perform first refresh.""" @@ -39,7 +52,7 @@ class OmadaSiteController: gateway = next((d for d in devices if d.type == "gateway"), None) if gateway: self._gateway_coordinator = OmadaGatewayCoordinator( - self._hass, self._omada_client, gateway.mac + self._hass, self._config_entry, self._omada_client, gateway.mac ) await self._gateway_coordinator.async_config_entry_first_refresh() @@ -56,7 +69,7 @@ class OmadaSiteController: """Get coordinator for network port information of a given switch.""" if switch.mac not in self._switch_port_coordinators: self._switch_port_coordinators[switch.mac] = OmadaSwitchPortCoordinator( - self._hass, self._omada_client, switch + self._hass, self._config_entry, self._omada_client, switch ) return self._switch_port_coordinators[switch.mac] diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index a80bedeb65e..1552b568297 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,8 +1,11 @@ """Generic Omada API coordinator.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging +from typing import TYPE_CHECKING from tplink_omada_client import OmadaSiteClient, OmadaSwitchPortDetails from tplink_omada_client.clients import OmadaWirelessClient @@ -12,6 +15,9 @@ from tplink_omada_client.exceptions import OmadaClientException from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import OmadaConfigEntry + _LOGGER = logging.getLogger(__name__) POLL_SWITCH_PORT = 300 @@ -23,9 +29,12 @@ POLL_DEVICES = 300 class OmadaCoordinator[_T](DataUpdateCoordinator[dict[str, _T]]): """Coordinator for synchronizing bulk Omada data.""" + config_entry: OmadaConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, name: str, poll_delay: int | None = 300, @@ -34,6 +43,7 @@ class OmadaCoordinator[_T](DataUpdateCoordinator[dict[str, _T]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Omada API Data - {name}", update_interval=timedelta(seconds=poll_delay) if poll_delay else None, ) @@ -58,12 +68,17 @@ class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, network_switch: OmadaSwitch, ) -> None: """Initialize my coordinator.""" super().__init__( - hass, omada_client, f"{network_switch.name} Ports", POLL_SWITCH_PORT + hass, + config_entry, + omada_client, + f"{network_switch.name} Ports", + POLL_SWITCH_PORT, ) self._network_switch = network_switch @@ -79,11 +94,12 @@ class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, mac: str, ) -> None: """Initialize my coordinator.""" - super().__init__(hass, omada_client, "Gateway", POLL_GATEWAY) + super().__init__(hass, config_entry, omada_client, "Gateway", POLL_GATEWAY) self.mac = mac async def poll_update(self) -> dict[str, OmadaGateway]: @@ -98,10 +114,11 @@ class OmadaDevicesCoordinator(OmadaCoordinator[OmadaListDevice]): def __init__( self, hass: HomeAssistant, + config_entry: OmadaConfigEntry, omada_client: OmadaSiteClient, ) -> None: """Initialize my coordinator.""" - super().__init__(hass, omada_client, "DeviceList", POLL_CLIENTS) + super().__init__(hass, config_entry, omada_client, "DeviceList", POLL_CLIENTS) async def poll_update(self) -> dict[str, OmadaListDevice]: """Poll the site's current registered Omada devices.""" @@ -111,9 +128,14 @@ class OmadaDevicesCoordinator(OmadaCoordinator[OmadaListDevice]): class OmadaClientsCoordinator(OmadaCoordinator[OmadaWirelessClient]): """Coordinator for getting details about the site's connected clients.""" - def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: OmadaConfigEntry, + omada_client: OmadaSiteClient, + ) -> None: """Initialize my coordinator.""" - super().__init__(hass, omada_client, "ClientsList", POLL_CLIENTS) + super().__init__(hass, config_entry, omada_client, "ClientsList", POLL_CLIENTS) async def poll_update(self) -> dict[str, OmadaWirelessClient]: """Poll the site's current active wi-fi clients.""" diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 54b586794be..8b7fcfba394 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -43,7 +43,9 @@ class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): # devices_coordinator: OmadaDevicesCoordinator, ) -> None: """Initialize my coordinator.""" - super().__init__(hass, omada_client, "Firmware Updates", poll_delay=None) + super().__init__( + hass, config_entry, omada_client, "Firmware Updates", poll_delay=None + ) self._devices_coordinator = devices_coordinator self._config_entry = config_entry From 028c74e488af536783d9426a9a72d6e18f69f6e8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:33:13 +0100 Subject: [PATCH 0186/1941] Explicitly pass in the config_entry in totalconnect coordinator (#137898) explicitly pass in the config_entry in coordinator --- .../components/totalconnect/__init__.py | 7 ++----- .../totalconnect/alarm_control_panel.py | 7 ++++--- .../components/totalconnect/binary_sensor.py | 7 ++++--- homeassistant/components/totalconnect/button.py | 7 ++++--- .../components/totalconnect/coordinator.py | 17 ++++++++++++++--- .../components/totalconnect/diagnostics.py | 5 +++-- 6 files changed, 31 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index 9f291ea15a6..a481fd41c84 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -3,18 +3,15 @@ from total_connect_client.client import TotalConnectClient from total_connect_client.exceptions import AuthenticationError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from .const import AUTO_BYPASS, CONF_USERCODES -from .coordinator import TotalConnectDataUpdateCoordinator +from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON] -type TotalConnectConfigEntry = ConfigEntry[TotalConnectDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, entry: TotalConnectConfigEntry @@ -41,7 +38,7 @@ async def async_setup_entry( "TotalConnect authentication failed during setup" ) from exception - coordinator = TotalConnectDataUpdateCoordinator(hass, client) + coordinator = TotalConnectDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 021d1c7b886..7121e5bf806 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -12,14 +12,13 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CODE_REQUIRED, DOMAIN -from .coordinator import TotalConnectDataUpdateCoordinator +from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity SERVICE_ALARM_ARM_AWAY_INSTANT = "arm_away_instant" @@ -27,7 +26,9 @@ SERVICE_ALARM_ARM_HOME_INSTANT = "arm_home_instant" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TotalConnectConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up TotalConnect alarm panels based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 9a3c2558999..5a67385cd20 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -12,12 +12,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import TotalConnectDataUpdateCoordinator +from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity LOW_BATTERY = "low_battery" @@ -119,7 +118,9 @@ LOCATION_BINARY_SENSORS: tuple[TotalConnectAlarmBinarySensorEntityDescription, . async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TotalConnectConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up TotalConnect device sensors based on a config entry.""" sensors: list = [] diff --git a/homeassistant/components/totalconnect/button.py b/homeassistant/components/totalconnect/button.py index e228f03ec6b..7cdad00534d 100644 --- a/homeassistant/components/totalconnect/button.py +++ b/homeassistant/components/totalconnect/button.py @@ -7,12 +7,11 @@ from total_connect_client.location import TotalConnectLocation from total_connect_client.zone import TotalConnectZone from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import TotalConnectDataUpdateCoordinator +from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity @@ -38,7 +37,9 @@ PANEL_BUTTONS: tuple[TotalConnectButtonEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TotalConnectConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up TotalConnect buttons based on a config entry.""" buttons: list = [] diff --git a/homeassistant/components/totalconnect/coordinator.py b/homeassistant/components/totalconnect/coordinator.py index 9b500db1951..673c168d204 100644 --- a/homeassistant/components/totalconnect/coordinator.py +++ b/homeassistant/components/totalconnect/coordinator.py @@ -20,17 +20,28 @@ from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) +type TotalConnectConfigEntry = ConfigEntry[TotalConnectDataUpdateCoordinator] + class TotalConnectDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to fetch data from TotalConnect.""" - config_entry: ConfigEntry + config_entry: TotalConnectConfigEntry - def __init__(self, hass: HomeAssistant, client: TotalConnectClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: TotalConnectConfigEntry, + client: TotalConnectClient, + ) -> None: """Initialize.""" self.client = client super().__init__( - hass, logger=_LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL + hass, + logger=_LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, ) async def _async_update_data(self) -> None: diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py index 85f52ccc670..f42ed5e44c3 100644 --- a/homeassistant/components/totalconnect/diagnostics.py +++ b/homeassistant/components/totalconnect/diagnostics.py @@ -5,9 +5,10 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from .coordinator import TotalConnectConfigEntry + TO_REDACT = [ "username", "Password", @@ -22,7 +23,7 @@ TO_REDACT = [ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: TotalConnectConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" client = config_entry.runtime_data.client From 14733de68c07376d744f8b5178eb10cc7c20b648 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:33:25 +0100 Subject: [PATCH 0187/1941] Explicitly pass in the config_entry in tomorrowio coordinator (#137900) explicitly pass in the config_entry in coordinator --- homeassistant/components/tomorrowio/__init__.py | 2 +- homeassistant/components/tomorrowio/coordinator.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 73f62735e06..7d6b9ed3f73 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -29,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # we will not use the class's lat and long so we can pass in garbage # lats and longs api = TomorrowioV4(api_key, 361.0, 361.0, unit_system="metric", session=session) - coordinator = TomorrowioDataUpdateCoordinator(hass, api) + coordinator = TomorrowioDataUpdateCoordinator(hass, entry, api) hass.data[DOMAIN][api_key] = coordinator await coordinator.async_setup_entry(entry) diff --git a/homeassistant/components/tomorrowio/coordinator.py b/homeassistant/components/tomorrowio/coordinator.py index 60b997e4c0d..2a6b3675792 100644 --- a/homeassistant/components/tomorrowio/coordinator.py +++ b/homeassistant/components/tomorrowio/coordinator.py @@ -116,14 +116,23 @@ def async_set_update_interval( class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an object to hold Tomorrow.io data.""" - def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: TomorrowioV4 + ) -> None: """Initialize.""" self._api = api self.data = {CURRENT: {}, FORECASTS: {}} self.entry_id_to_location_dict: dict[str, str] = {} self._coordinator_ready: asyncio.Event | None = None - super().__init__(hass, LOGGER, name=f"{DOMAIN}_{self._api.api_key_masked}") + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{self._api.api_key_masked}", + ) def add_entry_to_location_dict(self, entry: ConfigEntry) -> None: """Add an entry to the location dict.""" From eb81c935ce18be64316411395d530b721e395fce Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:33:40 +0100 Subject: [PATCH 0188/1941] Explicitly pass in the config_entry in tolo coordinator (#137902) explicitly pass in the config_entry in coordinator --- homeassistant/components/tolo/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/tolo/coordinator.py b/homeassistant/components/tolo/coordinator.py index 632cc819f5a..729073b16c4 100644 --- a/homeassistant/components/tolo/coordinator.py +++ b/homeassistant/components/tolo/coordinator.py @@ -28,6 +28,8 @@ class ToloSaunaData(NamedTuple): class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): """DataUpdateCoordinator for TOLO Sauna.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize ToloSaunaUpdateCoordinator.""" self.client = ToloClient( @@ -38,6 +40,7 @@ class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): super().__init__( hass=hass, logger=_LOGGER, + config_entry=entry, name=f"{entry.title} ({entry.data[CONF_HOST]}) Data Update Coordinator", update_interval=timedelta(seconds=5), ) From 794143c32fb257917ba6580873ecf9f4bf423a35 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:33:54 +0100 Subject: [PATCH 0189/1941] Explicitly pass in the config_entry in tibber coordinator (#137904) explicitly pass in the config_entry in coordinator --- homeassistant/components/tibber/coordinator.py | 8 +++++++- homeassistant/components/tibber/sensor.py | 2 +- tests/components/tibber/test_statistics.py | 7 +++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 78841f9db91..2de9ebd1ec6 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -33,11 +33,17 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, tibber_connection: tibber.Tibber) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + tibber_connection: tibber.Tibber, + ) -> None: """Initialize the data handler.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Tibber {tibber_connection.name}", update_interval=timedelta(minutes=20), ) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index c1ec7bf2a9e..c14a62bb608 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -285,7 +285,7 @@ async def async_setup_entry( if home.has_active_subscription: entities.append(TibberSensorElPrice(home)) if coordinator is None: - coordinator = TibberDataCoordinator(hass, tibber_connection) + coordinator = TibberDataCoordinator(hass, entry, tibber_connection) entities.extend( TibberDataSensor(home, coordinator, entity_description) for entity_description in SENSORS diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index d817c9612aa..845df86a88c 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -10,10 +10,13 @@ from homeassistant.util import dt as dt_util from .test_common import CONSUMPTION_DATA_1, PRODUCTION_DATA_1, mock_get_homes +from tests.common import MockConfigEntry from tests.components.recorder.common import async_wait_recording_done -async def test_async_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_async_setup_entry( + recorder_mock: Recorder, hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Test setup Tibber.""" tibber_connection = AsyncMock() tibber_connection.name = "tibber" @@ -21,7 +24,7 @@ async def test_async_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) - tibber_connection.fetch_production_data_active_homes.return_value = None tibber_connection.get_homes = mock_get_homes - coordinator = TibberDataCoordinator(hass, tibber_connection) + coordinator = TibberDataCoordinator(hass, config_entry, tibber_connection) await coordinator._async_update_data() await async_wait_recording_done(hass) From 4031f85acc0094e883501427bf4cd6b5f61081ae Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:34:36 +0100 Subject: [PATCH 0190/1941] Explicitly pass in the config_entry in thethingsnetwork coordinator (#137905) explicitly pass in the config_entry in coordinator --- homeassistant/components/thethingsnetwork/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/thethingsnetwork/coordinator.py b/homeassistant/components/thethingsnetwork/coordinator.py index 64608c2f064..78ffceecf84 100644 --- a/homeassistant/components/thethingsnetwork/coordinator.py +++ b/homeassistant/components/thethingsnetwork/coordinator.py @@ -19,11 +19,14 @@ _LOGGER = logging.getLogger(__name__) class TTNCoordinator(DataUpdateCoordinator[TTNClient.DATA_TYPE]): """TTN coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=entry, # Name of the data. For logging purposes. name=f"TheThingsNetwork_{entry.data[CONF_APP_ID]}", # Polling interval. Will only be polled if there are subscribers. From 4646d35054a2611d5b42a5cbc8b0723ab26c7f93 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:37:31 +0100 Subject: [PATCH 0191/1941] Explicitly pass in the config_entry in venstar coordinator (#137880) * explicitly pass in the config_entry in coordinator * use common name config_entry --- homeassistant/components/venstar/__init__.py | 25 +++++++++++-------- .../components/venstar/coordinator.py | 6 ++++- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 3243c7a6f47..faa47bfc8e4 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -21,14 +21,14 @@ from .coordinator import VenstarDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Venstar thermostat.""" - username = config.data.get(CONF_USERNAME) - password = config.data.get(CONF_PASSWORD) - pin = config.data.get(CONF_PIN) - host = config.data[CONF_HOST] + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + pin = config_entry.data.get(CONF_PIN) + host = config_entry.data[CONF_HOST] timeout = VENSTAR_TIMEOUT - protocol = "https" if config.data[CONF_SSL] else "http" + protocol = "https" if config_entry.data[CONF_SSL] else "http" client = VenstarColorTouch( addr=host, @@ -41,19 +41,22 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: venstar_data_coordinator = VenstarDataUpdateCoordinator( hass, + config_entry, venstar_connection=client, ) await venstar_data_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[config.entry_id] = venstar_data_coordinator - await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = venstar_data_coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload the config and platforms.""" - unload_ok = await hass.config_entries.async_unload_platforms(config, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) if unload_ok: - hass.data[DOMAIN].pop(config.entry_id) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok diff --git a/homeassistant/components/venstar/coordinator.py b/homeassistant/components/venstar/coordinator.py index b825775de7f..1d0ff60c1e0 100644 --- a/homeassistant/components/venstar/coordinator.py +++ b/homeassistant/components/venstar/coordinator.py @@ -8,6 +8,7 @@ from datetime import timedelta from requests import RequestException from venstarcolortouch import VenstarColorTouch +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import update_coordinator @@ -17,16 +18,19 @@ from .const import _LOGGER, DOMAIN, VENSTAR_SLEEP class VenstarDataUpdateCoordinator(update_coordinator.DataUpdateCoordinator[None]): """Class to manage fetching Venstar data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, + config_entry: ConfigEntry, venstar_connection: VenstarColorTouch, ) -> None: """Initialize global Venstar data updater.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), ) From 0baa6b366829d8d5592ff730b80007e7876a8962 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:42:04 +0100 Subject: [PATCH 0192/1941] Explicitly pass in the config_entry in tessie coordinator (#137906) explicitly pass in the config_entry in coordinator --- homeassistant/components/tessie/__init__.py | 9 +++++-- .../components/tessie/coordinator.py | 25 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index a0bc58896e4..f73ecc7a729 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -69,6 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo vin=vehicle["vin"], data_coordinator=TessieStateUpdateCoordinator( hass, + entry, api_key=api_key, vin=vehicle["vin"], data=vehicle["last_state"], @@ -127,8 +128,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo TessieEnergyData( api=api, id=site_id, - live_coordinator=TessieEnergySiteLiveCoordinator(hass, api), - info_coordinator=TessieEnergySiteInfoCoordinator(hass, api), + live_coordinator=TessieEnergySiteLiveCoordinator( + hass, entry, api + ), + info_coordinator=TessieEnergySiteInfoCoordinator( + hass, entry, api + ), device=DeviceInfo( identifiers={(DOMAIN, str(site_id))}, manufacturer="Tesla", diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py index 4582260bfb2..b06fe6123a5 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -1,9 +1,11 @@ """Tessie Data Coordinator.""" +from __future__ import annotations + from datetime import timedelta from http import HTTPStatus import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp import ClientResponseError from tesla_fleet_api import EnergySpecific @@ -15,6 +17,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import TessieConfigEntry + from .const import TessieStatus # This matches the update interval Tessie performs server side @@ -40,9 +45,12 @@ def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Tessie API.""" + config_entry: TessieConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: TessieConfigEntry, api_key: str, vin: str, data: dict[str, Any], @@ -51,6 +59,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Tessie", update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), ) @@ -90,11 +99,16 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the Tessie API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + config_entry: TessieConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + ) -> None: """Initialize Tessie Energy Site Live coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Tessie Energy Site Live", update_interval=TESSIE_FLEET_API_SYNC_INTERVAL, ) @@ -121,11 +135,16 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): class TessieEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Tessie API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + config_entry: TessieConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific + ) -> None: """Initialize Tessie Energy Info coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Tessie Energy Site Info", update_interval=TESSIE_FLEET_API_SYNC_INTERVAL, ) From 4c331d3942fd7f7b4888ed4c23c0ae6dee9af40c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:43:29 +0100 Subject: [PATCH 0193/1941] Explicitly pass in the config_entry in qnap_qsw coordinator (#138027) explicitly pass in the config_entry in coordinator --- homeassistant/components/qnap_qsw/__init__.py | 4 ++-- homeassistant/components/qnap_qsw/coordinator.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/qnap_qsw/__init__.py b/homeassistant/components/qnap_qsw/__init__.py index d7352435b07..f9faca025a5 100644 --- a/homeassistant/components/qnap_qsw/__init__.py +++ b/homeassistant/components/qnap_qsw/__init__.py @@ -35,10 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: qsw = QnapQswApi(aiohttp_client.async_get_clientsession(hass), options) - coord_data = QswDataCoordinator(hass, qsw) + coord_data = QswDataCoordinator(hass, entry, qsw) await coord_data.async_config_entry_first_refresh() - coord_fw = QswFirmwareCoordinator(hass, qsw) + coord_fw = QswFirmwareCoordinator(hass, entry, qsw) try: await coord_fw.async_config_entry_first_refresh() except ConfigEntryNotReady as error: diff --git a/homeassistant/components/qnap_qsw/coordinator.py b/homeassistant/components/qnap_qsw/coordinator.py index c873f2a773d..b72bed7105c 100644 --- a/homeassistant/components/qnap_qsw/coordinator.py +++ b/homeassistant/components/qnap_qsw/coordinator.py @@ -10,6 +10,7 @@ from typing import Any from aioqsw.exceptions import QswError from aioqsw.localapi import QnapQswApi +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,13 +25,18 @@ _LOGGER = logging.getLogger(__name__) class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the QNAP QSW device.""" - def __init__(self, hass: HomeAssistant, qsw: QnapQswApi) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, qsw: QnapQswApi + ) -> None: """Initialize.""" self.qsw = qsw super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=DATA_SCAN_INTERVAL, ) @@ -48,13 +54,18 @@ class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class QswFirmwareCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching firmware data from the QNAP QSW device.""" - def __init__(self, hass: HomeAssistant, qsw: QnapQswApi) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, qsw: QnapQswApi + ) -> None: """Initialize.""" self.qsw = qsw super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=FW_SCAN_INTERVAL, ) From ac3eead8ac125b65b878b17d5836ed81c468e6ce Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:44:37 +0100 Subject: [PATCH 0194/1941] Explicitly pass in the config_entry in ping coordinator (#138041) explicitly pass in the config_entry in coordinator --- homeassistant/components/ping/__init__.py | 8 ++------ homeassistant/components/ping/binary_sensor.py | 6 ++---- homeassistant/components/ping/coordinator.py | 6 ++++++ homeassistant/components/ping/device_tracker.py | 6 ++---- homeassistant/components/ping/entity.py | 5 ++--- homeassistant/components/ping/sensor.py | 3 +-- 6 files changed, 15 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 4b03e5e4407..14203541359 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -6,7 +6,6 @@ import logging from icmplib import SocketPermissionError, async_ping -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -14,7 +13,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from .const import CONF_PING_COUNT, DOMAIN -from .coordinator import PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingUpdateCoordinator from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) @@ -24,9 +23,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR] DATA_PRIVILEGED_KEY: HassKey[bool | None] = HassKey(DOMAIN) -type PingConfigEntry = ConfigEntry[PingUpdateCoordinator] - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ping integration.""" hass.data[DATA_PRIVILEGED_KEY] = await _can_use_icmp_lib_with_privilege() @@ -47,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool ping_cls = PingDataICMPLib coordinator = PingUpdateCoordinator( - hass=hass, ping=ping_cls(hass, host, count, privileged) + hass=hass, config_entry=entry, ping=ping_cls(hass, host, count, privileged) ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 5c50e4335f9..060d2532309 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -6,13 +6,11 @@ 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 . import PingConfigEntry from .const import CONF_IMPORTED_BY -from .coordinator import PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingUpdateCoordinator from .entity import PingEntity @@ -31,7 +29,7 @@ class PingBinarySensor(PingEntity, BinarySensorEntity): _attr_name = None def __init__( - self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + self, config_entry: PingConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping Binary sensor.""" super().__init__(config_entry, coordinator, config_entry.entry_id) diff --git a/homeassistant/components/ping/coordinator.py b/homeassistant/components/ping/coordinator.py index 38ab2e79ffc..afb7de4dce3 100644 --- a/homeassistant/components/ping/coordinator.py +++ b/homeassistant/components/ping/coordinator.py @@ -7,6 +7,7 @@ from datetime import timedelta import logging from typing import Any +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,6 +15,8 @@ from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) +type PingConfigEntry = ConfigEntry[PingUpdateCoordinator] + @dataclass(slots=True, frozen=True) class PingResult: @@ -27,11 +30,13 @@ class PingResult: class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): """The Ping update coordinator.""" + config_entry: PingConfigEntry ping: PingDataSubProcess | PingDataICMPLib def __init__( self, hass: HomeAssistant, + config_entry: PingConfigEntry, ping: PingDataSubProcess | PingDataICMPLib, ) -> None: """Initialize the Ping coordinator.""" @@ -40,6 +45,7 @@ class PingUpdateCoordinator(DataUpdateCoordinator[PingResult]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"Ping {ping.ip_address}", update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 29a4e922234..43969aaac03 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -9,15 +9,13 @@ from homeassistant.components.device_tracker import ( DEFAULT_CONSIDER_HOME, ScannerEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import PingConfigEntry from .const import CONF_IMPORTED_BY -from .coordinator import PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingUpdateCoordinator async def async_setup_entry( @@ -33,7 +31,7 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) _last_seen: datetime | None = None def __init__( - self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator + self, config_entry: PingConfigEntry, coordinator: PingUpdateCoordinator ) -> None: """Initialize the Ping device tracker.""" super().__init__(coordinator) diff --git a/homeassistant/components/ping/entity.py b/homeassistant/components/ping/entity.py index a1f84f6ef32..d592ef6b549 100644 --- a/homeassistant/components/ping/entity.py +++ b/homeassistant/components/ping/entity.py @@ -1,11 +1,10 @@ """Base entity for the Ping component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .coordinator import PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingUpdateCoordinator class PingEntity(CoordinatorEntity[PingUpdateCoordinator]): @@ -15,7 +14,7 @@ class PingEntity(CoordinatorEntity[PingUpdateCoordinator]): def __init__( self, - config_entry: ConfigEntry, + config_entry: PingConfigEntry, coordinator: PingUpdateCoordinator, unique_id: str, ) -> None: diff --git a/homeassistant/components/ping/sensor.py b/homeassistant/components/ping/sensor.py index 6e6c4cf2cde..afd6f53db7c 100644 --- a/homeassistant/components/ping/sensor.py +++ b/homeassistant/components/ping/sensor.py @@ -14,8 +14,7 @@ from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PingConfigEntry -from .coordinator import PingResult, PingUpdateCoordinator +from .coordinator import PingConfigEntry, PingResult, PingUpdateCoordinator from .entity import PingEntity From 242bbaeff9a68d79a00e1c24b17cf9bd2e1764e0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:48:15 +0100 Subject: [PATCH 0195/1941] Explicitly pass in the config_entry in technove coordinator (#137910) explicitly pass in the config_entry in coordinator --- homeassistant/components/technove/__init__.py | 7 ++----- homeassistant/components/technove/binary_sensor.py | 3 +-- homeassistant/components/technove/coordinator.py | 13 +++++++------ homeassistant/components/technove/diagnostics.py | 2 +- homeassistant/components/technove/number.py | 3 +-- homeassistant/components/technove/sensor.py | 3 +-- homeassistant/components/technove/switch.py | 3 +-- 7 files changed, 14 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py index b886dabc80c..df4fc7713aa 100644 --- a/homeassistant/components/technove/__init__.py +++ b/homeassistant/components/technove/__init__.py @@ -2,16 +2,13 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] -TechnoVEConfigEntry = ConfigEntry[TechnoVEDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: TechnoVEConfigEntry) -> bool: """Set up TechnoVE from a config entry.""" @@ -25,6 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TechnoVEConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: TechnoVEConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py index f231e206c96..4c0e1111e9a 100644 --- a/homeassistant/components/technove/binary_sensor.py +++ b/homeassistant/components/technove/binary_sensor.py @@ -16,8 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TechnoVEConfigEntry -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity diff --git a/homeassistant/components/technove/coordinator.py b/homeassistant/components/technove/coordinator.py index 8527c6e543a..53108463301 100644 --- a/homeassistant/components/technove/coordinator.py +++ b/homeassistant/components/technove/coordinator.py @@ -2,10 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from technove import Station as TechnoVEStation, TechnoVE, TechnoVEError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -13,22 +12,24 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, SCAN_INTERVAL -if TYPE_CHECKING: - from . import TechnoVEConfigEntry +type TechnoVEConfigEntry = ConfigEntry[TechnoVEDataUpdateCoordinator] class TechnoVEDataUpdateCoordinator(DataUpdateCoordinator[TechnoVEStation]): """Class to manage fetching TechnoVE data from single endpoint.""" - def __init__(self, hass: HomeAssistant, entry: TechnoVEConfigEntry) -> None: + config_entry: TechnoVEConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: TechnoVEConfigEntry) -> None: """Initialize global TechnoVE data updater.""" self.technove = TechnoVE( - entry.data[CONF_HOST], + config_entry.data[CONF_HOST], session=async_get_clientsession(hass), ) super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/technove/diagnostics.py b/homeassistant/components/technove/diagnostics.py index f070d58ab6f..7ac0f6f44fd 100644 --- a/homeassistant/components/technove/diagnostics.py +++ b/homeassistant/components/technove/diagnostics.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import TechnoVEConfigEntry +from .coordinator import TechnoVEConfigEntry TO_REDACT = {"unique_id", "mac_address"} diff --git a/homeassistant/components/technove/number.py b/homeassistant/components/technove/number.py index a1cf094c6bf..529ce407c79 100644 --- a/homeassistant/components/technove/number.py +++ b/homeassistant/components/technove/number.py @@ -19,9 +19,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TechnoVEConfigEntry from .const import DOMAIN -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity from .helpers import technove_exception_handler diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py index e16ac23f89c..ad80f5f419e 100644 --- a/homeassistant/components/technove/sensor.py +++ b/homeassistant/components/technove/sensor.py @@ -24,8 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import TechnoVEConfigEntry -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity STATUS_TYPE = [s.value for s in Status if s != Status.UNKNOWN] diff --git a/homeassistant/components/technove/switch.py b/homeassistant/components/technove/switch.py index a8ad7581da5..943cd62f86e 100644 --- a/homeassistant/components/technove/switch.py +++ b/homeassistant/components/technove/switch.py @@ -14,9 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TechnoVEConfigEntry from .const import DOMAIN -from .coordinator import TechnoVEDataUpdateCoordinator +from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity from .helpers import technove_exception_handler From 583b2e285b063ca73364db51da1685b8163fc6a5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:48:27 +0100 Subject: [PATCH 0196/1941] Explicitly pass in the config_entry in tautulli coordinator (#137911) explicitly pass in the config_entry in coordinator --- homeassistant/components/tautulli/__init__.py | 6 ++---- homeassistant/components/tautulli/coordinator.py | 7 ++++--- homeassistant/components/tautulli/sensor.py | 3 +-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py index a031354ae7d..41089016fac 100644 --- a/homeassistant/components/tautulli/__init__.py +++ b/homeassistant/components/tautulli/__init__.py @@ -4,15 +4,13 @@ from __future__ import annotations from pytautulli import PyTautulli, PyTautulliHostConfiguration -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import TautulliDataUpdateCoordinator +from .coordinator import TautulliConfigEntry, TautulliDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type TautulliConfigEntry = ConfigEntry[TautulliDataUpdateCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: TautulliConfigEntry) -> bool: @@ -27,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TautulliConfigEntry) -> session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) entry.runtime_data = TautulliDataUpdateCoordinator( - hass, host_configuration, api_client + hass, entry, host_configuration, api_client ) await entry.runtime_data.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/tautulli/coordinator.py b/homeassistant/components/tautulli/coordinator.py index f392ab8df03..5d0f26b83b6 100644 --- a/homeassistant/components/tautulli/coordinator.py +++ b/homeassistant/components/tautulli/coordinator.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from datetime import timedelta -from typing import TYPE_CHECKING from pytautulli import ( PyTautulli, @@ -18,14 +17,14 @@ from pytautulli.exceptions import ( ) from pytautulli.models.host_configuration import PyTautulliHostConfiguration +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER -if TYPE_CHECKING: - from . import TautulliConfigEntry +type TautulliConfigEntry = ConfigEntry[TautulliDataUpdateCoordinator] class TautulliDataUpdateCoordinator(DataUpdateCoordinator[None]): @@ -36,6 +35,7 @@ class TautulliDataUpdateCoordinator(DataUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, + config_entry: TautulliConfigEntry, host_configuration: PyTautulliHostConfiguration, api_client: PyTautulli, ) -> None: @@ -43,6 +43,7 @@ class TautulliDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index cd21630031a..ee186a29225 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -26,9 +26,8 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from . import TautulliConfigEntry from .const import ATTR_TOP_USER, DOMAIN -from .coordinator import TautulliDataUpdateCoordinator +from .coordinator import TautulliConfigEntry, TautulliDataUpdateCoordinator from .entity import TautulliEntity From d92e2194d046eeefc8cea984938eef6a522f7400 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:48:38 +0100 Subject: [PATCH 0197/1941] Explicitly pass in the config_entry in tami4 coordinator (#137912) explicitly pass in the config_entry in coordinator --- homeassistant/components/tami4/__init__.py | 2 +- homeassistant/components/tami4/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tami4/__init__.py b/homeassistant/components/tami4/__init__.py index 8c597409c77..8b9a5e1a90f 100644 --- a/homeassistant/components/tami4/__init__.py +++ b/homeassistant/components/tami4/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except exceptions.TokenRefreshFailedException as ex: raise ConfigEntryNotReady("Error connecting to API") from ex - coordinator = Tami4EdgeCoordinator(hass, api) + coordinator = Tami4EdgeCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { diff --git a/homeassistant/components/tami4/coordinator.py b/homeassistant/components/tami4/coordinator.py index 4764562bc34..f65c819b3d8 100644 --- a/homeassistant/components/tami4/coordinator.py +++ b/homeassistant/components/tami4/coordinator.py @@ -7,6 +7,7 @@ import logging from Tami4EdgeAPI import Tami4EdgeAPI, exceptions from Tami4EdgeAPI.water_quality import WaterQuality +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -36,11 +37,16 @@ class FlattenedWaterQuality: class Tami4EdgeCoordinator(DataUpdateCoordinator[FlattenedWaterQuality]): """Tami4Edge water quality coordinator.""" - def __init__(self, hass: HomeAssistant, api: Tami4EdgeAPI) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: Tami4EdgeAPI + ) -> None: """Initialize the water quality coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Tami4Edge water quality coordinator", update_interval=timedelta(minutes=60), ) From 390af71c4938e53e70f04c3bb1ed348bb0651d76 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:49:55 +0100 Subject: [PATCH 0198/1941] Explicitly pass in the config_entry in ohme coordinator (#138055) explicitly pass in the config_entry in coordinator --- homeassistant/components/ohme/__init__.py | 22 +++++--------------- homeassistant/components/ohme/button.py | 2 +- homeassistant/components/ohme/coordinator.py | 22 +++++++++++++++++++- homeassistant/components/ohme/number.py | 2 +- homeassistant/components/ohme/select.py | 2 +- homeassistant/components/ohme/sensor.py | 2 +- homeassistant/components/ohme/switch.py | 2 +- homeassistant/components/ohme/time.py | 2 +- 8 files changed, 32 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py index 8518e55c0a3..e3e252cbf8b 100644 --- a/homeassistant/components/ohme/__init__.py +++ b/homeassistant/components/ohme/__init__.py @@ -1,10 +1,7 @@ """Set up ohme integration.""" -from dataclasses import dataclass - from ohme import ApiException, AuthException, OhmeApiClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -15,23 +12,14 @@ from .const import DOMAIN, PLATFORMS from .coordinator import ( OhmeAdvancedSettingsCoordinator, OhmeChargeSessionCoordinator, + OhmeConfigEntry, OhmeDeviceInfoCoordinator, + OhmeRuntimeData, ) from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -type OhmeConfigEntry = ConfigEntry[OhmeRuntimeData] - - -@dataclass() -class OhmeRuntimeData: - """Dataclass to hold ohme coordinators.""" - - charge_session_coordinator: OhmeChargeSessionCoordinator - advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator - device_info_coordinator: OhmeDeviceInfoCoordinator - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Ohme integration.""" @@ -62,9 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool ) from e coordinators = ( - OhmeChargeSessionCoordinator(hass, client), - OhmeAdvancedSettingsCoordinator(hass, client), - OhmeDeviceInfoCoordinator(hass, client), + OhmeChargeSessionCoordinator(hass, entry, client), + OhmeAdvancedSettingsCoordinator(hass, entry, client), + OhmeDeviceInfoCoordinator(hass, entry, client), ) for coordinator in coordinators: diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py index 0b0590428ce..dad4416a29c 100644 --- a/homeassistant/components/ohme/button.py +++ b/homeassistant/components/ohme/button.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OhmeConfigEntry from .const import DOMAIN +from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py index 199eb7380a7..864b03e9a7c 100644 --- a/homeassistant/components/ohme/coordinator.py +++ b/homeassistant/components/ohme/coordinator.py @@ -1,11 +1,15 @@ """Ohme coordinators.""" +from __future__ import annotations + from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import logging from ohme import ApiException, OhmeApiClient +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,18 +18,34 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +@dataclass() +class OhmeRuntimeData: + """Dataclass to hold ohme coordinators.""" + + charge_session_coordinator: OhmeChargeSessionCoordinator + advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator + device_info_coordinator: OhmeDeviceInfoCoordinator + + +type OhmeConfigEntry = ConfigEntry[OhmeRuntimeData] + + class OhmeBaseCoordinator(DataUpdateCoordinator[None]): """Base for all Ohme coordinators.""" + config_entry: OhmeConfigEntry client: OhmeApiClient _default_update_interval: timedelta | None = timedelta(minutes=1) coordinator_name: str = "" - def __init__(self, hass: HomeAssistant, client: OhmeApiClient) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient + ) -> None: """Initialise coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="", update_interval=self._default_update_interval, ) diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py index d618d4a873b..875f8c93bb3 100644 --- a/homeassistant/components/ohme/number.py +++ b/homeassistant/components/ohme/number.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OhmeConfigEntry from .const import DOMAIN +from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py index a357e98f0a6..311d27f4bbb 100644 --- a/homeassistant/components/ohme/select.py +++ b/homeassistant/components/ohme/select.py @@ -13,8 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OhmeConfigEntry from .const import DOMAIN +from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 230314cba83..8085f55068f 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OhmeConfigEntry +from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py index d1eb1a80b56..d8b9fb52595 100644 --- a/homeassistant/components/ohme/switch.py +++ b/homeassistant/components/ohme/switch.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OhmeConfigEntry from .const import DOMAIN +from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py index a7de913ef8e..be3da84ed72 100644 --- a/homeassistant/components/ohme/time.py +++ b/homeassistant/components/ohme/time.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OhmeConfigEntry from .const import DOMAIN +from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription PARALLEL_UPDATES = 1 From fb0db36886275365d81cce3d5852cbadd456ed63 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:50:57 +0100 Subject: [PATCH 0199/1941] Explicitly pass in the config_entry in tailscale coordinator (#137913) explicitly pass in the config_entry in coordinator --- .../components/tailscale/coordinator.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tailscale/coordinator.py b/homeassistant/components/tailscale/coordinator.py index 64ce0147664..1b29cfbf4be 100644 --- a/homeassistant/components/tailscale/coordinator.py +++ b/homeassistant/components/tailscale/coordinator.py @@ -19,18 +19,22 @@ class TailscaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the Tailscale coordinator.""" - self.config_entry = entry - session = async_get_clientsession(hass) self.tailscale = Tailscale( session=session, - api_key=entry.data[CONF_API_KEY], - tailnet=entry.data[CONF_TAILNET], + api_key=config_entry.data[CONF_API_KEY], + tailnet=config_entry.data[CONF_TAILNET], ) - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) async def _async_update_data(self) -> dict[str, Device]: """Fetch devices from Tailscale.""" From 7eb01716577296c9e284ede84cc0bd6de7952585 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:51:12 +0100 Subject: [PATCH 0200/1941] Explicitly pass in the config_entry in system_bridge coordinator (#137921) explicitly pass in the config_entry in coordinator --- homeassistant/components/system_bridge/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 7151805f154..1690bad4a4d 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -40,6 +40,8 @@ from .data import SystemBridgeData class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]): """Class to manage fetching System Bridge data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -65,6 +67,7 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData]) super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) From a0e7560b1ea37f6e49bf969666fd26208a34d8cd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:51:29 +0100 Subject: [PATCH 0201/1941] Explicitly pass in the config_entry in switchbot_cloud coordinator (#137922) explicitly pass in the config_entry in coordinator --- .../components/switchbot_cloud/__init__.py | 31 ++++++++++--------- .../components/switchbot_cloud/coordinator.py | 9 +++++- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index d7812158260..44e130cc7a4 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -47,13 +47,14 @@ class SwitchbotCloudData: async def coordinator_for_device( hass: HomeAssistant, + entry: ConfigEntry, api: SwitchBotAPI, device: Device | Remote, coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> SwitchBotCoordinator: """Instantiate coordinator and adds to list for gathering.""" coordinator = coordinators_by_id.setdefault( - device.device_id, SwitchBotCoordinator(hass, api, device) + device.device_id, SwitchBotCoordinator(hass, entry, api, device) ) if coordinator.data is None: @@ -64,6 +65,7 @@ async def coordinator_for_device( async def make_switchbot_devices( hass: HomeAssistant, + entry: ConfigEntry, api: SwitchBotAPI, devices: list[Device | Remote], coordinators_by_id: dict[str, SwitchBotCoordinator], @@ -72,7 +74,7 @@ async def make_switchbot_devices( devices_data = SwitchbotDevices() await gather( *[ - make_device_data(hass, api, device, devices_data, coordinators_by_id) + make_device_data(hass, entry, api, device, devices_data, coordinators_by_id) for device in devices ] ) @@ -82,6 +84,7 @@ async def make_switchbot_devices( async def make_device_data( hass: HomeAssistant, + entry: ConfigEntry, api: SwitchBotAPI, device: Device | Remote, devices_data: SwitchbotDevices, @@ -90,7 +93,7 @@ async def make_device_data( """Make device data.""" if isinstance(device, Remote) and device.device_type.endswith("Air Conditioner"): coordinator = await coordinator_for_device( - hass, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id ) devices_data.climates.append((device, coordinator)) if ( @@ -101,7 +104,7 @@ async def make_device_data( ) ) or isinstance(device, Remote): coordinator = await coordinator_for_device( - hass, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id ) devices_data.switches.append((device, coordinator)) @@ -117,7 +120,7 @@ async def make_device_data( "Plug Mini (JP)", ]: coordinator = await coordinator_for_device( - hass, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id ) devices_data.sensors.append((device, coordinator)) @@ -128,19 +131,19 @@ async def make_device_data( "Robot Vacuum Cleaner S1 Plus", ]: coordinator = await coordinator_for_device( - hass, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id ) devices_data.vacuums.append((device, coordinator)) if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): coordinator = await coordinator_for_device( - hass, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id ) devices_data.locks.append((device, coordinator)) if isinstance(device, Device) and device.device_type in ["Bot"]: coordinator = await coordinator_for_device( - hass, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id ) if coordinator.data is not None: if coordinator.data.get("deviceMode") == "pressMode": @@ -149,10 +152,10 @@ async def make_device_data( devices_data.switches.append((device, coordinator)) -async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" - token = config.data[CONF_API_TOKEN] - secret = config.data[CONF_API_KEY] + token = entry.data[CONF_API_TOKEN] + secret = entry.data[CONF_API_KEY] api = SwitchBotAPI(token=token, secret=secret) try: @@ -168,13 +171,13 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: coordinators_by_id: dict[str, SwitchBotCoordinator] = {} switchbot_devices = await make_switchbot_devices( - hass, api, devices, coordinators_by_id + hass, entry, api, devices, coordinators_by_id ) hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( + hass.data[DOMAIN][entry.entry_id] = SwitchbotCloudData( api=api, devices=switchbot_devices ) - await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/switchbot_cloud/coordinator.py b/homeassistant/components/switchbot_cloud/coordinator.py index 0ebd04f7e5a..02ead5940e4 100644 --- a/homeassistant/components/switchbot_cloud/coordinator.py +++ b/homeassistant/components/switchbot_cloud/coordinator.py @@ -6,6 +6,7 @@ from typing import Any from switchbot_api import CannotConnect, Device, Remote, SwitchBotAPI +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,16 +20,22 @@ type Status = dict[str, Any] | None class SwitchBotCoordinator(DataUpdateCoordinator[Status]): """SwitchBot Cloud coordinator.""" + config_entry: ConfigEntry _api: SwitchBotAPI _device_id: str def __init__( - self, hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api: SwitchBotAPI, + device: Device | Remote, ) -> None: """Initialize SwitchBot Cloud.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) From e05023810697e0ae4b038b4db236703a9d6a4b94 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:51:58 +0100 Subject: [PATCH 0202/1941] Explicitly pass in the config_entry in ondilo_ico coordinator (#138054) explicitly pass in the config_entry in coordinator --- homeassistant/components/ondilo_ico/__init__.py | 4 +++- homeassistant/components/ondilo_ico/coordinator.py | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index fb78035c630..9a1fac6aba4 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -28,7 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - coordinator = OndiloIcoCoordinator(hass, OndiloClient(hass, entry, implementation)) + coordinator = OndiloIcoCoordinator( + hass, entry, OndiloClient(hass, entry, implementation) + ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index ff1502a89fd..349dac7de72 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -7,6 +7,7 @@ from typing import Any from ondilo import OndiloError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -28,11 +29,16 @@ class OndiloIcoData: class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]): """Class to manage fetching Ondilo ICO data from API.""" - def __init__(self, hass: HomeAssistant, api: OndiloClient) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: OndiloClient + ) -> None: """Initialize.""" super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(hours=1), ) From 6d776469d20b5aca0e63f3d0b85de7e5c9b14d89 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:52:19 +0100 Subject: [PATCH 0203/1941] Explicitly pass in the config_entry in opengarage coordinator (#138052) explicitly pass in the config_entry in coordinator --- homeassistant/components/opengarage/__init__.py | 3 +-- homeassistant/components/opengarage/coordinator.py | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index 12c2f96d7e4..f1f080b30f8 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -24,8 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_get_clientsession(hass), ) open_garage_data_coordinator = OpenGarageDataUpdateCoordinator( - hass, - open_garage_connection=open_garage_connection, + hass, entry, open_garage_connection ) await open_garage_data_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = open_garage_data_coordinator diff --git a/homeassistant/components/opengarage/coordinator.py b/homeassistant/components/opengarage/coordinator.py index d35dc22d288..5d5440d6b1b 100644 --- a/homeassistant/components/opengarage/coordinator.py +++ b/homeassistant/components/opengarage/coordinator.py @@ -8,6 +8,7 @@ from typing import Any import opengarage +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import update_coordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -20,10 +21,12 @@ _LOGGER = logging.getLogger(__name__) class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Opengarage data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, + config_entry: ConfigEntry, open_garage_connection: opengarage.OpenGarage, ) -> None: """Initialize global Opengarage data updater.""" @@ -32,6 +35,7 @@ class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=5), ) From 906beb48a4672c6b1b62cc154965a55c207ca68c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:52:37 +0100 Subject: [PATCH 0204/1941] Explicitly pass in the config_entry in starlink coordinator (#137932) explicitly pass in the config_entry in coordinator --- homeassistant/components/starlink/__init__.py | 8 ++------ homeassistant/components/starlink/coordinator.py | 10 +++++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index 17081a7491e..4528a35858c 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -21,11 +21,7 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Starlink from a config entry.""" - coordinator = StarlinkUpdateCoordinator( - hass=hass, - url=entry.data[CONF_IP_ADDRESS], - name=entry.title, - ) + coordinator = StarlinkUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 6fcfd8e0bfe..d65777b7435 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -26,6 +26,8 @@ from starlink_grpc import ( status_data, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -49,15 +51,17 @@ class StarlinkData: class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): """Coordinates updates between all Starlink sensors defined in this file.""" - def __init__(self, hass: HomeAssistant, name: str, url: str) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize an UpdateCoordinator for a group of sensors.""" - self.channel_context = ChannelContext(target=url) + self.channel_context = ChannelContext(target=config_entry.data[CONF_IP_ADDRESS]) self.history_stats_start = None self.timezone = ZoneInfo(hass.config.time_zone) super().__init__( hass, _LOGGER, - name=name, + name=config_entry.title, update_interval=timedelta(seconds=5), ) From 91c95efb9614528713e4b448faa8c71614c89f6b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:53:00 +0100 Subject: [PATCH 0205/1941] Explicitly pass in the config_entry in openuv coordinator (#138050) explicitly pass in the config_entry in coordinator --- homeassistant/components/openuv/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py index 32d502cb8ce..cc09161b3e9 100644 --- a/homeassistant/components/openuv/coordinator.py +++ b/homeassistant/components/openuv/coordinator.py @@ -38,6 +38,7 @@ class OpenUvCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, LOGGER, + config_entry=entry, name=name, update_method=update_method, request_refresh_debouncer=Debouncer( @@ -48,7 +49,6 @@ class OpenUvCoordinator(DataUpdateCoordinator[dict[str, Any]]): ), ) - self._entry = entry self.latitude = latitude self.longitude = longitude From 64cbf44da77b0673f678ce280634e8054b5c25a1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:53:52 +0100 Subject: [PATCH 0206/1941] Explicitly pass in the config_entry in purpleair coordinator (#138034) explicitly pass in the config_entry in coordinator --- homeassistant/components/purpleair/coordinator.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/purpleair/coordinator.py b/homeassistant/components/purpleair/coordinator.py index 7bf0770c6fc..f1511733cfa 100644 --- a/homeassistant/components/purpleair/coordinator.py +++ b/homeassistant/components/purpleair/coordinator.py @@ -49,16 +49,21 @@ UPDATE_INTERVAL = timedelta(minutes=2) class PurpleAirDataUpdateCoordinator(DataUpdateCoordinator[GetSensorsResponse]): """Define a PurpleAir-specific coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize.""" - self._entry = entry self._api = API( entry.data[CONF_API_KEY], session=aiohttp_client.async_get_clientsession(hass), ) super().__init__( - hass, LOGGER, name=entry.title, update_interval=UPDATE_INTERVAL + hass, + LOGGER, + config_entry=entry, + name=entry.title, + update_interval=UPDATE_INTERVAL, ) async def _async_update_data(self) -> GetSensorsResponse: @@ -66,7 +71,7 @@ class PurpleAirDataUpdateCoordinator(DataUpdateCoordinator[GetSensorsResponse]): try: return await self._api.sensors.async_get_sensors( SENSOR_FIELDS_TO_RETRIEVE, - sensor_indices=self._entry.options[CONF_SENSOR_INDICES], + sensor_indices=self.config_entry.options[CONF_SENSOR_INDICES], ) except InvalidApiKeyError as err: raise ConfigEntryAuthFailed("Invalid API key") from err From 9c5928c2d057115c572d7f7906dd666b27b1a149 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:54:04 +0100 Subject: [PATCH 0207/1941] Explicitly pass in the config_entry in opensky coordinator (#138051) explicitly pass in the config_entry in coordinator --- homeassistant/components/opensky/__init__.py | 2 +- homeassistant/components/opensky/coordinator.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index c95dc1283a4..c69cade5842 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except OpenSkyError as exc: raise ConfigEntryNotReady from exc - coordinator = OpenSkyDataUpdateCoordinator(hass, client) + coordinator = OpenSkyDataUpdateCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/opensky/coordinator.py b/homeassistant/components/opensky/coordinator.py index f54e01b0006..f9aab88c904 100644 --- a/homeassistant/components/opensky/coordinator.py +++ b/homeassistant/components/opensky/coordinator.py @@ -36,11 +36,14 @@ class OpenSkyDataUpdateCoordinator(DataUpdateCoordinator[int]): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, opensky: OpenSky) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, opensky: OpenSky + ) -> None: """Initialize the OpenSky data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval={ True: timedelta(seconds=90), @@ -50,11 +53,11 @@ class OpenSkyDataUpdateCoordinator(DataUpdateCoordinator[int]): self._opensky = opensky self._previously_tracked: set[str] | None = None self._bounding_box = OpenSky.get_bounding_box( - self.config_entry.data[CONF_LATITUDE], - self.config_entry.data[CONF_LONGITUDE], - self.config_entry.options[CONF_RADIUS], + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + config_entry.options[CONF_RADIUS], ) - self._altitude = self.config_entry.options.get(CONF_ALTITUDE, DEFAULT_ALTITUDE) + self._altitude = config_entry.options.get(CONF_ALTITUDE, DEFAULT_ALTITUDE) async def _async_update_data(self) -> int: try: From 51d3e449ab9d34441f44d5c3b4fe626efe8bf94b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:54:26 +0100 Subject: [PATCH 0208/1941] Explicitly pass in the config_entry in srp_energy coordinator (#137933) explicitly pass in the config_entry in coordinator --- homeassistant/components/srp_energy/__init__.py | 6 ++---- homeassistant/components/srp_energy/coordinator.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index 591ba5043e9..13c21709445 100644 --- a/homeassistant/components/srp_energy/__init__.py +++ b/homeassistant/components/srp_energy/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import CONF_IS_TOU, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER from .coordinator import SRPEnergyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -26,9 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_password, ) - coordinator = SRPEnergyDataUpdateCoordinator( - hass, api_instance, entry.data[CONF_IS_TOU] - ) + coordinator = SRPEnergyDataUpdateCoordinator(hass, entry, api_instance) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/srp_energy/coordinator.py b/homeassistant/components/srp_energy/coordinator.py index e5a72457433..f3821891afa 100644 --- a/homeassistant/components/srp_energy/coordinator.py +++ b/homeassistant/components/srp_energy/coordinator.py @@ -12,7 +12,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER, MIN_TIME_BETWEEN_UPDATES, PHOENIX_TIME_ZONE +from .const import ( + CONF_IS_TOU, + DOMAIN, + LOGGER, + MIN_TIME_BETWEEN_UPDATES, + PHOENIX_TIME_ZONE, +) TIMEOUT = 10 PHOENIX_ZONE_INFO = dt_util.get_time_zone(PHOENIX_TIME_ZONE) @@ -24,14 +30,15 @@ class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): config_entry: ConfigEntry def __init__( - self, hass: HomeAssistant, client: SrpEnergyClient, is_time_of_use: bool + self, hass: HomeAssistant, config_entry: ConfigEntry, client: SrpEnergyClient ) -> None: """Initialize the srp_energy data coordinator.""" self._client = client - self._is_time_of_use = is_time_of_use + self._is_time_of_use = config_entry.data[CONF_IS_TOU] super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=MIN_TIME_BETWEEN_UPDATES, ) From 1976fdfa55f172fae7f4c6f46eefa3b4d02e20d8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:54:46 +0100 Subject: [PATCH 0209/1941] Explicitly pass in the config_entry in squeezebox coordinator (#137934) explicitly pass in the config_entry in coordinator --- homeassistant/components/squeezebox/__init__.py | 2 +- homeassistant/components/squeezebox/coordinator.py | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index f94ea118c6a..3aec55a90d2 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -127,7 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - ) _LOGGER.debug("LMS Device %s", device) - server_coordinator = LMSStatusDataUpdateCoordinator(hass, lms) + server_coordinator = LMSStatusDataUpdateCoordinator(hass, entry, lms) entry.runtime_data = SqueezeboxData( coordinator=server_coordinator, diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index f3aacbc9833..f51a481818d 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -1,11 +1,13 @@ """DataUpdateCoordinator for the Squeezebox integration.""" +from __future__ import annotations + from asyncio import timeout from collections.abc import Callable from datetime import timedelta import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any from pysqueezebox import Player, Server @@ -14,6 +16,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +if TYPE_CHECKING: + from . import SqueezeboxConfigEntry + from .const import ( PLAYER_UPDATE_INTERVAL, SENSOR_UPDATE_INTERVAL, @@ -30,11 +35,16 @@ _LOGGER = logging.getLogger(__name__) class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): """LMS Status custom coordinator.""" - def __init__(self, hass: HomeAssistant, lms: Server) -> None: + config_entry: SqueezeboxConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: SqueezeboxConfigEntry, lms: Server + ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=lms.name, update_interval=timedelta(seconds=SENSOR_UPDATE_INTERVAL), always_update=False, From 62461d7525cee2614d66d3c6e4c74412892cb81f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:55:16 +0100 Subject: [PATCH 0210/1941] Explicitly pass in the config_entry in spotify coordinator (#137935) explicitly pass in the config_entry in coordinator --- homeassistant/components/spotify/__init__.py | 5 ++--- homeassistant/components/spotify/coordinator.py | 8 +++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index 663b3f30caa..1c4ea961ce3 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING import aiohttp from spotifyaio import Device, SpotifyClient, SpotifyConnectionError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -63,7 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b spotify.refresh_token_function = _refresh_token - coordinator = SpotifyCoordinator(hass, spotify) + coordinator = SpotifyCoordinator(hass, entry, spotify) await coordinator.async_config_entry_first_refresh() @@ -92,6 +91,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> bool: """Unload Spotify config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 8b8539d715a..2d5fffebb7b 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -56,11 +56,17 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): current_user: UserProfile config_entry: SpotifyConfigEntry - def __init__(self, hass: HomeAssistant, client: SpotifyClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SpotifyConfigEntry, + client: SpotifyClient, + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=UPDATE_INTERVAL, ) From 552a5b1bb121d695dc51d187c531474b1d6328d4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 14:56:17 +0100 Subject: [PATCH 0211/1941] Explicitly pass in the config_entry in pyload coordinator (#138031) explicitly pass in the config_entry in coordinator --- homeassistant/components/pyload/__init__.py | 7 ++----- homeassistant/components/pyload/button.py | 2 +- homeassistant/components/pyload/coordinator.py | 10 ++++++++-- homeassistant/components/pyload/diagnostics.py | 3 +-- homeassistant/components/pyload/sensor.py | 3 +-- homeassistant/components/pyload/switch.py | 3 +-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index f07db509630..3dd2fd9b2ba 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -6,7 +6,6 @@ from aiohttp import CookieJar from pyloadapi.api import PyLoadAPI from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -21,12 +20,10 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import DOMAIN -from .coordinator import PyLoadCoordinator +from .coordinator import PyLoadConfigEntry, PyLoadCoordinator PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] -type PyLoadConfigEntry = ConfigEntry[PyLoadCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Set up pyLoad from a config entry.""" @@ -66,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo translation_key="setup_authentication_exception", translation_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]}, ) from e - coordinator = PyLoadCoordinator(hass, pyloadapi) + coordinator = PyLoadCoordinator(hass, entry, pyloadapi) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py index 386fe6968de..f849200a70e 100644 --- a/homeassistant/components/pyload/button.py +++ b/homeassistant/components/pyload/button.py @@ -14,8 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PyLoadConfigEntry from .const import DOMAIN +from .coordinator import PyLoadConfigEntry from .entity import BasePyLoadEntity diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index 7eadefcd260..8b2db605c94 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -34,16 +34,22 @@ class PyLoadData: free_space: int +type PyLoadConfigEntry = ConfigEntry[PyLoadCoordinator] + + class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): """pyLoad coordinator.""" - config_entry: ConfigEntry + config_entry: PyLoadConfigEntry - def __init__(self, hass: HomeAssistant, pyload: PyLoadAPI) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: PyLoadConfigEntry, pyload: PyLoadAPI + ) -> None: """Initialize pyLoad coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/pyload/diagnostics.py b/homeassistant/components/pyload/diagnostics.py index e9688a3369b..105a9a953e2 100644 --- a/homeassistant/components/pyload/diagnostics.py +++ b/homeassistant/components/pyload/diagnostics.py @@ -9,8 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import PyLoadConfigEntry -from .coordinator import PyLoadData +from .coordinator import PyLoadConfigEntry, PyLoadData TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_HOST} diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index 38f681d30d5..b36dbb806be 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -17,9 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import PyLoadConfigEntry from .const import UNIT_DOWNLOADS -from .coordinator import PyLoadData +from .coordinator import PyLoadConfigEntry, PyLoadData from .entity import BasePyLoadEntity diff --git a/homeassistant/components/pyload/switch.py b/homeassistant/components/pyload/switch.py index ea189ed9a8f..1187e545f25 100644 --- a/homeassistant/components/pyload/switch.py +++ b/homeassistant/components/pyload/switch.py @@ -18,9 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PyLoadConfigEntry from .const import DOMAIN -from .coordinator import PyLoadData +from .coordinator import PyLoadConfigEntry, PyLoadData from .entity import BasePyLoadEntity From 7e2eef7079332c067a733045e41a075d28746d2f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:01:28 +0100 Subject: [PATCH 0212/1941] Explicitly pass in the config_entry in pvoutput coordinator (#138033) explicitly pass in the config_entry in coordinator --- homeassistant/components/pvoutput/coordinator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pvoutput/coordinator.py b/homeassistant/components/pvoutput/coordinator.py index 5c38792c553..ce3642421bf 100644 --- a/homeassistant/components/pvoutput/coordinator.py +++ b/homeassistant/components/pvoutput/coordinator.py @@ -21,14 +21,15 @@ class PVOutputDataUpdateCoordinator(DataUpdateCoordinator[Status]): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the PVOutput coordinator.""" - self.config_entry = entry self.pvoutput = PVOutput( api_key=entry.data[CONF_API_KEY], system_id=entry.data[CONF_SYSTEM_ID], session=async_get_clientsession(hass), ) - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, LOGGER, config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL + ) async def _async_update_data(self) -> Status: """Fetch system status from PVOutput.""" From 15b8687c530bcaa1e3f63ca4e5c5623f40c927ee Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:02:18 +0100 Subject: [PATCH 0213/1941] Explicitly pass in the config_entry in pure_energie coordinator (#138035) explicitly pass in the config_entry in coordinator --- homeassistant/components/pure_energie/__init__.py | 7 ++----- homeassistant/components/pure_energie/coordinator.py | 6 +++++- homeassistant/components/pure_energie/diagnostics.py | 2 +- homeassistant/components/pure_energie/sensor.py | 7 +++++-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/pure_energie/__init__.py b/homeassistant/components/pure_energie/__init__.py index 4de1ce02810..4ece35a3f1c 100644 --- a/homeassistant/components/pure_energie/__init__.py +++ b/homeassistant/components/pure_energie/__init__.py @@ -2,22 +2,19 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .coordinator import PureEnergieDataUpdateCoordinator +from .coordinator import PureEnergieConfigEntry, PureEnergieDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type PureEnergieConfigEntry = ConfigEntry[PureEnergieDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: PureEnergieConfigEntry) -> bool: """Set up Pure Energie from a config entry.""" - coordinator = PureEnergieDataUpdateCoordinator(hass) + coordinator = PureEnergieDataUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: diff --git a/homeassistant/components/pure_energie/coordinator.py b/homeassistant/components/pure_energie/coordinator.py index fdd848eb4c6..cd66ab060eb 100644 --- a/homeassistant/components/pure_energie/coordinator.py +++ b/homeassistant/components/pure_energie/coordinator.py @@ -14,6 +14,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type PureEnergieConfigEntry = ConfigEntry[PureEnergieDataUpdateCoordinator] + class PureEnergieData(NamedTuple): """Class for defining data in dict.""" @@ -25,16 +27,18 @@ class PureEnergieData(NamedTuple): class PureEnergieDataUpdateCoordinator(DataUpdateCoordinator[PureEnergieData]): """Class to manage fetching Pure Energie data from single eindpoint.""" - config_entry: ConfigEntry + config_entry: PureEnergieConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: PureEnergieConfigEntry, ) -> None: """Initialize global Pure Energie data updater.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/pure_energie/diagnostics.py b/homeassistant/components/pure_energie/diagnostics.py index de9134129ed..5098a298e85 100644 --- a/homeassistant/components/pure_energie/diagnostics.py +++ b/homeassistant/components/pure_energie/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import PureEnergieConfigEntry +from .coordinator import PureEnergieConfigEntry TO_REDACT = { CONF_HOST, diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 468858f117f..9dd234ac2f6 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -18,9 +18,12 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PureEnergieConfigEntry from .const import DOMAIN -from .coordinator import PureEnergieData, PureEnergieDataUpdateCoordinator +from .coordinator import ( + PureEnergieConfigEntry, + PureEnergieData, + PureEnergieDataUpdateCoordinator, +) @dataclass(frozen=True, kw_only=True) From 6cdc3acffbfdb6baea665720ed7c80c23c26a9ee Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:02:55 +0100 Subject: [PATCH 0214/1941] Explicitly pass in the config_entry in plaato coordinator (#138040) explicitly pass in the config_entry in coordinator --- homeassistant/components/plaato/__init__.py | 4 +++- homeassistant/components/plaato/coordinator.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index 6001a243a2d..14e757d4623 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -121,7 +121,9 @@ async def async_setup_coordinator(hass: HomeAssistant, entry: ConfigEntry): else: update_interval = timedelta(minutes=DEFAULT_SCAN_INTERVAL) - coordinator = PlaatoCoordinator(hass, auth_token, device_type, update_interval) + coordinator = PlaatoCoordinator( + hass, entry, auth_token, device_type, update_interval + ) await coordinator.async_config_entry_first_refresh() _set_entry_data(entry, hass, coordinator, auth_token) diff --git a/homeassistant/components/plaato/coordinator.py b/homeassistant/components/plaato/coordinator.py index 8d21f17880a..df360d50068 100644 --- a/homeassistant/components/plaato/coordinator.py +++ b/homeassistant/components/plaato/coordinator.py @@ -5,6 +5,7 @@ import logging from pyplaato.plaato import Plaato, PlaatoDeviceType +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client @@ -18,9 +19,12 @@ _LOGGER = logging.getLogger(__name__) class PlaatoCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, auth_token: str, device_type: PlaatoDeviceType, update_interval: timedelta, @@ -34,6 +38,7 @@ class PlaatoCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=update_interval, ) From c3fae96bcff6c7ad38266a2c8a370083167d5c7a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:07:10 +0100 Subject: [PATCH 0215/1941] Explicitly pass in the config_entry in openweathermap coordinator (#138049) explicitly pass in the config_entry in coordinator --- .../components/openweathermap/__init__.py | 23 +++++++----------- .../components/openweathermap/coordinator.py | 24 ++++++++++++++----- .../components/openweathermap/repairs.py | 10 +++++--- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 33cd23c4f6c..fa51b91dc6d 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -8,14 +8,7 @@ import logging from pyopenweathermap import create_owm_client from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_API_KEY, - CONF_LANGUAGE, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MODE, - CONF_NAME, -) +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS @@ -43,8 +36,6 @@ async def async_setup_entry( """Set up OpenWeatherMap as config entry.""" name = entry.data[CONF_NAME] api_key = entry.data[CONF_API_KEY] - latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude) - longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude) language = entry.options[CONF_LANGUAGE] mode = entry.options[CONF_MODE] @@ -54,9 +45,7 @@ async def async_setup_entry( async_delete_issue(hass, entry.entry_id) owm_client = create_owm_client(api_key, mode, lang=language) - weather_coordinator = WeatherUpdateCoordinator( - owm_client, latitude, longitude, hass - ) + weather_coordinator = WeatherUpdateCoordinator(hass, entry, owm_client) await weather_coordinator.async_config_entry_first_refresh() @@ -69,7 +58,9 @@ async def async_setup_entry( return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, entry: OpenweathermapConfigEntry +) -> bool: """Migrate old entry.""" config_entries = hass.config_entries data = entry.data @@ -93,7 +84,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options( + hass: HomeAssistant, entry: OpenweathermapConfigEntry +) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py index 3ef0eda0c8f..55c1aa469c2 100644 --- a/homeassistant/components/openweathermap/coordinator.py +++ b/homeassistant/components/openweathermap/coordinator.py @@ -1,7 +1,10 @@ """Weather data coordinator for the OpenWeatherMap (OWM) service.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import TYPE_CHECKING from pyopenweathermap import ( CurrentWeather, @@ -17,11 +20,15 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, Forecast, ) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util +if TYPE_CHECKING: + from . import OpenweathermapConfigEntry + from .const import ( ATTR_API_CLOUDS, ATTR_API_CONDITION, @@ -56,20 +63,25 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" + config_entry: OpenweathermapConfigEntry + def __init__( self, - owm_client: OWMClient, - latitude, - longitude, hass: HomeAssistant, + config_entry: OpenweathermapConfigEntry, + owm_client: OWMClient, ) -> None: """Initialize coordinator.""" self._owm_client = owm_client - self._latitude = latitude - self._longitude = longitude + self._latitude = config_entry.data.get(CONF_LATITUDE, hass.config.latitude) + self._longitude = config_entry.data.get(CONF_LONGITUDE, hass.config.longitude) super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=WEATHER_UPDATE_INTERVAL, ) async def _async_update_data(self): diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index c54484e1e1e..2bde5750ca4 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -1,14 +1,18 @@ """Issues for OpenWeatherMap.""" -from typing import cast +from __future__ import annotations + +from typing import TYPE_CHECKING, cast from homeassistant import data_entry_flow from homeassistant.components.repairs import RepairsFlow -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_MODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir +if TYPE_CHECKING: + from . import OpenweathermapConfigEntry + from .const import DOMAIN, OWM_MODE_V30 from .utils import validate_api_key @@ -16,7 +20,7 @@ from .utils import validate_api_key class DeprecatedV25RepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: OpenweathermapConfigEntry) -> None: """Create flow.""" super().__init__() self.entry = entry From cc37ff9221f18bd5f443aaa191267f238eccaee7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:30:48 +0100 Subject: [PATCH 0216/1941] Bump py-synologydsm-api to 2.6.2 (#138060) bump py-synologydsm-api to 2.6.2 --- 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 ab6fc20b5cb..a083fa5a15f 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.0"], + "requirements": ["py-synologydsm-api==2.6.2"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index d6b95256f37..e6c5f7dcf32 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.0 +py-synologydsm-api==2.6.2 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f37799d1a56..0d85fd7dda5 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.0 +py-synologydsm-api==2.6.2 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From 9110557e36244e2c6695f11aabc5e3796db41b56 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:32:18 +0100 Subject: [PATCH 0217/1941] Explicitly pass in the config_entry in smlight coordinator (#137943) explicitly pass in the config_entry in coordinator --- homeassistant/components/smlight/__init__.py | 27 +++++------------ .../components/smlight/binary_sensor.py | 5 ++-- homeassistant/components/smlight/button.py | 5 ++-- .../components/smlight/coordinator.py | 29 ++++++++++++++----- .../components/smlight/diagnostics.py | 2 +- homeassistant/components/smlight/sensor.py | 3 +- homeassistant/components/smlight/switch.py | 3 +- homeassistant/components/smlight/update.py | 4 +-- 8 files changed, 38 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 11c6ffb73fb..8f3e675ef6b 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -2,16 +2,18 @@ from __future__ import annotations -from dataclasses import dataclass - from pysmlight import Api2, Info, Radio -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import SmDataUpdateCoordinator, SmFirmwareUpdateCoordinator +from .coordinator import ( + SmConfigEntry, + SmDataUpdateCoordinator, + SmFirmwareUpdateCoordinator, + SmlightData, +) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -22,25 +24,12 @@ PLATFORMS: list[Platform] = [ ] -@dataclass(kw_only=True) -class SmlightData: - """Coordinator data class.""" - - data: SmDataUpdateCoordinator - firmware: SmFirmwareUpdateCoordinator - - -type SmConfigEntry = ConfigEntry[SmlightData] - - async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Set up SMLIGHT Zigbee from a config entry.""" client = Api2(host=entry.data[CONF_HOST], session=async_get_clientsession(hass)) - data_coordinator = SmDataUpdateCoordinator(hass, entry.data[CONF_HOST], client) - firmware_coordinator = SmFirmwareUpdateCoordinator( - hass, entry.data[CONF_HOST], client - ) + data_coordinator = SmDataUpdateCoordinator(hass, entry, client) + firmware_coordinator = SmFirmwareUpdateCoordinator(hass, entry, client) await data_coordinator.async_config_entry_first_refresh() await firmware_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index b1aba3a52fe..de13e648961 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -14,13 +14,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SCAN_INTERNET_INTERVAL -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity SCAN_INTERVAL = SCAN_INTERNET_INTERVAL @@ -56,7 +55,7 @@ SENSORS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SmConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up SMLIGHT sensor based on a config entry.""" diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index d82034b87fb..20ad507fa78 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -14,14 +14,13 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity _LOGGER = logging.getLogger(__name__) @@ -65,7 +64,7 @@ ROUTER = SmButtonDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SmConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up SMLIGHT buttons based on a config entry.""" diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 341c627afe5..5a118e7de15 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -4,14 +4,14 @@ from __future__ import annotations from abc import abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING from pysmlight import Api2, Info, Sensors from pysmlight.const import Settings, SettingsProp from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError from pysmlight.models import FirmwareList -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir @@ -21,8 +21,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, SCAN_FIRMWARE_INTERVAL, SCAN_INTERVAL -if TYPE_CHECKING: - from . import SmConfigEntry + +@dataclass(kw_only=True) +class SmlightData: + """Coordinator data class.""" + + data: SmDataUpdateCoordinator + firmware: SmFirmwareUpdateCoordinator @dataclass @@ -42,17 +47,23 @@ class SmFwData: zb_firmware: list[FirmwareList] +type SmConfigEntry = ConfigEntry[SmlightData] + + class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Base Coordinator for SMLIGHT.""" config_entry: SmConfigEntry - def __init__(self, hass: HomeAssistant, host: str, client: Api2) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: SmConfigEntry, client: Api2 + ) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, - name=f"{DOMAIN}_{host}", + config_entry=config_entry, + name=f"{DOMAIN}_{config_entry.data[CONF_HOST]}", update_interval=SCAN_INTERVAL, ) @@ -133,9 +144,11 @@ class SmDataUpdateCoordinator(SmBaseDataUpdateCoordinator[SmData]): class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): """Class to manage fetching SMLIGHT firmware update data from cloud.""" - def __init__(self, hass: HomeAssistant, host: str, client: Api2) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: SmConfigEntry, client: Api2 + ) -> None: """Initialize the coordinator.""" - super().__init__(hass, host, client) + super().__init__(hass, config_entry, client) self.update_interval = SCAN_FIRMWARE_INTERVAL # only one update can run at a time (core or zibgee) diff --git a/homeassistant/components/smlight/diagnostics.py b/homeassistant/components/smlight/diagnostics.py index d303e5803bb..3812175e673 100644 --- a/homeassistant/components/smlight/diagnostics.py +++ b/homeassistant/components/smlight/diagnostics.py @@ -8,7 +8,7 @@ from pysmlight.const import Actions from homeassistant.core import HomeAssistant -from . import SmConfigEntry +from .coordinator import SmConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 1116b99f8c1..3b7683f61fe 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -21,9 +21,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from . import SmConfigEntry from .const import UPTIME_DEVIATION -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 1c591e3dbe8..ce473da358e 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -19,8 +19,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SmConfigEntry -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 50a123345c6..662195bdfc0 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -22,9 +22,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SmConfigEntry, get_radio +from . import get_radio from .const import LOGGER -from .coordinator import SmFirmwareUpdateCoordinator, SmFwData +from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity From e163c15bb96c1dd1015829f7eb706dd9d921b414 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:32:31 +0100 Subject: [PATCH 0218/1941] Explicitly pass in the config_entry in ourgroceries coordinator (#138047) explicitly pass in the config_entry in coordinator --- homeassistant/components/ourgroceries/__init__.py | 2 +- homeassistant/components/ourgroceries/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index 5086a5cfc9b..a83430b3531 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -30,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except InvalidLoginException: return False - coordinator = OurGroceriesDataUpdateCoordinator(hass, og) + coordinator = OurGroceriesDataUpdateCoordinator(hass, entry, og) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = coordinator diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py index bc645b2bdb3..a822931e88c 100644 --- a/homeassistant/components/ourgroceries/coordinator.py +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -8,6 +8,7 @@ import logging from ourgroceries import OurGroceries +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -21,7 +22,11 @@ _LOGGER = logging.getLogger(__name__) class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage fetching OurGroceries data.""" - def __init__(self, hass: HomeAssistant, og: OurGroceries) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, og: OurGroceries + ) -> None: """Initialize global OurGroceries data updater.""" self.og = og self.lists: list[dict] = [] @@ -30,6 +35,7 @@ class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=interval, ) From 8073bccc870a5f8f79a3955e47a8b3f0a669e3b2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:32:56 +0100 Subject: [PATCH 0219/1941] Explicitly pass in the config_entry in sharkiq coordinator (#137954) explicitly pass in the config_entry in coordinator --- homeassistant/components/sharkiq/coordinator.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sharkiq/coordinator.py b/homeassistant/components/sharkiq/coordinator.py index 381f6ca1a7d..1a4a819cdf6 100644 --- a/homeassistant/components/sharkiq/coordinator.py +++ b/homeassistant/components/sharkiq/coordinator.py @@ -24,6 +24,8 @@ from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): """Define a wrapper class to update Shark IQ data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, @@ -36,10 +38,15 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): self.shark_vacs: dict[str, SharkIqVacuum] = { sharkiq.serial_number: sharkiq for sharkiq in shark_vacs } - self._config_entry = config_entry self._online_dsns: set[str] = set() - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) @property def online_dsns(self) -> set[str]: From 00803f98d49dd9c28521fab53f51a9c51ae1a8f6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:33:09 +0100 Subject: [PATCH 0220/1941] Explicitly pass in the config_entry in sfr_box coordinator (#137955) explicitly pass in the config_entry in coordinator --- homeassistant/components/sfr_box/__init__.py | 16 +++++++++++----- homeassistant/components/sfr_box/coordinator.py | 12 +++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index 927e3cb0ef2..a56d208d515 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -37,12 +37,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = DomainData( box=box, - dsl=SFRDataUpdateCoordinator(hass, box, "dsl", lambda b: b.dsl_get_info()), - ftth=SFRDataUpdateCoordinator(hass, box, "ftth", lambda b: b.ftth_get_info()), - system=SFRDataUpdateCoordinator( - hass, box, "system", lambda b: b.system_get_info() + dsl=SFRDataUpdateCoordinator( + hass, entry, box, "dsl", lambda b: b.dsl_get_info() + ), + ftth=SFRDataUpdateCoordinator( + hass, entry, box, "ftth", lambda b: b.ftth_get_info() + ), + system=SFRDataUpdateCoordinator( + hass, entry, box, "system", lambda b: b.system_get_info() + ), + wan=SFRDataUpdateCoordinator( + hass, entry, box, "wan", lambda b: b.wan_get_info() ), - wan=SFRDataUpdateCoordinator(hass, box, "wan", lambda b: b.wan_get_info()), ) # Preload system information await data.system.async_config_entry_first_refresh() diff --git a/homeassistant/components/sfr_box/coordinator.py b/homeassistant/components/sfr_box/coordinator.py index 5877d5a454a..e9cb3c592e1 100644 --- a/homeassistant/components/sfr_box/coordinator.py +++ b/homeassistant/components/sfr_box/coordinator.py @@ -8,6 +8,7 @@ from typing import Any from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,9 +19,12 @@ _SCAN_INTERVAL = timedelta(minutes=1) class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT | None]): """Coordinator to manage data updates.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, box: SFRBox, name: str, method: Callable[[SFRBox], Coroutine[Any, Any, _DataT | None]], @@ -28,7 +32,13 @@ class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT | None]): """Initialize coordinator.""" self.box = box self._method = method - super().__init__(hass, _LOGGER, name=name, update_interval=_SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=name, + update_interval=_SCAN_INTERVAL, + ) async def _async_update_data(self) -> _DataT | None: """Update data.""" From 7fec225e79d96fbd370a325d6f9744470f93d185 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:33:29 +0100 Subject: [PATCH 0221/1941] Explicitly pass in the config_entry in sensoterra coordinator (#137957) explicitly pass in the config_entry in coordinator --- homeassistant/components/sensoterra/__init__.py | 7 ++----- homeassistant/components/sensoterra/coordinator.py | 10 +++++++++- homeassistant/components/sensoterra/sensor.py | 3 +-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensoterra/__init__.py b/homeassistant/components/sensoterra/__init__.py index b1428351f09..1559dc10c43 100644 --- a/homeassistant/components/sensoterra/__init__.py +++ b/homeassistant/components/sensoterra/__init__.py @@ -4,16 +4,13 @@ from __future__ import annotations from sensoterra.customerapi import CustomerApi -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from .coordinator import SensoterraCoordinator +from .coordinator import SensoterraConfigEntry, SensoterraCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type SensoterraConfigEntry = ConfigEntry[SensoterraCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: SensoterraConfigEntry) -> bool: """Set up Sensoterra platform based on a configuration entry.""" @@ -24,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SensoterraConfigEntry) - api.set_language(hass.config.language) api.set_token(entry.data[CONF_TOKEN]) - coordinator = SensoterraCoordinator(hass, api) + coordinator = SensoterraCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/sensoterra/coordinator.py b/homeassistant/components/sensoterra/coordinator.py index 2dffdceb443..9020633a2a3 100644 --- a/homeassistant/components/sensoterra/coordinator.py +++ b/homeassistant/components/sensoterra/coordinator.py @@ -10,21 +10,29 @@ from sensoterra.customerapi import ( ) from sensoterra.probe import Probe, Sensor +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER, SCAN_INTERVAL_MINUTES +type SensoterraConfigEntry = ConfigEntry[SensoterraCoordinator] + class SensoterraCoordinator(DataUpdateCoordinator[list[Probe]]): """Sensoterra coordinator.""" - def __init__(self, hass: HomeAssistant, api: CustomerApi) -> None: + config_entry: SensoterraConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: SensoterraConfigEntry, api: CustomerApi + ) -> None: """Initialize Sensoterra coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Sensoterra probe", update_interval=timedelta(minutes=SCAN_INTERVAL_MINUTES), ) diff --git a/homeassistant/components/sensoterra/sensor.py b/homeassistant/components/sensoterra/sensor.py index 7e9f4d0840e..a32fe3d98c9 100644 --- a/homeassistant/components/sensoterra/sensor.py +++ b/homeassistant/components/sensoterra/sensor.py @@ -25,9 +25,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SensoterraConfigEntry from .const import CONFIGURATION_URL, DOMAIN, SENSOR_EXPIRATION_DAYS -from .coordinator import SensoterraCoordinator +from .coordinator import SensoterraConfigEntry, SensoterraCoordinator class ProbeSensorType(StrEnum): From 71d47aef2e7b641246f28aa612a80f98056ce805 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:33:43 +0100 Subject: [PATCH 0222/1941] Explicitly pass in the config_entry in sense coordinator (#137958) explicitly pass in the config_entry in coordinator --- homeassistant/components/sense/__init__.py | 4 +-- homeassistant/components/sense/coordinator.py | 34 ++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index e919d48e96d..a5393181057 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -89,8 +89,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> boo except SENSE_WEBSOCKET_EXCEPTIONS as err: raise ConfigEntryNotReady(str(err) or "Error during realtime update") from err - trends_coordinator = SenseTrendCoordinator(hass, gateway) - realtime_coordinator = SenseRealtimeCoordinator(hass, gateway) + trends_coordinator = SenseTrendCoordinator(hass, entry, gateway) + realtime_coordinator = SenseRealtimeCoordinator(hass, entry, gateway) # This can take longer than 60s and we already know # sense is online since get_discovered_device_data was diff --git a/homeassistant/components/sense/coordinator.py b/homeassistant/components/sense/coordinator.py index c0029cd79ea..1957352aea6 100644 --- a/homeassistant/components/sense/coordinator.py +++ b/homeassistant/components/sense/coordinator.py @@ -1,7 +1,10 @@ """Sense Coordinators.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import TYPE_CHECKING from sense_energy import ( ASyncSenseable, @@ -13,6 +16,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import SenseConfigEntry + from .const import ( ACTIVE_UPDATE_RATE, SENSE_CONNECT_EXCEPTIONS, @@ -27,13 +33,21 @@ _LOGGER = logging.getLogger(__name__) class SenseCoordinator(DataUpdateCoordinator[None]): """Sense Trend Coordinator.""" + config_entry: SenseConfigEntry + def __init__( - self, hass: HomeAssistant, gateway: ASyncSenseable, name: str, update: int + self, + hass: HomeAssistant, + config_entry: SenseConfigEntry, + gateway: ASyncSenseable, + name: str, + update: int, ) -> None: """Initialize.""" super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=f"Sense {name} {gateway.sense_monitor_id}", update_interval=timedelta(seconds=update), ) @@ -44,9 +58,14 @@ class SenseCoordinator(DataUpdateCoordinator[None]): class SenseTrendCoordinator(SenseCoordinator): """Sense Trend Coordinator.""" - def __init__(self, hass: HomeAssistant, gateway: ASyncSenseable) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SenseConfigEntry, + gateway: ASyncSenseable, + ) -> None: """Initialize.""" - super().__init__(hass, gateway, "Trends", TREND_UPDATE_RATE) + super().__init__(hass, config_entry, gateway, "Trends", TREND_UPDATE_RATE) async def _async_update_data(self) -> None: """Update the trend data.""" @@ -62,9 +81,14 @@ class SenseTrendCoordinator(SenseCoordinator): class SenseRealtimeCoordinator(SenseCoordinator): """Sense Realtime Coordinator.""" - def __init__(self, hass: HomeAssistant, gateway: ASyncSenseable) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SenseConfigEntry, + gateway: ASyncSenseable, + ) -> None: """Initialize.""" - super().__init__(hass, gateway, "Realtime", ACTIVE_UPDATE_RATE) + super().__init__(hass, config_entry, gateway, "Realtime", ACTIVE_UPDATE_RATE) async def _async_update_data(self) -> None: """Retrieve latest state.""" From 5464e245a2c0bda1f4bcaac9bb9de9745dc42e67 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:35:34 +0100 Subject: [PATCH 0223/1941] Explicitly pass in the config_entry in ruuvi_gateway coordinator (#137964) explicitly pass in the config_entry in coordinator --- .../components/ruuvi_gateway/__init__.py | 12 ++------- .../components/ruuvi_gateway/coordinator.py | 25 ++++++++++++------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ruuvi_gateway/__init__.py b/homeassistant/components/ruuvi_gateway/__init__.py index 77b3e9b57de..da93a89a9f3 100644 --- a/homeassistant/components/ruuvi_gateway/__init__.py +++ b/homeassistant/components/ruuvi_gateway/__init__.py @@ -5,11 +5,10 @@ from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from .bluetooth import async_connect_scanner -from .const import DOMAIN, SCAN_INTERVAL +from .const import DOMAIN from .coordinator import RuuviGatewayUpdateCoordinator from .models import RuuviGatewayRuntimeData @@ -18,14 +17,7 @@ _LOGGER = logging.getLogger(DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ruuvi Gateway from a config entry.""" - coordinator = RuuviGatewayUpdateCoordinator( - hass, - logger=_LOGGER, - name=entry.title, - update_interval=SCAN_INTERVAL, - host=entry.data[CONF_HOST], - token=entry.data[CONF_TOKEN], - ) + coordinator = RuuviGatewayUpdateCoordinator(hass, entry, _LOGGER) scanner, unload_scanner = async_connect_scanner(hass, entry, coordinator) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RuuviGatewayRuntimeData( update_coordinator=coordinator, diff --git a/homeassistant/components/ruuvi_gateway/coordinator.py b/homeassistant/components/ruuvi_gateway/coordinator.py index ba72dfe4cbc..0c42cd0cb38 100644 --- a/homeassistant/components/ruuvi_gateway/coordinator.py +++ b/homeassistant/components/ruuvi_gateway/coordinator.py @@ -2,34 +2,41 @@ from __future__ import annotations -from datetime import timedelta import logging from aioruuvigateway.api import get_gateway_history_data from aioruuvigateway.models import TagData +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import SCAN_INTERVAL + class RuuviGatewayUpdateCoordinator(DataUpdateCoordinator[list[TagData]]): """Polls the gateway for data and returns a list of TagData objects that have changed since the last poll.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, logger: logging.Logger, - *, - name: str, - update_interval: timedelta | None = None, - host: str, - token: str, ) -> None: """Initialize the coordinator using the given configuration (host, token).""" - super().__init__(hass, logger, name=name, update_interval=update_interval) - self.host = host - self.token = token + super().__init__( + hass, + logger, + config_entry=config_entry, + name=config_entry.title, + update_interval=SCAN_INTERVAL, + ) + self.host = config_entry.data[CONF_HOST] + self.token = config_entry.data[CONF_TOKEN] self.last_tag_datas: dict[str, TagData] = {} async def _async_update_data(self) -> list[TagData]: From 7d4888920a04af55644f21ad4aec928b7fd6c8da Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:35:46 +0100 Subject: [PATCH 0224/1941] Explicitly pass in the config_entry in ruckus_unleashed coordinator (#137965) explicitly pass in the config_entry in coordinator --- .../components/ruckus_unleashed/__init__.py | 2 +- .../components/ruckus_unleashed/coordinator.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index 4ee870e8322..8e9219985ce 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await ruckus.close() raise ConfigEntryAuthFailed from autherr - coordinator = RuckusDataUpdateCoordinator(hass, ruckus=ruckus) + coordinator = RuckusDataUpdateCoordinator(hass, entry, ruckus) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py index d9f20883559..7ffaab2e977 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -6,6 +6,7 @@ import logging from aioruckus import AjaxSession from aioruckus.exceptions import AuthenticationError, SchemaError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,17 +19,20 @@ _LOGGER = logging.getLogger(__package__) class RuckusDataUpdateCoordinator(DataUpdateCoordinator): """Coordinator to manage data from Ruckus client.""" - def __init__(self, hass: HomeAssistant, *, ruckus: AjaxSession) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, ruckus: AjaxSession + ) -> None: """Initialize global Ruckus data updater.""" self.ruckus = ruckus - update_interval = timedelta(seconds=SCAN_INTERVAL) - super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, - update_interval=update_interval, + update_interval=timedelta(seconds=SCAN_INTERVAL), ) async def _fetch_clients(self) -> dict: From 42adc5c1e0757a6753663826d80cdc8b507f6537 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:41:52 +0100 Subject: [PATCH 0225/1941] Explicitly pass in the config_entry in schlage coordinator (#137959) explicitly pass in the config_entry in coordinator --- homeassistant/components/schlage/__init__.py | 9 ++++----- .../components/schlage/binary_sensor.py | 3 +-- .../components/schlage/coordinator.py | 19 ++++++++++++++++--- .../components/schlage/diagnostics.py | 2 +- homeassistant/components/schlage/lock.py | 3 +-- homeassistant/components/schlage/select.py | 3 +-- homeassistant/components/schlage/sensor.py | 5 ++--- homeassistant/components/schlage/switch.py | 5 ++--- 8 files changed, 28 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index 6eae69d9542..509a335aafe 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -5,12 +5,11 @@ from __future__ import annotations from pycognito.exceptions import WarrantException import pyschlage -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .coordinator import SchlageDataUpdateCoordinator +from .coordinator import SchlageConfigEntry, SchlageDataUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -20,8 +19,6 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] -type SchlageConfigEntry = ConfigEntry[SchlageDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> bool: """Set up Schlage from a config entry.""" @@ -32,7 +29,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SchlageConfigEntry) -> b except WarrantException as ex: raise ConfigEntryAuthFailed from ex - coordinator = SchlageDataUpdateCoordinator(hass, username, pyschlage.Schlage(auth)) + coordinator = SchlageDataUpdateCoordinator( + hass, entry, username, pyschlage.Schlage(auth) + ) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py index f928d42b3ee..280853237d4 100644 --- a/homeassistant/components/schlage/binary_sensor.py +++ b/homeassistant/components/schlage/binary_sensor.py @@ -14,8 +14,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SchlageConfigEntry -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index 936ef9ee91e..eec143c574f 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -34,15 +34,28 @@ class SchlageData: locks: dict[str, LockData] +type SchlageConfigEntry = ConfigEntry[SchlageDataUpdateCoordinator] + + class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): """The Schlage data update coordinator.""" - config_entry: ConfigEntry + config_entry: SchlageConfigEntry - def __init__(self, hass: HomeAssistant, username: str, api: Schlage) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SchlageConfigEntry, + username: str, + api: Schlage, + ) -> None: """Initialize the class.""" super().__init__( - hass, LOGGER, name=f"{DOMAIN} ({username})", update_interval=UPDATE_INTERVAL + hass, + LOGGER, + config_entry=config_entry, + name=f"{DOMAIN} ({username})", + update_interval=UPDATE_INTERVAL, ) self.data = SchlageData(locks={}) self.api = api diff --git a/homeassistant/components/schlage/diagnostics.py b/homeassistant/components/schlage/diagnostics.py index ec4d9c489e3..357f04f00db 100644 --- a/homeassistant/components/schlage/diagnostics.py +++ b/homeassistant/components/schlage/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import SchlageConfigEntry +from .coordinator import SchlageConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index d203913191d..697c2e8399f 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -8,8 +8,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SchlageConfigEntry -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py index 6cf0853835f..f93eee78d34 100644 --- a/homeassistant/components/schlage/select.py +++ b/homeassistant/components/schlage/select.py @@ -7,8 +7,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SchlageConfigEntry -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity _DESCRIPTIONS = ( diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py index a15d1740b91..f7fb7c63b22 100644 --- a/homeassistant/components/schlage/sensor.py +++ b/homeassistant/components/schlage/sensor.py @@ -8,12 +8,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity _SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ @@ -29,7 +28,7 @@ _SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SchlageConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors based on a config entry.""" diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py index 39fe6dbbc99..56ff0ebe360 100644 --- a/homeassistant/components/schlage/switch.py +++ b/homeassistant/components/schlage/switch.py @@ -14,12 +14,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import LockData, SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -56,7 +55,7 @@ SWITCHES: tuple[SchlageSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SchlageConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches based on a config entry.""" From 96b4a71f6fd36b7bff208a3edbac4d6ded149d0c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 9 Feb 2025 15:43:43 +0100 Subject: [PATCH 0226/1941] Explicitly pass in the config_entry in imap coordinator (#138068) --- homeassistant/components/imap/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 1df107196ff..74f7a86c0d6 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -241,6 +241,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=update_interval, ) From fa35f29c27698f6d34b26de194db59c4dd986932 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:43:56 +0100 Subject: [PATCH 0227/1941] Explicitly pass in the config_entry in roku coordinator (#137968) explicitly pass in the config_entry in coordinator --- homeassistant/components/roku/__init__.py | 20 +++-------------- .../components/roku/binary_sensor.py | 2 +- homeassistant/components/roku/config_flow.py | 2 +- homeassistant/components/roku/coordinator.py | 22 ++++++++++++++----- homeassistant/components/roku/diagnostics.py | 2 +- homeassistant/components/roku/entity.py | 2 +- homeassistant/components/roku/media_player.py | 3 +-- homeassistant/components/roku/remote.py | 2 +- homeassistant/components/roku/select.py | 2 +- homeassistant/components/roku/sensor.py | 2 +- 10 files changed, 28 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index e6b92d91335..be0b20c97fb 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID -from .coordinator import RokuDataUpdateCoordinator +from .coordinator import RokuConfigEntry, RokuDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -17,22 +15,10 @@ PLATFORMS = [ Platform.SENSOR, ] -type RokuConfigEntry = ConfigEntry[RokuDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Set up Roku from a config entry.""" - if (device_id := entry.unique_id) is None: - device_id = entry.entry_id - - coordinator = RokuDataUpdateCoordinator( - hass, - host=entry.data[CONF_HOST], - device_id=device_id, - play_media_app_id=entry.options.get( - CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID - ), - ) + coordinator = RokuDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index 2e7fd12788c..1afc580f2fe 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry from .entity import RokuEntity # Coordinator is used to centralize the data updates diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 2fb016b5467..47bc86802d2 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -25,8 +25,8 @@ from homeassistant.helpers.service_info.ssdp import ( ) from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import RokuConfigEntry from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN +from .coordinator import RokuConfigEntry DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) diff --git a/homeassistant/components/roku/coordinator.py b/homeassistant/components/roku/coordinator.py index 7900669d02f..e3c20d8351f 100644 --- a/homeassistant/components/roku/coordinator.py +++ b/homeassistant/components/roku/coordinator.py @@ -8,33 +8,44 @@ import logging from rokuecp import Roku, RokuError from rokuecp.models import Device +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.dt import utcnow -from .const import DOMAIN +from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN REQUEST_REFRESH_DELAY = 0.35 SCAN_INTERVAL = timedelta(seconds=10) _LOGGER = logging.getLogger(__name__) +type RokuConfigEntry = ConfigEntry[RokuDataUpdateCoordinator] + class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Class to manage fetching Roku data.""" + config_entry: RokuConfigEntry last_full_update: datetime | None roku: Roku def __init__( - self, hass: HomeAssistant, *, host: str, device_id: str, play_media_app_id: str + self, + hass: HomeAssistant, + config_entry: RokuConfigEntry, ) -> None: """Initialize global Roku data updater.""" - self.device_id = device_id - self.roku = Roku(host=host, session=async_get_clientsession(hass)) - self.play_media_app_id = play_media_app_id + self.device_id = config_entry.unique_id or config_entry.entry_id + self.roku = Roku( + host=config_entry.data[CONF_HOST], session=async_get_clientsession(hass) + ) + self.play_media_app_id = config_entry.options.get( + CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID + ) self.full_update_interval = timedelta(minutes=15) self.last_full_update = None @@ -42,6 +53,7 @@ class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, # We don't want an immediate refresh since the device diff --git a/homeassistant/components/roku/diagnostics.py b/homeassistant/components/roku/diagnostics.py index e98837ca442..86e7a7ac1c9 100644 --- a/homeassistant/components/roku/diagnostics.py +++ b/homeassistant/components/roku/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/roku/entity.py b/homeassistant/components/roku/entity.py index 259cb092cb8..1321e3806d1 100644 --- a/homeassistant/components/roku/entity.py +++ b/homeassistant/components/roku/entity.py @@ -6,8 +6,8 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import RokuDataUpdateCoordinator from .const import DOMAIN +from .coordinator import RokuDataUpdateCoordinator class RokuEntity(CoordinatorEntity[RokuDataUpdateCoordinator]): diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 0c1f92521af..fb4f8b1c2e8 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -29,7 +29,6 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import RokuConfigEntry from .browse_media import async_browse_media from .const import ( ATTR_ARTIST_NAME, @@ -40,7 +39,7 @@ from .const import ( ATTR_THUMBNAIL, SERVICE_SEARCH, ) -from .coordinator import RokuDataUpdateCoordinator +from .coordinator import RokuConfigEntry, RokuDataUpdateCoordinator from .entity import RokuEntity from .helpers import format_channel_name, roku_exception_handler diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index f7916fb23a2..fd76e2e8dcf 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -9,7 +9,7 @@ from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry from .entity import RokuEntity from .helpers import roku_exception_handler diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index 360d4e25415..c99b9892b47 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -12,7 +12,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry from .entity import RokuEntity from .helpers import format_channel_name, roku_exception_handler diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index 870386945a6..96295984f76 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RokuConfigEntry +from .coordinator import RokuConfigEntry from .entity import RokuEntity # Coordinator is used to centralize the data updates From 8f4a466c3d6f7a52d66e4b3d5f07cdd016a79ca6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:44:06 +0100 Subject: [PATCH 0228/1941] Explicitly pass in the config_entry in rituals_perfume_genie coordinator (#137971) explicitly pass in the config_entry in coordinator --- homeassistant/components/rituals_perfume_genie/__init__.py | 4 +++- .../components/rituals_perfume_genie/coordinator.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index d0d16ba6324..e920c2426fe 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -44,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Create a coordinator for each diffuser coordinators = { - diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser, update_interval) + diffuser.hublot: RitualsDataUpdateCoordinator( + hass, entry, diffuser, update_interval + ) for diffuser in account_devices } diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py index a83e823bd4e..bbcb24b3e65 100644 --- a/homeassistant/components/rituals_perfume_genie/coordinator.py +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -5,6 +5,7 @@ import logging from pyrituals import Diffuser +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -16,9 +17,12 @@ _LOGGER = logging.getLogger(__name__) class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, diffuser: Diffuser, update_interval: timedelta, ) -> None: @@ -27,6 +31,7 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN}-{diffuser.hublot}", update_interval=update_interval, ) From 7b42dc5c35dd6350642df877f0c22dbd0e178faa Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:44:18 +0100 Subject: [PATCH 0229/1941] Explicitly pass in the config_entry in risco coordinator (#137972) explicitly pass in the config_entry in coordinator --- homeassistant/components/risco/__init__.py | 9 ++--- homeassistant/components/risco/coordinator.py | 34 ++++++++++++++----- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 7255c724e3f..56c7a509cca 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -16,7 +16,6 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PIN, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_TYPE, CONF_USERNAME, Platform, @@ -30,7 +29,6 @@ from .const import ( CONF_CONCURRENCY, DATA_COORDINATOR, DEFAULT_CONCURRENCY, - DEFAULT_SCAN_INTERVAL, DOMAIN, EVENTS_COORDINATOR, SYSTEM_UPDATE_SIGNAL, @@ -144,12 +142,9 @@ async def _async_setup_cloud_entry(hass: HomeAssistant, entry: ConfigEntry) -> b except UnauthorizedError as error: raise ConfigEntryAuthFailed from error - scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinator = RiscoDataUpdateCoordinator(hass, risco, scan_interval) + coordinator = RiscoDataUpdateCoordinator(hass, entry, risco) await coordinator.async_config_entry_first_refresh() - events_coordinator = RiscoEventsDataUpdateCoordinator( - hass, risco, entry.entry_id, 60 - ) + events_coordinator = RiscoEventsDataUpdateCoordinator(hass, entry, risco) entry.async_on_unload(entry.add_update_listener(_update_listener)) diff --git a/homeassistant/components/risco/coordinator.py b/homeassistant/components/risco/coordinator.py index 8430b6a6172..e7140eb9616 100644 --- a/homeassistant/components/risco/coordinator.py +++ b/homeassistant/components/risco/coordinator.py @@ -10,11 +10,13 @@ from pyrisco import CannotConnectError, OperationError, RiscoCloud, Unauthorized from pyrisco.cloud.alarm import Alarm from pyrisco.cloud.event import Event +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN LAST_EVENT_STORAGE_VERSION = 1 LAST_EVENT_TIMESTAMP_KEY = "last_event_timestamp" @@ -24,17 +26,26 @@ _LOGGER = logging.getLogger(__name__) class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): """Class to manage fetching risco data.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, risco: RiscoCloud, scan_interval: int + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + risco: RiscoCloud, ) -> None: """Initialize global risco data updater.""" self.risco = risco - interval = timedelta(seconds=scan_interval) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, - update_interval=interval, + update_interval=timedelta( + seconds=config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + ), ) async def _async_update_data(self) -> Alarm: @@ -48,20 +59,27 @@ class RiscoDataUpdateCoordinator(DataUpdateCoordinator[Alarm]): class RiscoEventsDataUpdateCoordinator(DataUpdateCoordinator[list[Event]]): """Class to manage fetching risco data.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, risco: RiscoCloud, eid: str, scan_interval: int + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + risco: RiscoCloud, ) -> None: """Initialize global risco data updater.""" self.risco = risco self._store = Store[dict[str, Any]]( - hass, LAST_EVENT_STORAGE_VERSION, f"risco_{eid}_last_event_timestamp" + hass, + LAST_EVENT_STORAGE_VERSION, + f"risco_{config_entry.entry_id}_last_event_timestamp", ) - interval = timedelta(seconds=scan_interval) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN}_events", - update_interval=interval, + update_interval=timedelta(seconds=60), ) async def _async_update_data(self) -> list[Event]: From 8afc3568fb002d0aaf1bdc704f9211d1c163b927 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:44:31 +0100 Subject: [PATCH 0230/1941] Explicitly pass in the config_entry in renson coordinator (#137974) explicitly pass in the config_entry in coordinator --- homeassistant/components/renson/__init__.py | 2 +- homeassistant/components/renson/coordinator.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index d1eebdf0a5f..b88f9bb036a 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Renson from a config entry.""" api = RensonVentilation(entry.data[CONF_HOST]) - coordinator = RensonCoordinator("Renson", hass, api) + coordinator = RensonCoordinator(hass, entry, api) if not await hass.async_add_executor_job(api.connect): raise ConfigEntryNotReady("Cannot connect to Renson device") diff --git a/homeassistant/components/renson/coordinator.py b/homeassistant/components/renson/coordinator.py index 8613220eee1..5d0a20e1c29 100644 --- a/homeassistant/components/renson/coordinator.py +++ b/homeassistant/components/renson/coordinator.py @@ -9,30 +9,35 @@ from typing import Any from renson_endura_delta.renson import RensonVentilation +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) class RensonCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Data update coordinator for Renson.""" + config_entry: ConfigEntry + def __init__( self, - name: str, hass: HomeAssistant, + config_entry: ConfigEntry, api: RensonVentilation, - update_interval=timedelta(seconds=30), ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, # Name of the data. For logging purposes. - name=name, + name=DOMAIN, # Polling interval. Will only be polled if there are subscribers. - update_interval=update_interval, + update_interval=timedelta(seconds=30), ) self.api = api From 4706beb6efcefa4b641e9bf724c1007ebcceadc8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:44:48 +0100 Subject: [PATCH 0231/1941] Explicitly pass in the config_entry in renault coordinator (#137977) explicitly pass in the config_entry in coordinator --- homeassistant/components/renault/coordinator.py | 8 +++++++- homeassistant/components/renault/renault_hub.py | 10 +++++++--- homeassistant/components/renault/renault_vehicle.py | 10 +++++++--- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index 89e62867130..a90331730bc 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -6,7 +6,7 @@ import asyncio from collections.abc import Awaitable, Callable from datetime import timedelta import logging -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from renault_api.kamereon.exceptions import ( AccessDeniedException, @@ -18,6 +18,9 @@ from renault_api.kamereon.models import KamereonVehicleDataAttributes from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import RenaultConfigEntry + T = TypeVar("T", bound=KamereonVehicleDataAttributes) # We have potentially 7 coordinators per vehicle @@ -27,11 +30,13 @@ _PARALLEL_SEMAPHORE = asyncio.Semaphore(1) class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): """Handle vehicle communication with Renault servers.""" + config_entry: RenaultConfigEntry update_method: Callable[[], Awaitable[T]] def __init__( self, hass: HomeAssistant, + config_entry: RenaultConfigEntry, logger: logging.Logger, *, name: str, @@ -42,6 +47,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): super().__init__( hass, logger, + config_entry=config_entry, name=name, update_interval=update_interval, update_method=update_method, diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 76b197b2aaf..b37390526cf 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -5,13 +5,13 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging +from typing import TYPE_CHECKING from renault_api.gigya.exceptions import InvalidCredentialsException from renault_api.kamereon.models import KamereonVehiclesLink from renault_api.renault_account import RenaultAccount from renault_api.renault_client import RenaultClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, @@ -24,6 +24,9 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +if TYPE_CHECKING: + from . import RenaultConfigEntry + from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL from .renault_vehicle import RenaultVehicleProxy @@ -52,7 +55,7 @@ class RenaultHub: return True return False - async def async_initialise(self, config_entry: ConfigEntry) -> None: + async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: """Set up proxy.""" account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) @@ -86,7 +89,7 @@ class RenaultHub: vehicle_link: KamereonVehiclesLink, renault_account: RenaultAccount, scan_interval: timedelta, - config_entry: ConfigEntry, + config_entry: RenaultConfigEntry, device_registry: dr.DeviceRegistry, ) -> None: """Set up proxy.""" @@ -95,6 +98,7 @@ class RenaultHub: # Generate vehicle proxy vehicle = RenaultVehicleProxy( hass=self._hass, + config_entry=config_entry, vehicle=await renault_account.get_api_vehicle(vehicle_link.vin), details=vehicle_link.vehicleDetails, scan_interval=scan_interval, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index d8266d75319..1cce0e4459f 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from functools import wraps import logging -from typing import Any, Concatenate, cast +from typing import TYPE_CHECKING, Any, Concatenate, cast from renault_api.exceptions import RenaultException from renault_api.kamereon import models @@ -18,6 +18,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo +if TYPE_CHECKING: + from . import RenaultConfigEntry + from .const import DOMAIN from .coordinator import RenaultDataUpdateCoordinator @@ -64,12 +67,14 @@ class RenaultVehicleProxy: def __init__( self, hass: HomeAssistant, + config_entry: RenaultConfigEntry, vehicle: RenaultVehicle, details: models.KamereonVehicleDetails, scan_interval: timedelta, ) -> None: """Initialise vehicle proxy.""" self.hass = hass + self.config_entry = config_entry self._vehicle = vehicle self._details = details self._device_info = DeviceInfo( @@ -98,11 +103,10 @@ class RenaultVehicleProxy: self.coordinators = { coord.key: RenaultDataUpdateCoordinator( self.hass, + self.config_entry, LOGGER, - # Name of the data. For logging purposes. name=f"{self.details.vin} {coord.key}", update_method=coord.update_method(self._vehicle), - # Polling interval. Will only be polled if there are subscribers. update_interval=self._scan_interval, ) for coord in COORDINATORS From 2418ef8e8e39a2e2c915d6657fa237ad05fdace0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:45:51 +0100 Subject: [PATCH 0232/1941] Explicitly pass in the config_entry in refoss coordinator (#137978) explicitly pass in the config_entry in coordinator --- homeassistant/components/refoss/__init__.py | 2 +- homeassistant/components/refoss/bridge.py | 8 ++++++-- homeassistant/components/refoss/coordinator.py | 8 +++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/refoss/__init__.py b/homeassistant/components/refoss/__init__.py index 0f0c852b043..eb2085efda4 100644 --- a/homeassistant/components/refoss/__init__.py +++ b/homeassistant/components/refoss/__init__.py @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Refoss from a config entry.""" hass.data.setdefault(DOMAIN, {}) discover = await refoss_discovery_server(hass) - refoss_discovery = DiscoveryService(hass, discover) + refoss_discovery = DiscoveryService(hass, entry, discover) hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] = refoss_discovery await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py index 11e92620fbb..a3ba9ea663d 100644 --- a/homeassistant/components/refoss/bridge.py +++ b/homeassistant/components/refoss/bridge.py @@ -6,6 +6,7 @@ from refoss_ha.device import DeviceInfo from refoss_ha.device_manager import async_build_base_device from refoss_ha.discovery import Discovery, Listener +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -16,9 +17,12 @@ from .coordinator import RefossDataUpdateCoordinator class DiscoveryService(Listener): """Discovery event handler for refoss devices.""" - def __init__(self, hass: HomeAssistant, discovery: Discovery) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, discovery: Discovery + ) -> None: """Init discovery service.""" self.hass = hass + self.config_entry = config_entry self.discovery = discovery self.discovery.add_listener(self) @@ -32,7 +36,7 @@ class DiscoveryService(Listener): if device is None: return - coordo = RefossDataUpdateCoordinator(self.hass, device) + coordo = RefossDataUpdateCoordinator(self.hass, self.config_entry, device) self.hass.data[DOMAIN][COORDINATORS].append(coordo) await coordo.async_refresh() diff --git a/homeassistant/components/refoss/coordinator.py b/homeassistant/components/refoss/coordinator.py index 929d1b3962b..381f64614b5 100644 --- a/homeassistant/components/refoss/coordinator.py +++ b/homeassistant/components/refoss/coordinator.py @@ -7,6 +7,7 @@ from datetime import timedelta from refoss_ha.controller.device import BaseDevice from refoss_ha.exceptions import DeviceTimeoutError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -16,11 +17,16 @@ from .const import _LOGGER, DOMAIN, MAX_ERRORS class RefossDataUpdateCoordinator(DataUpdateCoordinator[None]): """Manages polling for state changes from the device.""" - def __init__(self, hass: HomeAssistant, device: BaseDevice) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: BaseDevice + ) -> None: """Initialize the data update coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN}-{device.device_info.dev_name}", update_interval=timedelta(seconds=15), ) From dacb29e7fc214c64cbcdeddc782f6b31a28f65f4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:46:03 +0100 Subject: [PATCH 0233/1941] Explicitly pass in the config_entry in snapcast coordinator (#137942) * explicitly pass in the config_entry in coordinator * break up error message --- homeassistant/components/snapcast/__init__.py | 7 +++---- homeassistant/components/snapcast/coordinator.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index b853535b525..9c1602494e5 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -11,15 +11,14 @@ from .coordinator import SnapcastUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Snapcast from a config entry.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] - coordinator = SnapcastUpdateCoordinator(hass, host, port) + coordinator = SnapcastUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except OSError as ex: raise ConfigEntryNotReady( - f"Could not connect to Snapcast server at {host}:{port}" + "Could not connect to Snapcast server at " + f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}" ) from ex hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/snapcast/coordinator.py b/homeassistant/components/snapcast/coordinator.py index 5bb9ae4e51f..4c2f0cb81b7 100644 --- a/homeassistant/components/snapcast/coordinator.py +++ b/homeassistant/components/snapcast/coordinator.py @@ -6,6 +6,8 @@ import logging from snapcast.control.server import Snapserver +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -15,15 +17,20 @@ _LOGGER = logging.getLogger(__name__) class SnapcastUpdateCoordinator(DataUpdateCoordinator[None]): """Data update coordinator for pushed data from Snapcast server.""" - def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize coordinator.""" + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + super().__init__( hass, logger=_LOGGER, + config_entry=config_entry, name=f"{host}:{port}", update_interval=None, # Disable update interval as server pushes ) - self._server = Snapserver(hass.loop, host, port, True) self.last_update_success = False From d522af729ad76e7c76af01f4f5aea15efdf92eb5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:46:12 +0100 Subject: [PATCH 0234/1941] Explicitly pass in the config_entry in rainmachine coordinator (#137979) explicitly pass in the config_entry in coordinator --- homeassistant/components/rainmachine/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainmachine/coordinator.py b/homeassistant/components/rainmachine/coordinator.py index df7972ef31d..de43e5a073f 100644 --- a/homeassistant/components/rainmachine/coordinator.py +++ b/homeassistant/components/rainmachine/coordinator.py @@ -41,6 +41,7 @@ class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): super().__init__( hass, LOGGER, + config_entry=entry, name=name, update_interval=update_interval, update_method=update_method, @@ -49,7 +50,6 @@ class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): self._rebooting = False self._signal_handler_unsubs: list[Callable[[], None]] = [] - self.config_entry = entry self.signal_reboot_completed = SIGNAL_REBOOT_COMPLETED.format( self.config_entry.entry_id ) From 017af4fcf878642e50a2d1233122dac2bd022bc0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:46:31 +0100 Subject: [PATCH 0235/1941] Explicitly pass in the config_entry in solarlog coordinator (#137939) explicitly pass in the config_entry in coordinator --- homeassistant/components/solarlog/__init__.py | 4 +--- .../components/solarlog/coordinator.py | 21 ++++++++++++------- .../components/solarlog/diagnostics.py | 2 +- homeassistant/components/solarlog/sensor.py | 2 +- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index 5937c8a496d..7ad1ec8e547 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -2,18 +2,16 @@ import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .const import CONF_HAS_PWD -from .coordinator import SolarLogCoordinator +from .coordinator import SolarlogConfigEntry, SolarLogCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type SolarlogConfigEntry = ConfigEntry[SolarLogCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SolarlogConfigEntry) -> bool: diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index bf2bc849111..6292b1332d7 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import timedelta import logging -from typing import TYPE_CHECKING from urllib.parse import ParseResult, urlparse from solarlog_cli.solarlog_connector import SolarLogConnector @@ -16,6 +15,7 @@ from solarlog_cli.solarlog_exceptions import ( ) from solarlog_cli.solarlog_models import SolarlogData +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -28,30 +28,35 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -if TYPE_CHECKING: - from . import SolarlogConfigEntry +type SolarlogConfigEntry = ConfigEntry[SolarLogCoordinator] class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): """Get and update the latest data.""" - def __init__(self, hass: HomeAssistant, entry: SolarlogConfigEntry) -> None: + config_entry: SolarlogConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: SolarlogConfigEntry) -> None: """Initialize the data object.""" super().__init__( - hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) + hass, + _LOGGER, + config_entry=config_entry, + name="SolarLog", + update_interval=timedelta(seconds=60), ) self.new_device_callbacks: list[Callable[[int], None]] = [] self._devices_last_update: set[tuple[int, str]] = set() - host_entry = entry.data[CONF_HOST] - password = entry.data.get("password", "") + host_entry = config_entry.data[CONF_HOST] + password = config_entry.data.get("password", "") url = urlparse(host_entry, "http") netloc = url.netloc or url.path path = url.path if url.netloc else "" url = ParseResult("http", netloc, path, *url[3:]) - self.unique_id = entry.entry_id + self.unique_id = config_entry.entry_id self.host = url.geturl() self.solarlog = SolarLogConnector( diff --git a/homeassistant/components/solarlog/diagnostics.py b/homeassistant/components/solarlog/diagnostics.py index 02f6c96edc2..c99222542ea 100644 --- a/homeassistant/components/solarlog/diagnostics.py +++ b/homeassistant/components/solarlog/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import SolarlogConfigEntry +from .coordinator import SolarlogConfigEntry TO_REDACT = [ CONF_HOST, diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index bcff5d57e1b..8fd6e3c0194 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import SolarlogConfigEntry +from .coordinator import SolarlogConfigEntry from .entity import SolarLogCoordinatorEntity, SolarLogInverterEntity From e4ec217cfaf77e1deedb01aebed0ee7207665721 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:46:54 +0100 Subject: [PATCH 0236/1941] Explicitly pass in the config_entry in tesla_fleet coordinator (#137909) explicitly pass in the config_entry in coordinator --- .../components/tesla_fleet/__init__.py | 12 +++-- .../components/tesla_fleet/coordinator.py | 44 ++++++++++++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 634e8f845f9..27bfb9134ab 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -139,7 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - api = VehicleSigned(tesla.vehicle, vin) else: api = VehicleSpecific(tesla.vehicle, vin) - coordinator = TeslaFleetVehicleDataCoordinator(hass, api, product) + coordinator = TeslaFleetVehicleDataCoordinator(hass, entry, api, product) await coordinator.async_config_entry_first_refresh() @@ -175,9 +175,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - api = EnergySpecific(tesla.energy, site_id) - live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, api) - history_coordinator = TeslaFleetEnergySiteHistoryCoordinator(hass, api) - info_coordinator = TeslaFleetEnergySiteInfoCoordinator(hass, api, product) + live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, entry, api) + history_coordinator = TeslaFleetEnergySiteHistoryCoordinator( + hass, entry, api + ) + info_coordinator = TeslaFleetEnergySiteInfoCoordinator( + hass, entry, api, product + ) await live_coordinator.async_config_entry_first_refresh() await history_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 4d99319d49f..129f460ff90 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -1,9 +1,11 @@ """Tesla Fleet Data Coordinator.""" +from __future__ import annotations + from datetime import datetime, timedelta from random import randint from time import time -from typing import Any +from typing import TYPE_CHECKING, Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint @@ -21,6 +23,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import TeslaFleetConfigEntry + from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState VEHICLE_INTERVAL_SECONDS = 300 @@ -57,18 +62,24 @@ def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]: class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the TeslaFleet API.""" + config_entry: TeslaFleetConfigEntry updated_once: bool pre2021: bool last_active: datetime rate: RateCalculator def __init__( - self, hass: HomeAssistant, api: VehicleSpecific, product: dict + self, + hass: HomeAssistant, + config_entry: TeslaFleetConfigEntry, + api: VehicleSpecific, + product: dict, ) -> None: """Initialize TeslaFleet Vehicle Update Coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Tesla Fleet Vehicle", update_interval=VEHICLE_INTERVAL, ) @@ -141,13 +152,20 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the TeslaFleet API.""" + config_entry: TeslaFleetConfigEntry updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslaFleetConfigEntry, + api: EnergySpecific, + ) -> None: """Initialize TeslaFleet Energy Site Live coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Tesla Fleet Energy Site Live", update_interval=timedelta(seconds=10), ) @@ -188,11 +206,19 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site history import and export from the Tesla Fleet API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + config_entry: TeslaFleetConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslaFleetConfigEntry, + api: EnergySpecific, + ) -> None: """Initialize Tesla Fleet Energy Site History coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=f"Tesla Fleet Energy History {api.energy_site_id}", update_interval=timedelta(seconds=300), ) @@ -243,13 +269,21 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the TeslaFleet API.""" + config_entry: TeslaFleetConfigEntry updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific, product: dict) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslaFleetConfigEntry, + api: EnergySpecific, + product: dict, + ) -> None: """Initialize TeslaFleet Energy Info coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Tesla Fleet Energy Site Info", update_interval=timedelta(seconds=15), ) From 60a3dbae417c4e82150fe39b2c1798a0709643e3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:47:07 +0100 Subject: [PATCH 0237/1941] Explicitly pass in the config_entry in sonarr coordinator (#137938) explicitly pass in the config_entry in coordinator --- homeassistant/components/sonarr/__init__.py | 20 ++++++++++++------- .../components/sonarr/coordinator.py | 2 ++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 7718ff799f5..960227ff0da 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -67,13 +67,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = { - "upcoming": CalendarDataUpdateCoordinator(hass, host_configuration, sonarr), - "commands": CommandsDataUpdateCoordinator(hass, host_configuration, sonarr), - "diskspace": DiskSpaceDataUpdateCoordinator(hass, host_configuration, sonarr), - "queue": QueueDataUpdateCoordinator(hass, host_configuration, sonarr), - "series": SeriesDataUpdateCoordinator(hass, host_configuration, sonarr), - "status": StatusDataUpdateCoordinator(hass, host_configuration, sonarr), - "wanted": WantedDataUpdateCoordinator(hass, host_configuration, sonarr), + "upcoming": CalendarDataUpdateCoordinator( + hass, entry, host_configuration, sonarr + ), + "commands": CommandsDataUpdateCoordinator( + hass, entry, host_configuration, sonarr + ), + "diskspace": DiskSpaceDataUpdateCoordinator( + hass, entry, host_configuration, sonarr + ), + "queue": QueueDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + "series": SeriesDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + "status": StatusDataUpdateCoordinator(hass, entry, host_configuration, sonarr), + "wanted": WantedDataUpdateCoordinator(hass, entry, host_configuration, sonarr), } # Temporary, until we add diagnostic entities _version = None diff --git a/homeassistant/components/sonarr/coordinator.py b/homeassistant/components/sonarr/coordinator.py index 25fc736212b..a73ef838590 100644 --- a/homeassistant/components/sonarr/coordinator.py +++ b/homeassistant/components/sonarr/coordinator.py @@ -48,6 +48,7 @@ class SonarrDataUpdateCoordinator(DataUpdateCoordinator[SonarrDataT]): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, host_configuration: PyArrHostConfiguration, api_client: SonarrClient, ) -> None: @@ -55,6 +56,7 @@ class SonarrDataUpdateCoordinator(DataUpdateCoordinator[SonarrDataT]): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) From 8a7d96919d688d2c5448930986757e50f414ec00 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:47:19 +0100 Subject: [PATCH 0238/1941] Explicitly pass in the config_entry in speedtestdotnet coordinator (#137936) explicitly pass in the config_entry in coordinator --- .../components/speedtestdotnet/__init__.py | 14 ++++++++------ .../components/speedtestdotnet/config_flow.py | 2 +- .../components/speedtestdotnet/coordinator.py | 11 ++++++++--- homeassistant/components/speedtestdotnet/sensor.py | 3 +-- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index e4c51ab7aa0..e4f439013c6 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -6,18 +6,16 @@ from functools import partial import speedtest -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.start import async_at_started -from .coordinator import SpeedTestDataCoordinator +from .coordinator import SpeedTestConfigEntry, SpeedTestDataCoordinator PLATFORMS = [Platform.SENSOR] -type SpeedTestConfigEntry = ConfigEntry[SpeedTestDataCoordinator] - async def async_setup_entry( hass: HomeAssistant, config_entry: SpeedTestConfigEntry @@ -49,11 +47,15 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: SpeedTestConfigEntry +) -> bool: """Unload SpeedTest Entry from config_entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: SpeedTestConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index 3bfd4eb6e4a..4fbca5e0d29 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.core import callback -from . import SpeedTestConfigEntry from .const import ( CONF_SERVER_ID, CONF_SERVER_NAME, @@ -17,6 +16,7 @@ from .const import ( DEFAULT_SERVER, DOMAIN, ) +from .coordinator import SpeedTestConfigEntry class SpeedTestFlowHandler(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/speedtestdotnet/coordinator.py b/homeassistant/components/speedtestdotnet/coordinator.py index 299652ba0bd..1308cb1d825 100644 --- a/homeassistant/components/speedtestdotnet/coordinator.py +++ b/homeassistant/components/speedtestdotnet/coordinator.py @@ -14,23 +14,28 @@ from .const import CONF_SERVER_ID, DEFAULT_SCAN_INTERVAL, DEFAULT_SERVER, DOMAIN _LOGGER = logging.getLogger(__name__) +type SpeedTestConfigEntry = ConfigEntry[SpeedTestDataCoordinator] + class SpeedTestDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Get the latest data from speedtest.net.""" - config_entry: ConfigEntry + config_entry: SpeedTestConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: speedtest.Speedtest + self, + hass: HomeAssistant, + config_entry: SpeedTestConfigEntry, + api: speedtest.Speedtest, ) -> None: """Initialize the data object.""" self.hass = hass - self.config_entry = config_entry self.api = api self.servers: dict[str, dict] = {DEFAULT_SERVER: {}} super().__init__( self.hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=DEFAULT_SCAN_INTERVAL), ) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 10da1dc93af..4363be5cf93 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -19,7 +19,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SpeedTestConfigEntry from .const import ( ATTR_BYTES_RECEIVED, ATTR_BYTES_SENT, @@ -30,7 +29,7 @@ from .const import ( DEFAULT_NAME, DOMAIN, ) -from .coordinator import SpeedTestDataCoordinator +from .coordinator import SpeedTestConfigEntry, SpeedTestDataCoordinator @dataclass(frozen=True) From 133fdb0ed2f89d4b59c4916c71bdb6b7703c4403 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:50:04 +0100 Subject: [PATCH 0239/1941] Explicitly pass in the config_entry in teslemetry coordinator (#137907) explicitly pass in the config_entry in coordinator --- .../components/teslemetry/__init__.py | 16 ++++--- .../components/teslemetry/coordinator.py | 43 ++++++++++++++++--- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 6e60b34825f..eef974cc5a7 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -112,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - product.pop("cached_data", None) vin = product["vin"] api = VehicleSpecific(teslemetry.vehicle, vin) - coordinator = TeslemetryVehicleDataCoordinator(hass, api, product) + coordinator = TeslemetryVehicleDataCoordinator(hass, entry, api, product) device = DeviceInfo( identifiers={(DOMAIN, vin)}, manufacturer="Tesla", @@ -177,15 +177,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - TeslemetryEnergyData( api=api, live_coordinator=( - TeslemetryEnergySiteLiveCoordinator(hass, api, live_status) + TeslemetryEnergySiteLiveCoordinator( + hass, entry, api, live_status + ) if isinstance(live_status, dict) else None ), info_coordinator=TeslemetryEnergySiteInfoCoordinator( - hass, api, product + hass, entry, api, product ), history_coordinator=( - TeslemetryEnergyHistoryCoordinator(hass, api) + TeslemetryEnergyHistoryCoordinator(hass, entry, api) if powerwall else None ), @@ -242,7 +244,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: TeslemetryConfigEntry +) -> bool: """Migrate config entry.""" if config_entry.version > 1: return False @@ -282,7 +286,7 @@ def create_handle_vehicle_stream(vin: str, coordinator) -> Callable[[dict], None async def async_setup_stream( - hass: HomeAssistant, entry: ConfigEntry, vehicle: TeslemetryVehicleData + hass: HomeAssistant, entry: TeslemetryConfigEntry, vehicle: TeslemetryVehicleData ): """Set up the stream for a vehicle.""" diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index d39402c622c..aaf9726ad1b 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -1,7 +1,9 @@ """Teslemetry Data Coordinator.""" +from __future__ import annotations + from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from tesla_fleet_api import EnergySpecific, VehicleSpecific from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint @@ -15,6 +17,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import TeslemetryConfigEntry + from .const import ENERGY_HISTORY_FIELDS, LOGGER from .helpers import flatten @@ -37,15 +42,21 @@ ENDPOINTS = [ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the Teslemetry API.""" + config_entry: TeslemetryConfigEntry last_active: datetime def __init__( - self, hass: HomeAssistant, api: VehicleSpecific, product: dict + self, + hass: HomeAssistant, + config_entry: TeslemetryConfigEntry, + api: VehicleSpecific, + product: dict, ) -> None: """Initialize Teslemetry Vehicle Update Coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name="Teslemetry Vehicle", update_interval=VEHICLE_INTERVAL, ) @@ -69,9 +80,16 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site live status from the Teslemetry API.""" + config_entry: TeslemetryConfigEntry updated_once: bool - def __init__(self, hass: HomeAssistant, api: EnergySpecific, data: dict) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslemetryConfigEntry, + api: EnergySpecific, + data: dict, + ) -> None: """Initialize Teslemetry Energy Site Live coordinator.""" super().__init__( hass, @@ -108,7 +126,15 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Teslemetry API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific, product: dict) -> None: + config_entry: TeslemetryConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslemetryConfigEntry, + api: EnergySpecific, + product: dict, + ) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( hass, @@ -135,7 +161,14 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching energy site info from the Teslemetry API.""" - def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + config_entry: TeslemetryConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TeslemetryConfigEntry, + api: EnergySpecific, + ) -> None: """Initialize Teslemetry Energy Info coordinator.""" super().__init__( hass, From f30018d89ebc03f2904a187788a969062ae1a55a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:50:46 +0100 Subject: [PATCH 0240/1941] Explicitly pass in the config_entry in rainforest_eagle coordinator (#137981) explicitly pass in the config_entry in coordinator --- .../rainforest_eagle/coordinator.py | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/coordinator.py b/homeassistant/components/rainforest_eagle/coordinator.py index 9c714a291ee..11956681638 100644 --- a/homeassistant/components/rainforest_eagle/coordinator.py +++ b/homeassistant/components/rainforest_eagle/coordinator.py @@ -29,13 +29,13 @@ _LOGGER = logging.getLogger(__name__) class EagleDataCoordinator(DataUpdateCoordinator): """Get the latest data from the Eagle device.""" + config_entry: ConfigEntry eagle100_reader: Eagle100Reader | None = None eagle200_meter: aioeagle.ElectricMeter | None = None - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the data object.""" - self.entry = entry - if self.type == TYPE_EAGLE_100: + if config_entry.data[CONF_TYPE] == TYPE_EAGLE_100: self.model = "EAGLE-100" update_method = self._async_update_data_100 else: @@ -45,7 +45,8 @@ class EagleDataCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, - name=entry.data[CONF_CLOUD_ID], + config_entry=config_entry, + name=config_entry.data[CONF_CLOUD_ID], update_interval=timedelta(seconds=30), update_method=update_method, ) @@ -53,17 +54,12 @@ class EagleDataCoordinator(DataUpdateCoordinator): @property def cloud_id(self): """Return the cloud ID.""" - return self.entry.data[CONF_CLOUD_ID] - - @property - def type(self): - """Return entry type.""" - return self.entry.data[CONF_TYPE] + return self.config_entry.data[CONF_CLOUD_ID] @property def hardware_address(self): """Return hardware address of meter.""" - return self.entry.data[CONF_HARDWARE_ADDRESS] + return self.config_entry.data[CONF_HARDWARE_ADDRESS] @property def is_connected(self): @@ -79,8 +75,8 @@ class EagleDataCoordinator(DataUpdateCoordinator): hub = aioeagle.EagleHub( aiohttp_client.async_get_clientsession(self.hass), self.cloud_id, - self.entry.data[CONF_INSTALL_CODE], - host=self.entry.data[CONF_HOST], + self.config_entry.data[CONF_INSTALL_CODE], + host=self.config_entry.data[CONF_HOST], ) eagle200_meter = aioeagle.ElectricMeter.create_instance( hub, self.hardware_address @@ -115,8 +111,8 @@ class EagleDataCoordinator(DataUpdateCoordinator): if self.eagle100_reader is None: self.eagle100_reader = Eagle100Reader( self.cloud_id, - self.entry.data[CONF_INSTALL_CODE], - self.entry.data[CONF_HOST], + self.config_entry.data[CONF_INSTALL_CODE], + self.config_entry.data[CONF_HOST], ) out = {} From a90d471be0907e45cfaddae0c9f6b40154a69acf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:50:57 +0100 Subject: [PATCH 0241/1941] Explicitly pass in the config_entry in radiotherm coordinator (#137983) explicitly pass in the config_entry in coordinator --- homeassistant/components/radiotherm/__init__.py | 2 +- homeassistant/components/radiotherm/coordinator.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index 7b2eaba52c4..80dbcf44bc9 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host = entry.data[CONF_HOST] init_coro = async_get_init_data(hass, host) init_data = await _async_call_or_raise_not_ready(init_coro, host) - coordinator = RadioThermUpdateCoordinator(hass, init_data) + coordinator = RadioThermUpdateCoordinator(hass, entry, init_data) await coordinator.async_config_entry_first_refresh() # Only set the time if the thermostat is diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py index 06e3554c8d7..7d483426c83 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -8,6 +8,7 @@ from urllib.error import URLError from radiotherm.validate import RadiothermTstatError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,13 +22,21 @@ UPDATE_INTERVAL = timedelta(seconds=15) class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): """DataUpdateCoordinator to gather data for radio thermostats.""" - def __init__(self, hass: HomeAssistant, init_data: RadioThermInitData) -> None: + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + init_data: RadioThermInitData, + ) -> None: """Initialize DataUpdateCoordinator.""" self.init_data = init_data self._description = f"{init_data.name} ({init_data.host})" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"radiotherm {self.init_data.name}", update_interval=UPDATE_INTERVAL, ) From 106c5c661e1e66c541765b07276f0a3513d90644 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:51:10 +0100 Subject: [PATCH 0242/1941] Explicitly pass in the config_entry in radarr coordinator (#137984) explicitly pass in the config_entry in coordinator --- homeassistant/components/radarr/__init__.py | 32 +++++++------------ .../components/radarr/binary_sensor.py | 2 +- homeassistant/components/radarr/calendar.py | 3 +- .../components/radarr/coordinator.py | 24 +++++++++++--- homeassistant/components/radarr/sensor.py | 3 +- 5 files changed, 34 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 5c225697f98..11a9b6b4dc0 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import fields from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -18,24 +17,13 @@ from .coordinator import ( HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, QueueDataUpdateCoordinator, + RadarrConfigEntry, + RadarrData, RadarrDataUpdateCoordinator, StatusDataUpdateCoordinator, ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] -type RadarrConfigEntry = ConfigEntry[RadarrData] - - -@dataclass(kw_only=True, slots=True) -class RadarrData: - """Radarr data type.""" - - calendar: CalendarUpdateCoordinator - disk_space: DiskSpaceDataUpdateCoordinator - health: HealthDataUpdateCoordinator - movie: MoviesDataUpdateCoordinator - queue: QueueDataUpdateCoordinator - status: StatusDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: RadarrConfigEntry) -> bool: @@ -50,12 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: RadarrConfigEntry) -> bo session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) data = RadarrData( - calendar=CalendarUpdateCoordinator(hass, host_configuration, radarr), - disk_space=DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), - health=HealthDataUpdateCoordinator(hass, host_configuration, radarr), - movie=MoviesDataUpdateCoordinator(hass, host_configuration, radarr), - queue=QueueDataUpdateCoordinator(hass, host_configuration, radarr), - status=StatusDataUpdateCoordinator(hass, host_configuration, radarr), + calendar=CalendarUpdateCoordinator(hass, entry, host_configuration, radarr), + disk_space=DiskSpaceDataUpdateCoordinator( + hass, entry, host_configuration, radarr + ), + health=HealthDataUpdateCoordinator(hass, entry, host_configuration, radarr), + movie=MoviesDataUpdateCoordinator(hass, entry, host_configuration, radarr), + queue=QueueDataUpdateCoordinator(hass, entry, host_configuration, radarr), + status=StatusDataUpdateCoordinator(hass, entry, host_configuration, radarr), ) for field in fields(data): coordinator: RadarrDataUpdateCoordinator = getattr(data, field.name) diff --git a/homeassistant/components/radarr/binary_sensor.py b/homeassistant/components/radarr/binary_sensor.py index 953c7dead18..62f78cc9d6f 100644 --- a/homeassistant/components/radarr/binary_sensor.py +++ b/homeassistant/components/radarr/binary_sensor.py @@ -13,8 +13,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RadarrConfigEntry from .const import HEALTH_ISSUES +from .coordinator import RadarrConfigEntry from .entity import RadarrEntity BINARY_SENSOR_TYPE = BinarySensorEntityDescription( diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py index c741c178862..2976c7b6fea 100644 --- a/homeassistant/components/radarr/calendar.py +++ b/homeassistant/components/radarr/calendar.py @@ -9,8 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RadarrConfigEntry -from .coordinator import CalendarUpdateCoordinator, RadarrEvent +from .coordinator import CalendarUpdateCoordinator, RadarrConfigEntry, RadarrEvent from .entity import RadarrEntity CALENDAR_TYPE = EntityDescription( diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index 6e8a3d55d3e..d343675d7ea 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod import asyncio from dataclasses import dataclass from datetime import date, datetime, timedelta -from typing import TYPE_CHECKING, Generic, TypeVar, cast +from typing import Generic, TypeVar, cast from aiopyarr import ( Health, @@ -20,14 +20,27 @@ from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient from homeassistant.components.calendar import CalendarEvent +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER -if TYPE_CHECKING: - from . import RadarrConfigEntry + +@dataclass(kw_only=True, slots=True) +class RadarrData: + """Radarr data type.""" + + calendar: CalendarUpdateCoordinator + disk_space: DiskSpaceDataUpdateCoordinator + health: HealthDataUpdateCoordinator + movie: MoviesDataUpdateCoordinator + queue: QueueDataUpdateCoordinator + status: StatusDataUpdateCoordinator + + +type RadarrConfigEntry = ConfigEntry[RadarrData] T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int | None) @@ -53,6 +66,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): def __init__( self, hass: HomeAssistant, + config_entry: RadarrConfigEntry, host_configuration: PyArrHostConfiguration, api_client: RadarrClient, ) -> None: @@ -60,6 +74,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=self._update_interval, ) @@ -140,11 +155,12 @@ class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, + config_entry: RadarrConfigEntry, host_configuration: PyArrHostConfiguration, api_client: RadarrClient, ) -> None: """Initialize.""" - super().__init__(hass, host_configuration, api_client) + super().__init__(hass, config_entry, host_configuration, api_client) self.event: RadarrEvent | None = None self._events: list[RadarrEvent] = [] diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index df1a0686e00..e37fd51a494 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -19,8 +19,7 @@ from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RadarrConfigEntry -from .coordinator import RadarrDataUpdateCoordinator, T +from .coordinator import RadarrConfigEntry, RadarrDataUpdateCoordinator, T from .entity import RadarrEntity From a8c4cc726948f5f47fa0437a6c4341e2c62b93ea Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:52:46 +0100 Subject: [PATCH 0243/1941] Explicitly pass in the config_entry in rabbitair coordinator (#137985) explicitly pass in the config_entry in coordinator --- homeassistant/components/rabbitair/__init__.py | 2 +- homeassistant/components/rabbitair/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rabbitair/__init__.py b/homeassistant/components/rabbitair/__init__.py index e4eb67a67f5..d6530b322b0 100644 --- a/homeassistant/components/rabbitair/__init__.py +++ b/homeassistant/components/rabbitair/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: zeroconf_instance = await zeroconf.async_get_async_instance(hass) device: Client = UdpClient(host, token, zeroconf=zeroconf_instance) - coordinator = RabbitAirDataUpdateCoordinator(hass, device) + coordinator = RabbitAirDataUpdateCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/rabbitair/coordinator.py b/homeassistant/components/rabbitair/coordinator.py index 3c7db126c7d..75453fe4d24 100644 --- a/homeassistant/components/rabbitair/coordinator.py +++ b/homeassistant/components/rabbitair/coordinator.py @@ -7,6 +7,7 @@ from typing import Any, cast from rabbitair import Client, State +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -42,12 +43,17 @@ class RabbitAirDebouncer(Debouncer[Coroutine[Any, Any, None]]): class RabbitAirDataUpdateCoordinator(DataUpdateCoordinator[State]): """Class to manage fetching data from single endpoint.""" - def __init__(self, hass: HomeAssistant, device: Client) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: Client + ) -> None: """Initialize global data updater.""" self.device = device super().__init__( hass, _LOGGER, + config_entry=config_entry, name="rabbitair", update_interval=timedelta(seconds=10), request_refresh_debouncer=RabbitAirDebouncer(hass), From e496270c6b199f4e6541b9904f5d8249216f63fb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:52:58 +0100 Subject: [PATCH 0244/1941] Explicitly pass in the config_entry in qnap coordinator (#138028) explicitly pass in the config_entry in coordinator --- homeassistant/components/qnap/coordinator.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py index 8c2bf81a47f..297f6569d2b 100644 --- a/homeassistant/components/qnap/coordinator.py +++ b/homeassistant/components/qnap/coordinator.py @@ -33,7 +33,13 @@ class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the qnap coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) protocol = "https" if config_entry.data[CONF_SSL] else "http" self._api = QNAPStats( From 18ea4072767646085bc627c7ae67bcd89a671a74 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:24:28 +0100 Subject: [PATCH 0245/1941] Explicitly pass in the config_entry in nexia coordinator (#138073) --- homeassistant/components/nexia/__init__.py | 2 +- homeassistant/components/nexia/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index 66a8ec5bdb8..8d0d509f8a2 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -58,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NexiaConfigEntry) -> boo f"Error connecting to Nexia service: {os_error}" ) from os_error - coordinator = NexiaDataUpdateCoordinator(hass, nexia_home) + coordinator = NexiaDataUpdateCoordinator(hass, entry, nexia_home) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/nexia/coordinator.py b/homeassistant/components/nexia/coordinator.py index 894c491c45b..85e784218f4 100644 --- a/homeassistant/components/nexia/coordinator.py +++ b/homeassistant/components/nexia/coordinator.py @@ -8,6 +8,7 @@ from typing import Any from nexia.home import NexiaHome +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -19,9 +20,12 @@ DEFAULT_UPDATE_RATE = 120 class NexiaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """DataUpdateCoordinator for nexia homes.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, nexia_home: NexiaHome, ) -> None: """Initialize DataUpdateCoordinator for the nexia home.""" @@ -29,6 +33,7 @@ class NexiaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Nexia update", update_interval=timedelta(seconds=DEFAULT_UPDATE_RATE), always_update=False, From 3792888e9dc79f4fc2fac3943a4b1f7276089d77 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:30:30 +0100 Subject: [PATCH 0246/1941] Explicitly pass in the config_entry in myuplink coordinator (#138078) explicitly pass in the config_entry in coordinator --- homeassistant/components/myuplink/__init__.py | 7 ++----- homeassistant/components/myuplink/binary_sensor.py | 2 +- homeassistant/components/myuplink/coordinator.py | 11 ++++++++++- homeassistant/components/myuplink/diagnostics.py | 2 +- homeassistant/components/myuplink/number.py | 2 +- homeassistant/components/myuplink/select.py | 2 +- homeassistant/components/myuplink/sensor.py | 2 +- homeassistant/components/myuplink/switch.py | 2 +- homeassistant/components/myuplink/update.py | 2 +- 9 files changed, 19 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index 5ad114e973e..407e4da5475 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -9,7 +9,6 @@ from aiohttp import ClientError, ClientResponseError import jwt from myuplink import MyUplinkAPI, get_manufacturer, get_model, get_system_name -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -22,7 +21,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .api import AsyncConfigEntryAuth from .const import DOMAIN, OAUTH2_SCOPES -from .coordinator import MyUplinkDataCoordinator +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -35,8 +34,6 @@ PLATFORMS: list[Platform] = [ Platform.UPDATE, ] -type MyUplinkConfigEntry = ConfigEntry[MyUplinkDataCoordinator] - async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry @@ -77,7 +74,7 @@ async def async_setup_entry( # Setup MyUplinkAPI and coordinator for data fetch api = MyUplinkAPI(auth) - coordinator = MyUplinkDataCoordinator(hass, api) + coordinator = MyUplinkDataCoordinator(hass, config_entry, api) await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = coordinator diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py index d903c7cbfae..c24bf142b43 100644 --- a/homeassistant/components/myuplink/binary_sensor.py +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -11,8 +11,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import F_SERIES +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity, MyUplinkSystemEntity from .helpers import find_matching_platform, transform_model_series diff --git a/homeassistant/components/myuplink/coordinator.py b/homeassistant/components/myuplink/coordinator.py index 211fd894ac5..6bf762ad642 100644 --- a/homeassistant/components/myuplink/coordinator.py +++ b/homeassistant/components/myuplink/coordinator.py @@ -7,6 +7,7 @@ import logging from myuplink import Device, DevicePoint, MyUplinkAPI, System +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -23,14 +24,22 @@ class CoordinatorData: time: datetime +type MyUplinkConfigEntry = ConfigEntry[MyUplinkDataCoordinator] + + class MyUplinkDataCoordinator(DataUpdateCoordinator[CoordinatorData]): """Coordinator for myUplink data.""" - def __init__(self, hass: HomeAssistant, api: MyUplinkAPI) -> None: + config_entry: MyUplinkConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: MyUplinkConfigEntry, api: MyUplinkAPI + ) -> None: """Initialize myUplink coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="myuplink", update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/myuplink/diagnostics.py b/homeassistant/components/myuplink/diagnostics.py index 5e26cf273b4..61605a04fc8 100644 --- a/homeassistant/components/myuplink/diagnostics.py +++ b/homeassistant/components/myuplink/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from . import MyUplinkConfigEntry +from .coordinator import MyUplinkConfigEntry TO_REDACT = {"access_token", "refresh_token", "serialNumber"} diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py index e1cbd393947..126dc49163d 100644 --- a/homeassistant/components/myuplink/number.py +++ b/homeassistant/components/myuplink/number.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import DOMAIN, F_SERIES +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series diff --git a/homeassistant/components/myuplink/select.py b/homeassistant/components/myuplink/select.py index 0074d1c75ff..cad84d18646 100644 --- a/homeassistant/components/myuplink/select.py +++ b/homeassistant/components/myuplink/select.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import DOMAIN +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index fa50e8a7001..03734210e9c 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -24,8 +24,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import F_SERIES +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py index 3addc7ce6a9..e175db93278 100644 --- a/homeassistant/components/myuplink/switch.py +++ b/homeassistant/components/myuplink/switch.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator from .const import DOMAIN, F_SERIES +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity from .helpers import find_matching_platform, skip_entity, transform_model_series diff --git a/homeassistant/components/myuplink/update.py b/homeassistant/components/myuplink/update.py index 9e94de0a503..8f4975fe1a5 100644 --- a/homeassistant/components/myuplink/update.py +++ b/homeassistant/components/myuplink/update.py @@ -8,7 +8,7 @@ from homeassistant.components.update import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity UPDATE_DESCRIPTION = UpdateEntityDescription( From 41532ed509508134d36e49ba3be82d87a592a6b5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:30:41 +0100 Subject: [PATCH 0247/1941] Explicitly pass in the config_entry in nam coordinator (#138076) explicitly pass in the config_entry in coordinator --- homeassistant/components/nam/__init__.py | 11 ++--------- homeassistant/components/nam/button.py | 2 +- homeassistant/components/nam/coordinator.py | 22 +++++++++++++++++---- homeassistant/components/nam/diagnostics.py | 2 +- homeassistant/components/nam/sensor.py | 2 +- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 624415adb12..6b4ca6ff324 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING from aiohttp.client_exceptions import ClientConnectorError, ClientError from nettigo_air_monitor import ( @@ -14,7 +13,6 @@ from nettigo_air_monitor import ( ) from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -22,14 +20,12 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ATTR_SDS011, ATTR_SPS30, DOMAIN -from .coordinator import NAMDataUpdateCoordinator +from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BUTTON, Platform.SENSOR] -type NAMConfigEntry = ConfigEntry[NAMDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: """Set up Nettigo as config entry.""" @@ -52,10 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: except AuthFailedError as err: raise ConfigEntryAuthFailed from err - if TYPE_CHECKING: - assert entry.unique_id - - coordinator = NAMDataUpdateCoordinator(hass, nam, entry.unique_id) + coordinator = NAMDataUpdateCoordinator(hass, entry, nam) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index 8ac56f3d70e..980201be28c 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NAMConfigEntry, NAMDataUpdateCoordinator +from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/nam/coordinator.py b/homeassistant/components/nam/coordinator.py index 5019f0e3a1d..3e2c9c24474 100644 --- a/homeassistant/components/nam/coordinator.py +++ b/homeassistant/components/nam/coordinator.py @@ -1,6 +1,7 @@ """The Nettigo Air Monitor coordinator.""" import logging +from typing import TYPE_CHECKING from nettigo_air_monitor import ( ApiError, @@ -10,6 +11,7 @@ from nettigo_air_monitor import ( ) from tenacity import RetryError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,20 +20,28 @@ from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) +type NAMConfigEntry = ConfigEntry[NAMDataUpdateCoordinator] + class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): """Class to manage fetching Nettigo Air Monitor data.""" + config_entry: NAMConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: NAMConfigEntry, nam: NettigoAirMonitor, - unique_id: str, ) -> None: """Initialize.""" - self.unique_id = unique_id + if TYPE_CHECKING: + assert config_entry.unique_id + + self.unique_id = config_entry.unique_id + self.device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, unique_id)}, + connections={(CONNECTION_NETWORK_MAC, self.unique_id)}, name="Nettigo Air Monitor", sw_version=nam.software_version, manufacturer=MANUFACTURER, @@ -40,7 +50,11 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): self.nam = nam super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_UPDATE_INTERVAL, ) async def _async_update_data(self) -> NAMSensors: diff --git a/homeassistant/components/nam/diagnostics.py b/homeassistant/components/nam/diagnostics.py index d29eb40ced7..905c1669496 100644 --- a/homeassistant/components/nam/diagnostics.py +++ b/homeassistant/components/nam/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import NAMConfigEntry +from .coordinator import NAMConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 27fae62be8a..24080d1c3c1 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -32,7 +32,6 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from . import NAMConfigEntry, NAMDataUpdateCoordinator from .const import ( ATTR_BME280_HUMIDITY, ATTR_BME280_PRESSURE, @@ -69,6 +68,7 @@ from .const import ( DOMAIN, MIGRATION_SENSORS, ) +from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator PARALLEL_UPDATES = 1 From 711d42387785e65c9ee53da1262c86f25cbf25db Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:30:55 +0100 Subject: [PATCH 0248/1941] Explicitly pass in the config_entry in nanoleaf coordinator (#138075) explicitly pass in the config_entry in coordinator --- homeassistant/components/nanoleaf/__init__.py | 8 ++------ homeassistant/components/nanoleaf/button.py | 3 +-- homeassistant/components/nanoleaf/coordinator.py | 15 +++++++++++++-- homeassistant/components/nanoleaf/diagnostics.py | 2 +- homeassistant/components/nanoleaf/entity.py | 2 +- homeassistant/components/nanoleaf/event.py | 2 +- homeassistant/components/nanoleaf/light.py | 3 +-- 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 4a34c2843aa..7ee1c14a9b1 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -8,7 +8,6 @@ import logging from aionanoleaf import EffectsEvent, Nanoleaf, StateEvent, TouchEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_HOST, @@ -22,23 +21,20 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import DOMAIN, NANOLEAF_EVENT, TOUCH_GESTURE_TRIGGER_MAP, TOUCH_MODELS -from .coordinator import NanoleafCoordinator +from .coordinator import NanoleafConfigEntry, NanoleafCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BUTTON, Platform.EVENT, Platform.LIGHT] -type NanoleafConfigEntry = ConfigEntry[NanoleafCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: NanoleafConfigEntry) -> bool: """Set up Nanoleaf from a config entry.""" nanoleaf = Nanoleaf( async_get_clientsession(hass), entry.data[CONF_HOST], entry.data[CONF_TOKEN] ) - coordinator = NanoleafCoordinator(hass, nanoleaf) + coordinator = NanoleafCoordinator(hass, entry, nanoleaf) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nanoleaf/button.py b/homeassistant/components/nanoleaf/button.py index 34d0f4f5076..eb997036b48 100644 --- a/homeassistant/components/nanoleaf/button.py +++ b/homeassistant/components/nanoleaf/button.py @@ -5,8 +5,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NanoleafConfigEntry -from .coordinator import NanoleafCoordinator +from .coordinator import NanoleafConfigEntry, NanoleafCoordinator from .entity import NanoleafEntity diff --git a/homeassistant/components/nanoleaf/coordinator.py b/homeassistant/components/nanoleaf/coordinator.py index e080afc492e..495a63b9164 100644 --- a/homeassistant/components/nanoleaf/coordinator.py +++ b/homeassistant/components/nanoleaf/coordinator.py @@ -5,20 +5,31 @@ import logging from aionanoleaf import InvalidToken, Nanoleaf, Unavailable +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) +type NanoleafConfigEntry = ConfigEntry[NanoleafCoordinator] + class NanoleafCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Nanoleaf data.""" - def __init__(self, hass: HomeAssistant, nanoleaf: Nanoleaf) -> None: + config_entry: NanoleafConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: NanoleafConfigEntry, nanoleaf: Nanoleaf + ) -> None: """Initialize the Nanoleaf data coordinator.""" super().__init__( - hass, _LOGGER, name="Nanoleaf", update_interval=timedelta(minutes=1) + hass, + _LOGGER, + config_entry=config_entry, + name="Nanoleaf", + update_interval=timedelta(minutes=1), ) self.nanoleaf = nanoleaf diff --git a/homeassistant/components/nanoleaf/diagnostics.py b/homeassistant/components/nanoleaf/diagnostics.py index 6f8691905ef..ce2045acf7b 100644 --- a/homeassistant/components/nanoleaf/diagnostics.py +++ b/homeassistant/components/nanoleaf/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -from . import NanoleafConfigEntry +from .coordinator import NanoleafConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/nanoleaf/entity.py b/homeassistant/components/nanoleaf/entity.py index ffe4a098022..dd0b455fa0f 100644 --- a/homeassistant/components/nanoleaf/entity.py +++ b/homeassistant/components/nanoleaf/entity.py @@ -3,8 +3,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NanoleafCoordinator from .const import DOMAIN +from .coordinator import NanoleafCoordinator class NanoleafEntity(CoordinatorEntity[NanoleafCoordinator]): diff --git a/homeassistant/components/nanoleaf/event.py b/homeassistant/components/nanoleaf/event.py index 5763c2aa595..e77ee03681a 100644 --- a/homeassistant/components/nanoleaf/event.py +++ b/homeassistant/components/nanoleaf/event.py @@ -5,8 +5,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NanoleafConfigEntry, NanoleafCoordinator from .const import TOUCH_MODELS +from .coordinator import NanoleafConfigEntry, NanoleafCoordinator from .entity import NanoleafEntity diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 681053fa573..4d73a012765 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -17,8 +17,7 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NanoleafConfigEntry -from .coordinator import NanoleafCoordinator +from .coordinator import NanoleafConfigEntry, NanoleafCoordinator from .entity import NanoleafEntity RESERVED_EFFECTS = ("*Solid*", "*Static*", "*Dynamic*") From 5dba229c67e868b11533983ca85edfd1e9a3ab66 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:31:11 +0100 Subject: [PATCH 0249/1941] Explicitly pass in the config_entry in netgear_lte coordinator (#138074) explicitly pass in the config_entry in coordinator --- homeassistant/components/netgear_lte/__init__.py | 7 +++---- homeassistant/components/netgear_lte/binary_sensor.py | 2 +- homeassistant/components/netgear_lte/coordinator.py | 7 ++++--- homeassistant/components/netgear_lte/entity.py | 3 +-- homeassistant/components/netgear_lte/sensor.py | 2 +- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 1846d1f7992..a756d85c866 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -6,7 +6,7 @@ from aiohttp.cookiejar import CookieJar import eternalegypt from eternalegypt.eternalegypt import SMS -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -23,7 +23,7 @@ from .const import ( DATA_SESSION, DOMAIN, ) -from .coordinator import NetgearLTEDataUpdateCoordinator +from .coordinator import NetgearLTEConfigEntry, NetgearLTEDataUpdateCoordinator from .services import async_setup_services EVENT_SMS = "netgear_lte_sms" @@ -55,7 +55,6 @@ PLATFORMS = [ Platform.NOTIFY, Platform.SENSOR, ] -type NetgearLTEConfigEntry = ConfigEntry[NetgearLTEDataUpdateCoordinator] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -94,7 +93,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) - await modem.add_sms_listener(fire_sms_event) - coordinator = NetgearLTEDataUpdateCoordinator(hass, modem) + coordinator = NetgearLTEDataUpdateCoordinator(hass, entry, modem) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 280d240b90f..cf7e757e8f1 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NetgearLTEConfigEntry +from .coordinator import NetgearLTEConfigEntry from .entity import LTEEntity BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( diff --git a/homeassistant/components/netgear_lte/coordinator.py b/homeassistant/components/netgear_lte/coordinator.py index afd0cb743bf..7bcefca6403 100644 --- a/homeassistant/components/netgear_lte/coordinator.py +++ b/homeassistant/components/netgear_lte/coordinator.py @@ -3,17 +3,16 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING from eternalegypt.eternalegypt import Error, Information, Modem +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER -if TYPE_CHECKING: - from . import NetgearLTEConfigEntry +type NetgearLTEConfigEntry = ConfigEntry[NetgearLTEDataUpdateCoordinator] class NetgearLTEDataUpdateCoordinator(DataUpdateCoordinator[Information]): @@ -24,12 +23,14 @@ class NetgearLTEDataUpdateCoordinator(DataUpdateCoordinator[Information]): def __init__( self, hass: HomeAssistant, + config_entry: NetgearLTEConfigEntry, modem: Modem, ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py index 3353da6dc77..9d56605b7d2 100644 --- a/homeassistant/components/netgear_lte/entity.py +++ b/homeassistant/components/netgear_lte/entity.py @@ -5,9 +5,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NetgearLTEConfigEntry from .const import DOMAIN, MANUFACTURER -from .coordinator import NetgearLTEDataUpdateCoordinator +from .coordinator import NetgearLTEConfigEntry, NetgearLTEDataUpdateCoordinator class LTEEntity(CoordinatorEntity[NetgearLTEDataUpdateCoordinator]): diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 73e5de7eaeb..525d7f8aea0 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import NetgearLTEConfigEntry +from .coordinator import NetgearLTEConfigEntry from .entity import LTEEntity From 2e496411a15d943c74e6c2aeeae2e64029bdecc0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:31:24 +0100 Subject: [PATCH 0250/1941] Explicitly pass in the config_entry in nextdns coordinator (#138072) explicitly pass in the config_entry in coordinator --- homeassistant/components/nextdns/__init__.py | 4 +++- .../components/nextdns/coordinator.py | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 7f0729bca1e..478ff215c30 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -98,7 +98,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b # Independent DataUpdateCoordinator is used for each API endpoint to avoid # unnecessary requests when entities using this endpoint are disabled. for coordinator_name, coordinator_class, update_interval in COORDINATORS: - coordinator = coordinator_class(hass, nextdns, profile_id, update_interval) + coordinator = coordinator_class( + hass, entry, nextdns, profile_id, update_interval + ) tasks.append(coordinator.async_config_entry_first_refresh()) coordinators[coordinator_name] = coordinator diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 6b35e35a027..850702e4488 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -1,8 +1,10 @@ """NextDns coordinator.""" +from __future__ import annotations + from datetime import timedelta import logging -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from aiohttp.client_exceptions import ClientConnectorError from nextdns import ( @@ -25,6 +27,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import NextDnsConfigEntry + from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -35,9 +40,12 @@ CoordinatorDataT = TypeVar("CoordinatorDataT", bound=NextDnsData) class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): """Class to manage fetching NextDNS data API.""" + config_entry: NextDnsConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: NextDnsConfigEntry, nextdns: NextDns, profile_id: str, update_interval: timedelta, @@ -54,7 +62,13 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): name=self.profile_name, ) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self) -> CoordinatorDataT: """Update data via internal method.""" From b7949dba44379398ce13390413c4e8ace8966ef8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:31:37 +0100 Subject: [PATCH 0251/1941] Explicitly pass in the config_entry in nibe_heatpump coordinator (#138071) explicitly pass in the config_entry in coordinator --- homeassistant/components/nibe_heatpump/__init__.py | 2 +- homeassistant/components/nibe_heatpump/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index b3ceb00a834..ac201ed2322 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -81,7 +81,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) ) - coordinator = CoilCoordinator(hass, heatpump, connection) + coordinator = CoilCoordinator(hass, entry, heatpump, connection) data = hass.data.setdefault(DOMAIN, {}) data[entry.entry_id] = coordinator diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index faaac5f165a..2451e2fbda9 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -71,12 +71,17 @@ class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, heatpump: HeatPump, connection: Connection, ) -> None: """Initialize coordinator.""" super().__init__( - hass, LOGGER, name="Nibe Heat Pump", update_interval=timedelta(seconds=60) + hass, + LOGGER, + config_entry=config_entry, + name="Nibe Heat Pump", + update_interval=timedelta(seconds=60), ) self.data = {} From ac9444a9bafc8a84dcff54a87d939f92f6dfb59b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:31:48 +0100 Subject: [PATCH 0252/1941] Explicitly pass in the config_entry in nice_go coordinator (#138070) explicitly pass in the config_entry in coordinator --- homeassistant/components/nice_go/__init__.py | 7 ++----- homeassistant/components/nice_go/coordinator.py | 13 ++++++------- homeassistant/components/nice_go/cover.py | 2 +- homeassistant/components/nice_go/diagnostics.py | 2 +- homeassistant/components/nice_go/event.py | 2 +- homeassistant/components/nice_go/light.py | 2 +- homeassistant/components/nice_go/switch.py | 2 +- 7 files changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/nice_go/__init__.py b/homeassistant/components/nice_go/__init__.py index b217112c192..a8d2bd71ac4 100644 --- a/homeassistant/components/nice_go/__init__.py +++ b/homeassistant/components/nice_go/__init__.py @@ -4,11 +4,10 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant -from .coordinator import NiceGOUpdateCoordinator +from .coordinator import NiceGOConfigEntry, NiceGOUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ @@ -18,13 +17,11 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] -type NiceGOConfigEntry = ConfigEntry[NiceGOUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: NiceGOConfigEntry) -> bool: """Set up Nice G.O. from a config entry.""" - coordinator = NiceGOUpdateCoordinator(hass) + coordinator = NiceGOUpdateCoordinator(hass, entry) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.async_ha_stop) ) diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index 07b20bbbf10..e486263fbe5 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -54,19 +54,18 @@ class NiceGODevice: vacation_mode: bool | None +type NiceGOConfigEntry = ConfigEntry[NiceGOUpdateCoordinator] + + class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): """DataUpdateCoordinator for Nice G.O.""" - config_entry: ConfigEntry + config_entry: NiceGOConfigEntry organization_id: str - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: NiceGOConfigEntry) -> None: """Initialize DataUpdateCoordinator for Nice G.O.""" - super().__init__( - hass, - _LOGGER, - name="Nice G.O.", - ) + super().__init__(hass, _LOGGER, config_entry=config_entry, name="Nice G.O.") self.refresh_token = self.config_entry.data[CONF_REFRESH_TOKEN] self.refresh_token_creation_time = self.config_entry.data[ diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py index 6360e398b96..79afbcad532 100644 --- a/homeassistant/components/nice_go/cover.py +++ b/homeassistant/components/nice_go/cover.py @@ -14,8 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NiceGOConfigEntry from .const import DOMAIN +from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity DEVICE_CLASSES = { diff --git a/homeassistant/components/nice_go/diagnostics.py b/homeassistant/components/nice_go/diagnostics.py index 2c9a695d4b5..2a663d8925a 100644 --- a/homeassistant/components/nice_go/diagnostics.py +++ b/homeassistant/components/nice_go/diagnostics.py @@ -9,8 +9,8 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from . import NiceGOConfigEntry from .const import CONF_REFRESH_TOKEN +from .coordinator import NiceGOConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_EMAIL, CONF_REFRESH_TOKEN, "title", "unique_id"} diff --git a/homeassistant/components/nice_go/event.py b/homeassistant/components/nice_go/event.py index cd9198bcd26..a02c14f87ab 100644 --- a/homeassistant/components/nice_go/event.py +++ b/homeassistant/components/nice_go/event.py @@ -7,7 +7,7 @@ from homeassistant.components.event import EventEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NiceGOConfigEntry +from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nice_go/light.py b/homeassistant/components/nice_go/light.py index abb192adde1..cd8170ae353 100644 --- a/homeassistant/components/nice_go/light.py +++ b/homeassistant/components/nice_go/light.py @@ -12,13 +12,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NiceGOConfigEntry from .const import ( DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) +from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nice_go/switch.py b/homeassistant/components/nice_go/switch.py index e3b85528f3b..607b0c827d2 100644 --- a/homeassistant/components/nice_go/switch.py +++ b/homeassistant/components/nice_go/switch.py @@ -14,13 +14,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NiceGOConfigEntry from .const import ( DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) +from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity _LOGGER = logging.getLogger(__name__) From af3e38a11b1164ba2ba0b2c6f08672550778e6ca Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:32:12 +0100 Subject: [PATCH 0253/1941] Explicitly pass in the config_entry in palazzetti coordinator (#138044) explicitly pass in the config_entry in coordinator --- homeassistant/components/palazzetti/__init__.py | 2 +- homeassistant/components/palazzetti/button.py | 3 +-- homeassistant/components/palazzetti/climate.py | 3 +-- homeassistant/components/palazzetti/coordinator.py | 2 ++ homeassistant/components/palazzetti/diagnostics.py | 2 +- homeassistant/components/palazzetti/number.py | 3 +-- homeassistant/components/palazzetti/sensor.py | 3 +-- 7 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/palazzetti/__init__.py b/homeassistant/components/palazzetti/__init__.py index dbf1baa0c28..a698cdcd8b7 100644 --- a/homeassistant/components/palazzetti/__init__.py +++ b/homeassistant/components/palazzetti/__init__.py @@ -18,7 +18,7 @@ PLATFORMS: list[Platform] = [ async def async_setup_entry(hass: HomeAssistant, entry: PalazzettiConfigEntry) -> bool: """Set up Palazzetti from a config entry.""" - coordinator = PalazzettiDataUpdateCoordinator(hass) + coordinator = PalazzettiDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/palazzetti/button.py b/homeassistant/components/palazzetti/button.py index cd4765576ed..32a60e195e9 100644 --- a/homeassistant/components/palazzetti/button.py +++ b/homeassistant/components/palazzetti/button.py @@ -9,9 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PalazzettiConfigEntry from .const import DOMAIN -from .coordinator import PalazzettiDataUpdateCoordinator +from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index 0722b97e4b7..2c7053073ea 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -15,9 +15,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PalazzettiConfigEntry from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES -from .coordinator import PalazzettiDataUpdateCoordinator +from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity diff --git a/homeassistant/components/palazzetti/coordinator.py b/homeassistant/components/palazzetti/coordinator.py index d992bd3fb62..1e4069e58ea 100644 --- a/homeassistant/components/palazzetti/coordinator.py +++ b/homeassistant/components/palazzetti/coordinator.py @@ -22,11 +22,13 @@ class PalazzettiDataUpdateCoordinator(DataUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, + config_entry: PalazzettiConfigEntry, ) -> None: """Initialize global Palazzetti data updater.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/palazzetti/diagnostics.py b/homeassistant/components/palazzetti/diagnostics.py index 3843f0ec111..e386ffc7833 100644 --- a/homeassistant/components/palazzetti/diagnostics.py +++ b/homeassistant/components/palazzetti/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import PalazzettiConfigEntry +from .coordinator import PalazzettiConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py index 2b303f71fd6..bba729c523c 100644 --- a/homeassistant/components/palazzetti/number.py +++ b/homeassistant/components/palazzetti/number.py @@ -10,9 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PalazzettiConfigEntry from .const import DOMAIN -from .coordinator import PalazzettiDataUpdateCoordinator +from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py index 11462201f4e..fdad817da4d 100644 --- a/homeassistant/components/palazzetti/sensor.py +++ b/homeassistant/components/palazzetti/sensor.py @@ -13,9 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import PalazzettiConfigEntry from .const import STATUS_TO_HA -from .coordinator import PalazzettiDataUpdateCoordinator +from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator from .entity import PalazzettiEntity From 2bea300a7b7afd560004c5c39bf13c0b3534fe3d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:32:41 +0100 Subject: [PATCH 0254/1941] Explicitly pass in the config_entry in notion coordinator (#138066) explicitly pass in the config_entry in coordinator --- homeassistant/components/notion/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notion/coordinator.py b/homeassistant/components/notion/coordinator.py index c3fd23abc84..d77bfa95f47 100644 --- a/homeassistant/components/notion/coordinator.py +++ b/homeassistant/components/notion/coordinator.py @@ -117,16 +117,16 @@ class NotionDataUpdateCoordinator(DataUpdateCoordinator[NotionData]): super().__init__( hass, LOGGER, + config_entry=entry, name=entry.data[CONF_USERNAME], update_interval=DEFAULT_SCAN_INTERVAL, ) self._client = client - self._entry = entry async def _async_update_data(self) -> NotionData: """Fetch data from Notion.""" - data = NotionData(hass=self.hass, entry=self._entry) + data = NotionData(hass=self.hass, entry=self.config_entry) try: async with asyncio.TaskGroup() as tg: From 9d6b031bf9928f3f741284c55b70e24479df78bf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:33:03 +0100 Subject: [PATCH 0255/1941] Explicitly pass in the config_entry in nuki coordinator (#138064) explicitly pass in the config_entry in coordinator --- homeassistant/components/nuki/__init__.py | 2 +- homeassistant/components/nuki/coordinator.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 4f3f56f7f03..5c02b6e972e 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -222,7 +222,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) ) - coordinator = NukiCoordinator(hass, bridge, locks, openers) + coordinator = NukiCoordinator(hass, entry, bridge, locks, openers) hass.data[DOMAIN][entry.entry_id] = NukiEntryData( coordinator=coordinator, bridge=bridge, diff --git a/homeassistant/components/nuki/coordinator.py b/homeassistant/components/nuki/coordinator.py index 114b4aee4c9..cccff99e397 100644 --- a/homeassistant/components/nuki/coordinator.py +++ b/homeassistant/components/nuki/coordinator.py @@ -12,6 +12,7 @@ from pynuki.bridge import InvalidCredentialsException from pynuki.device import NukiDevice from requests.exceptions import RequestException +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -28,9 +29,12 @@ UPDATE_INTERVAL = timedelta(seconds=30) class NukiCoordinator(DataUpdateCoordinator[None]): """Data Update Coordinator for the Nuki integration.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, bridge: NukiBridge, locks: list[NukiLock], openers: list[NukiOpener], @@ -39,9 +43,8 @@ class NukiCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, - # Name of the data. For logging purposes. + config_entry=config_entry, name="nuki devices", - # Polling interval. Will only be polled if there are subscribers. update_interval=UPDATE_INTERVAL, ) self.bridge = bridge From d66ac97c34388b5622c796b84c7dbc66ec7e32fa Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:33:13 +0100 Subject: [PATCH 0256/1941] Explicitly pass in the config_entry in nws coordinator (#138063) explicitly pass in the config_entry in coordinator --- homeassistant/components/nws/__init__.py | 5 +---- homeassistant/components/nws/coordinator.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index c700476ed3d..633619bcf05 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -101,10 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NWSConfigEntry) -> bool: return update_forecast_hourly - coordinator_observation = NWSObservationDataUpdateCoordinator( - hass, - nws_data, - ) + coordinator_observation = NWSObservationDataUpdateCoordinator(hass, entry, nws_data) # Don't use retries in setup coordinator_forecast = TimestampDataUpdateCoordinator( diff --git a/homeassistant/components/nws/coordinator.py b/homeassistant/components/nws/coordinator.py index 104b1812c67..4e6560947e8 100644 --- a/homeassistant/components/nws/coordinator.py +++ b/homeassistant/components/nws/coordinator.py @@ -1,7 +1,10 @@ """The NWS coordinator.""" +from __future__ import annotations + from datetime import datetime import logging +from typing import TYPE_CHECKING from aiohttp import ClientResponseError from pynws import NwsNoDataError, SimpleNWS, call_with_retry @@ -14,6 +17,9 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util.dt import utcnow +if TYPE_CHECKING: + from . import NWSConfigEntry + from .const import ( DEBOUNCE_TIME, DEFAULT_SCAN_INTERVAL, @@ -29,9 +35,12 @@ _LOGGER = logging.getLogger(__name__) class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): """Class to manage fetching NWS observation data.""" + config_entry: NWSConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: NWSConfigEntry, nws: SimpleNWS, ) -> None: """Initialize.""" @@ -42,6 +51,7 @@ class NWSObservationDataUpdateCoordinator(TimestampDataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"NWS observation station {nws.station}", update_interval=DEFAULT_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( From 7097faa95031e3070ff14c7e5c5fa421a3e4584f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:33:24 +0100 Subject: [PATCH 0257/1941] Explicitly pass in the config_entry in nyt_games coordinator (#138062) explicitly pass in the config_entry in coordinator --- homeassistant/components/nyt_games/__init__.py | 8 ++------ .../components/nyt_games/coordinator.py | 16 +++++++++++----- homeassistant/components/nyt_games/sensor.py | 3 +-- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/nyt_games/__init__.py b/homeassistant/components/nyt_games/__init__.py index 94dc22fe89e..d1c6ca5c2a4 100644 --- a/homeassistant/components/nyt_games/__init__.py +++ b/homeassistant/components/nyt_games/__init__.py @@ -4,21 +4,17 @@ from __future__ import annotations from nyt_games import NYTGamesClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .coordinator import NYTGamesCoordinator +from .coordinator import NYTGamesConfigEntry, NYTGamesCoordinator PLATFORMS: list[Platform] = [ Platform.SENSOR, ] -type NYTGamesConfigEntry = ConfigEntry[NYTGamesCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: NYTGamesConfigEntry) -> bool: """Set up NYTGames from a config entry.""" @@ -26,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NYTGamesConfigEntry) -> entry.data[CONF_TOKEN], session=async_create_clientsession(hass) ) - coordinator = NYTGamesCoordinator(hass, client) + coordinator = NYTGamesCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nyt_games/coordinator.py b/homeassistant/components/nyt_games/coordinator.py index 5e88a5dd92a..ae9ea4f03a0 100644 --- a/homeassistant/components/nyt_games/coordinator.py +++ b/homeassistant/components/nyt_games/coordinator.py @@ -4,18 +4,15 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from typing import TYPE_CHECKING from nyt_games import Connections, NYTGamesClient, NYTGamesError, SpellingBee, Wordle +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER -if TYPE_CHECKING: - from . import NYTGamesConfigEntry - @dataclass class NYTGamesData: @@ -26,16 +23,25 @@ class NYTGamesData: connections: Connections | None +type NYTGamesConfigEntry = ConfigEntry[NYTGamesCoordinator] + + class NYTGamesCoordinator(DataUpdateCoordinator[NYTGamesData]): """Class to manage fetching NYT Games data.""" config_entry: NYTGamesConfigEntry - def __init__(self, hass: HomeAssistant, client: NYTGamesClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: NYTGamesConfigEntry, + client: NYTGamesClient, + ) -> None: """Initialize coordinator.""" super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name="NYT Games", update_interval=timedelta(minutes=15), ) diff --git a/homeassistant/components/nyt_games/sensor.py b/homeassistant/components/nyt_games/sensor.py index 01b2db4620b..4191c888ae1 100644 --- a/homeassistant/components/nyt_games/sensor.py +++ b/homeassistant/components/nyt_games/sensor.py @@ -17,8 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import NYTGamesConfigEntry -from .coordinator import NYTGamesCoordinator +from .coordinator import NYTGamesConfigEntry, NYTGamesCoordinator from .entity import ConnectionsEntity, SpellingBeeEntity, WordleEntity From 8241429c5e1a0a9c96eaa46085ad641c1a788476 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:33:38 +0100 Subject: [PATCH 0258/1941] Explicitly pass in the config_entry in nzbget coordinator (#138061) explicitly pass in the config_entry in coordinator --- homeassistant/components/nzbget/__init__.py | 5 +--- .../components/nzbget/coordinator.py | 26 +++++++++++-------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index 84456c4c006..e9e5856d524 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -31,10 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NZBGet from a config entry.""" hass.data.setdefault(DOMAIN, {}) - coordinator = NZBGetDataUpdateCoordinator( - hass, - config=entry.data, - ) + coordinator = NZBGetDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index cf9625ce4ec..9e6b06da760 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -1,13 +1,12 @@ """Provides the NZBGet DataUpdateCoordinator.""" import asyncio -from collections.abc import Mapping from datetime import timedelta import logging -from typing import Any from pynzbgetapi import NZBGetAPI, NZBGetAPIException +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -27,27 +26,32 @@ _LOGGER = logging.getLogger(__name__) class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching NZBGet data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, - config: Mapping[str, Any], + config_entry: ConfigEntry, ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( - config[CONF_HOST], - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - config[CONF_SSL], - config[CONF_VERIFY_SSL], - config[CONF_PORT], + config_entry.data[CONF_HOST], + config_entry.data.get(CONF_USERNAME), + config_entry.data.get(CONF_PASSWORD), + config_entry.data[CONF_SSL], + config_entry.data[CONF_VERIFY_SSL], + config_entry.data[CONF_PORT], ) self._completed_downloads_init = False self._completed_downloads = set[tuple]() super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=5) + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(seconds=5), ) def _check_completed_downloads(self, history): From 020e8fe9396ffa4c7165ba991a169830e02724f7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:33:51 +0100 Subject: [PATCH 0259/1941] Explicitly pass in the config_entry in opower coordinator (#138048) explicitly pass in the config_entry in coordinator --- homeassistant/components/opower/__init__.py | 2 +- homeassistant/components/opower/coordinator.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py index b8e4f4381d0..23c8e7a8136 100644 --- a/homeassistant/components/opower/__init__.py +++ b/homeassistant/components/opower/__init__.py @@ -13,7 +13,7 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: OpowerConfigEntry) -> bool: """Set up Opower from a config entry.""" - coordinator = OpowerCoordinator(hass, entry.data) + coordinator = OpowerCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 8d7ef1ace94..c351f99339a 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -2,8 +2,7 @@ from datetime import datetime, timedelta import logging -from types import MappingProxyType -from typing import Any, cast +from typing import cast from opower import ( Account, @@ -46,12 +45,13 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): def __init__( self, hass: HomeAssistant, - entry_data: MappingProxyType[str, Any], + config_entry: OpowerConfigEntry, ) -> None: """Initialize the data handler.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Opower", # Data is updated daily on Opower. # Refresh every 12h to be at most 12h behind. @@ -59,10 +59,10 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ) self.api = Opower( aiohttp_client.async_get_clientsession(hass), - entry_data[CONF_UTILITY], - entry_data[CONF_USERNAME], - entry_data[CONF_PASSWORD], - entry_data.get(CONF_TOTP_SECRET), + config_entry.data[CONF_UTILITY], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + config_entry.data.get(CONF_TOTP_SECRET), ) self._statistic_ids: set[str] = set() From fa1a03ded15210e854b24bdc7718da0870518313 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:34:17 +0100 Subject: [PATCH 0260/1941] Explicitly pass in the config_entry in moehlenhoff_alpha2 coordinator (#138083) explicitly pass in the config_entry in coordinator --- homeassistant/components/moehlenhoff_alpha2/__init__.py | 2 +- .../components/moehlenhoff_alpha2/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 244e3bc701b..b015f9a09dd 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -17,7 +17,7 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" base = Alpha2Base(entry.data[CONF_HOST]) - coordinator = Alpha2BaseCoordinator(hass, base) + coordinator = Alpha2BaseCoordinator(hass, entry, base) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/moehlenhoff_alpha2/coordinator.py b/homeassistant/components/moehlenhoff_alpha2/coordinator.py index 2bac4b49575..50c2f9a5297 100644 --- a/homeassistant/components/moehlenhoff_alpha2/coordinator.py +++ b/homeassistant/components/moehlenhoff_alpha2/coordinator.py @@ -8,6 +8,7 @@ import logging import aiohttp from moehlenhoff_alpha2 import Alpha2Base +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -20,12 +21,17 @@ UPDATE_INTERVAL = timedelta(seconds=60) class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Keep the base instance in one place and centralize the update.""" - def __init__(self, hass: HomeAssistant, base: Alpha2Base) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, base: Alpha2Base + ) -> None: """Initialize Alpha2Base data updater.""" self.base = base super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name="alpha2_base", update_interval=UPDATE_INTERVAL, ) From 63735da5a0590777ba7ff332ff7e505c251f9a64 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:34:30 +0100 Subject: [PATCH 0261/1941] Explicitly pass in the config_entry in monarch_money coordinator (#138082) explicitly pass in the config_entry in coordinator --- homeassistant/components/monarch_money/__init__.py | 7 ++----- homeassistant/components/monarch_money/coordinator.py | 7 ++++++- homeassistant/components/monarch_money/sensor.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/monarch_money/__init__.py b/homeassistant/components/monarch_money/__init__.py index 5f9aba7dd07..8b7cfa6aa5b 100644 --- a/homeassistant/components/monarch_money/__init__.py +++ b/homeassistant/components/monarch_money/__init__.py @@ -4,13 +4,10 @@ from __future__ import annotations from typedmonarchmoney import TypedMonarchMoney -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from .coordinator import MonarchMoneyDataUpdateCoordinator - -type MonarchMoneyConfigEntry = ConfigEntry[MonarchMoneyDataUpdateCoordinator] +from .coordinator import MonarchMoneyConfigEntry, MonarchMoneyDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -21,7 +18,7 @@ async def async_setup_entry( """Set up Monarch Money from a config entry.""" monarch_client = TypedMonarchMoney(token=entry.data.get(CONF_TOKEN)) - mm_coordinator = MonarchMoneyDataUpdateCoordinator(hass, monarch_client) + mm_coordinator = MonarchMoneyDataUpdateCoordinator(hass, entry, monarch_client) await mm_coordinator.async_config_entry_first_refresh() entry.runtime_data = mm_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/monarch_money/coordinator.py b/homeassistant/components/monarch_money/coordinator.py index 3e689c48e91..7f3dac9419f 100644 --- a/homeassistant/components/monarch_money/coordinator.py +++ b/homeassistant/components/monarch_money/coordinator.py @@ -30,21 +30,26 @@ class MonarchData: cashflow_summary: MonarchCashflowSummary +type MonarchMoneyConfigEntry = ConfigEntry[MonarchMoneyDataUpdateCoordinator] + + class MonarchMoneyDataUpdateCoordinator(DataUpdateCoordinator[MonarchData]): """Data update coordinator for Monarch Money.""" - config_entry: ConfigEntry + config_entry: MonarchMoneyConfigEntry subscription_id: str def __init__( self, hass: HomeAssistant, + config_entry: MonarchMoneyConfigEntry, client: TypedMonarchMoney, ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name="monarchmoney", update_interval=timedelta(hours=4), ) diff --git a/homeassistant/components/monarch_money/sensor.py b/homeassistant/components/monarch_money/sensor.py index fe7c728cf41..e0dff7d565c 100644 --- a/homeassistant/components/monarch_money/sensor.py +++ b/homeassistant/components/monarch_money/sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MonarchMoneyConfigEntry +from .coordinator import MonarchMoneyConfigEntry from .entity import MonarchMoneyAccountEntity, MonarchMoneyCashFlowEntity From 8382577ccb8a834c4bcafbeeb135c7084d55263b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 16:34:39 +0100 Subject: [PATCH 0262/1941] Explicitly pass in the config_entry in monzo coordinator (#138081) explicitly pass in the config_entry in coordinator --- homeassistant/components/monzo/__init__.py | 2 +- homeassistant/components/monzo/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/monzo/__init__.py b/homeassistant/components/monzo/__init__.py index a88082b2ce6..662cfecd2e9 100644 --- a/homeassistant/components/monzo/__init__.py +++ b/homeassistant/components/monzo/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: external_api = AuthenticatedMonzoAPI(async_get_clientsession(hass), session) - coordinator = MonzoCoordinator(hass, external_api) + coordinator = MonzoCoordinator(hass, entry, external_api) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/monzo/coordinator.py b/homeassistant/components/monzo/coordinator.py index caac551f986..06c751a23e0 100644 --- a/homeassistant/components/monzo/coordinator.py +++ b/homeassistant/components/monzo/coordinator.py @@ -8,6 +8,7 @@ from typing import Any from monzopy import AuthorisationExpiredError, InvalidMonzoAPIResponseError +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,11 +30,16 @@ class MonzoData: class MonzoCoordinator(DataUpdateCoordinator[MonzoData]): """Class to manage fetching Monzo data from the API.""" - def __init__(self, hass: HomeAssistant, api: AuthenticatedMonzoAPI) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: AuthenticatedMonzoAPI + ) -> None: """Initialize.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=1), ) From ed3160344d25edec0980e606ab4236b4105e832e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:31:12 +0100 Subject: [PATCH 0263/1941] Explicitly pass in the config_entry in plugwise coordinator (#138039) explicitly pass in the config_entry in coordinator --- homeassistant/components/plugwise/__init__.py | 7 ++----- homeassistant/components/plugwise/binary_sensor.py | 3 +-- homeassistant/components/plugwise/button.py | 3 +-- homeassistant/components/plugwise/climate.py | 3 +-- homeassistant/components/plugwise/coordinator.py | 7 +++++-- homeassistant/components/plugwise/diagnostics.py | 2 +- homeassistant/components/plugwise/number.py | 3 +-- homeassistant/components/plugwise/select.py | 3 +-- homeassistant/components/plugwise/sensor.py | 3 +-- homeassistant/components/plugwise/switch.py | 3 +-- 10 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index f1cc7c6c11d..e97493a78a7 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -4,22 +4,19 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN, LOGGER, PLATFORMS -from .coordinator import PlugwiseDataUpdateCoordinator - -type PlugwiseConfigEntry = ConfigEntry[PlugwiseDataUpdateCoordinator] +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> bool: """Set up Plugwise components from a config entry.""" await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) - coordinator = PlugwiseDataUpdateCoordinator(hass) + coordinator = PlugwiseDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() migrate_sensor_entities(hass, coordinator) diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index a4c6e051c78..e8e658da5bb 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -17,8 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PlugwiseConfigEntry -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity SEVERITIES = ["other", "info", "warning", "error"] diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py index 139b358162c..aa541378a36 100644 --- a/homeassistant/components/plugwise/button.py +++ b/homeassistant/components/plugwise/button.py @@ -7,9 +7,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PlugwiseConfigEntry from .const import REBOOT -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 7abdfcfde54..a7e17f6b688 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -18,9 +18,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PlugwiseConfigEntry from .const import DOMAIN, MASTER_THERMOSTATS -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 9a85ae2a5df..b346f26492c 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -24,19 +24,22 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_PORT, DEFAULT_USERNAME, DOMAIN, LOGGER +type PlugwiseConfigEntry = ConfigEntry[PlugwiseDataUpdateCoordinator] + class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData]]): """Class to manage fetching Plugwise data from single endpoint.""" _connected: bool = False - config_entry: ConfigEntry + config_entry: PlugwiseConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: PlugwiseConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), # Don't refresh immediately, give the device time to process diff --git a/homeassistant/components/plugwise/diagnostics.py b/homeassistant/components/plugwise/diagnostics.py index a576e60dbe1..e97405f6279 100644 --- a/homeassistant/components/plugwise/diagnostics.py +++ b/homeassistant/components/plugwise/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import PlugwiseConfigEntry +from .coordinator import PlugwiseConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 2de49f17d4a..57e3ba77972 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -14,9 +14,8 @@ from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PlugwiseConfigEntry from .const import NumberType -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 307091f0ff9..9c43b71f5f4 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -9,9 +9,8 @@ from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PlugwiseConfigEntry from .const import SelectOptionsType, SelectType -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 8b630c39878..33419abb4dc 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -27,8 +27,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PlugwiseConfigEntry -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity # Coordinator is used to centralize the data updates diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 86496a4311e..9a36d0d708c 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -16,8 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PlugwiseConfigEntry -from .coordinator import PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command From cb3a7dc503b605331bfe1e99c475754c68c98c32 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:31:24 +0100 Subject: [PATCH 0264/1941] Explicitly pass in the config_entry in poolsense coordinator (#138038) explicitly pass in the config_entry in coordinator --- .../components/poolsense/__init__.py | 7 ++---- .../components/poolsense/binary_sensor.py | 2 +- .../components/poolsense/coordinator.py | 22 ++++++++++++++----- homeassistant/components/poolsense/sensor.py | 2 +- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/poolsense/__init__.py b/homeassistant/components/poolsense/__init__.py index a4b6f7b60d8..a2e54712566 100644 --- a/homeassistant/components/poolsense/__init__.py +++ b/homeassistant/components/poolsense/__init__.py @@ -4,14 +4,11 @@ import logging from poolsense import PoolSense -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .coordinator import PoolSenseDataUpdateCoordinator - -type PoolSenseConfigEntry = ConfigEntry[PoolSenseDataUpdateCoordinator] +from .coordinator import PoolSenseConfigEntry, PoolSenseDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -33,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PoolSenseConfigEntry) -> _LOGGER.error("Invalid authentication") return False - coordinator = PoolSenseDataUpdateCoordinator(hass, poolsense) + coordinator = PoolSenseDataUpdateCoordinator(hass, entry, poolsense) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index 7668845f318..dbff3d4cef4 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PoolSenseConfigEntry +from .coordinator import PoolSenseConfigEntry from .entity import PoolSenseEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( diff --git a/homeassistant/components/poolsense/coordinator.py b/homeassistant/components/poolsense/coordinator.py index d9e7e8468ff..557686f9145 100644 --- a/homeassistant/components/poolsense/coordinator.py +++ b/homeassistant/components/poolsense/coordinator.py @@ -5,11 +5,11 @@ from __future__ import annotations import asyncio from datetime import timedelta import logging -from typing import TYPE_CHECKING from poolsense import PoolSense from poolsense.exceptions import PoolSenseError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import StateType @@ -17,20 +17,30 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN -if TYPE_CHECKING: - from . import PoolSenseConfigEntry - _LOGGER = logging.getLogger(__name__) +type PoolSenseConfigEntry = ConfigEntry[PoolSenseDataUpdateCoordinator] + class PoolSenseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, StateType]]): """Define an object to hold PoolSense data.""" config_entry: PoolSenseConfigEntry - def __init__(self, hass: HomeAssistant, poolsense: PoolSense) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: PoolSenseConfigEntry, + poolsense: PoolSense, + ) -> None: """Initialize.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=timedelta(hours=1)) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(hours=1), + ) self.poolsense = poolsense self.email = self.config_entry.data[CONF_EMAIL] diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index 8cfb982d33b..11d94167b6d 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import PoolSenseConfigEntry +from .coordinator import PoolSenseConfigEntry from .entity import PoolSenseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( From f8169f11107b8188f90f14433a4594b332d60055 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:31:38 +0100 Subject: [PATCH 0265/1941] Explicitly pass in the config_entry in powerfox coordinator (#138037) explicitly pass in the config_entry in coordinator --- homeassistant/components/powerfox/__init__.py | 7 ++----- homeassistant/components/powerfox/coordinator.py | 6 +++++- homeassistant/components/powerfox/diagnostics.py | 2 +- homeassistant/components/powerfox/sensor.py | 3 +-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/powerfox/__init__.py b/homeassistant/components/powerfox/__init__.py index 243f3aacc4f..8e51985211d 100644 --- a/homeassistant/components/powerfox/__init__.py +++ b/homeassistant/components/powerfox/__init__.py @@ -6,18 +6,15 @@ import asyncio from powerfox import Powerfox, PowerfoxConnectionError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import PowerfoxDataUpdateCoordinator +from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]] - async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> bool: """Set up Powerfox from a config entry.""" @@ -34,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) -> raise ConfigEntryNotReady from err coordinators: list[PowerfoxDataUpdateCoordinator] = [ - PowerfoxDataUpdateCoordinator(hass, client, device) for device in devices + PowerfoxDataUpdateCoordinator(hass, entry, client, device) for device in devices ] await asyncio.gather( diff --git a/homeassistant/components/powerfox/coordinator.py b/homeassistant/components/powerfox/coordinator.py index a4a26759b69..bd76b7cc166 100644 --- a/homeassistant/components/powerfox/coordinator.py +++ b/homeassistant/components/powerfox/coordinator.py @@ -18,15 +18,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]] + class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]): """Class to manage fetching Powerfox data from the API.""" - config_entry: ConfigEntry + config_entry: PowerfoxConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: PowerfoxConfigEntry, client: Powerfox, device: Device, ) -> None: @@ -34,6 +37,7 @@ class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]): super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/powerfox/diagnostics.py b/homeassistant/components/powerfox/diagnostics.py index 4c6b0f8c6eb..8514e42537e 100644 --- a/homeassistant/components/powerfox/diagnostics.py +++ b/homeassistant/components/powerfox/diagnostics.py @@ -9,7 +9,7 @@ from powerfox import HeatMeter, PowerMeter, WaterMeter from homeassistant.core import HomeAssistant -from . import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator +from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py index 6505139fcd9..d293c5c7a53 100644 --- a/homeassistant/components/powerfox/sensor.py +++ b/homeassistant/components/powerfox/sensor.py @@ -17,8 +17,7 @@ from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PowerfoxConfigEntry -from .coordinator import PowerfoxDataUpdateCoordinator +from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator from .entity import PowerfoxEntity From 685e8828475310aeeb19d15724cd586e17e06fae Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:31:52 +0100 Subject: [PATCH 0266/1941] Explicitly pass in the config_entry in prusalink coordinator (#138036) explicitly pass in the config_entry in coordinator --- homeassistant/components/prusalink/__init__.py | 11 ++++++----- homeassistant/components/prusalink/coordinator.py | 10 ++++++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 1415e3dd0a6..4bb7dee411d 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -24,6 +24,7 @@ from .coordinator import ( InfoUpdateCoordinator, JobUpdateCoordinator, LegacyStatusCoordinator, + PrusaLinkUpdateCoordinator, StatusCoordinator, ) @@ -47,11 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], ) - coordinators = { - "legacy_status": LegacyStatusCoordinator(hass, api), - "status": StatusCoordinator(hass, api), - "job": JobUpdateCoordinator(hass, api), - "info": InfoUpdateCoordinator(hass, api), + coordinators: dict[str, PrusaLinkUpdateCoordinator] = { + "legacy_status": LegacyStatusCoordinator(hass, entry, api), + "status": StatusCoordinator(hass, entry, api), + "job": JobUpdateCoordinator(hass, entry, api), + "info": InfoUpdateCoordinator(hass, entry, api), } for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/prusalink/coordinator.py b/homeassistant/components/prusalink/coordinator.py index 1d887983931..e6f54bc6fa5 100644 --- a/homeassistant/components/prusalink/coordinator.py +++ b/homeassistant/components/prusalink/coordinator.py @@ -37,12 +37,18 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): config_entry: ConfigEntry expect_change_until = 0.0 - def __init__(self, hass: HomeAssistant, api: PrusaLink) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: PrusaLink + ) -> None: """Initialize the update coordinator.""" self.api = api super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=self._get_update_interval(None) + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=self._get_update_interval(None), ) async def _async_update_data(self) -> T: From 474d8bbd6581715b7f3e6c7cdd1ce92b0fae65fc Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:32:22 +0100 Subject: [PATCH 0267/1941] Explicitly pass in the config_entry in qbittorrent coordinator (#138029) explicitly pass in the config_entry in coordinator --- homeassistant/components/qbittorrent/__init__.py | 2 +- homeassistant/components/qbittorrent/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index d95136965f8..513b49d3561 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -124,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except APIConnectionError as exc: raise ConfigEntryNotReady("Fail to connect to qBittorrent") from exc - coordinator = QBittorrentDataCoordinator(hass, client) + coordinator = QBittorrentDataCoordinator(hass, config_entry, client) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index c590bb9d81a..8fd23fb3b5b 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -15,6 +15,7 @@ from qbittorrentapi import ( ) from qbittorrentapi.torrents import TorrentStatusesT +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -27,7 +28,11 @@ _LOGGER = logging.getLogger(__name__) class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]): """Coordinator for updating QBittorrent data.""" - def __init__(self, hass: HomeAssistant, client: Client) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, client: Client + ) -> None: """Initialize coordinator.""" self.client = client self._is_alternative_mode_enabled = False @@ -42,6 +47,7 @@ class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) From 659032c4d869569d7e91698778938ce9af8f0ff1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:32:39 +0100 Subject: [PATCH 0268/1941] Explicitly pass in the config_entry in motion_blinds coordinator (#138080) explicitly pass in the config_entry in coordinator --- homeassistant/components/motion_blinds/__init__.py | 10 +--------- .../components/motion_blinds/coordinator.py | 12 +++++++----- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 182ea310029..fa1664353e1 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -1,7 +1,6 @@ """The motion_blinds component.""" import asyncio -from datetime import timedelta import logging from typing import TYPE_CHECKING @@ -25,7 +24,6 @@ from .const import ( KEY_SETUP_LOCK, KEY_UNSUB_STOP, PLATFORMS, - UPDATE_INTERVAL, ) from .coordinator import DataUpdateCoordinatorMotionBlinds from .gateway import ConnectMotionGateway @@ -94,13 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } coordinator = DataUpdateCoordinatorMotionBlinds( - hass, - _LOGGER, - coordinator_info, - # Name of the data. For logging purposes. - name=entry.title, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=UPDATE_INTERVAL), + hass, entry, _LOGGER, coordinator_info ) # Fetch initial data so we have data when entities subscribe diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index b2abd205ce5..79e26e5aed4 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -7,6 +7,7 @@ from typing import Any from motionblinds import DEVICE_TYPES_WIFI, ParseException +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -25,21 +26,22 @@ _LOGGER = logging.getLogger(__name__) class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): """Class to manage fetching data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, logger: logging.Logger, coordinator_info: dict[str, Any], - *, - name: str, - update_interval: timedelta, ) -> None: """Initialize global data updater.""" super().__init__( hass, logger, - name=name, - update_interval=update_interval, + config_entry=config_entry, + name=config_entry.title, + update_interval=timedelta(seconds=UPDATE_INTERVAL), ) self.api_lock = coordinator_info[KEY_API_LOCK] From a60f30509ad37ed30ee96b8aaef17444b12fefde Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:32:55 +0100 Subject: [PATCH 0269/1941] Explicitly pass in the config_entry in modern_forms coordinator (#138085) explicitly pass in the config_entry in coordinator --- homeassistant/components/modern_forms/__init__.py | 4 ++-- homeassistant/components/modern_forms/coordinator.py | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index ef2bbad70ce..901e3f431a1 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -9,7 +9,7 @@ from typing import Any, Concatenate from aiomodernforms import ModernFormsConnectionError, ModernFormsError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -30,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Modern Forms device from a config entry.""" # Create Modern Forms instance for this entry - coordinator = ModernFormsDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) + coordinator = ModernFormsDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/modern_forms/coordinator.py b/homeassistant/components/modern_forms/coordinator.py index ecd928aa922..203ba54380d 100644 --- a/homeassistant/components/modern_forms/coordinator.py +++ b/homeassistant/components/modern_forms/coordinator.py @@ -8,6 +8,8 @@ import logging from aiomodernforms import ModernFormsDevice, ModernFormsError from aiomodernforms.models import Device as ModernFormsDeviceState +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,20 +23,22 @@ _LOGGER = logging.getLogger(__name__) class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceState]): """Class to manage fetching Modern Forms data from single endpoint.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - *, - host: str, + config_entry: ConfigEntry, ) -> None: """Initialize global Modern Forms data updater.""" self.modern_forms = ModernFormsDevice( - host, session=async_get_clientsession(hass) + config_entry.data[CONF_HOST], session=async_get_clientsession(hass) ) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) From 200eb9a63d1ea7da8389c8b7ab7cd18d95d682b4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:33:05 +0100 Subject: [PATCH 0270/1941] Explicitly pass in the config_entry in minecraft_server coordinator (#138086) explicitly pass in the config_entry in coordinator --- .../components/minecraft_server/__init__.py | 11 ++--------- .../components/minecraft_server/coordinator.py | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index f1392ea488a..55bf96a7b89 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -10,14 +10,7 @@ import dns.rdataclass import dns.rdatatype from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ADDRESS, - CONF_HOST, - CONF_NAME, - CONF_PORT, - CONF_TYPE, - Platform, -) +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -58,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Initialization failed: {error}") from error # Create coordinator instance. - coordinator = MinecraftServerCoordinator(hass, entry.data[CONF_NAME], api) + coordinator = MinecraftServerCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() # Store coordinator instance. diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 37eeb9f2ac2..f66e4acf214 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -5,6 +5,8 @@ from __future__ import annotations from datetime import timedelta import logging +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,13 +25,21 @@ _LOGGER = logging.getLogger(__name__) class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Minecraft Server data update coordinator.""" - def __init__(self, hass: HomeAssistant, name: str, api: MinecraftServer) -> None: + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api: MinecraftServer, + ) -> None: """Initialize coordinator instance.""" self._api = api super().__init__( hass=hass, - name=name, + name=config_entry.data[CONF_NAME], + config_entry=config_entry, logger=_LOGGER, update_interval=SCAN_INTERVAL, ) From e1c222c54e4f4d7bf568319e2ad632d6d953e437 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:33:16 +0100 Subject: [PATCH 0271/1941] Explicitly pass in the config_entry in mill coordinator (#138088) explicitly pass in the config_entry in coordinator --- homeassistant/components/mill/__init__.py | 4 +--- homeassistant/components/mill/coordinator.py | 8 ++++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 116b3ef0341..2fcf2033930 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -47,9 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except TimeoutError as error: raise ConfigEntryNotReady from error data_coordinator = MillDataUpdateCoordinator( - hass, - mill_data_connection=mill_data_connection, - update_interval=update_interval, + hass, entry, mill_data_connection, update_interval ) await data_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/mill/coordinator.py b/homeassistant/components/mill/coordinator.py index 9821519ca84..ae527f8cce5 100644 --- a/homeassistant/components/mill/coordinator.py +++ b/homeassistant/components/mill/coordinator.py @@ -8,6 +8,7 @@ import logging from mill import Mill from mill_local import Mill as MillLocal +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -19,12 +20,14 @@ _LOGGER = logging.getLogger(__name__) class MillDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Mill data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - update_interval: timedelta | None = None, - *, + config_entry: ConfigEntry, mill_data_connection: Mill | MillLocal, + update_interval: timedelta, ) -> None: """Initialize global Mill data updater.""" self.mill_data_connection = mill_data_connection @@ -32,6 +35,7 @@ class MillDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_method=mill_data_connection.fetch_heater_and_sensor_data, update_interval=update_interval, From de19f8550fd5fecc9bb78f7343cd8fcdf026d1f9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:33:26 +0100 Subject: [PATCH 0272/1941] Explicitly pass in the config_entry in mikrotik coordinator (#138089) explicitly pass in the config_entry in coordinator --- homeassistant/components/mikrotik/__init__.py | 9 ++++----- .../components/mikrotik/coordinator.py | 18 ++++++++++++------ .../components/mikrotik/device_tracker.py | 3 +-- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index cecf96a6c3e..4e17653c05a 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -1,19 +1,16 @@ """The Mikrotik component.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import ATTR_MANUFACTURER, DOMAIN -from .coordinator import MikrotikDataUpdateCoordinator, get_api +from .coordinator import MikrotikConfigEntry, MikrotikDataUpdateCoordinator, get_api from .errors import CannotConnect, LoginError PLATFORMS = [Platform.DEVICE_TRACKER] -type MikrotikConfigEntry = ConfigEntry[MikrotikDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, config_entry: MikrotikConfigEntry @@ -47,6 +44,8 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: MikrotikConfigEntry +) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index 6cb36d58fbe..c68b13eeca8 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -45,6 +45,8 @@ from .errors import CannotConnect, LoginError _LOGGER = logging.getLogger(__name__) +type MikrotikConfigEntry = ConfigEntry[MikrotikDataUpdateCoordinator] + class MikrotikData: """Handle all communication with the Mikrotik API.""" @@ -246,17 +248,21 @@ class MikrotikData: class MikrotikDataUpdateCoordinator(DataUpdateCoordinator[None]): """Mikrotik Hub Object.""" + config_entry: MikrotikConfigEntry + def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: librouteros.Api + self, + hass: HomeAssistant, + config_entry: MikrotikConfigEntry, + api: librouteros.Api, ) -> None: """Initialize the Mikrotik Client.""" - self.hass = hass - self.config_entry: ConfigEntry = config_entry - self._mk_data = MikrotikData(self.hass, self.config_entry, api) + self._mk_data = MikrotikData(hass, config_entry, api) super().__init__( - self.hass, + hass, _LOGGER, - name=f"{DOMAIN} - {self.host}", + config_entry=config_entry, + name=f"{DOMAIN} - {config_entry.data[CONF_HOST]}", update_interval=timedelta(seconds=10), ) diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 19d5c789c09..db4727ec1ec 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -14,8 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import MikrotikConfigEntry -from .coordinator import Device, MikrotikDataUpdateCoordinator +from .coordinator import Device, MikrotikConfigEntry, MikrotikDataUpdateCoordinator async def async_setup_entry( From bd3eec90ba609d3a6e45bdf0694088e43a2d0f37 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:33:38 +0100 Subject: [PATCH 0273/1941] Explicitly pass in the config_entry in microbees coordinator (#138090) explicitly pass in the config_entry in coordinator --- homeassistant/components/microbees/__init__.py | 2 +- homeassistant/components/microbees/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/microbees/__init__.py b/homeassistant/components/microbees/__init__.py index 488988ab593..12c536121da 100644 --- a/homeassistant/components/microbees/__init__.py +++ b/homeassistant/components/microbees/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex raise ConfigEntryNotReady from ex microbees = MicroBees(token=session.token[CONF_ACCESS_TOKEN]) - coordinator = MicroBeesUpdateCoordinator(hass, microbees) + coordinator = MicroBeesUpdateCoordinator(hass, entry, microbees) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantMicroBeesData( connector=microbees, diff --git a/homeassistant/components/microbees/coordinator.py b/homeassistant/components/microbees/coordinator.py index af207507e77..0094dc33e81 100644 --- a/homeassistant/components/microbees/coordinator.py +++ b/homeassistant/components/microbees/coordinator.py @@ -9,6 +9,7 @@ import logging import aiohttp from microBeesPy import Actuator, Bee, MicroBees, MicroBeesException, Sensor +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -28,11 +29,16 @@ class MicroBeesCoordinatorData: class MicroBeesUpdateCoordinator(DataUpdateCoordinator[MicroBeesCoordinatorData]): """MicroBees coordinator.""" - def __init__(self, hass: HomeAssistant, microbees: MicroBees) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, microbees: MicroBees + ) -> None: """Initialize microBees coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="microBees Coordinator", update_interval=timedelta(seconds=30), ) From c8f035b5c54942d9811b2e7534f31f8aceae204d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:33:52 +0100 Subject: [PATCH 0274/1941] Explicitly pass in the config_entry in met coordinator (#138091) explicitly pass in the config_entry in coordinator --- homeassistant/components/met/__init__.py | 5 +---- homeassistant/components/met/coordinator.py | 16 ++++++++++++++-- homeassistant/components/met/weather.py | 3 +-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 1cd7a4bde57..17fc411bf20 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -15,14 +14,12 @@ from .const import ( DEFAULT_HOME_LONGITUDE, DOMAIN, ) -from .coordinator import MetDataUpdateCoordinator +from .coordinator import MetDataUpdateCoordinator, MetWeatherConfigEntry PLATFORMS = [Platform.WEATHER] _LOGGER = logging.getLogger(__name__) -type MetWeatherConfigEntry = ConfigEntry[MetDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, config_entry: MetWeatherConfigEntry diff --git a/homeassistant/components/met/coordinator.py b/homeassistant/components/met/coordinator.py index 3887a29f83c..de27da7a07f 100644 --- a/homeassistant/components/met/coordinator.py +++ b/homeassistant/components/met/coordinator.py @@ -31,6 +31,8 @@ URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/2.0/comp _LOGGER = logging.getLogger(__name__) +type MetWeatherConfigEntry = ConfigEntry[MetDataUpdateCoordinator] + class CannotConnect(HomeAssistantError): """Unable to connect to the web site.""" @@ -89,7 +91,11 @@ class MetWeatherData: class MetDataUpdateCoordinator(DataUpdateCoordinator[MetWeatherData]): """Class to manage fetching Met data.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + config_entry: MetWeatherConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: MetWeatherConfigEntry + ) -> None: """Initialize global Met data updater.""" self._unsub_track_home: Callable[[], None] | None = None self.weather = MetWeatherData(hass, config_entry.data) @@ -97,7 +103,13 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator[MetWeatherData]): update_interval = timedelta(minutes=randrange(55, 65)) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=update_interval, + ) async def _async_update_data(self) -> MetWeatherData: """Fetch data from Met.""" diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 7b95567366b..d1f0e8bc834 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -37,7 +37,6 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM -from . import MetWeatherConfigEntry from .const import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, @@ -47,7 +46,7 @@ from .const import ( DOMAIN, FORECAST_MAP, ) -from .coordinator import MetDataUpdateCoordinator +from .coordinator import MetDataUpdateCoordinator, MetWeatherConfigEntry DEFAULT_NAME = "Met.no" From af8efadd1b469421ef68b09e47b9425ef6a90271 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:34:00 +0100 Subject: [PATCH 0275/1941] Explicitly pass in the config_entry in melnor coordinator (#138092) explicitly pass in the config_entry in coordinator --- homeassistant/components/melnor/__init__.py | 2 +- homeassistant/components/melnor/coordinator.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index afaf8eb95f8..6ab725d747c 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: bluetooth.BluetoothScanningMode.PASSIVE, ) - coordinator = MelnorDataUpdateCoordinator(hass, device) + coordinator = MelnorDataUpdateCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = coordinator diff --git a/homeassistant/components/melnor/coordinator.py b/homeassistant/components/melnor/coordinator.py index 669fe916082..52662fd0c4c 100644 --- a/homeassistant/components/melnor/coordinator.py +++ b/homeassistant/components/melnor/coordinator.py @@ -5,6 +5,7 @@ import logging from melnor_bluetooth.device import Device +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -14,13 +15,17 @@ _LOGGER = logging.getLogger(__name__) class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Melnor data update coordinator.""" + config_entry: ConfigEntry _device: Device - def __init__(self, hass: HomeAssistant, device: Device) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, device: Device + ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Melnor Bluetooth", update_interval=timedelta(seconds=5), ) From f8d4a63644a3e2cb0dc6e765176f306f0066c1f7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:34:10 +0100 Subject: [PATCH 0276/1941] Explicitly pass in the config_entry in mealie coordinator (#138093) explicitly pass in the config_entry in coordinator --- homeassistant/components/mealie/__init__.py | 6 +++--- homeassistant/components/mealie/coordinator.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index 5e1523b939a..e019dae2c33 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -86,9 +86,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo sw_version=about.version, ) - mealplan_coordinator = MealieMealplanCoordinator(hass, client) - shoppinglist_coordinator = MealieShoppingListCoordinator(hass, client) - statistics_coordinator = MealieStatisticsCoordinator(hass, client) + mealplan_coordinator = MealieMealplanCoordinator(hass, entry, client) + shoppinglist_coordinator = MealieShoppingListCoordinator(hass, entry, client) + statistics_coordinator = MealieStatisticsCoordinator(hass, entry, client) await mealplan_coordinator.async_config_entry_first_refresh() await shoppinglist_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/mealie/coordinator.py b/homeassistant/components/mealie/coordinator.py index cf8dfb5bc90..ae5b9cd8c97 100644 --- a/homeassistant/components/mealie/coordinator.py +++ b/homeassistant/components/mealie/coordinator.py @@ -48,11 +48,14 @@ class MealieDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): _name: str _update_interval: timedelta - def __init__(self, hass: HomeAssistant, client: MealieClient) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: MealieConfigEntry, client: MealieClient + ) -> None: """Initialize the Mealie data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=f"Mealie {self._name}", update_interval=self._update_interval, ) From e3822ed27730eb21acac08baa569644b538732a4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:34:20 +0100 Subject: [PATCH 0277/1941] Explicitly pass in the config_entry in mastodon coordinator (#138094) explicitly pass in the config_entry in coordinator --- homeassistant/components/mastodon/__init__.py | 2 +- homeassistant/components/mastodon/coordinator.py | 12 ++++++++++-- homeassistant/components/mastodon/diagnostics.py | 2 +- homeassistant/components/mastodon/entity.py | 3 +-- homeassistant/components/mastodon/sensor.py | 2 +- homeassistant/components/mastodon/services.py | 2 +- 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 2f713a97dfe..ab8514c8321 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> assert entry.unique_id - coordinator = MastodonCoordinator(hass, client) + coordinator = MastodonCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index 4c6fe6b1c88..5d2b193b4a8 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -32,10 +32,18 @@ type MastodonConfigEntry = ConfigEntry[MastodonData] class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Mastodon data.""" - def __init__(self, hass: HomeAssistant, client: Mastodon) -> None: + config_entry: MastodonConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: MastodonConfigEntry, client: Mastodon + ) -> None: """Initialize coordinator.""" super().__init__( - hass, logger=LOGGER, name="Mastodon", update_interval=timedelta(hours=1) + hass, + logger=LOGGER, + config_entry=config_entry, + name="Mastodon", + update_interval=timedelta(hours=1), ) self.client = client diff --git a/homeassistant/components/mastodon/diagnostics.py b/homeassistant/components/mastodon/diagnostics.py index 7246ae9cf63..dc7c1b785ab 100644 --- a/homeassistant/components/mastodon/diagnostics.py +++ b/homeassistant/components/mastodon/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import MastodonConfigEntry +from .coordinator import MastodonConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/mastodon/entity.py b/homeassistant/components/mastodon/entity.py index 93d630627d7..2ae8c0d852e 100644 --- a/homeassistant/components/mastodon/entity.py +++ b/homeassistant/components/mastodon/entity.py @@ -4,9 +4,8 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import MastodonConfigEntry from .const import DEFAULT_NAME, DOMAIN, INSTANCE_VERSION -from .coordinator import MastodonCoordinator +from .coordinator import MastodonConfigEntry, MastodonCoordinator from .utils import construct_mastodon_username diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py index 1bb59ad7c05..93ec77032ce 100644 --- a/homeassistant/components/mastodon/sensor.py +++ b/homeassistant/components/mastodon/sensor.py @@ -15,12 +15,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MastodonConfigEntry from .const import ( ACCOUNT_FOLLOWERS_COUNT, ACCOUNT_FOLLOWING_COUNT, ACCOUNT_STATUSES_COUNT, ) +from .coordinator import MastodonConfigEntry from .entity import MastodonEntity # Coordinator is used to centralize the data updates diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index ab3a89c0c4b..2a919e5fa5f 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -12,7 +12,6 @@ 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, @@ -22,6 +21,7 @@ from .const import ( ATTR_VISIBILITY, DOMAIN, ) +from .coordinator import MastodonConfigEntry from .utils import get_media_type From 0e4db4265abca28389abddac7599d8cf2f104aa0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:36:04 +0100 Subject: [PATCH 0278/1941] Explicitly pass in the config_entry in permobil coordinator (#138043) explicitly pass in the config_entry in coordinator --- homeassistant/components/permobil/__init__.py | 2 +- homeassistant/components/permobil/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py index 675a803ce91..441c6a2646e 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed(f"Config error for {p_api.email}") from err # create the coordinator with the API object - coordinator = MyPermobilCoordinator(hass, p_api) + coordinator = MyPermobilCoordinator(hass, entry, p_api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py index 6efde26d341..ea7ddadff9f 100644 --- a/homeassistant/components/permobil/coordinator.py +++ b/homeassistant/components/permobil/coordinator.py @@ -7,6 +7,7 @@ import logging from mypermobil import MyPermobil, MyPermobilAPIException +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,11 +26,16 @@ class MyPermobilData: class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): """MyPermobil coordinator.""" - def __init__(self, hass: HomeAssistant, p_api: MyPermobil) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, p_api: MyPermobil + ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="permobil", update_interval=timedelta(minutes=5), ) From d0e2a9e0bffacd68f03cee691564ccae1ceb129f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:36:48 +0100 Subject: [PATCH 0279/1941] Explicitly pass in the config_entry in p1_monitor coordinator (#138045) explicitly pass in the config_entry in coordinator --- homeassistant/components/p1_monitor/__init__.py | 15 +++++++-------- .../components/p1_monitor/coordinator.py | 6 +++++- .../components/p1_monitor/diagnostics.py | 4 ++-- homeassistant/components/p1_monitor/sensor.py | 9 +++++---- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/p1_monitor/__init__.py b/homeassistant/components/p1_monitor/__init__.py index d2ccc83972a..e12c092453c 100644 --- a/homeassistant/components/p1_monitor/__init__.py +++ b/homeassistant/components/p1_monitor/__init__.py @@ -2,23 +2,20 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import LOGGER -from .coordinator import P1MonitorDataUpdateCoordinator +from .coordinator import P1MonitorConfigEntry, P1MonitorDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type P1MonitorConfigEntry = ConfigEntry[P1MonitorDataUpdateCoordinator] - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: P1MonitorConfigEntry) -> bool: """Set up P1 Monitor from a config entry.""" - coordinator = P1MonitorDataUpdateCoordinator(hass) + coordinator = P1MonitorDataUpdateCoordinator(hass, entry) try: await coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: @@ -31,7 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: P1MonitorConfigEntry +) -> bool: """Migrate old entry.""" LOGGER.debug("Migrating from version %s", config_entry.version) @@ -54,6 +53,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: P1MonitorConfigEntry) -> bool: """Unload P1 Monitor config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/p1_monitor/coordinator.py b/homeassistant/components/p1_monitor/coordinator.py index 5459f88c388..3be78f8efd5 100644 --- a/homeassistant/components/p1_monitor/coordinator.py +++ b/homeassistant/components/p1_monitor/coordinator.py @@ -30,6 +30,8 @@ from .const import ( SERVICE_WATERMETER, ) +type P1MonitorConfigEntry = ConfigEntry[P1MonitorDataUpdateCoordinator] + class P1MonitorData(TypedDict): """Class for defining data in dict.""" @@ -43,17 +45,19 @@ class P1MonitorData(TypedDict): class P1MonitorDataUpdateCoordinator(DataUpdateCoordinator[P1MonitorData]): """Class to manage fetching P1 Monitor data from single endpoint.""" - config_entry: ConfigEntry + config_entry: P1MonitorConfigEntry has_water_meter: bool | None = None def __init__( self, hass: HomeAssistant, + config_entry: P1MonitorConfigEntry, ) -> None: """Initialize global P1 Monitor data updater.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py index d2e2ec5c24e..ac670486e79 100644 --- a/homeassistant/components/p1_monitor/diagnostics.py +++ b/homeassistant/components/p1_monitor/diagnostics.py @@ -6,7 +6,6 @@ from dataclasses import asdict from typing import TYPE_CHECKING, Any, cast from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -16,6 +15,7 @@ from .const import ( SERVICE_SMARTMETER, SERVICE_WATERMETER, ) +from .coordinator import P1MonitorConfigEntry if TYPE_CHECKING: from _typeshed import DataclassInstance @@ -24,7 +24,7 @@ TO_REDACT = {CONF_HOST, CONF_PORT} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: P1MonitorConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" data = { diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 771ef0e19af..84e331a4099 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CURRENCY_EURO, @@ -33,7 +32,7 @@ from .const import ( SERVICE_SMARTMETER, SERVICE_WATERMETER, ) -from .coordinator import P1MonitorDataUpdateCoordinator +from .coordinator import P1MonitorConfigEntry, P1MonitorDataUpdateCoordinator SENSORS_SMARTMETER: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -236,7 +235,9 @@ SENSORS_WATERMETER: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: P1MonitorConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up P1 Monitor Sensors based on a config entry.""" entities: list[P1MonitorSensorEntity] = [] @@ -290,7 +291,7 @@ class P1MonitorSensorEntity( def __init__( self, *, - entry: ConfigEntry, + entry: P1MonitorConfigEntry, description: SensorEntityDescription, name: str, service: Literal["smartmeter", "watermeter", "phases", "settings"], From 6fe47a0f1b8adea995459a3423cee4e94eeaa335 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:37:07 +0100 Subject: [PATCH 0280/1941] Explicitly pass in the config_entry in overkiz coordinator (#138046) explicitly pass in the config_entry in coordinator --- homeassistant/components/overkiz/__init__.py | 5 +---- .../components/overkiz/coordinator.py | 22 ++++++++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 51efb52e55d..8aa1ed0e4fe 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -39,7 +39,6 @@ from .const import ( LOGGER, OVERKIZ_DEVICE_TO_PLATFORM, PLATFORMS, - UPDATE_INTERVAL, UPDATE_INTERVAL_ALL_ASSUMED_STATE, UPDATE_INTERVAL_LOCAL, ) @@ -104,13 +103,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) coordinator = OverkizDataUpdateCoordinator( hass, + entry, LOGGER, - name="device events", client=client, devices=setup.devices, places=setup.root_place, - update_interval=UPDATE_INTERVAL, - config_entry_id=entry.entry_id, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 484ef138cf7..4b79cfc9c06 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp import ClientConnectorError, ServerDisconnectedError from pyoverkiz.client import OverkizClient @@ -26,7 +26,10 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.decorator import Registry -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER +if TYPE_CHECKING: + from . import OverkizDataConfigEntry + +from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, LOGGER, UPDATE_INTERVAL EVENT_HANDLERS: Registry[ str, Callable[[OverkizDataUpdateCoordinator, Event], Coroutine[Any, Any, None]] @@ -36,26 +39,26 @@ EVENT_HANDLERS: Registry[ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Class to manage fetching data from Overkiz platform.""" + config_entry: OverkizDataConfigEntry _default_update_interval: timedelta def __init__( self, hass: HomeAssistant, + config_entry: OverkizDataConfigEntry, logger: logging.Logger, *, - name: str, client: OverkizClient, devices: list[Device], places: Place | None, - update_interval: timedelta, - config_entry_id: str, ) -> None: """Initialize global data updater.""" super().__init__( hass, logger, - name=name, - update_interval=update_interval, + config_entry=config_entry, + name="device events", + update_interval=UPDATE_INTERVAL, ) self.data = {} @@ -63,8 +66,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): self.devices: dict[str, Device] = {d.device_url: d for d in devices} self.executions: dict[str, dict[str, str]] = {} self.areas = self._places_to_area(places) if places else None - self.config_entry_id = config_entry_id - self._default_update_interval = update_interval + self._default_update_interval = UPDATE_INTERVAL self.is_stateless = all( device.protocol in (Protocol.RTS, Protocol.INTERNAL) @@ -164,7 +166,7 @@ async def on_device_created_updated( ) -> None: """Handle device unavailable / disabled event.""" coordinator.hass.async_create_task( - coordinator.hass.config_entries.async_reload(coordinator.config_entry_id) + coordinator.hass.config_entries.async_reload(coordinator.config_entry.entry_id) ) From 0bd161a45a43c34188faea46c015f5cc18efa974 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 9 Feb 2025 09:30:52 -0800 Subject: [PATCH 0281/1941] Use resumable uploads in Google Drive (#138010) * Use resumable uploads in Google Drive * tests --- homeassistant/components/google_drive/api.py | 3 ++- homeassistant/components/google_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../google_drive/snapshots/test_backup.ambr | 6 ++++-- tests/components/google_drive/test_backup.py | 12 +++++++----- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py index 475eddb6231..c21d42e0f3a 100644 --- a/homeassistant/components/google_drive/api.py +++ b/homeassistant/components/google_drive/api.py @@ -146,9 +146,10 @@ class DriveClient: backup.backup_id, backup_metadata, ) - await self._api.upload_file( + await self._api.resumable_upload_file( backup_metadata, open_stream, + backup.size, timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT), ) _LOGGER.debug( diff --git a/homeassistant/components/google_drive/manifest.json b/homeassistant/components/google_drive/manifest.json index a1abb9b260a..6b199a5d3eb 100644 --- a/homeassistant/components/google_drive/manifest.json +++ b/homeassistant/components/google_drive/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_polling", "loggers": ["google_drive_api"], "quality_scale": "platinum", - "requirements": ["python-google-drive-api==0.0.2"] + "requirements": ["python-google-drive-api==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e6c5f7dcf32..ce5d60c37cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2391,7 +2391,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.google_drive -python-google-drive-api==0.0.2 +python-google-drive-api==0.1.0 # homeassistant.components.analytics_insights python-homeassistant-analytics==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d85fd7dda5..b13a2d677e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1936,7 +1936,7 @@ python-fullykiosk==0.0.14 # python-gammu==3.2.4 # homeassistant.components.google_drive -python-google-drive-api==0.0.2 +python-google-drive-api==0.1.0 # homeassistant.components.analytics_insights python-homeassistant-analytics==0.9.0 diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 2f3df3eed7f..891eb0e1cbe 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -136,7 +136,7 @@ }), ), tuple( - 'upload_file', + 'resumable_upload_file', tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', @@ -151,6 +151,7 @@ }), }), "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + 987, ), dict({ 'timeout': dict({ @@ -207,7 +208,7 @@ }), ), tuple( - 'upload_file', + 'resumable_upload_file', tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', @@ -222,6 +223,7 @@ }), }), "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + 987, ), dict({ 'timeout': dict({ diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 115a30a3eb6..70431e2049f 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -281,7 +281,7 @@ async def test_agents_upload( snapshot: SnapshotAssertion, ) -> None: """Test agent upload backup.""" - mock_api.upload_file = AsyncMock(return_value=None) + mock_api.resumable_upload_file = AsyncMock(return_value=None) client = await hass_client() @@ -306,7 +306,7 @@ async def test_agents_upload( assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text - mock_api.upload_file.assert_called_once() + mock_api.resumable_upload_file.assert_called_once() assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot @@ -322,7 +322,7 @@ async def test_agents_upload_create_folder_if_missing( mock_api.create_file = AsyncMock( return_value={"id": "new folder id", "name": "Home Assistant"} ) - mock_api.upload_file = AsyncMock(return_value=None) + mock_api.resumable_upload_file = AsyncMock(return_value=None) client = await hass_client() @@ -348,7 +348,7 @@ async def test_agents_upload_create_folder_if_missing( assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text mock_api.create_file.assert_called_once() - mock_api.upload_file.assert_called_once() + mock_api.resumable_upload_file.assert_called_once() assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot @@ -359,7 +359,9 @@ async def test_agents_upload_fail( mock_api: MagicMock, ) -> None: """Test agent upload backup fails.""" - mock_api.upload_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + mock_api.resumable_upload_file = AsyncMock( + side_effect=GoogleDriveApiError("some error") + ) client = await hass_client() From b1f3068b41dc06583ac14371277f85f29bf3a736 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 9 Feb 2025 09:31:18 -0800 Subject: [PATCH 0282/1941] Refresh the nest authentication token on integration start before invoking the pub/sub subsciber (#138003) * Refresh the nest authentication token on integration start before invoking the pub/sub subscriber * Apply suggestions from code review --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/nest/__init__.py | 11 +++- homeassistant/components/nest/api.py | 18 ++++-- tests/components/nest/test_api.py | 77 ----------------------- 3 files changed, 22 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 8adc0e4f714..67c14bbf544 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -198,7 +198,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool entry, unique_id=entry.data[CONF_PROJECT_ID] ) - subscriber = await api.new_subscriber(hass, entry) + auth = await api.new_auth(hass, entry) + try: + await auth.async_get_access_token() + except AuthException as err: + raise ConfigEntryAuthFailed(f"Authentication error: {err!s}") from err + except ConfigurationException as err: + _LOGGER.error("Configuration error: %s", err) + return False + + subscriber = await api.new_subscriber(hass, entry, auth) if not subscriber: return False # Keep media for last N events in memory diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index e86e326b1c2..727b126dda4 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -101,9 +101,7 @@ class AccessTokenAuthImpl(AbstractAuth): ) -async def new_subscriber( - hass: HomeAssistant, entry: NestConfigEntry -) -> GoogleNestSubscriber | None: +async def new_auth(hass: HomeAssistant, entry: NestConfigEntry) -> AbstractAuth: """Create a GoogleNestSubscriber.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -114,14 +112,22 @@ async def new_subscriber( implementation, config_entry_oauth2_flow.LocalOAuth2Implementation ): raise TypeError(f"Unexpected auth implementation {implementation}") - if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: - subscription_name = entry.data[CONF_SUBSCRIBER_ID] - auth = AsyncConfigEntryAuth( + return AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), implementation.client_id, implementation.client_secret, ) + + +async def new_subscriber( + hass: HomeAssistant, + entry: NestConfigEntry, + auth: AbstractAuth, +) -> GoogleNestSubscriber: + """Create a GoogleNestSubscriber.""" + if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: + subscription_name = entry.data[CONF_SUBSCRIBER_ID] return GoogleNestSubscriber(auth, entry.data[CONF_PROJECT_ID], subscription_name) diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 98c3e06cfb8..1a5c4d63dba 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -89,80 +89,3 @@ async def test_auth( assert creds.client_id == CLIENT_ID assert creds.client_secret == CLIENT_SECRET assert creds.scopes == SDM_SCOPES - - -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize( - "token_expiration_time", - [time.time() - 7 * 86400], - ids=["expires-in-past"], -) -async def test_auth_expired_token( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - setup_platform: PlatformSetup, - token_expiration_time: float, -) -> None: - """Verify behavior of an expired token.""" - # Prepare a token refresh response - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "access_token": FAKE_UPDATED_TOKEN, - "expires_at": time.time() + 86400, - "expires_in": 86400, - }, - ) - # Prepare to capture credentials in API request. Empty payloads just mean - # no devices or structures are loaded. - aioclient_mock.get(f"{API_URL}/enterprises/{PROJECT_ID}/structures", json={}) - aioclient_mock.get(f"{API_URL}/enterprises/{PROJECT_ID}/devices", json={}) - - # Prepare to capture credentials for Subscriber - captured_creds = None - - def async_new_subscriber( - credentials: Credentials, - ) -> Mock: - """Capture credentials for tests.""" - nonlocal captured_creds - captured_creds = credentials - return AsyncMock() - - with patch( - "google_nest_sdm.subscriber_client.pubsub_v1.SubscriberAsyncClient", - side_effect=async_new_subscriber, - ) as new_subscriber_mock: - await setup_platform() - - calls = aioclient_mock.mock_calls - assert len(calls) == 3 - # Verify refresh token call to get an updated token - (method, url, data, headers) = calls[0] - assert data == { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "grant_type": "refresh_token", - "refresh_token": FAKE_REFRESH_TOKEN, - } - # Verify API requests are made with the new token - (method, url, data, headers) = calls[1] - assert headers == {"Authorization": f"Bearer {FAKE_UPDATED_TOKEN}"} - (method, url, data, headers) = calls[2] - assert headers == {"Authorization": f"Bearer {FAKE_UPDATED_TOKEN}"} - - # The subscriber is created with a token that is expired. Verify that the - # credential is expired so the subscriber knows it needs to refresh it. - assert len(new_subscriber_mock.mock_calls) == 1 - assert captured_creds - creds = captured_creds - assert creds.token == FAKE_TOKEN - assert creds.refresh_token == FAKE_REFRESH_TOKEN - assert int(dt_util.as_timestamp(creds.expiry)) == int(token_expiration_time) - assert not creds.valid - assert creds.expired - assert creds.token_uri == OAUTH2_TOKEN - assert creds.client_id == CLIENT_ID - assert creds.client_secret == CLIENT_SECRET - assert creds.scopes == SDM_SCOPES From 233f6416f260e8dc0299a40ab908dbc9906e8e35 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:32:34 +0100 Subject: [PATCH 0283/1941] Explicitly pass in the config_entry in nina coordinator (#138069) * explicitly pass in the config_entry in coordinator * add accidential removed typing --- homeassistant/components/nina/__init__.py | 11 +------ homeassistant/components/nina/coordinator.py | 30 ++++++++++++++------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index d5b1c5ccb35..b02d6711e74 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -11,7 +11,6 @@ from .const import ( CONF_AREA_FILTER, CONF_FILTER_CORONA, CONF_HEADLINE_FILTER, - CONF_REGIONS, DOMAIN, NO_MATCH_REGEX, ) @@ -22,9 +21,6 @@ PLATFORMS: list[str] = [Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up platform from a ConfigEntry.""" - - regions: dict[str, str] = entry.data[CONF_REGIONS] - if CONF_HEADLINE_FILTER not in entry.data: filter_regex = NO_MATCH_REGEX @@ -39,12 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data = {**entry.data, CONF_AREA_FILTER: ALL_MATCH_REGEX} hass.config_entries.async_update_entry(entry, data=new_data) - coordinator = NINADataUpdateCoordinator( - hass, - regions, - entry.data[CONF_HEADLINE_FILTER], - entry.data[CONF_AREA_FILTER], - ) + coordinator = NINADataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index 2d9548f3d12..3c27729ef09 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -9,11 +9,19 @@ from typing import Any from pynina import ApiError, Nina +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import _LOGGER, DOMAIN, SCAN_INTERVAL +from .const import ( + _LOGGER, + CONF_AREA_FILTER, + CONF_HEADLINE_FILTER, + CONF_REGIONS, + DOMAIN, + SCAN_INTERVAL, +) @dataclass @@ -39,23 +47,29 @@ class NINADataUpdateCoordinator( ): """Class to manage fetching NINA data API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - regions: dict[str, str], - headline_filter: str, - area_filter: str, + config_entry: ConfigEntry, ) -> None: """Initialize.""" - self._regions: dict[str, str] = regions self._nina: Nina = Nina(async_get_clientsession(hass)) - self.headline_filter: str = headline_filter - self.area_filter: str = area_filter + self.headline_filter: str = config_entry.data[CONF_HEADLINE_FILTER] + self.area_filter: str = config_entry.data[CONF_AREA_FILTER] + regions: dict[str, str] = config_entry.data[CONF_REGIONS] for region in regions: self._nina.addRegion(region) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: """Update data.""" From 974e1c17d6a535f760dee846bebd6ae9d8bbdd1b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:51:43 +0100 Subject: [PATCH 0284/1941] Explicitly pass in the config_entry in teslemetry coordinator (#138102) explicitly pass in the config_entry in coordinator --- homeassistant/components/teslemetry/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index aaf9726ad1b..0cd2a5a62d6 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -94,6 +94,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]) super().__init__( hass, LOGGER, + config_entry=config_entry, name="Teslemetry Energy Site Live", update_interval=ENERGY_LIVE_INTERVAL, ) @@ -139,6 +140,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) super().__init__( hass, LOGGER, + config_entry=config_entry, name="Teslemetry Energy Site Info", update_interval=ENERGY_INFO_INTERVAL, ) @@ -173,6 +175,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, LOGGER, + config_entry=config_entry, name=f"Teslemetry Energy History {api.energy_site_id}", update_interval=ENERGY_HISTORY_INTERVAL, ) From 3175cb9c4dcea9a12c0ab398107106ebcf8cbf89 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:52:22 +0100 Subject: [PATCH 0285/1941] Explicitly pass in the config_entry in starlink coordinator (#138103) explicitly pass in the config_entry in coordinator --- homeassistant/components/starlink/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index d65777b7435..4ae771c9582 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -61,6 +61,7 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=config_entry.title, update_interval=timedelta(seconds=5), ) From 7beb1c0921d4fc9366d4753573b982a114591ed4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:53:36 +0100 Subject: [PATCH 0286/1941] Explicitly pass in the config_entry in loqed coordinator (#138106) explicitly pass in the config_entry in coordinator --- homeassistant/components/loqed/__init__.py | 2 +- homeassistant/components/loqed/coordinator.py | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index b6408880c96..b308e2c0f1d 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -44,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: aiohttp.ClientError, ) as ex: raise ConfigEntryNotReady(f"Unable to connect to bridge at {host}") from ex - coordinator = LoqedDataCoordinator(hass, api, lock, entry) + coordinator = LoqedDataCoordinator(hass, entry, api, lock) await coordinator.ensure_webhooks() await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py index 1447934103e..7b60385a759 100644 --- a/homeassistant/components/loqed/coordinator.py +++ b/homeassistant/components/loqed/coordinator.py @@ -71,19 +71,20 @@ class StatusMessage(TypedDict): class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): """Data update coordinator for the loqed platform.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, api: loqed.LoqedAPI, lock: loqed.Lock, - entry: ConfigEntry, ) -> None: """Initialize the Loqed Data Update coordinator.""" - super().__init__(hass, _LOGGER, name="Loqed sensors") + super().__init__(hass, _LOGGER, config_entry=config_entry, name="Loqed sensors") self._api = api - self._entry = entry self.lock = lock - self.device_name = self._entry.data[CONF_NAME] + self.device_name = config_entry.data[CONF_NAME] async def _async_update_data(self) -> StatusMessage: """Fetch data from API endpoint.""" @@ -110,17 +111,19 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): async def ensure_webhooks(self) -> None: """Register webhook on LOQED bridge.""" - webhook_id = self._entry.data[CONF_WEBHOOK_ID] + webhook_id = self.config_entry.data[CONF_WEBHOOK_ID] webhook.async_register( self.hass, DOMAIN, "Loqed", webhook_id, self._handle_webhook ) if cloud.async_active_subscription(self.hass): - webhook_url = await async_cloudhook_generate_url(self.hass, self._entry) + webhook_url = await async_cloudhook_generate_url( + self.hass, self.config_entry + ) else: webhook_url = webhook.async_generate_url( - self.hass, self._entry.data[CONF_WEBHOOK_ID] + self.hass, self.config_entry.data[CONF_WEBHOOK_ID] ) _LOGGER.debug("Webhook URL: %s", webhook_url) @@ -140,10 +143,10 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): async def remove_webhooks(self) -> None: """Remove webhook from LOQED bridge.""" - webhook_id = self._entry.data[CONF_WEBHOOK_ID] + webhook_id = self.config_entry.data[CONF_WEBHOOK_ID] - if CONF_CLOUDHOOK_URL in self._entry.data: - webhook_url = self._entry.data[CONF_CLOUDHOOK_URL] + if CONF_CLOUDHOOK_URL in self.config_entry.data: + webhook_url = self.config_entry.data[CONF_CLOUDHOOK_URL] else: webhook_url = webhook.async_generate_url(self.hass, webhook_id) From cd8e1beb37155b07fd18bd27f4b73d2ea861fdab Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:57:25 +0100 Subject: [PATCH 0287/1941] Limit nordpool ConfigEntrySelect to integration domain (#137768) --- homeassistant/components/nordpool/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index 872bd5b1e6b..6607edfdbcb 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -41,7 +41,7 @@ ATTR_CURRENCY = "currency" SERVICE_GET_PRICES_FOR_DATE = "get_prices_for_date" SERVICE_GET_PRICES_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_DATE): cv.date, vol.Optional(ATTR_AREAS): vol.All(vol.In(list(AREAS)), cv.ensure_list, [str]), vol.Optional(ATTR_CURRENCY): vol.All( From eebe1820011700ea99aad5d5a617a934a59d946f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:59:00 +0100 Subject: [PATCH 0288/1941] Explicitly pass in the config_entry in linear_garage_door coordinator (#138109) explicitly pass in the config_entry in coordinator --- homeassistant/components/linear_garage_door/__init__.py | 2 +- homeassistant/components/linear_garage_door/coordinator.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py index e4aa30c98df..5e524fbb512 100644 --- a/homeassistant/components/linear_garage_door/__init__.py +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -31,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: }, ) - coordinator = LinearUpdateCoordinator(hass) + coordinator = LinearUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py index 38b1306ec38..b55affe92e7 100644 --- a/homeassistant/components/linear_garage_door/coordinator.py +++ b/homeassistant/components/linear_garage_door/coordinator.py @@ -34,15 +34,16 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]): _devices: list[dict[str, Any]] | None = None config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize DataUpdateCoordinator for Linear.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="Linear Garage Door", update_interval=timedelta(seconds=60), ) - self.site_id = self.config_entry.data["site_id"] + self.site_id = config_entry.data["site_id"] async def _async_update_data(self) -> dict[str, LinearDevice]: """Get the data for Linear.""" From 0d0e751700a2c74cd12c2ab0f7a26b8a2aae902c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:59:20 +0100 Subject: [PATCH 0289/1941] Explicitly pass in the config_entry in squeezebox coordinator (#138105) explicitly pass in the config_entry in coordinator --- homeassistant/components/squeezebox/__init__.py | 2 +- homeassistant/components/squeezebox/coordinator.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 3aec55a90d2..789f6ddb3a8 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -151,7 +151,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - else: _LOGGER.debug("Adding new entity: %s", player) player_coordinator = SqueezeBoxPlayerUpdateCoordinator( - hass, player, lms.uuid + hass, entry, player, lms.uuid ) known_players.append(player.player_id) async_dispatcher_send( diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index f51a481818d..955e2896947 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -90,11 +90,20 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator for Squeezebox players.""" - def __init__(self, hass: HomeAssistant, player: Player, server_uuid: str) -> None: + config_entry: SqueezeboxConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SqueezeboxConfigEntry, + player: Player, + server_uuid: str, + ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=player.name, update_interval=timedelta(seconds=PLAYER_UPDATE_INTERVAL), always_update=True, From c81963f464bba606d23aa1ee5a53b86dad09ee5b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:59:44 +0100 Subject: [PATCH 0290/1941] Explicitly pass in the config_entry in lookin coordinator (#138107) explicitly pass in the config_entry in coordinator --- homeassistant/components/lookin/__init__.py | 2 ++ homeassistant/components/lookin/coordinator.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index a0e529bc189..2fbabc12747 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -122,6 +122,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator_class = LookinDataUpdateCoordinator[MeteoSensor] meteo_coordinator = coordinator_class( hass, + entry, push_coordinator, name=entry.title, update_method=lookin_protocol.get_meteo_sensor, @@ -140,6 +141,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: updater = _async_remote_updater(lookin_protocol, uuid) coordinator = LookinDataUpdateCoordinator( hass, + entry, push_coordinator, name=f"{entry.title} {uuid}", update_method=updater, diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py index d9834bd1d94..a74cd0e4861 100644 --- a/homeassistant/components/lookin/coordinator.py +++ b/homeassistant/components/lookin/coordinator.py @@ -7,6 +7,7 @@ from datetime import timedelta import logging import time +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -43,9 +44,12 @@ class LookinPushCoordinator: class LookinDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator to gather data for a specific lookin devices.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, push_coordinator: LookinPushCoordinator, name: str, update_interval: timedelta | None = None, @@ -56,6 +60,7 @@ class LookinDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=name, update_interval=update_interval, update_method=update_method, From e1ed46f593ec9137b4ee2fce6146052abed0b5b6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 19:41:33 +0100 Subject: [PATCH 0291/1941] Explicitly pass in the config_entry in livisi coordinator (#138108) explicitly pass in the config_entry in coordinator --- homeassistant/components/livisi/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index b8b282c2829..6557416ed3a 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -39,10 +39,10 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): super().__init__( hass, LOGGER, + config_entry=config_entry, name="Livisi devices", update_interval=timedelta(seconds=DEVICE_POLLING_DELAY), ) - self.config_entry = config_entry self.hass = hass self.aiolivisi = aiolivisi self.websocket = Websocket(aiolivisi) From 9be5976807d99c45aa99b3920e2fdb0da8ec3653 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 19:41:44 +0100 Subject: [PATCH 0292/1941] Explicitly pass in the config_entry in lidarr coordinator (#138111) explicitly pass in the config_entry in coordinator --- homeassistant/components/lidarr/__init__.py | 30 +++++++------------ .../components/lidarr/coordinator.py | 19 +++++++++++- homeassistant/components/lidarr/sensor.py | 3 +- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/lidarr/__init__.py b/homeassistant/components/lidarr/__init__.py index a421a881b69..e3a5cf250b2 100644 --- a/homeassistant/components/lidarr/__init__.py +++ b/homeassistant/components/lidarr/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import fields from aiopyarr.lidarr_client import LidarrClient from aiopyarr.models.host_configuration import PyArrHostConfiguration -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -18,27 +17,16 @@ from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( AlbumsDataUpdateCoordinator, DiskSpaceDataUpdateCoordinator, + LidarrConfigEntry, + LidarrData, QueueDataUpdateCoordinator, StatusDataUpdateCoordinator, WantedDataUpdateCoordinator, ) -type LidarrConfigEntry = ConfigEntry[LidarrData] - PLATFORMS = [Platform.SENSOR] -@dataclass(kw_only=True, slots=True) -class LidarrData: - """Lidarr data type.""" - - disk_space: DiskSpaceDataUpdateCoordinator - queue: QueueDataUpdateCoordinator - status: StatusDataUpdateCoordinator - wanted: WantedDataUpdateCoordinator - albums: AlbumsDataUpdateCoordinator - - async def async_setup_entry(hass: HomeAssistant, entry: LidarrConfigEntry) -> bool: """Set up Lidarr from a config entry.""" host_configuration = PyArrHostConfiguration( @@ -52,11 +40,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: LidarrConfigEntry) -> bo request_timeout=60, ) data = LidarrData( - disk_space=DiskSpaceDataUpdateCoordinator(hass, host_configuration, lidarr), - queue=QueueDataUpdateCoordinator(hass, host_configuration, lidarr), - status=StatusDataUpdateCoordinator(hass, host_configuration, lidarr), - wanted=WantedDataUpdateCoordinator(hass, host_configuration, lidarr), - albums=AlbumsDataUpdateCoordinator(hass, host_configuration, lidarr), + disk_space=DiskSpaceDataUpdateCoordinator( + hass, entry, host_configuration, lidarr + ), + queue=QueueDataUpdateCoordinator(hass, entry, host_configuration, lidarr), + status=StatusDataUpdateCoordinator(hass, entry, host_configuration, lidarr), + wanted=WantedDataUpdateCoordinator(hass, entry, host_configuration, lidarr), + albums=AlbumsDataUpdateCoordinator(hass, entry, host_configuration, lidarr), ) for field in fields(data): coordinator = getattr(data, field.name) diff --git a/homeassistant/components/lidarr/coordinator.py b/homeassistant/components/lidarr/coordinator.py index 1010f708748..3f9d2be4bec 100644 --- a/homeassistant/components/lidarr/coordinator.py +++ b/homeassistant/components/lidarr/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass from datetime import timedelta from typing import Generic, TypeVar, cast @@ -17,17 +18,32 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER + +@dataclass(kw_only=True, slots=True) +class LidarrData: + """Lidarr data type.""" + + disk_space: DiskSpaceDataUpdateCoordinator + queue: QueueDataUpdateCoordinator + status: StatusDataUpdateCoordinator + wanted: WantedDataUpdateCoordinator + albums: AlbumsDataUpdateCoordinator + + T = TypeVar("T", bound=list[LidarrRootFolder] | LidarrQueue | str | LidarrAlbum | int) +type LidarrConfigEntry = ConfigEntry[LidarrData] + class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Lidarr integration.""" - config_entry: ConfigEntry + config_entry: LidarrConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: LidarrConfigEntry, host_configuration: PyArrHostConfiguration, api_client: LidarrClient, ) -> None: @@ -35,6 +51,7 @@ class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 805fcce53ad..7334241d0ed 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -18,9 +18,8 @@ from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LidarrConfigEntry from .const import BYTE_SIZES -from .coordinator import LidarrDataUpdateCoordinator, T +from .coordinator import LidarrConfigEntry, LidarrDataUpdateCoordinator, T from .entity import LidarrEntity From 12c5ad7249330168456f1e37339acc6bd4e8a918 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 19:51:13 +0100 Subject: [PATCH 0293/1941] Explicitly pass in the config_entry in lg_thinq coordinator (#138113) explicitly pass in the config_entry in coordinator --- homeassistant/components/lg_thinq/__init__.py | 2 +- homeassistant/components/lg_thinq/coordinator.py | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index 657524f0ef5..72d81af4ff0 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -100,7 +100,7 @@ async def async_setup_coordinators( # Setup coordinator per device. task_list = [ - hass.async_create_task(async_setup_device_coordinator(hass, bridge)) + hass.async_create_task(async_setup_device_coordinator(hass, entry, bridge)) for bridge in bridge_list ] task_result = await asyncio.gather(*task_list) diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index 9f317dc21d9..d6991d15297 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any from thinqconnect import ThinQAPIException from thinqconnect.integration import HABridge @@ -11,6 +11,9 @@ from thinqconnect.integration import HABridge from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +if TYPE_CHECKING: + from . import ThinqConfigEntry + from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -19,11 +22,16 @@ _LOGGER = logging.getLogger(__name__) class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """LG Device's Data Update Coordinator.""" - def __init__(self, hass: HomeAssistant, ha_bridge: HABridge) -> None: + config_entry: ThinqConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ThinqConfigEntry, ha_bridge: HABridge + ) -> None: """Initialize data coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"{DOMAIN}_{ha_bridge.device.device_id}", ) @@ -71,10 +79,10 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_setup_device_coordinator( - hass: HomeAssistant, ha_bridge: HABridge + hass: HomeAssistant, config_entry: ThinqConfigEntry, ha_bridge: HABridge ) -> DeviceDataUpdateCoordinator: """Create DeviceDataUpdateCoordinator and device_api per device.""" - coordinator = DeviceDataUpdateCoordinator(hass, ha_bridge) + coordinator = DeviceDataUpdateCoordinator(hass, config_entry, ha_bridge) await coordinator.async_refresh() _LOGGER.debug( From 75cf47be2bf3b5bd35feaccb94ead3d373d2529c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:08:13 +0100 Subject: [PATCH 0294/1941] Explicitly pass in the config_entry in lektrico coordinator (#138114) explicitly pass in the config_entry in coordinator --- homeassistant/components/lektrico/__init__.py | 16 +++++----------- homeassistant/components/lektrico/coordinator.py | 9 ++++++--- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/lektrico/__init__.py b/homeassistant/components/lektrico/__init__.py index 475b6132541..0a6675237dd 100644 --- a/homeassistant/components/lektrico/__init__.py +++ b/homeassistant/components/lektrico/__init__.py @@ -4,11 +4,10 @@ from __future__ import annotations from lektricowifi import Device -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, Platform +from homeassistant.const import CONF_TYPE, Platform from homeassistant.core import HomeAssistant -from .coordinator import LektricoDeviceDataUpdateCoordinator +from .coordinator import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator # List the platforms that charger supports. CHARGERS_PLATFORMS: list[Platform] = [ @@ -26,15 +25,10 @@ LB_DEVICES_PLATFORMS: list[Platform] = [ Platform.SENSOR, ] -type LektricoConfigEntry = ConfigEntry[LektricoDeviceDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: LektricoConfigEntry) -> bool: """Set up Lektrico Charging Station from a config entry.""" - coordinator = LektricoDeviceDataUpdateCoordinator( - hass, - f"{entry.data[CONF_TYPE]}_{entry.data[ATTR_SERIAL_NUMBER]}", - ) + coordinator = LektricoDeviceDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -45,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LektricoConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LektricoConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms( @@ -53,7 +47,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) -def _get_platforms(entry: ConfigEntry) -> list[Platform]: +def _get_platforms(entry: LektricoConfigEntry) -> list[Platform]: """Return the platforms for this type of device.""" _device_type: str = entry.data[CONF_TYPE] if _device_type in (Device.TYPE_1P7K, Device.TYPE_3P22K): diff --git a/homeassistant/components/lektrico/coordinator.py b/homeassistant/components/lektrico/coordinator.py index 7c72a00e2d3..aa96cf49e07 100644 --- a/homeassistant/components/lektrico/coordinator.py +++ b/homeassistant/components/lektrico/coordinator.py @@ -22,18 +22,21 @@ from .const import LOGGER SCAN_INTERVAL = timedelta(seconds=10) +type LektricoConfigEntry = ConfigEntry[LektricoDeviceDataUpdateCoordinator] + class LektricoDeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Data update coordinator for Lektrico device.""" - config_entry: ConfigEntry + config_entry: LektricoConfigEntry - def __init__(self, hass: HomeAssistant, device_name: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: LektricoConfigEntry) -> None: """Initialize a Lektrico Device.""" super().__init__( hass, LOGGER, - name=device_name, + config_entry=config_entry, + name=f"{config_entry.data[CONF_TYPE]}_{config_entry.data[ATTR_SERIAL_NUMBER]}", update_interval=SCAN_INTERVAL, ) self.device = Device( From 9244e843266a9af76c3416b3dcf43db1c684f941 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:08:23 +0100 Subject: [PATCH 0295/1941] Explicitly pass in the config_entry in ld2410_ble coordinator (#138115) explicitly pass in the config_entry in coordinator --- homeassistant/components/ld2410_ble/__init__.py | 2 +- homeassistant/components/ld2410_ble/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index 57e3dfa4617..db67010823d 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -41,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ld2410_ble = LD2410BLE(ble_device) - coordinator = LD2410BLECoordinator(hass, ld2410_ble) + coordinator = LD2410BLECoordinator(hass, entry, ld2410_ble) try: await ld2410_ble.initialise() diff --git a/homeassistant/components/ld2410_ble/coordinator.py b/homeassistant/components/ld2410_ble/coordinator.py index 2f0fd079773..b318542e798 100644 --- a/homeassistant/components/ld2410_ble/coordinator.py +++ b/homeassistant/components/ld2410_ble/coordinator.py @@ -6,6 +6,7 @@ import time from ld2410_ble import LD2410BLE, LD2410BLEState +from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -21,11 +22,16 @@ DEBOUNCE_SECONDS = 1.0 class LD2410BLECoordinator(DataUpdateCoordinator[None]): """Data coordinator for receiving LD2410B updates.""" - def __init__(self, hass: HomeAssistant, ld2410_ble: LD2410BLE) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, ld2410_ble: LD2410BLE + ) -> None: """Initialise the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, ) self._ld2410_ble = ld2410_ble From 8234c9a183d99d65f3072f86cc0a21e3f2570ae5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:08:33 +0100 Subject: [PATCH 0296/1941] Explicitly pass in the config_entry in laundrify coordinator (#138116) explicitly pass in the config_entry in coordinator --- homeassistant/components/laundrify/__init__.py | 4 ++-- homeassistant/components/laundrify/coordinator.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index b08624b6d23..7e3dd848348 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_POLL_INTERVAL, DOMAIN +from .const import DOMAIN from .coordinator import LaundrifyUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -34,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ApiConnectionException as err: raise ConfigEntryNotReady("Cannot reach laundrify API") from err - coordinator = LaundrifyUpdateCoordinator(hass, api_client, DEFAULT_POLL_INTERVAL) + coordinator = LaundrifyUpdateCoordinator(hass, entry, api_client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 22f68a7c5ae..928e30a9ed5 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -7,11 +7,12 @@ import logging from laundrify_aio import LaundrifyAPI, LaundrifyDevice from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, REQUEST_TIMEOUT +from .const import DEFAULT_POLL_INTERVAL, DOMAIN, REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -19,15 +20,21 @@ _LOGGER = logging.getLogger(__name__) class LaundrifyUpdateCoordinator(DataUpdateCoordinator[dict[str, LaundrifyDevice]]): """Class to manage fetching laundrify API data.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, laundrify_api: LaundrifyAPI, poll_interval: int + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + laundrify_api: LaundrifyAPI, ) -> None: """Initialize laundrify coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, - update_interval=timedelta(seconds=poll_interval), + update_interval=timedelta(seconds=DEFAULT_POLL_INTERVAL), ) self.laundrify_api = laundrify_api From b9fd5d01dd6897e92357424069f34e1262ac062f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:08:44 +0100 Subject: [PATCH 0297/1941] Explicitly pass in the config_entry in lastfm coordinator (#138117) explicitly pass in the config_entry in coordinator --- homeassistant/components/lastfm/__init__.py | 2 +- homeassistant/components/lastfm/coordinator.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index ebcc929c39c..8611d06eee1 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -12,7 +12,7 @@ from .coordinator import LastFMDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up lastfm from a config entry.""" - coordinator = LastFMDataUpdateCoordinator(hass) + coordinator = LastFMDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/lastfm/coordinator.py b/homeassistant/components/lastfm/coordinator.py index 18473745450..ae89e103b80 100644 --- a/homeassistant/components/lastfm/coordinator.py +++ b/homeassistant/components/lastfm/coordinator.py @@ -39,15 +39,16 @@ class LastFMDataUpdateCoordinator(DataUpdateCoordinator[dict[str, LastFMUserData config_entry: ConfigEntry _client: LastFMNetwork - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the LastFM data coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=30), ) - self._client = LastFMNetwork(api_key=self.config_entry.options[CONF_API_KEY]) + self._client = LastFMNetwork(api_key=config_entry.options[CONF_API_KEY]) async def _async_update_data(self) -> dict[str, LastFMUserData]: res = {} From 9e7f8b7bffe5a11c715e45b5afae31457f405f73 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:08:59 +0100 Subject: [PATCH 0298/1941] Explicitly pass in the config_entry in landisgyr_heat_meter coordinator (#138119) explicitly pass in the config_entry in coordinator --- homeassistant/components/landisgyr_heat_meter/__init__.py | 2 +- .../components/landisgyr_heat_meter/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 5cbdc593100..7e7ebe61eb7 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -27,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: reader = ultraheat_api.UltraheatReader(entry.data[CONF_DEVICE]) api = ultraheat_api.HeatMeterService(reader) - coordinator = UltraheatCoordinator(hass, api) + coordinator = UltraheatCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index db265449f37..4214fa1db3e 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -7,6 +7,7 @@ import serial from ultraheat_api.response import HeatMeterResponse from ultraheat_api.service import HeatMeterService +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,11 +19,16 @@ _LOGGER = logging.getLogger(__name__) class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): """Coordinator for getting data from the ultraheat api.""" - def __init__(self, hass: HomeAssistant, api: HeatMeterService) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: HeatMeterService + ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name="ultraheat", update_interval=POLLING_INTERVAL, ) From faf4ad07fc8d7cbccbdef87696fbac66e324cd9f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:09:11 +0100 Subject: [PATCH 0299/1941] Explicitly pass in the config_entry in lametric coordinator (#138120) explicitly pass in the config_entry in coordinator --- homeassistant/components/lametric/coordinator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lametric/coordinator.py b/homeassistant/components/lametric/coordinator.py index 6655b035740..c292b2971b6 100644 --- a/homeassistant/components/lametric/coordinator.py +++ b/homeassistant/components/lametric/coordinator.py @@ -21,14 +21,15 @@ class LaMetricDataUpdateCoordinator(DataUpdateCoordinator[Device]): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the LaMatric coordinator.""" - self.config_entry = entry self.lametric = LaMetricDevice( host=entry.data[CONF_HOST], api_key=entry.data[CONF_API_KEY], session=async_get_clientsession(hass), ) - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + super().__init__( + hass, LOGGER, config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL + ) async def _async_update_data(self) -> Device: """Fetch device information of the LaMetric device.""" From 56eecf05e7d22a8676ca865ac73795a3048145c5 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:17:48 +0100 Subject: [PATCH 0300/1941] Explicitly pass in the config_entry in lifx coordinator (#138110) explicitly pass in the config_entry in coordinator --- homeassistant/components/lifx/__init__.py | 2 +- homeassistant/components/lifx/coordinator.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 2847862029f..7a6d95549ff 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -211,7 +211,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except socket.gaierror as ex: connection.async_stop() raise ConfigEntryNotReady(f"Could not resolve {host}: {ex}") from ex - coordinator = LIFXUpdateCoordinator(hass, connection, entry.title) + coordinator = LIFXUpdateCoordinator(hass, entry, connection) coordinator.async_setup() try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index eaaff7e6540..b77dbdc015a 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -23,6 +23,7 @@ from aiolifx_themes.themes import ThemeLibrary, ThemePainter from awesomeversion import AwesomeVersion from propcache.api import cached_property +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -86,11 +87,13 @@ class SkyType(IntEnum): class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific lifx device.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, connection: LIFXConnection, - title: str, ) -> None: """Initialize DataUpdateCoordinator.""" assert connection.device is not None @@ -105,7 +108,8 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, - name=f"{title} ({self.device.ip_addr})", + config_entry=config_entry, + name=f"{config_entry.title} ({self.device.ip_addr})", update_interval=timedelta(seconds=LIGHT_UPDATE_INTERVAL), # We don't want an immediate refresh since the device # takes a moment to reflect the state change From 2dbf475d6f770012f6919bf66dcaa13b61fe3d23 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 9 Feb 2025 20:19:09 +0100 Subject: [PATCH 0301/1941] Explicitly pass in the config_entry in incomfort coordinator (#138131) --- homeassistant/components/incomfort/__init__.py | 12 +++++++----- homeassistant/components/incomfort/binary_sensor.py | 3 +-- homeassistant/components/incomfort/climate.py | 8 +++++--- homeassistant/components/incomfort/config_flow.py | 3 +-- homeassistant/components/incomfort/coordinator.py | 13 +++++++++++-- homeassistant/components/incomfort/diagnostics.py | 2 +- homeassistant/components/incomfort/entity.py | 5 ++++- homeassistant/components/incomfort/sensor.py | 3 +-- homeassistant/components/incomfort/water_heater.py | 3 +-- 9 files changed, 32 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 4d05a57bcfa..307ff09206f 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -5,14 +5,18 @@ from __future__ import annotations from aiohttp import ClientResponseError from incomfortclient import InvalidGateway, InvalidHeaterList -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import InComfortData, InComfortDataCoordinator, async_connect_gateway +from .coordinator import ( + InComfortConfigEntry, + InComfortData, + InComfortDataCoordinator, + async_connect_gateway, +) from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound PLATFORMS = ( @@ -24,8 +28,6 @@ PLATFORMS = ( INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" -type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] - @callback def async_cleanup_stale_devices( @@ -93,7 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> name="RFGateway", ) async_cleanup_stale_devices(hass, entry, data, gateway_device) - coordinator = InComfortDataCoordinator(hass, data, entry.entry_id) + coordinator = InComfortDataCoordinator(hass, entry, data) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index e4353e457a5..323ba7e6eee 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -17,8 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import InComfortConfigEntry -from .coordinator import InComfortDataCoordinator +from .coordinator import InComfortConfigEntry, InComfortDataCoordinator from .entity import IncomfortBoilerEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index f814b1fb1f5..3a4b4e56fd5 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -17,9 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import InComfortConfigEntry from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN -from .coordinator import InComfortDataCoordinator +from .coordinator import InComfortConfigEntry, InComfortDataCoordinator from .entity import IncomfortEntity PARALLEL_UPDATES = 1 @@ -74,7 +73,10 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): name=f"Thermostat {room.room_no}", ) if coordinator.unique_id: - self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id) + self._attr_device_info["via_device"] = ( + DOMAIN, + coordinator.config_entry.entry_id, + ) @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 8e4a5f72619..875bc25bd2f 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -28,9 +28,8 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import InComfortConfigEntry from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN -from .coordinator import async_connect_gateway +from .coordinator import InComfortConfigEntry, async_connect_gateway TITLE = "Intergas InComfort/Intouch Lan2RF gateway" diff --git a/homeassistant/components/incomfort/coordinator.py b/homeassistant/components/incomfort/coordinator.py index 3436d40298a..5c72b9daa06 100644 --- a/homeassistant/components/incomfort/coordinator.py +++ b/homeassistant/components/incomfort/coordinator.py @@ -12,12 +12,15 @@ from incomfortclient import ( InvalidHeaterList, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] + _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = 30 @@ -50,14 +53,20 @@ async def async_connect_gateway( class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]): """Data coordinator for InComfort entities.""" + config_entry: InComfortConfigEntry + def __init__( - self, hass: HomeAssistant, incomfort_data: InComfortData, unique_id: str | None + self, + hass: HomeAssistant, + config_entry: InComfortConfigEntry, + incomfort_data: InComfortData, ) -> None: """Initialize coordinator.""" - self.unique_id = unique_id + self.unique_id = config_entry.unique_id super().__init__( hass, _LOGGER, + config_entry=config_entry, name="InComfort datacoordinator", update_interval=timedelta(seconds=UPDATE_INTERVAL), ) diff --git a/homeassistant/components/incomfort/diagnostics.py b/homeassistant/components/incomfort/diagnostics.py index a2f89a94f58..4d7af14eac7 100644 --- a/homeassistant/components/incomfort/diagnostics.py +++ b/homeassistant/components/incomfort/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback -from . import InComfortConfigEntry +from .coordinator import InComfortConfigEntry REDACT_CONFIG = {CONF_PASSWORD} diff --git a/homeassistant/components/incomfort/entity.py b/homeassistant/components/incomfort/entity.py index 1924c91376b..1e0d357e8e0 100644 --- a/homeassistant/components/incomfort/entity.py +++ b/homeassistant/components/incomfort/entity.py @@ -29,4 +29,7 @@ class IncomfortBoilerEntity(IncomfortEntity): serial_number=heater.serial_no, ) if coordinator.unique_id: - self._attr_device_info["via_device"] = (DOMAIN, coordinator.unique_id) + self._attr_device_info["via_device"] = ( + DOMAIN, + coordinator.config_entry.entry_id, + ) diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index e3f3fc785b2..8507e9f9ebf 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -18,8 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import InComfortConfigEntry -from .coordinator import InComfortDataCoordinator +from .coordinator import InComfortConfigEntry, InComfortDataCoordinator from .entity import IncomfortBoilerEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 0ab4a6a06b8..334fc187538 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -12,8 +12,7 @@ from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import InComfortConfigEntry -from .coordinator import InComfortDataCoordinator +from .coordinator import InComfortConfigEntry, InComfortDataCoordinator from .entity import IncomfortBoilerEntity _LOGGER = logging.getLogger(__name__) From b6afe130fcd3a0e333f54fdf8e89af3f0d3fbe28 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:27:28 +0100 Subject: [PATCH 0302/1941] Explicitly pass in the config_entry in iskra coordinator (#138134) explicitly pass in the config_entry in coordinator --- homeassistant/components/iskra/__init__.py | 10 +++------- homeassistant/components/iskra/coordinator.py | 14 ++++++++++---- homeassistant/components/iskra/sensor.py | 3 +-- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/iskra/__init__.py b/homeassistant/components/iskra/__init__.py index b841da9df26..21c60db20fe 100644 --- a/homeassistant/components/iskra/__init__.py +++ b/homeassistant/components/iskra/__init__.py @@ -6,7 +6,6 @@ from pyiskra.adapters import Modbus, RestAPI from pyiskra.devices import Device from pyiskra.exceptions import DeviceConnectionError, DeviceNotSupported, NotAuthorised -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ADDRESS, CONF_HOST, @@ -21,14 +20,11 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import DOMAIN, MANUFACTURER -from .coordinator import IskraDataUpdateCoordinator +from .coordinator import IskraConfigEntry, IskraDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -type IskraConfigEntry = ConfigEntry[list[IskraDataUpdateCoordinator]] - - async def async_setup_entry(hass: HomeAssistant, entry: IskraConfigEntry) -> bool: """Set up iskra device from a config entry.""" conf = entry.data @@ -79,11 +75,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: IskraConfigEntry) -> boo ) coordinators = [ - IskraDataUpdateCoordinator(hass, child_device) + IskraDataUpdateCoordinator(hass, entry, child_device) for child_device in base_device.get_child_devices() ] else: - coordinators = [IskraDataUpdateCoordinator(hass, base_device)] + coordinators = [IskraDataUpdateCoordinator(hass, entry, base_device)] for coordinator in coordinators: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/iskra/coordinator.py b/homeassistant/components/iskra/coordinator.py index 175d8ed4c86..d476556e96d 100644 --- a/homeassistant/components/iskra/coordinator.py +++ b/homeassistant/components/iskra/coordinator.py @@ -11,6 +11,7 @@ from pyiskra.exceptions import ( NotAuthorised, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,21 +19,26 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type IskraConfigEntry = ConfigEntry[list[IskraDataUpdateCoordinator]] + class IskraDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching Iskra data.""" - def __init__(self, hass: HomeAssistant, device: Device) -> None: + config_entry: IskraConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: IskraConfigEntry, device: Device + ) -> None: """Initialize.""" self.device = device - update_interval = timedelta(seconds=60) - super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, - update_interval=update_interval, + update_interval=timedelta(seconds=60), ) async def _async_update_data(self) -> None: diff --git a/homeassistant/components/iskra/sensor.py b/homeassistant/components/iskra/sensor.py index df9e3ec53f9..a61951dedb9 100644 --- a/homeassistant/components/iskra/sensor.py +++ b/homeassistant/components/iskra/sensor.py @@ -26,7 +26,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import IskraConfigEntry from .const import ( ATTR_FREQUENCY, ATTR_NON_RESETTABLE_COUNTER, @@ -44,7 +43,7 @@ from .const import ( ATTR_TOTAL_APPARENT_POWER, ATTR_TOTAL_REACTIVE_POWER, ) -from .coordinator import IskraDataUpdateCoordinator +from .coordinator import IskraConfigEntry, IskraDataUpdateCoordinator from .entity import IskraEntity From db5605223fdb60dc7980398fa86ea62c7cb7840e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:28:56 +0100 Subject: [PATCH 0303/1941] Explicitly pass in the config_entry in knocki coordinator (#138125) explicitly pass in the config_entry in coordinator --- homeassistant/components/knocki/__init__.py | 7 ++----- homeassistant/components/knocki/coordinator.py | 10 +++++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/knocki/__init__.py b/homeassistant/components/knocki/__init__.py index dfdf060e3b5..966f1dbf309 100644 --- a/homeassistant/components/knocki/__init__.py +++ b/homeassistant/components/knocki/__init__.py @@ -4,17 +4,14 @@ from __future__ import annotations from knocki import Event, EventType, KnockiClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import KnockiCoordinator +from .coordinator import KnockiConfigEntry, KnockiCoordinator PLATFORMS: list[Platform] = [Platform.EVENT] -type KnockiConfigEntry = ConfigEntry[KnockiCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bool: """Set up Knocki from a config entry.""" @@ -22,7 +19,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: KnockiConfigEntry) -> bo session=async_get_clientsession(hass), token=entry.data[CONF_TOKEN] ) - coordinator = KnockiCoordinator(hass, client) + coordinator = KnockiCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/knocki/coordinator.py b/homeassistant/components/knocki/coordinator.py index c1e32b817e1..f5cc373f5c1 100644 --- a/homeassistant/components/knocki/coordinator.py +++ b/homeassistant/components/knocki/coordinator.py @@ -3,21 +3,29 @@ from knocki import Event, KnockiClient, KnockiConnectionError, Trigger from homeassistant.components.event import DOMAIN as EVENT_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER +type KnockiConfigEntry = ConfigEntry[KnockiCoordinator] + class KnockiCoordinator(DataUpdateCoordinator[dict[int, Trigger]]): """The Knocki coordinator.""" - def __init__(self, hass: HomeAssistant, client: KnockiClient) -> None: + config_entry: KnockiConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: KnockiConfigEntry, client: KnockiClient + ) -> None: """Initialize the coordinator.""" super().__init__( hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, ) self.client = client From fd57803b1594ecaf8dc637f130c439f37fcc3992 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:29:30 +0100 Subject: [PATCH 0304/1941] Explicitly pass in the config_entry in ista_ecotrend coordinator (#138130) explicitly pass in the config_entry in coordinator --- homeassistant/components/ista_ecotrend/__init__.py | 7 ++----- homeassistant/components/ista_ecotrend/coordinator.py | 9 +++++++-- homeassistant/components/ista_ecotrend/sensor.py | 3 +-- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ista_ecotrend/__init__.py b/homeassistant/components/ista_ecotrend/__init__.py index 76ef8d13fd4..4262b354acb 100644 --- a/homeassistant/components/ista_ecotrend/__init__.py +++ b/homeassistant/components/ista_ecotrend/__init__.py @@ -6,20 +6,17 @@ import logging from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN -from .coordinator import IstaCoordinator +from .coordinator import IstaConfigEntry, IstaCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] -type IstaConfigEntry = ConfigEntry[IstaCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool: """Set up ista EcoTrend from a config entry.""" @@ -42,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]}, ) from e - coordinator = IstaCoordinator(hass, ista) + coordinator = IstaCoordinator(hass, entry, ista) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py index 0f14cd06fe3..53ef4a46d20 100644 --- a/homeassistant/components/ista_ecotrend/coordinator.py +++ b/homeassistant/components/ista_ecotrend/coordinator.py @@ -18,17 +18,22 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type IstaConfigEntry = ConfigEntry[IstaCoordinator] + class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Ista EcoTrend data update coordinator.""" - config_entry: ConfigEntry + config_entry: IstaConfigEntry - def __init__(self, hass: HomeAssistant, ista: PyEcotrendIsta) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: IstaConfigEntry, ista: PyEcotrendIsta + ) -> None: """Initialize ista EcoTrend data update coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(days=1), ) diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index e96ac103741..59fd48a5fe9 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -34,9 +34,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import IstaConfigEntry from .const import DOMAIN -from .coordinator import IstaCoordinator +from .coordinator import IstaConfigEntry, IstaCoordinator from .util import IstaConsumptionType, IstaValueType, get_native_value, get_statistics _LOGGER = logging.getLogger(__name__) From 6d2f8b10766078599d023aa5b8efd0dcfb5307c2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:30:53 +0100 Subject: [PATCH 0305/1941] Explicitly pass in the config_entry in jellyfin coordinator (#138129) explicitly pass in the config_entry in coordinator --- homeassistant/components/jellyfin/__init__.py | 9 ++++----- homeassistant/components/jellyfin/config_flow.py | 2 +- homeassistant/components/jellyfin/coordinator.py | 8 ++++++-- homeassistant/components/jellyfin/diagnostics.py | 2 +- homeassistant/components/jellyfin/media_player.py | 3 +-- homeassistant/components/jellyfin/media_source.py | 2 +- homeassistant/components/jellyfin/remote.py | 3 +-- homeassistant/components/jellyfin/sensor.py | 2 +- 8 files changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 4f0886dfa22..1cb6219ada0 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -2,16 +2,13 @@ from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, PLATFORMS -from .coordinator import JellyfinDataUpdateCoordinator - -type JellyfinConfigEntry = ConfigEntry[JellyfinDataUpdateCoordinator] +from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> bool: @@ -35,7 +32,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> server_info: dict[str, Any] = connect_result["Servers"][0] - coordinator = JellyfinDataUpdateCoordinator(hass, client, server_info, user_id) + coordinator = JellyfinDataUpdateCoordinator( + hass, entry, client, server_info, user_id + ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 0c170d2485f..03c637a989f 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -13,9 +13,9 @@ from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import callback from homeassistant.util.uuid import random_uuid_hex -from . import JellyfinConfigEntry from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, SUPPORTED_AUDIO_CODECS +from .coordinator import JellyfinConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index 20428250254..cd22ad4ab39 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -13,15 +13,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, LOGGER, USER_APP_NAME +type JellyfinConfigEntry = ConfigEntry[JellyfinDataUpdateCoordinator] + class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Data update coordinator for the Jellyfin integration.""" - config_entry: ConfigEntry + config_entry: JellyfinConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: JellyfinConfigEntry, api_client: JellyfinClient, system_info: dict[str, Any], user_id: str, @@ -30,6 +33,7 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=10), ) @@ -37,7 +41,7 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An self.server_id: str = system_info["Id"] self.server_name: str = system_info["Name"] self.server_version: str | None = system_info.get("Version") - self.client_device_id: str = self.config_entry.data[CONF_CLIENT_DEVICE_ID] + self.client_device_id: str = config_entry.data[CONF_CLIENT_DEVICE_ID] self.user_id: str = user_id self.session_ids: set[str] = set() diff --git a/homeassistant/components/jellyfin/diagnostics.py b/homeassistant/components/jellyfin/diagnostics.py index 8042d588d1b..721e0ae654e 100644 --- a/homeassistant/components/jellyfin/diagnostics.py +++ b/homeassistant/components/jellyfin/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant -from . import JellyfinConfigEntry +from .coordinator import JellyfinConfigEntry TO_REDACT = {CONF_PASSWORD} diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index bf6e95c0c96..bb0d914162d 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -15,11 +15,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import parse_datetime -from . import JellyfinConfigEntry from .browse_media import build_item_response, build_root_response from .client_wrapper import get_artwork_url from .const import CONTENT_TYPE_MAP, LOGGER -from .coordinator import JellyfinDataUpdateCoordinator +from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator from .entity import JellyfinClientEntity diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index a061118dd0a..a4d08d8d024 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -19,7 +19,6 @@ from homeassistant.components.media_source import ( ) from homeassistant.core import HomeAssistant -from . import JellyfinConfigEntry from .const import ( COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MUSIC, @@ -48,6 +47,7 @@ from .const import ( PLAYABLE_ITEM_TYPES, SUPPORTED_COLLECTION_TYPES, ) +from .coordinator import JellyfinConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/jellyfin/remote.py b/homeassistant/components/jellyfin/remote.py index ae33d58cc0c..7c543813a13 100644 --- a/homeassistant/components/jellyfin/remote.py +++ b/homeassistant/components/jellyfin/remote.py @@ -16,9 +16,8 @@ from homeassistant.components.remote import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import JellyfinConfigEntry from .const import LOGGER -from .coordinator import JellyfinDataUpdateCoordinator +from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator from .entity import JellyfinClientEntity diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index 5c519f661ee..934f2eb4e32 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import JellyfinConfigEntry, JellyfinDataUpdateCoordinator +from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator from .entity import JellyfinServerEntity From b533cd31078377269afe2c4851f04fc3209c1dd8 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:50:05 +0100 Subject: [PATCH 0306/1941] Explicitly pass in the config_entry in imgw_pib coordinator (#138144) explicitly pass in the config_entry in coordinator --- homeassistant/components/imgw_pib/__init__.py | 15 ++--------- .../components/imgw_pib/coordinator.py | 25 ++++++++++++++++++- .../components/imgw_pib/diagnostics.py | 2 +- homeassistant/components/imgw_pib/sensor.py | 3 +-- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index eb12e1a2bb4..f9524316570 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass import logging from aiohttp import ClientError @@ -10,7 +9,6 @@ from imgw_pib import ImgwPib from imgw_pib.exceptions import ApiError from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_PLATFORM -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -18,21 +16,12 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_STATION_ID, DOMAIN -from .coordinator import ImgwPibDataUpdateCoordinator +from .coordinator import ImgwPibConfigEntry, ImgwPibData, ImgwPibDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -type ImgwPibConfigEntry = ConfigEntry[ImgwPibData] - - -@dataclass -class ImgwPibData: - """Data for the IMGW-PIB integration.""" - - coordinator: ImgwPibDataUpdateCoordinator - async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> bool: """Set up IMGW-PIB from a config entry.""" @@ -51,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b except (ClientError, TimeoutError, ApiError) as err: raise ConfigEntryNotReady from err - coordinator = ImgwPibDataUpdateCoordinator(hass, imgwpib, station_id) + coordinator = ImgwPibDataUpdateCoordinator(hass, entry, imgwpib, station_id) await coordinator.async_config_entry_first_refresh() # Remove binary_sensor entities for which the endpoint has been blocked by IMGW-PIB API diff --git a/homeassistant/components/imgw_pib/coordinator.py b/homeassistant/components/imgw_pib/coordinator.py index 77a58001a6f..fbe470ca953 100644 --- a/homeassistant/components/imgw_pib/coordinator.py +++ b/homeassistant/components/imgw_pib/coordinator.py @@ -1,9 +1,13 @@ """Data Update Coordinator for IMGW-PIB integration.""" +from __future__ import annotations + +from dataclasses import dataclass import logging from imgw_pib import ApiError, HydrologicalData, ImgwPib +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -13,12 +17,25 @@ from .const import DOMAIN, UPDATE_INTERVAL _LOGGER = logging.getLogger(__name__) +@dataclass +class ImgwPibData: + """Data for the IMGW-PIB integration.""" + + coordinator: ImgwPibDataUpdateCoordinator + + +type ImgwPibConfigEntry = ConfigEntry[ImgwPibData] + + class ImgwPibDataUpdateCoordinator(DataUpdateCoordinator[HydrologicalData]): """Class to manage fetching IMGW-PIB data API.""" + config_entry: ImgwPibConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ImgwPibConfigEntry, imgwpib: ImgwPib, station_id: str, ) -> None: @@ -33,7 +50,13 @@ class ImgwPibDataUpdateCoordinator(DataUpdateCoordinator[HydrologicalData]): configuration_url=f"https://hydro.imgw.pl/#/station/hydro/{station_id}", ) - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) async def _async_update_data(self) -> HydrologicalData: """Update data via internal method.""" diff --git a/homeassistant/components/imgw_pib/diagnostics.py b/homeassistant/components/imgw_pib/diagnostics.py index d135208115f..ce9cb3f9e95 100644 --- a/homeassistant/components/imgw_pib/diagnostics.py +++ b/homeassistant/components/imgw_pib/diagnostics.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import ImgwPibConfigEntry +from .coordinator import ImgwPibConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 15043af2015..332c3bcedf8 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -20,9 +20,8 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import ImgwPibConfigEntry from .const import DOMAIN -from .coordinator import ImgwPibDataUpdateCoordinator +from .coordinator import ImgwPibConfigEntry, ImgwPibDataUpdateCoordinator from .entity import ImgwPibEntity PARALLEL_UPDATES = 1 From b9828c5edd59fe224212a58fce1caca2af1c5909 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:50:38 +0100 Subject: [PATCH 0307/1941] Explicitly pass in the config_entry in justnimbus coordinator (#138128) explicitly pass in the config_entry in coordinator --- homeassistant/components/justnimbus/__init__.py | 2 +- homeassistant/components/justnimbus/coordinator.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/justnimbus/__init__.py b/homeassistant/components/justnimbus/__init__.py index 101a2086962..123807d887c 100644 --- a/homeassistant/components/justnimbus/__init__.py +++ b/homeassistant/components/justnimbus/__init__.py @@ -13,7 +13,7 @@ from .coordinator import JustNimbusCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up JustNimbus from a config entry.""" if "zip_code" in entry.data: - coordinator = JustNimbusCoordinator(hass=hass, entry=entry) + coordinator = JustNimbusCoordinator(hass, entry) else: raise ConfigEntryAuthFailed await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/justnimbus/coordinator.py b/homeassistant/components/justnimbus/coordinator.py index 4031ad86fdf..a6945c45417 100644 --- a/homeassistant/components/justnimbus/coordinator.py +++ b/homeassistant/components/justnimbus/coordinator.py @@ -20,16 +20,20 @@ _LOGGER = logging.getLogger(__name__) class JustNimbusCoordinator(DataUpdateCoordinator[justnimbus.JustNimbusModel]): """Data update coordinator.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=1), ) self._client = justnimbus.JustNimbusClient( - client_id=entry.data[CONF_CLIENT_ID], zip_code=entry.data[CONF_ZIP_CODE] + client_id=config_entry.data[CONF_CLIENT_ID], + zip_code=config_entry.data[CONF_ZIP_CODE], ) async def _async_update_data(self) -> justnimbus.JustNimbusModel: From ca77b94565859d97d9d89dd2353e5ea6cdbac26c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:50:52 +0100 Subject: [PATCH 0308/1941] Explicitly pass in the config_entry in jvc_projector coordinator (#138127) explicitly pass in the config_entry in coordinator --- homeassistant/components/jvc_projector/__init__.py | 7 ++----- .../components/jvc_projector/binary_sensor.py | 2 +- homeassistant/components/jvc_projector/coordinator.py | 10 +++++++++- homeassistant/components/jvc_projector/remote.py | 2 +- homeassistant/components/jvc_projector/select.py | 2 +- homeassistant/components/jvc_projector/sensor.py | 2 +- 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py index 09e93127e40..ad7e333ca13 100644 --- a/homeassistant/components/jvc_projector/__init__.py +++ b/homeassistant/components/jvc_projector/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -15,9 +14,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .coordinator import JvcProjectorDataUpdateCoordinator - -type JVCConfigEntry = ConfigEntry[JvcProjectorDataUpdateCoordinator] +from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.REMOTE, Platform.SELECT, Platform.SENSOR] @@ -41,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: JVCConfigEntry) -> bool: await device.disconnect() raise ConfigEntryAuthFailed("Password authentication failed") from err - coordinator = JvcProjectorDataUpdateCoordinator(hass, device) + coordinator = JvcProjectorDataUpdateCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/jvc_projector/binary_sensor.py b/homeassistant/components/jvc_projector/binary_sensor.py index 6dfac63892b..0e1d8ce00a3 100644 --- a/homeassistant/components/jvc_projector/binary_sensor.py +++ b/homeassistant/components/jvc_projector/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import JVCConfigEntry, JvcProjectorDataUpdateCoordinator +from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .entity import JvcProjectorEntity ON_STATUS = (const.ON, const.WARMING) diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py index a2ecfa8eb52..db97b05f980 100644 --- a/homeassistant/components/jvc_projector/coordinator.py +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -13,6 +13,7 @@ from jvcprojector import ( const, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import format_mac @@ -25,15 +26,22 @@ _LOGGER = logging.getLogger(__name__) INTERVAL_SLOW = timedelta(seconds=10) INTERVAL_FAST = timedelta(seconds=5) +type JVCConfigEntry = ConfigEntry[JvcProjectorDataUpdateCoordinator] + class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): """Data update coordinator for the JVC Projector integration.""" - def __init__(self, hass: HomeAssistant, device: JvcProjector) -> None: + config_entry: JVCConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: JVCConfigEntry, device: JvcProjector + ) -> None: """Initialize the coordinator.""" super().__init__( hass=hass, logger=_LOGGER, + config_entry=config_entry, name=NAME, update_interval=INTERVAL_SLOW, ) diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index f90a2816363..bbee5ca11f6 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import JVCConfigEntry +from .coordinator import JVCConfigEntry from .entity import JvcProjectorEntity COMMANDS = { diff --git a/homeassistant/components/jvc_projector/select.py b/homeassistant/components/jvc_projector/select.py index 60c80f98fc0..4b2cea3c3a0 100644 --- a/homeassistant/components/jvc_projector/select.py +++ b/homeassistant/components/jvc_projector/select.py @@ -12,7 +12,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import JVCConfigEntry, JvcProjectorDataUpdateCoordinator +from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .entity import JvcProjectorEntity diff --git a/homeassistant/components/jvc_projector/sensor.py b/homeassistant/components/jvc_projector/sensor.py index 3edf51e4316..5854e60c97a 100644 --- a/homeassistant/components/jvc_projector/sensor.py +++ b/homeassistant/components/jvc_projector/sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import JVCConfigEntry, JvcProjectorDataUpdateCoordinator +from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .entity import JvcProjectorEntity JVC_SENSORS = ( From 4705df9ec841ec4301c7a7872d009bc399962336 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:51:02 +0100 Subject: [PATCH 0309/1941] Explicitly pass in the config_entry in kostal_plenticore coordinator (#138124) explicitly pass in the config_entry in coordinator --- .../components/kostal_plenticore/coordinator.py | 9 +++++++++ homeassistant/components/kostal_plenticore/number.py | 6 +----- homeassistant/components/kostal_plenticore/select.py | 6 +----- homeassistant/components/kostal_plenticore/sensor.py | 6 +----- homeassistant/components/kostal_plenticore/switch.py | 6 +----- 5 files changed, 13 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index 5f4393146f0..a404a997663 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -16,6 +16,7 @@ from pykoplenti import ( ExtendedApiClient, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -162,9 +163,12 @@ class DataUpdateCoordinatorMixin: class PlenticoreUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Base implementation of DataUpdateCoordinator for Plenticore data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, logger: logging.Logger, name: str, update_inverval: timedelta, @@ -174,6 +178,7 @@ class PlenticoreUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): super().__init__( hass=hass, logger=logger, + config_entry=config_entry, name=name, update_interval=update_inverval, ) @@ -240,9 +245,12 @@ class SettingDataUpdateCoordinator( class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Base implementation of DataUpdateCoordinator for Plenticore data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, logger: logging.Logger, name: str, update_inverval: timedelta, @@ -252,6 +260,7 @@ class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): super().__init__( hass=hass, logger=logger, + config_entry=config_entry, name=name, update_interval=update_inverval, ) diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 8afe69a7749..059a09aadf2 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -82,11 +82,7 @@ async def async_setup_entry( available_settings_data = await plenticore.client.get_settings() settings_data_update_coordinator = SettingDataUpdateCoordinator( - hass, - _LOGGER, - "Settings Data", - timedelta(seconds=30), - plenticore, + hass, entry, _LOGGER, "Settings Data", timedelta(seconds=30), plenticore ) for description in NUMBER_SETTINGS_DATA: diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 73f3f94eda8..941b1566609 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -49,11 +49,7 @@ async def async_setup_entry( available_settings_data = await plenticore.client.get_settings() select_data_update_coordinator = SelectDataUpdateCoordinator( - hass, - _LOGGER, - "Settings Data", - timedelta(seconds=30), - plenticore, + hass, entry, _LOGGER, "Settings Data", timedelta(seconds=30), plenticore ) entities = [] diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 67de34f2fce..567ade278c3 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -816,11 +816,7 @@ async def async_setup_entry( available_process_data = await plenticore.client.get_process_data() process_data_update_coordinator = ProcessDataUpdateCoordinator( - hass, - _LOGGER, - "Process Data", - timedelta(seconds=10), - plenticore, + hass, entry, _LOGGER, "Process Data", timedelta(seconds=10), plenticore ) for description in SENSOR_PROCESS_DATA: module_id = description.module_id diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 7ce2d468c88..86d1fe2b9be 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -57,11 +57,7 @@ async def async_setup_entry( available_settings_data = await plenticore.client.get_settings() settings_data_update_coordinator = SettingDataUpdateCoordinator( - hass, - _LOGGER, - "Settings Data", - timedelta(seconds=30), - plenticore, + hass, entry, _LOGGER, "Settings Data", timedelta(seconds=30), plenticore ) for description in SWITCH_SETTINGS_DATA: if ( From 284a70932edbcf6c684c7cd04b4b1a29782fe2bd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:51:15 +0100 Subject: [PATCH 0310/1941] Explicitly pass in the config_entry in lacrosse_view coordinator (#138122) explicitly pass in the config_entry in coordinator --- homeassistant/components/lacrosse_view/__init__.py | 2 +- homeassistant/components/lacrosse_view/coordinator.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lacrosse_view/__init__.py b/homeassistant/components/lacrosse_view/__init__.py index d977af418a2..e98d1d421be 100644 --- a/homeassistant/components/lacrosse_view/__init__.py +++ b/homeassistant/components/lacrosse_view/__init__.py @@ -30,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except LoginError as error: raise ConfigEntryAuthFailed from error - coordinator = LaCrosseUpdateCoordinator(hass, api, entry) + coordinator = LaCrosseUpdateCoordinator(hass, entry, api) _LOGGER.debug("First refresh") await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 8d7e44ecd99..3d741e8f1a8 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -27,12 +27,13 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): id: str hass: HomeAssistant devices: list[Sensor] | None = None + config_entry: ConfigEntry def __init__( self, hass: HomeAssistant, - api: LaCrosse, entry: ConfigEntry, + api: LaCrosse, ) -> None: """Initialize DataUpdateCoordinator for LaCrosse View.""" self.api = api @@ -45,6 +46,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): super().__init__( hass, _LOGGER, + config_entry=entry, name="LaCrosse View", update_interval=timedelta(seconds=SCAN_INTERVAL), ) From 52363d53696e4d1fcd4e9d369c94994290d0752d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:57:24 +0100 Subject: [PATCH 0311/1941] Explicitly pass in the config_entry in ialarm coordinator (#138147) explicitly pass in the config_entry in coordinator --- homeassistant/components/ialarm/__init__.py | 2 +- homeassistant/components/ialarm/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index 95c62b87a19..2484a46f906 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -29,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (TimeoutError, ConnectionError) as ex: raise ConfigEntryNotReady from ex - coordinator = IAlarmDataUpdateCoordinator(hass, ialarm, mac) + coordinator = IAlarmDataUpdateCoordinator(hass, entry, ialarm, mac) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/ialarm/coordinator.py b/homeassistant/components/ialarm/coordinator.py index ad0f2298a3b..61e87c36796 100644 --- a/homeassistant/components/ialarm/coordinator.py +++ b/homeassistant/components/ialarm/coordinator.py @@ -11,6 +11,7 @@ from homeassistant.components.alarm_control_panel import ( SCAN_INTERVAL, AlarmControlPanelState, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,7 +23,11 @@ _LOGGER = logging.getLogger(__name__) class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage fetching iAlarm data.""" - def __init__(self, hass: HomeAssistant, ialarm: IAlarm, mac: str) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, ialarm: IAlarm, mac: str + ) -> None: """Initialize global iAlarm data updater.""" self.ialarm = ialarm self.state: AlarmControlPanelState | None = None @@ -32,6 +37,7 @@ class IAlarmDataUpdateCoordinator(DataUpdateCoordinator[None]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) From 733d9de0422b0e51319fbc4f783e5a7eda12db43 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 20:58:09 +0100 Subject: [PATCH 0312/1941] Explicitly pass in the config_entry in israel_rail coordinator (#138132) explicitly pass in the config_entry in coordinator --- homeassistant/components/israel_rail/__init__.py | 8 ++------ homeassistant/components/israel_rail/coordinator.py | 7 ++++++- homeassistant/components/israel_rail/sensor.py | 7 +++++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/israel_rail/__init__.py b/homeassistant/components/israel_rail/__init__.py index 3c33a159a63..ed800f559d4 100644 --- a/homeassistant/components/israel_rail/__init__.py +++ b/homeassistant/components/israel_rail/__init__.py @@ -4,13 +4,12 @@ import logging from israelrailapi import TrainSchedule -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_DESTINATION, CONF_START, DOMAIN -from .coordinator import IsraelRailDataUpdateCoordinator +from .coordinator import IsraelRailConfigEntry, IsraelRailDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -18,9 +17,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] -type IsraelRailConfigEntry = ConfigEntry[IsraelRailDataUpdateCoordinator] - - async def async_setup_entry(hass: HomeAssistant, entry: IsraelRailConfigEntry) -> bool: """Set up Israel rail from a config entry.""" config = entry.data @@ -43,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsraelRailConfigEntry) - ) from e israel_rail_coordinator = IsraelRailDataUpdateCoordinator( - hass, train_schedule, start, destination + hass, entry, train_schedule, start, destination ) await israel_rail_coordinator.async_config_entry_first_refresh() entry.runtime_data = israel_rail_coordinator diff --git a/homeassistant/components/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py index b022e3fd790..190ed938790 100644 --- a/homeassistant/components/israel_rail/coordinator.py +++ b/homeassistant/components/israel_rail/coordinator.py @@ -38,14 +38,18 @@ def departure_time(train_route: TrainRoute) -> datetime | None: return start_datetime.astimezone() if start_datetime else None +type IsraelRailConfigEntry = ConfigEntry[IsraelRailDataUpdateCoordinator] + + class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]]): """A IsraelRail Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: IsraelRailConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: IsraelRailConfigEntry, train_schedule: TrainSchedule, start: str, destination: str, @@ -54,6 +58,7 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection] super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) diff --git a/homeassistant/components/israel_rail/sensor.py b/homeassistant/components/israel_rail/sensor.py index 132a9a74826..d0c93da3451 100644 --- a/homeassistant/components/israel_rail/sensor.py +++ b/homeassistant/components/israel_rail/sensor.py @@ -19,9 +19,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import IsraelRailConfigEntry from .const import ATTRIBUTION, DEPARTURES_COUNT, DOMAIN -from .coordinator import DataConnection, IsraelRailDataUpdateCoordinator +from .coordinator import ( + DataConnection, + IsraelRailConfigEntry, + IsraelRailDataUpdateCoordinator, +) _LOGGER = logging.getLogger(__name__) From 4eccc9d9a4f00a7ef9b16bb3cd9269fc1adb94b9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:01:13 +0100 Subject: [PATCH 0313/1941] Explicitly pass in the config_entry in iotty coordinator (#138140) explicitly pass in the config_entry in coordinator --- homeassistant/components/iotty/__init__.py | 26 +++++-------------- homeassistant/components/iotty/coordinator.py | 17 +++++++++--- homeassistant/components/iotty/cover.py | 3 +-- homeassistant/components/iotty/switch.py | 3 +-- tests/components/iotty/conftest.py | 2 +- 5 files changed, 24 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/iotty/__init__.py b/homeassistant/components/iotty/__init__.py index 804f3f40196..c9eb2639348 100644 --- a/homeassistant/components/iotty/__init__.py +++ b/homeassistant/components/iotty/__init__.py @@ -2,12 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass import logging -from iottycloud.device import Device - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -15,22 +11,16 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( async_get_config_entry_implementation, ) -from . import coordinator +from .coordinator import ( + IottyConfigEntry, + IottyConfigEntryData, + IottyDataUpdateCoordinator, +) _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.COVER, Platform.SWITCH] -type IottyConfigEntry = ConfigEntry[IottyConfigEntryData] - - -@dataclass -class IottyConfigEntryData: - """Contains config entry data for iotty.""" - - known_devices: set[Device] - coordinator: coordinator.IottyDataUpdateCoordinator - async def async_setup_entry(hass: HomeAssistant, entry: IottyConfigEntry) -> bool: """Set up iotty from a config entry.""" @@ -39,9 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IottyConfigEntry) -> boo implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) - data_update_coordinator = coordinator.IottyDataUpdateCoordinator( - hass, entry, session - ) + data_update_coordinator = IottyDataUpdateCoordinator(hass, entry, session) entry.runtime_data = IottyConfigEntryData(set(), data_update_coordinator) @@ -51,6 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IottyConfigEntry) -> boo return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IottyConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/iotty/coordinator.py b/homeassistant/components/iotty/coordinator.py index 420248f7724..af870c347bd 100644 --- a/homeassistant/components/iotty/coordinator.py +++ b/homeassistant/components/iotty/coordinator.py @@ -32,16 +32,27 @@ class IottyData: devices: list[Device] +@dataclass +class IottyConfigEntryData: + """Contains config entry data for iotty.""" + + known_devices: set[Device] + coordinator: IottyDataUpdateCoordinator + + +type IottyConfigEntry = ConfigEntry[IottyConfigEntryData] + + class IottyDataUpdateCoordinator(DataUpdateCoordinator[IottyData]): """Class to manage fetching Iotty data.""" - config_entry: ConfigEntry + config_entry: IottyConfigEntry _entities: dict[str, Entity] _devices: list[Device] _device_registry: dr.DeviceRegistry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, session: OAuth2Session + self, hass: HomeAssistant, entry: IottyConfigEntry, session: OAuth2Session ) -> None: """Initialize the coordinator.""" _LOGGER.debug("Initializing iotty data update coordinator") @@ -49,11 +60,11 @@ class IottyDataUpdateCoordinator(DataUpdateCoordinator[IottyData]): super().__init__( hass, _LOGGER, + config_entry=entry, name=f"{DOMAIN}_coordinator", update_interval=UPDATE_INTERVAL, ) - self.config_entry = entry self._entities = {} self._devices = [] self.iotty = api.IottyProxy( diff --git a/homeassistant/components/iotty/cover.py b/homeassistant/components/iotty/cover.py index 50a4a1deeba..31d363868db 100644 --- a/homeassistant/components/iotty/cover.py +++ b/homeassistant/components/iotty/cover.py @@ -18,9 +18,8 @@ from homeassistant.components.cover import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import IottyConfigEntry from .api import IottyProxy -from .coordinator import IottyDataUpdateCoordinator +from .coordinator import IottyConfigEntry, IottyDataUpdateCoordinator from .entity import IottyEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/iotty/switch.py b/homeassistant/components/iotty/switch.py index b06e3ea308d..a748ac10783 100644 --- a/homeassistant/components/iotty/switch.py +++ b/homeassistant/components/iotty/switch.py @@ -22,9 +22,8 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import IottyConfigEntry from .api import IottyProxy -from .coordinator import IottyDataUpdateCoordinator +from .coordinator import IottyConfigEntry, IottyDataUpdateCoordinator from .entity import IottyEntity _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/iotty/conftest.py b/tests/components/iotty/conftest.py index 51a23bf18c7..1ce645b402e 100644 --- a/tests/components/iotty/conftest.py +++ b/tests/components/iotty/conftest.py @@ -169,7 +169,7 @@ def mock_iotty() -> Generator[MagicMock]: def mock_coordinator() -> Generator[MagicMock]: """Mock IottyDataUpdateCoordinator.""" with patch( - "homeassistant.components.iotty.coordinator.IottyDataUpdateCoordinator", + "homeassistant.components.iotty.IottyDataUpdateCoordinator", autospec=True, ) as coordinator_mock: yield coordinator_mock From e7d49823e49dcadb26a808a1daf92fa30f9b438a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:01:44 +0100 Subject: [PATCH 0314/1941] Explicitly pass in the config_entry in islamic_prayer_times coordinator (#138133) explicitly pass in the config_entry in coordinator --- .../components/islamic_prayer_times/__init__.py | 14 ++++++++------ .../components/islamic_prayer_times/coordinator.py | 13 +++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index d61eba343ac..731d1324c71 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -4,20 +4,20 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from .coordinator import IslamicPrayerDataUpdateCoordinator +from .coordinator import ( + IslamicPrayerDataUpdateCoordinator, + IslamicPrayerTimesConfigEntry, +) PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -type IslamicPrayerTimesConfigEntry = ConfigEntry[IslamicPrayerDataUpdateCoordinator] - async def async_setup_entry( hass: HomeAssistant, config_entry: IslamicPrayerTimesConfigEntry @@ -36,7 +36,7 @@ async def async_setup_entry( await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) - coordinator = IslamicPrayerDataUpdateCoordinator(hass) + coordinator = IslamicPrayerDataUpdateCoordinator(hass, config_entry) await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = coordinator @@ -48,7 +48,9 @@ async def async_setup_entry( return True -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: IslamicPrayerTimesConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 35903afa393..a6cd3fb151e 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -29,21 +29,26 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type IslamicPrayerTimesConfigEntry = ConfigEntry[IslamicPrayerDataUpdateCoordinator] + class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetime]]): """Islamic Prayer Client Object.""" - config_entry: ConfigEntry + config_entry: IslamicPrayerTimesConfigEntry - def __init__(self, hass: HomeAssistant) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: IslamicPrayerTimesConfigEntry + ) -> None: """Initialize the Islamic Prayer client.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, ) - self.latitude = self.config_entry.data[CONF_LATITUDE] - self.longitude = self.config_entry.data[CONF_LONGITUDE] + self.latitude = config_entry.data[CONF_LATITUDE] + self.longitude = config_entry.data[CONF_LONGITUDE] self.event_unsub: CALLBACK_TYPE | None = None @property From 0decb0cfba288ad6e16a0b0c010e829a405c3429 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:03:19 +0100 Subject: [PATCH 0315/1941] Explicitly pass in the config_entry in iotawatt coordinator (#138141) explicitly pass in the config_entry in coordinator --- homeassistant/components/iotawatt/coordinator.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/iotawatt/coordinator.py b/homeassistant/components/iotawatt/coordinator.py index 4f9ac1f94b7..13802ebdd76 100644 --- a/homeassistant/components/iotawatt/coordinator.py +++ b/homeassistant/components/iotawatt/coordinator.py @@ -26,13 +26,14 @@ class IotawattUpdater(DataUpdateCoordinator): """Class to manage fetching update data from the IoTaWatt Energy Device.""" api: Iotawatt | None = None + config_entry: ConfigEntry def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize IotaWattUpdater object.""" - self.entry = entry super().__init__( hass=hass, logger=_LOGGER, + config_entry=entry, name=entry.title, update_interval=timedelta(seconds=30), request_refresh_debouncer=Debouncer( @@ -57,11 +58,11 @@ class IotawattUpdater(DataUpdateCoordinator): """Fetch sensors from IoTaWatt device.""" if self.api is None: api = Iotawatt( - self.entry.title, - self.entry.data[CONF_HOST], + self.config_entry.title, + self.config_entry.data[CONF_HOST], httpx_client.get_async_client(self.hass), - self.entry.data.get(CONF_USERNAME), - self.entry.data.get(CONF_PASSWORD), + self.config_entry.data.get(CONF_USERNAME), + self.config_entry.data.get(CONF_PASSWORD), integratedInterval="d", includeNonTotalSensors=False, ) From b65403f3326c37bfccc377856e39739752a8ba94 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:04:46 +0100 Subject: [PATCH 0316/1941] Explicitly pass in the config_entry in idasen_desk coordinator (#138146) explicitly pass in the config_entry in coordinator --- homeassistant/components/idasen_desk/__init__.py | 7 ++----- homeassistant/components/idasen_desk/button.py | 2 +- homeassistant/components/idasen_desk/coordinator.py | 11 +++++++++-- homeassistant/components/idasen_desk/cover.py | 2 +- homeassistant/components/idasen_desk/entity.py | 2 +- homeassistant/components/idasen_desk/sensor.py | 2 +- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 671319e46eb..1ea0efeef72 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -9,25 +9,22 @@ from idasen_ha.errors import AuthFailedError from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .coordinator import IdasenDeskCoordinator +from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: IdasenDeskConfigEntry) -> bool: """Set up IKEA Idasen from a config entry.""" address: str = entry.data[CONF_ADDRESS].upper() - coordinator = IdasenDeskCoordinator(hass, entry.title, address) + coordinator = IdasenDeskCoordinator(hass, entry, address) entry.runtime_data = coordinator try: diff --git a/homeassistant/components/idasen_desk/button.py b/homeassistant/components/idasen_desk/button.py index cd7553da1ac..afd2f72917c 100644 --- a/homeassistant/components/idasen_desk/button.py +++ b/homeassistant/components/idasen_desk/button.py @@ -10,7 +10,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import IdasenDeskConfigEntry, IdasenDeskCoordinator +from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator from .entity import IdasenDeskEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/idasen_desk/coordinator.py b/homeassistant/components/idasen_desk/coordinator.py index d9e90cfe5ea..5da3d57cf9a 100644 --- a/homeassistant/components/idasen_desk/coordinator.py +++ b/homeassistant/components/idasen_desk/coordinator.py @@ -7,24 +7,31 @@ import logging from idasen_ha import Desk from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator] + class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): """Class to manage updates for the Idasen Desk.""" + config_entry: IdasenDeskConfigEntry + def __init__( self, hass: HomeAssistant, - name: str, + config_entry: IdasenDeskConfigEntry, address: str, ) -> None: """Init IdasenDeskCoordinator.""" - super().__init__(hass, _LOGGER, name=name) + super().__init__( + hass, _LOGGER, config_entry=config_entry, name=config_entry.title + ) self.address = address self._expected_connected = False diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index a8ba0983e99..b99eb67d8f5 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import IdasenDeskConfigEntry, IdasenDeskCoordinator +from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator from .entity import IdasenDeskEntity diff --git a/homeassistant/components/idasen_desk/entity.py b/homeassistant/components/idasen_desk/entity.py index bda7afd528c..46730ee13fe 100644 --- a/homeassistant/components/idasen_desk/entity.py +++ b/homeassistant/components/idasen_desk/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import IdasenDeskCoordinator +from .coordinator import IdasenDeskCoordinator class IdasenDeskEntity(CoordinatorEntity[IdasenDeskCoordinator]): diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py index 4613d316a52..f4ba163b123 100644 --- a/homeassistant/components/idasen_desk/sensor.py +++ b/homeassistant/components/idasen_desk/sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import IdasenDeskConfigEntry, IdasenDeskCoordinator +from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator from .entity import IdasenDeskEntity From ec3e888372ddde164742af3f5e039d8810ed76b9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:05:42 +0100 Subject: [PATCH 0317/1941] Explicitly pass in the config_entry in husqvarna_automower coordinator (#138149) explicitly pass in the config_entry in coordinator --- .../components/husqvarna_automower/__init__.py | 7 ++----- .../components/husqvarna_automower/coordinator.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index a08256fb0b5..1945647a706 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -5,7 +5,6 @@ import logging from aioautomower.session import AutomowerSession from aiohttp import ClientResponseError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -13,7 +12,7 @@ from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.util import dt as dt_util from . import api -from .coordinator import AutomowerDataUpdateCoordinator +from .coordinator import AutomowerConfigEntry, AutomowerDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -29,8 +28,6 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] -type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool: """Set up this integration using UI.""" @@ -61,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> # without the scope. So only polling would be possible. raise ConfigEntryAuthFailed - coordinator = AutomowerDataUpdateCoordinator(hass, automower_api) + coordinator = AutomowerDataUpdateCoordinator(hass, entry, automower_api) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index a587b4f3821..819ee41a43d 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -6,7 +6,6 @@ import asyncio from collections.abc import Callable from datetime import timedelta import logging -from typing import TYPE_CHECKING from aioautomower.exceptions import ( ApiError, @@ -17,6 +16,7 @@ from aioautomower.exceptions import ( from aioautomower.model import MowerAttributes from aioautomower.session import AutomowerSession +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -24,25 +24,30 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN -if TYPE_CHECKING: - from . import AutomowerConfigEntry - _LOGGER = logging.getLogger(__name__) MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time +type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] + class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): """Class to manage fetching Husqvarna data.""" config_entry: AutomowerConfigEntry - def __init__(self, hass: HomeAssistant, api: AutomowerSession) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: AutomowerConfigEntry, + api: AutomowerSession, + ) -> None: """Initialize data updater.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) From 1dbd23eae165a5e83cc225935c006f0a01c10a96 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:06:35 -0500 Subject: [PATCH 0318/1941] Remove non-existing via_device in La Crosse View (#137995) --- homeassistant/components/lacrosse_view/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 5c56a0328a2..624d97d482a 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -226,7 +226,6 @@ class LaCrosseViewSensor( name=sensor.name, manufacturer="LaCrosse Technology", model=sensor.model, - via_device=(DOMAIN, sensor.location.id), ) self.index = index From 8c27a75d6b1d9882ef7f9e005cbbeec7558c8f2c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:08:49 +0100 Subject: [PATCH 0319/1941] Explicitly pass in the config_entry in husqvarna_automower_ble coordinator (#138150) explicitly pass in the config_entry in coordinator --- homeassistant/components/husqvarna_automower_ble/__init__.py | 2 +- .../components/husqvarna_automower_ble/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index 2025ba64cf1..ca07d1ab8d2 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model = await mower.get_model() LOGGER.debug("Connected to Automower: %s", model) - coordinator = HusqvarnaCoordinator(hass, mower, address, channel_id, model) + coordinator = HusqvarnaCoordinator(hass, entry, mower, address, channel_id, model) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/husqvarna_automower_ble/coordinator.py b/homeassistant/components/husqvarna_automower_ble/coordinator.py index c577ccd9196..dde3462c081 100644 --- a/homeassistant/components/husqvarna_automower_ble/coordinator.py +++ b/homeassistant/components/husqvarna_automower_ble/coordinator.py @@ -9,6 +9,7 @@ from bleak import BleakError from bleak_retry_connector import close_stale_connections_by_address from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -20,9 +21,12 @@ SCAN_INTERVAL = timedelta(seconds=60) class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]): """Class to manage fetching data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, mower: Mower, address: str, channel_id: str, @@ -32,6 +36,7 @@ class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]): super().__init__( hass=hass, logger=LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) From 8c3dab199e4248752643dc10a41b958ee15a6f20 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:10:21 +0100 Subject: [PATCH 0320/1941] Explicitly pass in the config_entry in homewizard coordinator (#138152) explicitly pass in the config_entry in coordinator --- .../components/homewizard/__init__.py | 8 +++----- homeassistant/components/homewizard/button.py | 3 +-- .../components/homewizard/coordinator.py | 19 ++++++++++++++++--- .../components/homewizard/diagnostics.py | 2 +- homeassistant/components/homewizard/number.py | 3 +-- homeassistant/components/homewizard/sensor.py | 3 +-- homeassistant/components/homewizard/switch.py | 3 +-- 7 files changed, 24 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 36c9681dcd2..3831146aed8 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -7,7 +7,7 @@ from homewizard_energy import ( has_v2_api, ) -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -15,9 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN, PLATFORMS -from .coordinator import HWEnergyDeviceUpdateCoordinator - -type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator] +from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -> bool: @@ -42,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - if is_battery: await async_check_v2_support_and_create_issue(hass, entry) - coordinator = HWEnergyDeviceUpdateCoordinator(hass, api) + coordinator = HWEnergyDeviceUpdateCoordinator(hass, entry, api) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index b86f797ec2d..d4484ee4be3 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -5,8 +5,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeWizardConfigEntry -from .coordinator import HWEnergyDeviceUpdateCoordinator +from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index 92beb99ad2c..e87381c5fa9 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -13,6 +13,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, UPDATE_INTERVAL +type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator] + class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]): """Gather data for the energy device.""" @@ -20,11 +22,22 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] api: HomeWizardEnergy api_disabled: bool = False - config_entry: ConfigEntry + config_entry: HomeWizardConfigEntry - def __init__(self, hass: HomeAssistant, api: HomeWizardEnergy) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: HomeWizardConfigEntry, + api: HomeWizardEnergy, + ) -> None: """Initialize update coordinator.""" - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) self.api = api async def _async_update_data(self) -> DeviceResponseEntry: diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index 12bd25671e0..a3ae2555173 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from . import HomeWizardConfigEntry +from .coordinator import HomeWizardConfigEntry TO_REDACT = { CONF_IP_ADDRESS, diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 5806295fc81..e936657f254 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -7,8 +7,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeWizardConfigEntry -from .coordinator import HWEnergyDeviceUpdateCoordinator +from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index f6f5588956c..5f3133fa9ba 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -37,9 +37,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from . import HomeWizardConfigEntry from .const import DOMAIN -from .coordinator import HWEnergyDeviceUpdateCoordinator +from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 8ebb56433b1..9f6b3ddd81f 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -18,8 +18,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeWizardConfigEntry -from .coordinator import HWEnergyDeviceUpdateCoordinator +from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler From 15af006fbe426aecdad3efe1fd0ff48a0dfb8fb6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:13:54 +0100 Subject: [PATCH 0321/1941] Explicitly pass in the config_entry in iometer coordinator (#138142) explicitly pass in the config_entry in coordinator --- homeassistant/components/iometer/__init__.py | 2 +- homeassistant/components/iometer/coordinator.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py index 5106d449fed..bbf046e70e9 100644 --- a/homeassistant/components/iometer/__init__.py +++ b/homeassistant/components/iometer/__init__.py @@ -26,7 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> b except IOmeterConnectionError as err: raise ConfigEntryNotReady from err - coordinator = IOMeterCoordinator(hass, client) + coordinator = IOMeterCoordinator(hass, entry, client) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py index 3321b032e4b..708983fb28e 100644 --- a/homeassistant/components/iometer/coordinator.py +++ b/homeassistant/components/iometer/coordinator.py @@ -32,17 +32,23 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): config_entry: IOmeterConfigEntry client: IOmeterClient - def __init__(self, hass: HomeAssistant, client: IOmeterClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: IOmeterConfigEntry, + client: IOmeterClient, + ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) self.client = client - self.identifier = self.config_entry.entry_id + self.identifier = config_entry.entry_id async def _async_update_data(self) -> IOmeterData: """Update data async.""" From 7c9d30eb067f6d7ae9b0315f7d77ed5e01e5a1d7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:14:19 +0100 Subject: [PATCH 0322/1941] Explicitly pass in the config_entry in intellifire coordinator (#138143) explicitly pass in the config_entry in coordinator --- homeassistant/components/intellifire/__init__.py | 4 +--- homeassistant/components/intellifire/coordinator.py | 5 +++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index ce78f1a6fa3..cda30820a2f 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -128,9 +128,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from err # Construct coordinator - data_update_coordinator = IntellifireDataUpdateCoordinator( - hass=hass, fireplace=fireplace - ) + data_update_coordinator = IntellifireDataUpdateCoordinator(hass, entry, fireplace) LOGGER.debug("Fireplace to Initialized - Awaiting first refresh") await data_update_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index b4f03f4b5c8..6a23e7438db 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -9,6 +9,7 @@ from intellifire4py.control import IntelliFireController from intellifire4py.model import IntelliFirePollData from intellifire4py.read import IntelliFireDataProvider +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -19,15 +20,19 @@ from .const import DOMAIN, LOGGER class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntelliFirePollData]): """Class to manage the polling of the fireplace API.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, fireplace: UnifiedFireplace, ) -> None: """Initialize the Coordinator.""" super().__init__( hass, LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=15), ) From d9a17506f54718de304cc6eb2d093392e617d211 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:16:05 +0100 Subject: [PATCH 0323/1941] Explicitly pass in the config_entry in here_travel_time coordinator (#138155) explicitly pass in the config_entry in coordinator --- homeassistant/components/here_travel_time/__init__.py | 2 +- homeassistant/components/here_travel_time/coordinator.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 1b99ba64827..132b12de4ce 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -56,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b else: cls = HERERoutingDataUpdateCoordinator - data_coordinator = cls(hass, api_key, here_travel_time_config) + data_coordinator = cls(hass, config_entry, api_key, here_travel_time_config) hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data_coordinator async def _async_update_at_start(_: HomeAssistant) -> None: diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index 65e1305e44e..a3345e78e4e 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -25,6 +25,7 @@ from here_transit import ( ) import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -44,9 +45,12 @@ _LOGGER = logging.getLogger(__name__) class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]): """here_routing DataUpdateCoordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, api_key: str, config: HERETravelTimeConfig, ) -> None: @@ -54,6 +58,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) @@ -168,9 +173,12 @@ class HERETransitDataUpdateCoordinator( ): """HERETravelTime DataUpdateCoordinator.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, api_key: str, config: HERETravelTimeConfig, ) -> None: @@ -178,6 +186,7 @@ class HERETransitDataUpdateCoordinator( super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) From 39bcef63bdca2a077df1f82edbed7399911856f3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:17:11 +0100 Subject: [PATCH 0324/1941] Explicitly pass in the config_entry in hko coordinator (#138154) explicitly pass in the config_entry in coordinator --- homeassistant/components/hko/__init__.py | 2 +- homeassistant/components/hko/coordinator.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hko/__init__.py b/homeassistant/components/hko/__init__.py index 5b06644580e..b7e21f731d8 100644 --- a/homeassistant/components/hko/__init__.py +++ b/homeassistant/components/hko/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: )[KEY_DISTRICT] websession = async_get_clientsession(hass) - coordinator = HKOUpdateCoordinator(hass, websession, district, location) + coordinator = HKOUpdateCoordinator(hass, entry, websession, district, location) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py index 566ba5dcf5e..5845e8831fe 100644 --- a/homeassistant/components/hko/coordinator.py +++ b/homeassistant/components/hko/coordinator.py @@ -26,6 +26,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -69,8 +70,15 @@ _LOGGER = logging.getLogger(__name__) class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """HKO Update Coordinator.""" + config_entry: ConfigEntry + def __init__( - self, hass: HomeAssistant, session: ClientSession, district: str, location: str + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + session: ClientSession, + district: str, + location: str, ) -> None: """Update data via library.""" self.location = location @@ -80,6 +88,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(minutes=15), ) From 6b24bae0841e9e33ab864c426098ccadc01a9992 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:19:57 +0100 Subject: [PATCH 0325/1941] Explicitly pass in the config_entry in hunterdouglas_powerview coordinator (#138151) explicitly pass in the config_entry in coordinator --- .../components/hunterdouglas_powerview/__init__.py | 2 +- .../components/hunterdouglas_powerview/coordinator.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index b4bbc37b1e8..3e9ff8727ce 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -91,7 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerviewConfigEntry) -> entry, unique_id=device_info.serial_number ) - coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub) + coordinator = PowerviewShadeUpdateCoordinator(hass, entry, shades, hub) coordinator.async_set_updated_data(PowerviewShadeData()) # populate raw shade data into the coordinator for diagnostics coordinator.data.store_group_data(shade_data) diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index f074b06b2bc..2ff1914079a 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -10,6 +10,7 @@ from aiopvapi.helpers.aiorequest import PvApiMaintenance from aiopvapi.hub import Hub from aiopvapi.shades import Shades +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,7 +23,11 @@ _LOGGER = logging.getLogger(__name__) class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]): """DataUpdateCoordinator to gather data from a powerview hub.""" - def __init__(self, hass: HomeAssistant, shades: Shades, hub: Hub) -> None: + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, shades: Shades, hub: Hub + ) -> None: """Initialize DataUpdateCoordinator to gather data for specific Powerview Hub.""" self.shades = shades self.hub = hub @@ -33,6 +38,7 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=f"powerview hub {hub.hub_address}", update_interval=timedelta(seconds=60), ) From a27dd08a7cae02e65e75f87d56711f79fa1f1b23 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:26:11 +0100 Subject: [PATCH 0326/1941] Explicitly pass in the config_entry in habitica coordinator (#138156) explicitly pass in the config_entry in coordinator --- homeassistant/components/habitica/__init__.py | 5 ++--- .../components/habitica/binary_sensor.py | 2 +- homeassistant/components/habitica/button.py | 7 +++++-- homeassistant/components/habitica/calendar.py | 3 +-- .../components/habitica/config_flow.py | 2 +- .../components/habitica/coordinator.py | 8 +++++++- .../components/habitica/diagnostics.py | 2 +- homeassistant/components/habitica/image.py | 3 +-- homeassistant/components/habitica/sensor.py | 3 +-- homeassistant/components/habitica/services.py | 2 +- homeassistant/components/habitica/switch.py | 7 +++++-- homeassistant/components/habitica/todo.py | 3 +-- homeassistant/components/habitica/types.py | 18 ------------------ 13 files changed, 27 insertions(+), 38 deletions(-) delete mode 100644 homeassistant/components/habitica/types.py diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 1972e89c58a..217b5e739d1 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -9,9 +9,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import CONF_API_USER, DOMAIN, X_CLIENT -from .coordinator import HabiticaDataUpdateCoordinator +from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator from .services import async_setup_services -from .types import HabiticaConfigEntry CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -51,7 +50,7 @@ async def async_setup_entry( x_client=X_CLIENT, ) - coordinator = HabiticaDataUpdateCoordinator(hass, api) + coordinator = HabiticaDataUpdateCoordinator(hass, config_entry, api) await coordinator.async_config_entry_first_refresh() config_entry.runtime_data = coordinator diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index 5e3040e0606..6198ed14de8 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ASSETS_URL +from .coordinator import HabiticaConfigEntry from .entity import HabiticaBase -from .types import HabiticaConfigEntry PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index 450a5cdcf20..40325c49a7b 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -28,9 +28,12 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ASSETS_URL, DOMAIN -from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator +from .coordinator import ( + HabiticaConfigEntry, + HabiticaData, + HabiticaDataUpdateCoordinator, +) from .entity import HabiticaBase -from .types import HabiticaConfigEntry PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index f33f3c3c12f..5ef9cd2eba1 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -21,8 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import HabiticaConfigEntry -from .coordinator import HabiticaDataUpdateCoordinator +from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator from .entity import HabiticaBase from .util import build_rrule, get_recurrence_rule diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 7a7f369cb09..91a13bd7918 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -33,7 +33,6 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) -from . import HabiticaConfigEntry from .const import ( CONF_API_USER, DEFAULT_URL, @@ -47,6 +46,7 @@ from .const import ( SITE_DATA_URL, X_CLIENT, ) +from .coordinator import HabiticaConfigEntry STEP_ADVANCED_DATA_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index f97b98410bb..19d31f18fd7 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -46,16 +46,22 @@ class HabiticaData: tasks: list[TaskData] +type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] + + class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): """Habitica Data Update Coordinator.""" config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, habitica: Habitica) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, habitica: Habitica + ) -> None: """Initialize the Habitica data coordinator.""" super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=60), request_refresh_debouncer=Debouncer( diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py index abfa0f35c4b..09b8b9ba0bb 100644 --- a/homeassistant/components/habitica/diagnostics.py +++ b/homeassistant/components/habitica/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from .const import CONF_API_USER -from .types import HabiticaConfigEntry +from .coordinator import HabiticaConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index f1dbbc64d41..b3b2fbb85a8 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -12,8 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import HabiticaConfigEntry -from .coordinator import HabiticaDataUpdateCoordinator +from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator from .entity import HabiticaBase PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 57c391f5c12..fa36025c5ce 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -37,9 +37,8 @@ from homeassistant.helpers.issue_registry import ( from homeassistant.helpers.typing import StateType from .const import ASSETS_URL, DOMAIN -from .coordinator import HabiticaDataUpdateCoordinator +from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator from .entity import HabiticaBase -from .types import HabiticaConfigEntry from .util import get_attribute_points, get_attributes_total, inventory_list _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 2537655dbfb..12d5b3e6ef8 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -62,7 +62,7 @@ from .const import ( SERVICE_START_QUEST, SERVICE_TRANSFORMATION, ) -from .types import HabiticaConfigEntry +from .coordinator import HabiticaConfigEntry _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py index ddc0db27108..fdad85ce3dc 100644 --- a/homeassistant/components/habitica/switch.py +++ b/homeassistant/components/habitica/switch.py @@ -15,9 +15,12 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import HabiticaData, HabiticaDataUpdateCoordinator +from .coordinator import ( + HabiticaConfigEntry, + HabiticaData, + HabiticaDataUpdateCoordinator, +) from .entity import HabiticaBase -from .types import HabiticaConfigEntry PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index c1786059300..c46cf92c724 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -30,9 +30,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from .const import ASSETS_URL, DOMAIN -from .coordinator import HabiticaDataUpdateCoordinator +from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator from .entity import HabiticaBase -from .types import HabiticaConfigEntry from .util import next_due_date _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/habitica/types.py b/homeassistant/components/habitica/types.py deleted file mode 100644 index 9789a65dc40..00000000000 --- a/homeassistant/components/habitica/types.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Types for Habitica integration.""" - -from enum import StrEnum - -from homeassistant.config_entries import ConfigEntry - -from .coordinator import HabiticaDataUpdateCoordinator - -type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] - - -class HabiticaTaskType(StrEnum): - """Habitica Entities.""" - - HABIT = "habit" - DAILY = "daily" - TODO = "todo" - REWARD = "reward" From 5dea4164a566f8b63079e8bd9333cdaaf3769f82 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:27:47 +0100 Subject: [PATCH 0327/1941] Explicitly pass in the config_entry in hydrawise coordinator (#138148) explicitly pass in the config_entry in coordinator --- homeassistant/components/hydrawise/__init__.py | 4 ++-- .../components/hydrawise/coordinator.py | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index ee5a8a66610..ce4d7a8f8c2 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -39,10 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b app_id=APP_ID, ) - main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise) + main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, config_entry, hydrawise) await main_coordinator.async_config_entry_first_refresh() water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator( - hass, hydrawise, main_coordinator + hass, config_entry, hydrawise, main_coordinator ) await water_use_coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ( diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 4721a9fb154..35d816b341b 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -7,6 +7,7 @@ from dataclasses import dataclass, field from pydrawise import HydrawiseBase from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import now @@ -39,6 +40,7 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """Base class for Hydrawise Data Update Coordinators.""" api: HydrawiseBase + config_entry: ConfigEntry class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): @@ -49,9 +51,17 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): integration are updated in a timely manner. """ - def __init__(self, hass: HomeAssistant, api: HydrawiseBase) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: HydrawiseBase + ) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL) + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=MAIN_SCAN_INTERVAL, + ) self.api = api async def _async_update_data(self) -> HydrawiseData: @@ -82,6 +92,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, api: HydrawiseBase, main_coordinator: HydrawiseMainDataUpdateCoordinator, ) -> None: @@ -89,6 +100,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator): super().__init__( hass, LOGGER, + config_entry=config_entry, name=f"{DOMAIN} water use", update_interval=WATER_USE_SCAN_INTERVAL, ) From 427013124c8794dd261a5e4d6901c390d700217c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:42:15 +0100 Subject: [PATCH 0328/1941] Explicitly pass in the config_entry in iron_os coordinator (#138137) explicitly pass in the config_entry in coordinator --- homeassistant/components/iron_os/__init__.py | 7 +- .../components/iron_os/coordinator.py | 65 +++++++++++-------- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py index 6af6abb1436..77099e48b41 100644 --- a/homeassistant/components/iron_os/__init__.py +++ b/homeassistant/components/iron_os/__init__.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING from pynecil import IronOSUpdate, Pynecil from homeassistant.components import bluetooth -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -19,6 +18,7 @@ from homeassistant.util.hass_dict import HassKey from .const import DOMAIN from .coordinator import ( + IronOSConfigEntry, IronOSCoordinators, IronOSFirmwareUpdateCoordinator, IronOSLiveDataCoordinator, @@ -39,7 +39,6 @@ PLATFORMS: list[Platform] = [ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -type IronOSConfigEntry = ConfigEntry[IronOSCoordinators] IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN) @@ -73,10 +72,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo device = Pynecil(ble_device) - live_data = IronOSLiveDataCoordinator(hass, device) + live_data = IronOSLiveDataCoordinator(hass, entry, device) await live_data.async_config_entry_first_refresh() - settings = IronOSSettingsCoordinator(hass, device) + settings = IronOSSettingsCoordinator(hass, entry, device) await settings.async_config_entry_first_refresh() entry.runtime_data = IronOSCoordinators( diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 080fee20762..fc89ecea43c 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -43,15 +43,19 @@ class IronOSCoordinators: settings: IronOSSettingsCoordinator +type IronOSConfigEntry = ConfigEntry[IronOSCoordinators] + + class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """IronOS base coordinator.""" device_info: DeviceInfoResponse - config_entry: ConfigEntry + config_entry: IronOSConfigEntry def __init__( self, hass: HomeAssistant, + config_entry: IronOSConfigEntry, device: Pynecil, update_interval: timedelta, ) -> None: @@ -60,6 +64,7 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=update_interval, request_refresh_debouncer=Debouncer( @@ -80,9 +85,11 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): """IronOS coordinator.""" - def __init__(self, hass: HomeAssistant, device: Pynecil) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: IronOSConfigEntry, device: Pynecil + ) -> None: """Initialize IronOS coordinator.""" - super().__init__(hass, device=device, update_interval=SCAN_INTERVAL) + super().__init__(hass, config_entry, device, SCAN_INTERVAL) async def _async_update_data(self) -> LiveDataResponse: """Fetch data from Device.""" @@ -109,35 +116,14 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): return False -class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): - """IronOS coordinator for retrieving update information from github.""" - - def __init__(self, hass: HomeAssistant, github: IronOSUpdate) -> None: - """Initialize IronOS coordinator.""" - super().__init__( - hass, - _LOGGER, - config_entry=None, - name=DOMAIN, - update_interval=SCAN_INTERVAL_GITHUB, - ) - self.github = github - - async def _async_update_data(self) -> LatestRelease: - """Fetch data from Github.""" - - try: - return await self.github.latest_release() - except UpdateException as e: - raise UpdateFailed("Failed to check for latest IronOS update") from e - - class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): """IronOS coordinator.""" - def __init__(self, hass: HomeAssistant, device: Pynecil) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: IronOSConfigEntry, device: Pynecil + ) -> None: """Initialize IronOS coordinator.""" - super().__init__(hass, device=device, update_interval=SCAN_INTERVAL_SETTINGS) + super().__init__(hass, config_entry, device, SCAN_INTERVAL_SETTINGS) async def _async_update_data(self) -> SettingsDataResponse: """Fetch data from Device.""" @@ -173,3 +159,26 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): ) self.async_update_listeners() await self.async_request_refresh() + + +class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]): + """IronOS coordinator for retrieving update information from github.""" + + def __init__(self, hass: HomeAssistant, github: IronOSUpdate) -> None: + """Initialize IronOS coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=None, + name=DOMAIN, + update_interval=SCAN_INTERVAL_GITHUB, + ) + self.github = github + + async def _async_update_data(self) -> LatestRelease: + """Fetch data from Github.""" + + try: + return await self.github.latest_release() + except UpdateException as e: + raise UpdateFailed("Failed to check for latest IronOS update") from e From 08dbd83a551e077366f0e6b53612931593b429cd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:43:46 +0100 Subject: [PATCH 0329/1941] Explicitly pass in the config_entry in ipp coordinator (#138138) explicitly pass in the config_entry in coordinator --- homeassistant/components/ipp/__init__.py | 30 +++--------------- homeassistant/components/ipp/coordinator.py | 35 ++++++++++----------- homeassistant/components/ipp/diagnostics.py | 2 +- homeassistant/components/ipp/sensor.py | 2 +- 4 files changed, 22 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 0a94795613b..99332dca0e2 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -2,39 +2,17 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - CONF_SSL, - CONF_VERIFY_SSL, - Platform, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_BASE_PATH -from .coordinator import IPPDataUpdateCoordinator +from .coordinator import IPPConfigEntry, IPPDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -type IPPConfigEntry = ConfigEntry[IPPDataUpdateCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: IPPConfigEntry) -> bool: """Set up IPP from a config entry.""" - # config flow sets this to either UUID, serial number or None - if (device_id := entry.unique_id) is None: - device_id = entry.entry_id - - coordinator = IPPDataUpdateCoordinator( - hass, - host=entry.data[CONF_HOST], - port=entry.data[CONF_PORT], - base_path=entry.data[CONF_BASE_PATH], - tls=entry.data[CONF_SSL], - verify_ssl=entry.data[CONF_VERIFY_SSL], - device_id=device_id, - ) + coordinator = IPPDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator @@ -44,6 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IPPConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: IPPConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ipp/coordinator.py b/homeassistant/components/ipp/coordinator.py index 535b18bcaf0..1c3dc4d0a03 100644 --- a/homeassistant/components/ipp/coordinator.py +++ b/homeassistant/components/ipp/coordinator.py @@ -7,45 +7,42 @@ import logging from pyipp import IPP, IPPError, Printer as IPPPrinter +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import CONF_BASE_PATH, DOMAIN SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) +type IPPConfigEntry = ConfigEntry[IPPDataUpdateCoordinator] + class IPPDataUpdateCoordinator(DataUpdateCoordinator[IPPPrinter]): """Class to manage fetching IPP data from single endpoint.""" - def __init__( - self, - hass: HomeAssistant, - *, - host: str, - port: int, - base_path: str, - tls: bool, - verify_ssl: bool, - device_id: str, - ) -> None: + config_entry: IPPConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: IPPConfigEntry) -> None: """Initialize global IPP data updater.""" - self.device_id = device_id + self.device_id = config_entry.unique_id or config_entry.entry_id self.ipp = IPP( - host=host, - port=port, - base_path=base_path, - tls=tls, - verify_ssl=verify_ssl, - session=async_get_clientsession(hass, verify_ssl), + host=config_entry.data[CONF_HOST], + port=config_entry.data[CONF_PORT], + base_path=config_entry.data[CONF_BASE_PATH], + tls=config_entry.data[CONF_SSL], + verify_ssl=config_entry.data[CONF_VERIFY_SSL], + session=async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]), ) super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/ipp/diagnostics.py b/homeassistant/components/ipp/diagnostics.py index 9b10dc68966..cd136e78373 100644 --- a/homeassistant/components/ipp/diagnostics.py +++ b/homeassistant/components/ipp/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant -from . import IPPConfigEntry +from .coordinator import IPPConfigEntry async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index a2792c7749b..8efbd21707f 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -20,7 +20,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import IPPConfigEntry from .const import ( ATTR_COMMAND_SET, ATTR_INFO, @@ -32,6 +31,7 @@ from .const import ( ATTR_STATE_REASON, ATTR_URI_SUPPORTED, ) +from .coordinator import IPPConfigEntry from .entity import IPPEntity From 49968904b2e5d2d4fa7188e156d3a631e04c6059 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:44:13 +0100 Subject: [PATCH 0330/1941] Explicitly pass in the config_entry in homeassistant_alerts coordinator (#138153) explicitly pass in the config_entry in coordinator --- homeassistant/components/homeassistant_alerts/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homeassistant_alerts/coordinator.py b/homeassistant/components/homeassistant_alerts/coordinator.py index a81824d2376..542ebf857df 100644 --- a/homeassistant/components/homeassistant_alerts/coordinator.py +++ b/homeassistant/components/homeassistant_alerts/coordinator.py @@ -40,6 +40,7 @@ class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]]) super().__init__( hass, _LOGGER, + config_entry=None, name=DOMAIN, update_interval=UPDATE_INTERVAL, ) From 7678f8fddd0da3ecb2cc4cdbfd3308c18b4ee6d3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 9 Feb 2025 22:12:04 +0100 Subject: [PATCH 0331/1941] Revert "Clear statistics when you unload the Opower integration (#135908)" (#138163) * Revert "Clear statistics when you unload the Opower integration (#135908)" This reverts commit aa19207ea4a12abc592781baeff674027ece33dd. * Fix OpowerConfigEntry imports * Re-add entry type hint to coordinator --- homeassistant/components/opower/coordinator.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index c351f99339a..aed89ccf46e 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -64,7 +64,6 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): config_entry.data[CONF_PASSWORD], config_entry.data.get(CONF_TOTP_SECRET), ) - self._statistic_ids: set[str] = set() @callback def _dummy_listener() -> None: @@ -76,12 +75,6 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): # _async_update_data not periodically getting called which is needed for _insert_statistics. self.async_add_listener(_dummy_listener) - self.config_entry.async_on_unload(self._clear_statistics) - - def _clear_statistics(self) -> None: - """Clear statistics.""" - get_instance(self.hass).async_clear_statistics(list(self._statistic_ids)) - async def _async_update_data( self, ) -> dict[str, Forecast]: @@ -127,8 +120,6 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ) cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" - self._statistic_ids.add(cost_statistic_id) - self._statistic_ids.add(consumption_statistic_id) _LOGGER.debug( "Updating Statistics for %s and %s", cost_statistic_id, From e8e4d2a83c356c1fbb1099f719ec6fc208305919 Mon Sep 17 00:00:00 2001 From: jdelaney72 <20731268+jdelaney72@users.noreply.github.com> Date: Sun, 9 Feb 2025 13:32:11 -0800 Subject: [PATCH 0332/1941] Add unique ID for NOAA Tides sensor (#137988) --- homeassistant/components/noaa_tides/helpers.py | 6 ++++++ homeassistant/components/noaa_tides/sensor.py | 3 +++ 2 files changed, 9 insertions(+) create mode 100644 homeassistant/components/noaa_tides/helpers.py diff --git a/homeassistant/components/noaa_tides/helpers.py b/homeassistant/components/noaa_tides/helpers.py new file mode 100644 index 00000000000..734cca68f44 --- /dev/null +++ b/homeassistant/components/noaa_tides/helpers.py @@ -0,0 +1,6 @@ +"""Helpers for NOAA Tides integration.""" + + +def get_station_unique_id(station_id: str) -> str: + """Convert a station ID to a unique ID.""" + return f"{station_id.lower()}" diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index 0af2c340960..3b5a13b0f15 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -22,6 +22,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_system import METRIC_SYSTEM +from .helpers import get_station_unique_id + if TYPE_CHECKING: from pandas import Timestamp @@ -105,6 +107,7 @@ class NOAATidesAndCurrentsSensor(SensorEntity): self._unit_system = unit_system self._station = station self.data: NOAATidesData | None = None + self._attr_unique_id = f"{get_station_unique_id(station_id)}_summary" @property def name(self) -> str: From 379bf106754dffd5c6c8cd8035a33597976cd866 Mon Sep 17 00:00:00 2001 From: Regev Brody Date: Sun, 9 Feb 2025 23:39:38 +0200 Subject: [PATCH 0333/1941] Add scene support to roborock (#137203) * feature: add scene buttons to roborock * feature: upgrade python-roborock * feature: upgrade python-roborock * feature: upgrade python-roborock * feature: upgrade python-roborock * feature: upgrade python-roborock * feature: upgrade python-roborock * feature: upgrade python-roborock * feature: upgrade python-roborock * feature: upgrade python-roborock --- 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, 278 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/roborock/scene.py create mode 100644 tests/components/roborock/test_scene.py diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 764518df636..1c25d527aa8 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -82,7 +82,13 @@ 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 + hass, + entry, + device_map, + user_data, + product_info, + home_data.rooms, + api_client, ), return_exceptions=True, ) @@ -134,6 +140,7 @@ def build_setup_functions( user_data: UserData, product_info: dict[str, HomeDataProduct], home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, ) -> list[ Coroutine[ Any, @@ -150,6 +157,7 @@ def build_setup_functions( device, product_info[device.product_id], home_data_rooms, + api_client, ) for device in device_map.values() ] @@ -162,11 +170,12 @@ 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 + hass, entry, user_data, device, product_info, home_data_rooms, api_client ) if device.pv == "A01": return await setup_device_a01(hass, entry, user_data, device, product_info) @@ -186,6 +195,7 @@ 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( @@ -207,7 +217,15 @@ async def setup_device_v1( await mqtt_client.async_release() raise coordinator = RoborockDataUpdateCoordinator( - hass, entry, device, networking, product_info, mqtt_client, home_data_rooms + hass, + entry, + device, + networking, + product_info, + mqtt_client, + home_data_rooms, + api_client, + user_data, ) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index cc8d34fbadc..fe9091a3ea7 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -36,6 +36,7 @@ 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 918c7159ee3..b35f62323e8 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,17 +10,26 @@ 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, NetworkInfo +from roborock.containers import ( + DeviceData, + HomeDataDevice, + HomeDataProduct, + HomeDataScene, + NetworkInfo, + UserData, +) 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 @@ -67,6 +76,8 @@ 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__( @@ -89,7 +100,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, - identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, + identifiers={(DOMAIN, self.duid)}, manufacturer="Roborock", model=self.roborock_device_info.product.model, model_id=self.roborock_device_info.product.model, @@ -103,8 +114,10 @@ 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, slugify(self.duid) + hass, self.config_entry.entry_id, self.duid_slug ) + self._user_data = user_data + self._api_client = api_client async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -134,7 +147,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.roborock_device_info.device.duid, + self.duid, ) await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. @@ -193,6 +206,34 @@ 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 new file mode 100644 index 00000000000..c07014431cd --- /dev/null +++ b/homeassistant/components/roborock/scene.py @@ -0,0 +1,64 @@ +"""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 AddEntitiesCallback + +from . import RoborockConfigEntry +from .coordinator import RoborockDataUpdateCoordinator +from .entity import RoborockEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: RoborockConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> 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 43e5148c9a8..9b3a6633c62 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -30,6 +30,7 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, + SCENES, USER_DATA, USER_EMAIL, ) @@ -67,8 +68,24 @@ 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() -> None: +def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: """Skip calls to the API.""" with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), @@ -76,10 +93,6 @@ def bypass_api_fixture() -> 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 6e3fb229aa9..59c54892687 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -9,6 +9,7 @@ from roborock.containers import ( Consumable, DnDTimer, HomeData, + HomeDataScene, MultiMapsList, NetworkInfo, S7Status, @@ -1150,3 +1151,19 @@ 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 new file mode 100644 index 00000000000..15707784feb --- /dev/null +++ b/tests/components/roborock/test_scene.py @@ -0,0 +1,112 @@ +"""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 57ab567d08fa02b81ab99dce6ea2525fc626f096 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 9 Feb 2025 13:52:01 -0800 Subject: [PATCH 0334/1941] Update ollama to use the ChatLog/ChatSession APIs (#138167) * Update ollama to use the ChatLog/ChatSession APIs * Add documentation about history trimming. * Revert changes to chat_log.py * Explicitly check for SystemContent when converting system messages * Remove half of a comment --- .../components/ollama/conversation.py | 269 ++++++++---------- homeassistant/components/ollama/models.py | 3 - .../ollama/snapshots/test_conversation.ambr | 4 +- tests/components/ollama/test_conversation.py | 75 +---- 4 files changed, 128 insertions(+), 223 deletions(-) diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index c0fbfae6444..2c83720f930 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -5,22 +5,18 @@ from __future__ import annotations from collections.abc import Callable import json import logging -import time from typing import Any, Literal import ollama -import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import intent, llm, template +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import chat_session, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid as ulid_util from .const import ( CONF_KEEP_ALIVE, @@ -32,7 +28,6 @@ from .const import ( DEFAULT_MAX_HISTORY, DEFAULT_NUM_CTX, DOMAIN, - MAX_HISTORY_SECONDS, ) from .models import MessageHistory, MessageRole @@ -93,6 +88,44 @@ def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} +def _convert_content( + chat_content: conversation.Content + | conversation.ToolResultContent + | conversation.AssistantContent, +) -> ollama.Message: + """Create tool response content.""" + if isinstance(chat_content, conversation.ToolResultContent): + return ollama.Message( + role=MessageRole.TOOL.value, + content=json.dumps(chat_content.tool_result), + ) + if isinstance(chat_content, conversation.AssistantContent): + return ollama.Message( + role=MessageRole.ASSISTANT.value, + content=chat_content.content, + tool_calls=[ + ollama.Message.ToolCall( + function=ollama.Message.ToolCall.Function( + name=tool_call.tool_name, + arguments=tool_call.tool_args, + ) + ) + for tool_call in chat_content.tool_calls or () + ], + ) + if isinstance(chat_content, conversation.UserContent): + return ollama.Message( + role=MessageRole.USER.value, + content=chat_content.content, + ) + if isinstance(chat_content, conversation.SystemContent): + return ollama.Message( + role=MessageRole.SYSTEM.value, + content=chat_content.content, + ) + raise ValueError(f"Unexpected content type: {type(chat_content)}") + + class OllamaConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -105,7 +138,6 @@ class OllamaConversationEntity( self.entry = entry # conversation id -> message history - self._history: dict[str, MessageHistory] = {} self._attr_name = entry.title self._attr_unique_id = entry.entry_id if self.entry.options.get(CONF_LLM_HASS_API): @@ -138,121 +170,48 @@ class OllamaConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, + ): + return await self._async_handle_message(user_input, chat_log) + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Call the API.""" settings = {**self.entry.data, **self.entry.options} client = self.hass.data[DOMAIN][self.entry.entry_id] - conversation_id = user_input.conversation_id or ulid_util.ulid_now() model = settings[CONF_MODEL] - intent_response = intent.IntentResponse(language=user_input.language) - llm_api: llm.APIInstance | None = None - tools: list[dict[str, Any]] | None = None - user_name: str | None = None - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - if settings.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - settings[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - _LOGGER.error("Error getting LLM API: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Error preparing LLM API: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=user_input.conversation_id - ) + try: + await chat_log.async_update_llm_data( + DOMAIN, + user_input, + settings.get(CONF_LLM_HASS_API), + settings.get(CONF_PROMPT), + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + tools: list[dict[str, Any]] | None = None + if chat_log.llm_api: tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools ] - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - ): - user_name = user.name - - # Look up message history - message_history: MessageHistory | None = None - message_history = self._history.get(conversation_id) - if message_history is None: - # New history - # - # Render prompt and error out early if there's a problem - try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + settings.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) - ] - - except TemplateError as err: - _LOGGER.error("Error rendering prompt: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem generating my prompt: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - if llm_api: - prompt_parts.append(llm_api.api_prompt) - - prompt = "\n".join(prompt_parts) - _LOGGER.debug("Prompt: %s", prompt) - _LOGGER.debug("Tools: %s", tools) - - message_history = MessageHistory( - timestamp=time.monotonic(), - messages=[ - ollama.Message(role=MessageRole.SYSTEM.value, content=prompt) - ], - ) - self._history[conversation_id] = message_history - else: - # Bump timestamp so this conversation won't get cleaned up - message_history.timestamp = time.monotonic() - - # Clean up old histories - self._prune_old_histories() - - # Trim this message history to keep a maximum number of *user* messages + message_history: MessageHistory = MessageHistory( + [_convert_content(content) for content in chat_log.content] + ) max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) self._trim_history(message_history, max_messages) - # Add new user message - message_history.messages.append( - ollama.Message(role=MessageRole.USER.value, content=user_input.text) - ) - - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - {"messages": message_history.messages}, - ) - # Get response # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): @@ -269,77 +228,75 @@ class OllamaConversationEntity( ) except (ollama.RequestError, ollama.ResponseError) as err: _LOGGER.error("Unexpected error talking to Ollama server: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem talking to the Ollama server: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) + raise HomeAssistantError( + f"Sorry, I had a problem talking to the Ollama server: {err}" + ) from err response_message = response["message"] + content = response_message.get("content") + tool_calls = response_message.get("tool_calls") message_history.messages.append( ollama.Message( role=response_message["role"], - content=response_message.get("content"), - tool_calls=response_message.get("tool_calls"), + content=content, + tool_calls=tool_calls, ) ) - - tool_calls = response_message.get("tool_calls") - if not tool_calls or not llm_api: - break - - for tool_call in tool_calls: - tool_input = llm.ToolInput( + tool_inputs = [ + llm.ToolInput( tool_name=tool_call["function"]["name"], tool_args=_parse_tool_args(tool_call["function"]["arguments"]), ) - _LOGGER.debug( - "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args - ) + for tool_call in tool_calls or () + ] - try: - tool_response = await llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - tool_response = {"error": type(e).__name__} - if str(e): - tool_response["error_text"] = str(e) - - _LOGGER.debug("Tool response: %s", tool_response) - message_history.messages.append( + message_history.messages.extend( + [ ollama.Message( role=MessageRole.TOOL.value, - content=json.dumps(tool_response), + content=json.dumps(tool_response.tool_result), ) - ) + async for tool_response in chat_log.async_add_assistant_content( + conversation.AssistantContent( + agent_id=user_input.agent_id, + content=content, + tool_calls=tool_inputs or None, + ) + ) + ] + ) + + if not tool_calls: + break # Create intent response + intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(response_message["content"]) return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=chat_log.conversation_id ) - def _prune_old_histories(self) -> None: - """Remove old message histories.""" - now = time.monotonic() - self._history = { - conversation_id: message_history - for conversation_id, message_history in self._history.items() - if (now - message_history.timestamp) <= MAX_HISTORY_SECONDS - } - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: - """Trims excess messages from a single history.""" + """Trims excess messages from a single history. + + This sets the max history to allow a configurable size history may take + up in the context window. + + Note that some messages in the history may not be from ollama only, and + may come from other anents, so the assumptions here may not strictly hold, + but generally should be effective. + """ if max_messages < 1: # Keep all messages return - if message_history.num_user_messages >= max_messages: + # Ignore the in progress user message + num_previous_rounds = message_history.num_user_messages - 1 + if num_previous_rounds >= max_messages: # Trim history but keep system prompt (first message). # Every other message should be an assistant message, so keep 2x - # message objects. - num_keep = 2 * max_messages + # message objects. Also keep the last in progress user message + num_keep = 2 * max_messages + 1 drop_index = len(message_history.messages) - num_keep message_history.messages = [ message_history.messages[0] diff --git a/homeassistant/components/ollama/models.py b/homeassistant/components/ollama/models.py index 3b6fc958587..fd268664919 100644 --- a/homeassistant/components/ollama/models.py +++ b/homeassistant/components/ollama/models.py @@ -19,9 +19,6 @@ class MessageRole(StrEnum): class MessageHistory: """Chat message history.""" - timestamp: float - """Timestamp of last use in seconds.""" - messages: list[ollama.Message] """List of message history, including system prompt and assistant responses.""" diff --git a/tests/components/ollama/snapshots/test_conversation.ambr b/tests/components/ollama/snapshots/test_conversation.ambr index e4dd7cd00bb..93f3b03d9af 100644 --- a/tests/components/ollama/snapshots/test_conversation.ambr +++ b/tests/components/ollama/snapshots/test_conversation.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ - 'conversation_id': None, + 'conversation_id': '1234', 'response': IntentResponse( card=dict({ }), @@ -20,7 +20,7 @@ speech=dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Error preparing LLM API: API non-existing not found', + 'speech': 'Error preparing LLM API', }), }), speech_slots=dict({ diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index b8e299f5e77..df7c6beca72 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -325,7 +325,11 @@ async def test_unknown_hass_api( await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + hass, + "hello", + "1234", + Context(), + agent_id=mock_config_entry.entry_id, ) assert result == snapshot @@ -428,70 +432,17 @@ async def test_message_history_trimming( assert args[4].kwargs["messages"][5]["content"] == "message 5" -async def test_message_history_pruning( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component -) -> None: - """Test that old message histories are pruned.""" - with patch( - "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, - ): - # Create 3 different message histories - conversation_ids: list[str] = [] - for i in range(3): - result = await conversation.async_converse( - hass, - f"message {i + 1}", - conversation_id=None, - context=Context(), - agent_id=mock_config_entry.entry_id, - ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert isinstance(result.conversation_id, str) - conversation_ids.append(result.conversation_id) - - agent = conversation.get_agent_manager(hass).async_get_agent( - mock_config_entry.entry_id - ) - assert len(agent._history) == 3 - assert agent._history.keys() == set(conversation_ids) - - # Modify the timestamps of the first 2 histories so they will be pruned - # on the next cycle. - for conversation_id in conversation_ids[:2]: - # Move back 2 hours - agent._history[conversation_id].timestamp -= 2 * 60 * 60 - - # Next cycle - result = await conversation.async_converse( - hass, - "test message", - conversation_id=None, - context=Context(), - agent_id=mock_config_entry.entry_id, - ) - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( - result - ) - - # Only the most recent histories should remain - assert len(agent._history) == 2 - assert conversation_ids[-1] in agent._history - assert result.conversation_id in agent._history - - async def test_message_history_unlimited( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component ) -> None: """Test that message history is not trimmed when max_history = 0.""" conversation_id = "1234" + with ( patch( "ollama.AsyncClient.chat", return_value={"message": {"role": "assistant", "content": "test response"}}, - ), + ) as mock_chat, ): hass.config_entries.async_update_entry( mock_config_entry, options={ollama.CONF_MAX_HISTORY: 0} @@ -508,13 +459,13 @@ async def test_message_history_unlimited( result.response.response_type == intent.IntentResponseType.ACTION_DONE ), result - agent = conversation.get_agent_manager(hass).async_get_agent( - mock_config_entry.entry_id + args = mock_chat.call_args_list + assert len(args) == 100 + recorded_messages = args[-1].kwargs["messages"] + message_count = sum( + (message["role"] == "user") for message in recorded_messages ) - - assert len(agent._history) == 1 - assert conversation_id in agent._history - assert agent._history[conversation_id].num_user_messages == 100 + assert message_count == 100 async def test_error_handling( From 94677090684661c1984464cb9b8009278e5e6839 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 9 Feb 2025 23:38:58 +0100 Subject: [PATCH 0335/1941] Use generics for deprecation helpers (#138171) --- homeassistant/helpers/deprecation.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index f02c6507d02..375ec58c26f 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -244,35 +244,35 @@ def _print_deprecation_warning_internal_impl( ) -class DeprecatedConstant(NamedTuple): +class DeprecatedConstant[T](NamedTuple): """Deprecated constant.""" - value: Any + value: T replacement: str breaks_in_ha_version: str | None -class DeprecatedConstantEnum(NamedTuple): +class DeprecatedConstantEnum[T: (StrEnum | IntEnum | IntFlag)](NamedTuple): """Deprecated constant.""" - enum: StrEnum | IntEnum | IntFlag + enum: T breaks_in_ha_version: str | None -class DeprecatedAlias(NamedTuple): +class DeprecatedAlias[T](NamedTuple): """Deprecated alias.""" - value: Any + value: T replacement: str breaks_in_ha_version: str | None -class DeferredDeprecatedAlias: +class DeferredDeprecatedAlias[T]: """Deprecated alias with deferred evaluation of the value.""" def __init__( self, - value_fn: Callable[[], Any], + value_fn: Callable[[], T], replacement: str, breaks_in_ha_version: str | None, ) -> None: @@ -282,7 +282,7 @@ class DeferredDeprecatedAlias: self._value_fn = value_fn @functools.cached_property - def value(self) -> Any: + def value(self) -> T: """Return the value.""" return self._value_fn() From 0408e732d79b0a1b8c9ea278191f9fd347c3a91b Mon Sep 17 00:00:00 2001 From: Brynley McDonald Date: Mon, 10 Feb 2025 11:40:34 +1300 Subject: [PATCH 0336/1941] Add extra tests to `flick_electric` (#138017) Add extra tests to flick_electric --- tests/components/flick_electric/__init__.py | 17 +- tests/components/flick_electric/conftest.py | 105 +++ .../flick_electric/fixtures/accounts.json | 105 +++ .../fixtures/accounts_multi.json | 144 ++++ .../flick_electric/fixtures/rated_period.json | 112 +++ .../flick_electric/test_config_flow.py | 642 +++++------------- tests/components/flick_electric/test_init.py | 251 +++---- 7 files changed, 795 insertions(+), 581 deletions(-) create mode 100644 tests/components/flick_electric/conftest.py create mode 100644 tests/components/flick_electric/fixtures/accounts.json create mode 100644 tests/components/flick_electric/fixtures/accounts_multi.json create mode 100644 tests/components/flick_electric/fixtures/rated_period.json diff --git a/tests/components/flick_electric/__init__.py b/tests/components/flick_electric/__init__.py index 36936cad047..3632ce204aa 100644 --- a/tests/components/flick_electric/__init__.py +++ b/tests/components/flick_electric/__init__.py @@ -7,15 +7,26 @@ from homeassistant.components.flick_electric.const import ( CONF_SUPPLY_NODE_REF, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry CONF = { - CONF_USERNAME: "test-username", + CONF_USERNAME: "9973debf-963f-49b0-9a73-ba9c3400cbed@anonymised.example.com", CONF_PASSWORD: "test-password", - CONF_ACCOUNT_ID: "1234", - CONF_SUPPLY_NODE_REF: "123", + CONF_ACCOUNT_ID: "134800", + CONF_SUPPLY_NODE_REF: "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef8299", } +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 _mock_flick_price(): return FlickPrice( { diff --git a/tests/components/flick_electric/conftest.py b/tests/components/flick_electric/conftest.py new file mode 100644 index 00000000000..2abfafab55d --- /dev/null +++ b/tests/components/flick_electric/conftest.py @@ -0,0 +1,105 @@ +"""Flick Electric tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import json_api_doc +from pyflick import FlickPrice +import pytest + +from homeassistant.components.flick_electric.const import CONF_ACCOUNT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from . import CONF + +from tests.common import MockConfigEntry, load_json_value_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="123 Fake Street, Newtown, Wellington 6021", + data={**CONF}, + version=2, + entry_id="974e52a5c0724d17b7ed876dd6ff4bc8", + unique_id=CONF[CONF_ACCOUNT_ID], + ) + + +@pytest.fixture +def mock_old_config_entry() -> MockConfigEntry: + """Mock an outdated config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + title=CONF[CONF_USERNAME], + unique_id=CONF[CONF_USERNAME], + version=1, + ) + + +@pytest.fixture +def mock_flick_client() -> Generator[AsyncMock]: + """Mock a Flick Electric client.""" + with ( + patch( + "homeassistant.components.flick_electric.FlickAPI", + autospec=True, + ) as mock_api, + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI", + new=mock_api, + ), + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + ): + api = mock_api.return_value + + api.getCustomerAccounts.return_value = json_api_doc.deserialize( + load_json_value_fixture("accounts.json", DOMAIN) + ) + api.getPricing.return_value = FlickPrice( + json_api_doc.deserialize( + load_json_value_fixture("rated_period.json", DOMAIN) + ) + ) + + yield api + + +@pytest.fixture +def mock_flick_client_multiple() -> Generator[AsyncMock]: + """Mock a Flick Electric with multiple accounts.""" + with ( + patch( + "homeassistant.components.flick_electric.FlickAPI", + autospec=True, + ) as mock_api, + patch( + "homeassistant.components.flick_electric.config_flow.FlickAPI", + new=mock_api, + ), + patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), + ): + api = mock_api.return_value + + api.getCustomerAccounts.return_value = json_api_doc.deserialize( + load_json_value_fixture("accounts_multi.json", DOMAIN) + ) + api.getPricing.return_value = FlickPrice( + json_api_doc.deserialize( + load_json_value_fixture("rated_period.json", DOMAIN) + ) + ) + + yield api diff --git a/tests/components/flick_electric/fixtures/accounts.json b/tests/components/flick_electric/fixtures/accounts.json new file mode 100644 index 00000000000..a1c08ecd7c0 --- /dev/null +++ b/tests/components/flick_electric/fixtures/accounts.json @@ -0,0 +1,105 @@ +{ + "data": [ + { + "id": "134800", + "type": "customer_account", + "attributes": { + "account_number": "10123404", + "billing_name": "9973debf-963f-49b0-9a73-Ba9c3400cbed@Anonymised Example", + "billing_email": null, + "address": "123 Fake Street, Newtown, Wellington 6021", + "brand": "flick", + "vulnerability_state": "none", + "medical_dependency": false, + "status": "active", + "start_at": "2023-03-02T00:00:00.000+13:00", + "end_at": null, + "application_id": "5dfc4978-07de-4d18-8ef7-055603805ba6", + "active": true, + "on_join_journey": false, + "placeholder": false + }, + "relationships": { + "user": { + "data": { + "id": "106676", + "type": "customer_user" + } + }, + "sign_up": { + "data": { + "id": "877039", + "type": "customer_sign_up" + } + }, + "main_customer": { + "data": { + "id": "108335", + "type": "customer_customer" + } + }, + "main_consumer": { + "data": { + "id": "108291", + "type": "customer_icp_consumer" + } + }, + "primary_contact": { + "data": { + "id": "121953", + "type": "customer_contact" + } + }, + "default_payment_method": { + "data": { + "id": "602801", + "type": "customer_payment_method" + } + }, + "phone_numbers": { + "data": [ + { + "id": "111604", + "type": "customer_phone_number" + } + ] + }, + "payment_methods": { + "data": [ + { + "id": "602801", + "type": "customer_payment_method" + } + ] + } + } + } + ], + "included": [ + { + "id": "108291", + "type": "customer_icp_consumer", + "attributes": { + "start_date": "2023-03-02", + "end_date": null, + "icp_number": "0001234567UNB12", + "supply_node_ref": "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef8299", + "physical_address": "123 FAKE STREET,NEWTOWN,WELLINGTON,6021" + } + } + ], + "meta": { + "verb": "get", + "type": "customer_account", + "params": [], + "permission": { + "uri": "flick:customer_app:resource:account:list", + "data_context": null + }, + "host": "https://api.flickuat.com", + "service": "customer", + "path": "/accounts", + "description": "Returns the accounts viewable by the current user", + "respond_with_array": true + } +} diff --git a/tests/components/flick_electric/fixtures/accounts_multi.json b/tests/components/flick_electric/fixtures/accounts_multi.json new file mode 100644 index 00000000000..7c1f3fba2ef --- /dev/null +++ b/tests/components/flick_electric/fixtures/accounts_multi.json @@ -0,0 +1,144 @@ +{ + "data": [ + { + "id": "134800", + "type": "customer_account", + "attributes": { + "account_number": "10123404", + "billing_name": "9973debf-963f-49b0-9a73-Ba9c3400cbed@Anonymised Example", + "billing_email": null, + "address": "123 Fake Street, Newtown, Wellington 6021", + "brand": "flick", + "vulnerability_state": "none", + "medical_dependency": false, + "status": "active", + "start_at": "2023-03-02T00:00:00.000+13:00", + "end_at": null, + "application_id": "5dfc4978-07de-4d18-8ef7-055603805ba6", + "active": true, + "on_join_journey": false, + "placeholder": false + }, + "relationships": { + "user": { + "data": { + "id": "106676", + "type": "customer_user" + } + }, + "sign_up": { + "data": { + "id": "877039", + "type": "customer_sign_up" + } + }, + "main_customer": { + "data": { + "id": "108335", + "type": "customer_customer" + } + }, + "main_consumer": { + "data": { + "id": "108291", + "type": "customer_icp_consumer" + } + }, + "primary_contact": { + "data": { + "id": "121953", + "type": "customer_contact" + } + }, + "default_payment_method": { + "data": { + "id": "602801", + "type": "customer_payment_method" + } + }, + "phone_numbers": { + "data": [ + { + "id": "111604", + "type": "customer_phone_number" + } + ] + }, + "payment_methods": { + "data": [ + { + "id": "602801", + "type": "customer_payment_method" + } + ] + } + } + }, + { + "id": "123456", + "type": "customer_account", + "attributes": { + "account_number": "123123123", + "billing_name": "9973debf-963f-49b0-9a73-Ba9c3400cbed@Anonymised Example", + "billing_email": null, + "address": "456 Fake Street, Newtown, Wellington 6021", + "brand": "flick", + "vulnerability_state": "none", + "medical_dependency": false, + "status": "active", + "start_at": "2023-03-02T00:00:00.000+13:00", + "end_at": null, + "application_id": "5dfc4978-07de-4d18-8ef7-055603805ba6", + "active": true, + "on_join_journey": false, + "placeholder": false + }, + "relationships": { + "main_consumer": { + "data": { + "id": "11223344", + "type": "customer_icp_consumer" + } + } + } + } + ], + "included": [ + { + "id": "108291", + "type": "customer_icp_consumer", + "attributes": { + "start_date": "2023-03-02", + "end_date": null, + "icp_number": "0001234567UNB12", + "supply_node_ref": "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef8299", + "physical_address": "123 FAKE STREET,NEWTOWN,WELLINGTON,6021" + } + }, + { + "id": "11223344", + "type": "customer_icp_consumer", + "attributes": { + "start_date": "2023-03-02", + "end_date": null, + "icp_number": "9991234567UNB12", + "supply_node_ref": "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef1234", + "physical_address": "456 FAKE STREET,NEWTOWN,WELLINGTON,6021" + } + } + ], + "meta": { + "verb": "get", + "type": "customer_account", + "params": [], + "permission": { + "uri": "flick:customer_app:resource:account:list", + "data_context": null + }, + "host": "https://api.flickuat.com", + "service": "customer", + "path": "/accounts", + "description": "Returns the accounts viewable by the current user", + "respond_with_array": true + } +} diff --git a/tests/components/flick_electric/fixtures/rated_period.json b/tests/components/flick_electric/fixtures/rated_period.json new file mode 100644 index 00000000000..8e6ce96a9b7 --- /dev/null +++ b/tests/components/flick_electric/fixtures/rated_period.json @@ -0,0 +1,112 @@ +{ + "data": { + "id": "_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_rated_period", + "attributes": { + "start_at": "2025-02-09T05:30:00.000Z", + "end_at": "2025-02-09T05:59:59.000Z", + "status": "final", + "cost": "0.20011", + "import_cost": "0.20011", + "export_cost": null, + "cost_unit": "NZD", + "quantity": "1.0", + "import_quantity": "1.0", + "export_quantity": null, + "quantity_unit": "kwh", + "renewable_quantity": null, + "generation_price_contract": null + }, + "relationships": { + "components": { + "data": [ + { + "id": "213507464_1_kwh_generation_UN_24_default_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_component" + }, + { + "id": "213507464_1_kwh_network_UN_24_offpeak_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_component" + } + ] + } + } + }, + "included": [ + { + "id": "213507464_1_kwh_generation_UN_24_default_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_component", + "attributes": { + "charge_method": "kwh", + "charge_setter": "generation", + "value": "0.20011", + "quantity": "1.0", + "unit_code": "NZD", + "charge_per": "kwh", + "flow_direction": "import", + "content_code": "UN", + "hours_of_availability": 24, + "channel_number": 1, + "meter_serial_number": "213507464", + "price_name": "default", + "applicable_periods": [], + "single_unit_price": "0.20011", + "billable": true, + "renewable_quantity": null, + "generation_price_contract": "FLICK_FLAT_2024_04_01_midpoint" + } + }, + { + "id": "213507464_1_kwh_network_UN_24_offpeak_2025-02-09 05:30:00 UTC..2025-02-09 05:59:59 UTC", + "type": "rating_component", + "attributes": { + "charge_method": "kwh", + "charge_setter": "network", + "value": "0.0406", + "quantity": "1.0", + "unit_code": "NZD", + "charge_per": "kwh", + "flow_direction": "import", + "content_code": "UN", + "hours_of_availability": 24, + "channel_number": 1, + "meter_serial_number": "213507464", + "price_name": "offpeak", + "applicable_periods": [], + "single_unit_price": "0.0406", + "billable": false, + "renewable_quantity": null, + "generation_price_contract": "FLICK_FLAT_2024_04_01_midpoint" + } + } + ], + "meta": { + "verb": "get", + "type": "rating_rated_period", + "params": [ + { + "name": "supply_node_ref", + "type": "String", + "description": "The supply node to rate", + "example": "/network/nz/supply_nodes/bccd6f52-448b-4edf-a0c1-459ee67d215b", + "required": true + }, + { + "name": "as_at", + "type": "DateTime", + "description": "The time to rate the supply node at; defaults to the current time", + "example": "2023-04-01T15:20:15-07:00", + "required": false + } + ], + "permission": { + "uri": "flick:rating:resource:rated_period:show", + "data_context": "supply_node" + }, + "host": "https://api.flickuat.com", + "service": "rating", + "path": "/rated_period", + "description": "Fetch a rated period for a supply node in a specific point in time", + "respond_with_array": false + } +} diff --git a/tests/components/flick_electric/test_config_flow.py b/tests/components/flick_electric/test_config_flow.py index 7ac605f1c8c..c14303278a3 100644 --- a/tests/components/flick_electric/test_config_flow.py +++ b/tests/components/flick_electric/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Flick Electric config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pyflick.authentication import AuthException from pyflick.types import APIException @@ -16,10 +16,16 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import CONF, _mock_flick_price +from . import CONF, setup_integration from tests.common import MockConfigEntry +# From test fixtures +ACCOUNT_NAME_1 = "123 Fake Street, Newtown, Wellington 6021" +ACCOUNT_NAME_2 = "456 Fake Street, Newtown, Wellington 6021" +ACCOUNT_ID_2 = "123456" +SUPPLY_NODE_REF_2 = "/network/nz/supply_nodes/ed7617df-4b10-4c8a-a05d-deadbeef1234" + async def _flow_submit(hass: HomeAssistant) -> ConfigFlowResult: return await hass.config_entries.flow.async_init( @@ -32,7 +38,7 @@ async def _flow_submit(hass: HomeAssistant) -> ConfigFlowResult: ) -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_flick_client: AsyncMock) -> None: """Test we get the form with only one, with no account picker.""" result = await hass.config_entries.flow.async_init( @@ -41,48 +47,24 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - } - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - patch( - "homeassistant.components.flick_electric.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "123 Fake St" + assert result2["title"] == ACCOUNT_NAME_1 assert result2["data"] == CONF - assert result2["result"].unique_id == "1234" - assert len(mock_setup_entry.mock_calls) == 1 + assert result2["result"].unique_id == CONF[CONF_ACCOUNT_ID] -async def test_form_multi_account(hass: HomeAssistant) -> None: +async def test_form_multi_account( + hass: HomeAssistant, mock_flick_client_multiple: AsyncMock +) -> None: """Test the form when multiple accounts are available.""" result = await hass.config_entries.flow.async_init( @@ -91,272 +73,114 @@ async def test_form_multi_account(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - patch( - "homeassistant.components.flick_electric.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "select_account" - assert len(mock_setup_entry.mock_calls) == 0 + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {"account_id": "5678"}, - ) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_ACCOUNT_ID: ACCOUNT_ID_2}, + ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "456 Fake St" - assert result3["data"] == { - **CONF, - CONF_SUPPLY_NODE_REF: "456", - CONF_ACCOUNT_ID: "5678", - } - assert result3["result"].unique_id == "5678" - assert len(mock_setup_entry.mock_calls) == 1 + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == ACCOUNT_NAME_2 + assert result3["data"] == { + **CONF, + CONF_SUPPLY_NODE_REF: SUPPLY_NODE_REF_2, + CONF_ACCOUNT_ID: ACCOUNT_ID_2, + } + assert result3["result"].unique_id == ACCOUNT_ID_2 -async def test_reauth_token(hass: HomeAssistant) -> None: +async def test_reauth_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: """Test reauth flow when username/password is wrong.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={**CONF}, - title="123 Fake St", - unique_id="1234", - version=2, + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + side_effect=AuthException, + ): + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: CONF[CONF_USERNAME], CONF_PASSWORD: CONF[CONF_PASSWORD]}, ) - entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - side_effect=AuthException, - ), - ): - result = await entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - assert result["step_id"] == "user" - - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - patch( - "homeassistant.config_entries.ConfigEntries.async_update_entry", - return_value=True, - ) as mock_update_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - assert len(mock_update_entry.mock_calls) > 0 + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" -async def test_form_reauth_migrate(hass: HomeAssistant) -> None: +async def test_form_reauth_migrate( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: """Test reauth flow for v1 with single account.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - title="123 Fake St", - unique_id="test-username", - version=1, - ) - entry.add_to_hass(hass) + mock_old_config_entry.add_to_hass(hass) + result = await mock_old_config_entry.start_reauth_flow(hass) - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - result = await entry.start_reauth_flow(hass) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" - assert entry.version == 2 - assert entry.unique_id == "1234" - assert entry.data == CONF + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_old_config_entry.version == 2 + assert mock_old_config_entry.unique_id == CONF[CONF_ACCOUNT_ID] + assert mock_old_config_entry.data == CONF -async def test_form_reauth_migrate_multi_account(hass: HomeAssistant) -> None: +async def test_form_reauth_migrate_multi_account( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + mock_flick_client_multiple: AsyncMock, +) -> None: """Test the form when multiple accounts are available.""" + mock_old_config_entry.add_to_hass(hass) + result = await mock_old_config_entry.start_reauth_flow(hass) - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - title="123 Fake St", - unique_id="test-username", - version=1, + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "select_account" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ACCOUNT_ID: CONF[CONF_ACCOUNT_ID]}, ) - entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - result = await entry.start_reauth_flow(hass) + await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "select_account" + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"account_id": "5678"}, - ) - - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - - assert entry.version == 2 - assert entry.unique_id == "5678" - assert entry.data == { - **CONF, - CONF_ACCOUNT_ID: "5678", - CONF_SUPPLY_NODE_REF: "456", - } + assert mock_old_config_entry.version == 2 + assert mock_old_config_entry.unique_id == CONF[CONF_ACCOUNT_ID] + assert mock_old_config_entry.data == CONF -async def test_form_duplicate_account(hass: HomeAssistant) -> None: +async def test_form_duplicate_account( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: """Test uniqueness for account_id.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={**CONF, CONF_ACCOUNT_ID: "1234", CONF_SUPPLY_NODE_REF: "123"}, - title="123 Fake St", - unique_id="1234", - version=2, - ) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - } - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - result = await _flow_submit(hass) + result = await _flow_submit(hass) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -398,7 +222,9 @@ async def test_form_generic_exception(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "unknown"} -async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_select_account_cannot_connect( + hass: HomeAssistant, mock_flick_client_multiple: AsyncMock +) -> None: """Test we handle connection errors for select account.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -406,38 +232,16 @@ async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - side_effect=APIException, - ), + with patch.object( + mock_flick_client_multiple, + "getPricing", + side_effect=APIException, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], }, ) await hass.async_block_till_done() @@ -447,7 +251,7 @@ async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - {"account_id": "5678"}, + {CONF_ACCOUNT_ID: CONF[CONF_ACCOUNT_ID]}, ) assert result3["type"] is FlowResultType.FORM @@ -455,7 +259,9 @@ async def test_form_select_account_cannot_connect(hass: HomeAssistant) -> None: assert result3["errors"] == {"base": "cannot_connect"} -async def test_form_select_account_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_select_account_invalid_auth( + hass: HomeAssistant, mock_flick_client_multiple: AsyncMock +) -> None: """Test we handle auth errors for select account.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -463,65 +269,41 @@ async def test_form_select_account_invalid_auth(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - side_effect=AuthException, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "select_account" + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" with ( patch( "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", side_effect=AuthException, ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + patch.object( + mock_flick_client_multiple, + "getPricing", side_effect=AuthException, ), ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - {"account_id": "5678"}, + {CONF_ACCOUNT_ID: CONF[CONF_ACCOUNT_ID]}, ) assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "no_permissions" -async def test_form_select_account_failed_to_connect(hass: HomeAssistant) -> None: +async def test_form_select_account_failed_to_connect( + hass: HomeAssistant, mock_flick_client_multiple: AsyncMock +) -> None: """Test we handle connection errors for select account.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -529,115 +311,56 @@ async def test_form_select_account_failed_to_connect(hass: HomeAssistant) -> Non assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - side_effect=AuthException, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], + }, + ) + await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "select_account" + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "select_account" with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", + patch.object( + mock_flick_client_multiple, + "getCustomerAccounts", side_effect=APIException, ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", + patch.object( + mock_flick_client_multiple, + "getPricing", side_effect=APIException, ), ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - {"account_id": "5678"}, + {CONF_ACCOUNT_ID: CONF[CONF_ACCOUNT_ID]}, ) assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": "cannot_connect"} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - patch( - "homeassistant.components.flick_electric.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - {"account_id": "5678"}, - ) + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_ACCOUNT_ID: ACCOUNT_ID_2}, + ) - assert result4["type"] is FlowResultType.CREATE_ENTRY - assert result4["title"] == "456 Fake St" - assert result4["data"] == { - **CONF, - CONF_SUPPLY_NODE_REF: "456", - CONF_ACCOUNT_ID: "5678", - } - assert result4["result"].unique_id == "5678" - assert len(mock_setup_entry.mock_calls) == 1 + assert result4["type"] is FlowResultType.CREATE_ENTRY + assert result4["title"] == ACCOUNT_NAME_2 + assert result4["data"] == { + **CONF, + CONF_SUPPLY_NODE_REF: SUPPLY_NODE_REF_2, + CONF_ACCOUNT_ID: ACCOUNT_ID_2, + } + assert result4["result"].unique_id == ACCOUNT_ID_2 -async def test_form_select_account_no_accounts(hass: HomeAssistant) -> None: +async def test_form_select_account_no_accounts( + hass: HomeAssistant, mock_flick_client: AsyncMock +) -> None: """Test we handle connection errors for select account.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -645,28 +368,23 @@ async def test_form_select_account_no_accounts(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.config_flow.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "closed", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - ], - ), + with patch.object( + mock_flick_client, + "getCustomerAccounts", + return_value=[ + { + "id": "1234", + "status": "closed", + "address": "123 Fake St", + "main_consumer": {"supply_node_ref": "123"}, + }, + ], ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_USERNAME: CONF[CONF_USERNAME], + CONF_PASSWORD: CONF[CONF_PASSWORD], }, ) await hass.async_block_till_done() diff --git a/tests/components/flick_electric/test_init.py b/tests/components/flick_electric/test_init.py index e022b6e03bc..d420a78ccfc 100644 --- a/tests/components/flick_electric/test_init.py +++ b/tests/components/flick_electric/test_init.py @@ -1,135 +1,154 @@ """Test the Flick Electric config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from pyflick.authentication import AuthException +import jwt +from pyflick.types import APIException, AuthException +import pytest -from homeassistant.components.flick_electric.const import CONF_ACCOUNT_ID, DOMAIN +from homeassistant.components.flick_electric import CONF_ID_TOKEN, HassFlickAuth +from homeassistant.components.flick_electric.const import ( + CONF_ACCOUNT_ID, + CONF_TOKEN_EXPIRY, +) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import CONF, _mock_flick_price +from . import CONF, setup_integration from tests.common import MockConfigEntry - -async def test_init_auth_failure_triggers_auth(hass: HomeAssistant) -> None: - """Test reauth flow is triggered when username/password is wrong.""" - with ( - patch( - "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", - side_effect=AuthException, - ), - ): - entry = MockConfigEntry( - domain=DOMAIN, - data={**CONF}, - title="123 Fake St", - unique_id="1234", - version=2, - ) - entry.add_to_hass(hass) - - # Ensure setup fails - assert not await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR - - # Ensure reauth flow is triggered - await hass.async_block_till_done() - assert len(hass.config_entries.flow.async_progress()) == 1 +NEW_TOKEN = jwt.encode( + {"exp": dt_util.now().timestamp() + 86400}, "secret", algorithm="HS256" +) +EXISTING_TOKEN = jwt.encode( + {"exp": dt_util.now().timestamp() + 3600}, "secret", algorithm="HS256" +) +EXPIRED_TOKEN = jwt.encode( + {"exp": dt_util.now().timestamp() - 3600}, "secret", algorithm="HS256" +) -async def test_init_migration_single_account(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "config_entry_state"), + [ + (AuthException, ConfigEntryState.SETUP_ERROR), + (APIException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_init_auth_failure_triggers_auth( + hass: HomeAssistant, + mock_flick_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + config_entry_state: ConfigEntryState, +) -> None: + """Test integration handles initialisation errors.""" + with patch.object(mock_flick_client, "getPricing", side_effect=exception): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state == config_entry_state + + +async def test_init_migration_single_account( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: """Test migration with single account.""" - with ( - patch( - "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - } - ], - ), - patch( - "homeassistant.components.flick_electric.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: CONF[CONF_USERNAME], - CONF_PASSWORD: CONF[CONF_PASSWORD], - }, - title=CONF_USERNAME, - unique_id=CONF_USERNAME, - version=1, - ) - entry.add_to_hass(hass) + await setup_integration(hass, mock_old_config_entry) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert len(hass.config_entries.flow.async_progress()) == 0 - assert entry.state is ConfigEntryState.LOADED - assert entry.version == 2 - assert entry.unique_id == CONF[CONF_ACCOUNT_ID] - assert entry.data == CONF + assert len(hass.config_entries.flow.async_progress()) == 0 + assert mock_old_config_entry.state is ConfigEntryState.LOADED + assert mock_old_config_entry.version == 2 + assert mock_old_config_entry.unique_id == CONF[CONF_ACCOUNT_ID] + assert mock_old_config_entry.data == CONF -async def test_init_migration_multi_account_reauth(hass: HomeAssistant) -> None: +async def test_init_migration_multi_account_reauth( + hass: HomeAssistant, + mock_old_config_entry: MockConfigEntry, + mock_flick_client_multiple: AsyncMock, +) -> None: """Test migration triggers reauth with multiple accounts.""" - with ( - patch( - "homeassistant.components.flick_electric.HassFlickAuth.async_get_access_token", - return_value="123456789abcdef", - ), - patch( - "homeassistant.components.flick_electric.FlickAPI.getCustomerAccounts", - return_value=[ - { - "id": "1234", - "status": "active", - "address": "123 Fake St", - "main_consumer": {"supply_node_ref": "123"}, - }, - { - "id": "5678", - "status": "active", - "address": "456 Fake St", - "main_consumer": {"supply_node_ref": "456"}, - }, - ], - ), - patch( - "homeassistant.components.flick_electric.FlickAPI.getPricing", - return_value=_mock_flick_price(), - ), - ): - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_USERNAME: CONF[CONF_USERNAME], - CONF_PASSWORD: CONF[CONF_PASSWORD], - }, - title=CONF_USERNAME, - unique_id=CONF_USERNAME, - version=1, - ) - entry.add_to_hass(hass) + await setup_integration(hass, mock_old_config_entry) - # ensure setup fails - assert not await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.MIGRATION_ERROR - await hass.async_block_till_done() + assert mock_old_config_entry.state is ConfigEntryState.MIGRATION_ERROR - # Ensure reauth flow is triggered - await hass.async_block_till_done() - assert len(hass.config_entries.flow.async_progress()) == 1 + # Ensure reauth flow is triggered + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 + + +async def test_fetch_fresh_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: + """Test fetching a fresh token.""" + await setup_integration(hass, mock_config_entry) + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.get_new_token", + return_value={CONF_ID_TOKEN: NEW_TOKEN}, + ) as mock_get_new_token: + auth = HassFlickAuth(hass, mock_config_entry) + + assert await auth.async_get_access_token() == NEW_TOKEN + assert mock_get_new_token.call_count == 1 + + +async def test_reuse_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: + """Test reusing entry token.""" + await setup_integration(hass, mock_config_entry) + + hass.config_entries.async_update_entry( + mock_config_entry, + data={ + **mock_config_entry.data, + CONF_ACCESS_TOKEN: {CONF_ID_TOKEN: EXISTING_TOKEN}, + CONF_TOKEN_EXPIRY: dt_util.now().timestamp() + 3600, + }, + ) + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.get_new_token", + return_value={CONF_ID_TOKEN: NEW_TOKEN}, + ) as mock_get_new_token: + auth = HassFlickAuth(hass, mock_config_entry) + + assert await auth.async_get_access_token() == EXISTING_TOKEN + assert mock_get_new_token.call_count == 0 + + +async def test_fetch_expired_token( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flick_client: AsyncMock, +) -> None: + """Test fetching token when existing token is expired.""" + await setup_integration(hass, mock_config_entry) + + hass.config_entries.async_update_entry( + mock_config_entry, + data={ + **mock_config_entry.data, + CONF_ACCESS_TOKEN: {CONF_ID_TOKEN: EXPIRED_TOKEN}, + CONF_TOKEN_EXPIRY: dt_util.now().timestamp() - 3600, + }, + ) + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.get_new_token", + return_value={CONF_ID_TOKEN: NEW_TOKEN}, + ) as mock_get_new_token: + auth = HassFlickAuth(hass, mock_config_entry) + + assert await auth.async_get_access_token() == NEW_TOKEN + assert mock_get_new_token.call_count == 1 From 0017192ca42cac194a2b0ac350bcfb01063c7b27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Feb 2025 16:44:17 -0600 Subject: [PATCH 0337/1941] Bump google-cloud-pubsub to 2.28.0 (#137742) changelog: https://github.com/googleapis/python-pubsub/compare/v2.23.0...v2.28.0 getting this updates so it will be a smaller bump once protobuf 6 is supported https://github.com/home-assistant/core/pull/137736 --- homeassistant/components/google_pubsub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index 9ea747898b2..d3e57c26e39 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_pubsub", "iot_class": "cloud_push", "quality_scale": "legacy", - "requirements": ["google-cloud-pubsub==2.23.0"] + "requirements": ["google-cloud-pubsub==2.28.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ce5d60c37cf..c56bfedddbb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1024,7 +1024,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.23.0 +google-cloud-pubsub==2.28.0 # homeassistant.components.google_cloud google-cloud-speech==2.27.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b13a2d677e6..a29e2acc67d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -874,7 +874,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.23.0 +google-cloud-pubsub==2.28.0 # homeassistant.components.google_cloud google-cloud-speech==2.27.0 From dafc331e8526e14f4dd8b0f851a5cda85ccba90d Mon Sep 17 00:00:00 2001 From: William Scanlon <6432770+w1ll1am23@users.noreply.github.com> Date: Sun, 9 Feb 2025 17:45:33 -0500 Subject: [PATCH 0338/1941] Bump pyeconet to 0.1.27 (#136400) --- homeassistant/components/econet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index bda52ee3d07..86e3b3527f0 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.26"] + "requirements": ["pyeconet==0.1.27"] } diff --git a/requirements_all.txt b/requirements_all.txt index c56bfedddbb..e0bbbf69a60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1912,7 +1912,7 @@ pyebox==1.1.4 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.26 +pyeconet==0.1.27 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a29e2acc67d..bcf50f76bc4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1559,7 +1559,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.26 +pyeconet==0.1.27 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 From c8b4e62710ab70ee2f198fc99976f03af521c870 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sun, 9 Feb 2025 23:50:03 +0100 Subject: [PATCH 0339/1941] Add battery level sensor for ViCare zigbee devices (#137813) * add battery level sensor * add uom * adapt test case --- homeassistant/components/vicare/sensor.py | 8 +++ .../vicare/fixtures/RoomSensor1.json | 18 +++++++ .../vicare/snapshots/test_sensor.ambr | 51 +++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index c99e7857d9b..56a95d5f513 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -883,6 +883,14 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_getter=lambda api: api.getSeasonalPerformanceFactorHeating(), ), + ViCareSensorEntityDescription( + key="battery_level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + value_getter=lambda api: api.getBatteryLevel(), + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( diff --git a/tests/components/vicare/fixtures/RoomSensor1.json b/tests/components/vicare/fixtures/RoomSensor1.json index b970e54a48c..6c2f38db8d1 100644 --- a/tests/components/vicare/fixtures/RoomSensor1.json +++ b/tests/components/vicare/fixtures/RoomSensor1.json @@ -51,6 +51,24 @@ "timestamp": "2024-03-01T04:40:59.911Z", "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/zigbee-d87a3bfffe5d844a/features/device.name" }, + { + "apiVersion": 1, + "commands": {}, + "deviceId": "zigbee-d87a3bfffe5d844a", + "feature": "device.power.battery", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "level": { + "type": "number", + "unit": "percent", + "value": 89 + } + }, + "timestamp": "2025-02-03T02:30:52.279Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/zigbee-d87a3bfffe5d844a/features/device.power.battery" + }, { "apiVersion": 1, "commands": {}, diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index ace22391797..d842ea0b299 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -2585,6 +2585,57 @@ 'state': 'permanent', }) # --- +# name: test_room_sensors[sensor.model0_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.model0_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'gateway0_zigbee_d87a3bfffe5d844a-battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_room_sensors[sensor.model0_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'model0 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.model0_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '89', + }) +# --- # name: test_room_sensors[sensor.model0_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 31d2d968c4b1f80ffd239aea14c2e8049499d39d Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 9 Feb 2025 23:01:53 +0000 Subject: [PATCH 0340/1941] Add optional media description to Mastodon post action (#137224) Add optional media description --- homeassistant/components/mastodon/const.py | 1 + homeassistant/components/mastodon/services.py | 10 +++++++++- homeassistant/components/mastodon/services.yaml | 4 ++++ homeassistant/components/mastodon/strings.json | 4 ++++ tests/components/mastodon/test_services.py | 17 +++++++++++++++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index b7e86eaad5a..a4af49a27a6 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -26,3 +26,4 @@ ATTR_VISIBILITY = "visibility" ATTR_CONTENT_WARNING = "content_warning" ATTR_MEDIA_WARNING = "media_warning" ATTR_MEDIA = "media" +ATTR_MEDIA_DESCRIPTION = "media_description" diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 2a919e5fa5f..7ab351f8c29 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -16,6 +16,7 @@ from .const import ( ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, + ATTR_MEDIA_DESCRIPTION, ATTR_MEDIA_WARNING, ATTR_STATUS, ATTR_VISIBILITY, @@ -42,6 +43,7 @@ SERVICE_POST_SCHEMA = vol.Schema( 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_DESCRIPTION): str, vol.Optional(ATTR_MEDIA_WARNING): bool, } ) @@ -81,6 +83,7 @@ def setup_services(hass: HomeAssistant) -> None: ) spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING) media_path: str | None = call.data.get(ATTR_MEDIA) + media_description: str | None = call.data.get(ATTR_MEDIA_DESCRIPTION) media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING) await hass.async_add_executor_job( @@ -91,6 +94,7 @@ def setup_services(hass: HomeAssistant) -> None: visibility=visibility, spoiler_text=spoiler_text, media_path=media_path, + media_description=media_description, sensitive=media_warning, ) ) @@ -112,9 +116,12 @@ def setup_services(hass: HomeAssistant) -> None: ) media_type = get_media_type(media_path) + media_description = kwargs.get("media_description") try: media_data = client.media_post( - media_file=media_path, mime_type=media_type + media_file=media_path, + mime_type=media_type, + description=media_description, ) except MastodonAPIError as err: @@ -125,6 +132,7 @@ def setup_services(hass: HomeAssistant) -> None: ) from err kwargs.pop("media_path", None) + kwargs.pop("media_description", None) try: media_ids: str | None = None diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index 161a0d152ca..206dc36c1a2 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -24,6 +24,10 @@ post: media: selector: text: + media_description: + required: false + selector: + text: media_warning: required: true selector: diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 87858f768e4..24a4247636d 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -89,6 +89,10 @@ "name": "Media", "description": "Attach an image or video to the post." }, + "media_description": { + "name": "Media description", + "description": "If an image or video is attached, will add a description for this media for people with visual impairments." + }, "media_warning": { "name": "Media warning", "description": "If an image or video is attached, will mark the media as sensitive (default: no media warning)." diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index b958bcff74c..4dafa9a8e5b 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -9,6 +9,7 @@ from homeassistant.components.mastodon.const import ( ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, + ATTR_MEDIA_DESCRIPTION, ATTR_STATUS, ATTR_VISIBILITY, DOMAIN, @@ -75,6 +76,21 @@ from tests.common import MockConfigEntry "sensitive": None, }, ), + ( + { + ATTR_STATUS: "test toot", + ATTR_CONTENT_WARNING: "Spoiler", + ATTR_MEDIA: "/image.jpg", + ATTR_MEDIA_DESCRIPTION: "A test image", + }, + { + "status": "test toot", + "spoiler_text": "Spoiler", + "visibility": None, + "media_ids": "1", + "sensitive": None, + }, + ), ], ) async def test_service_post( @@ -128,6 +144,7 @@ async def test_service_post( "spoiler_text": "Spoiler", "visibility": None, "media_ids": "1", + "media_description": None, "sensitive": None, }, ), From 2f121874987b5f19aed6b5769b9880c5322d95d0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 10 Feb 2025 00:04:00 +0100 Subject: [PATCH 0341/1941] Replace duplicate keys with reference, improve field description (#138123) - replace two fan_speed.name fields with references (analog to the fan_speed.description fields) - make the description field a little more informative (it presents a slider from 0 to 100 %) --- homeassistant/components/vallox/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index 8a30ed4ad01..f00206826d3 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -110,7 +110,7 @@ "fields": { "fan_speed": { "name": "Fan speed", - "description": "Fan speed." + "description": "Relative speed of the built-in fans." } } }, @@ -119,7 +119,7 @@ "description": "Sets the fan speed of the Away profile.", "fields": { "fan_speed": { - "name": "Fan speed", + "name": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::name%]", "description": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::description%]" } } @@ -129,7 +129,7 @@ "description": "Sets the fan speed of the Boost profile.", "fields": { "fan_speed": { - "name": "Fan speed", + "name": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::name%]", "description": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::description%]" } } From c2bb376c43ccf0bfd0d20cefa458fcb7c3a45361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 10 Feb 2025 00:31:55 +0000 Subject: [PATCH 0342/1941] Handle generic agent exceptions when getting and deleting backups (#138145) * Handle generic agent exceptions when getting backups * Update hassio test * Update delete_backup --- homeassistant/components/backup/manager.py | 31 ++- .../backup/snapshots/test_websocket.ambr | 212 ++++++++++++++++-- tests/components/backup/test_websocket.py | 6 +- tests/components/hassio/test_backup.py | 8 +- 4 files changed, 226 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 25393a872cc..afca501d450 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -560,8 +560,15 @@ class BackupManager: return_exceptions=True, ) for idx, result in enumerate(list_backups_results): + agent_id = agent_ids[idx] if isinstance(result, BackupAgentError): - agent_errors[agent_ids[idx]] = result + agent_errors[agent_id] = result + continue + if isinstance(result, Exception): + agent_errors[agent_id] = result + LOGGER.error( + "Unexpected error for %s: %s", agent_id, result, exc_info=result + ) continue if isinstance(result, BaseException): raise result # unexpected error @@ -588,7 +595,7 @@ class BackupManager: name=agent_backup.name, with_automatic_settings=with_automatic_settings, ) - backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus( + backups[backup_id].agents[agent_id] = AgentBackupStatus( protected=agent_backup.protected, size=agent_backup.size, ) @@ -611,8 +618,15 @@ class BackupManager: return_exceptions=True, ) for idx, result in enumerate(get_backup_results): + agent_id = agent_ids[idx] if isinstance(result, BackupAgentError): - agent_errors[agent_ids[idx]] = result + agent_errors[agent_id] = result + continue + if isinstance(result, Exception): + agent_errors[agent_id] = result + LOGGER.error( + "Unexpected error for %s: %s", agent_id, result, exc_info=result + ) continue if isinstance(result, BaseException): raise result # unexpected error @@ -640,7 +654,7 @@ class BackupManager: name=result.name, with_automatic_settings=with_automatic_settings, ) - backup.agents[agent_ids[idx]] = AgentBackupStatus( + backup.agents[agent_id] = AgentBackupStatus( protected=result.protected, size=result.size, ) @@ -676,8 +690,15 @@ class BackupManager: return_exceptions=True, ) for idx, result in enumerate(delete_backup_results): + agent_id = agent_ids[idx] if isinstance(result, BackupAgentError): - agent_errors[agent_ids[idx]] = result + agent_errors[agent_id] = result + continue + if isinstance(result, Exception): + agent_errors[agent_id] = result + LOGGER.error( + "Unexpected error for %s: %s", agent_id, result, exc_info=result + ) continue if isinstance(result, BaseException): raise result # unexpected error diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 421432fb66e..2f063262f34 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -3697,12 +3697,13 @@ # --- # name: test_delete_with_errors[side_effect1-storage_data0] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Boom!', + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -3757,12 +3758,13 @@ # --- # name: test_delete_with_errors[side_effect1-storage_data1] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Boom!', + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -4019,12 +4021,89 @@ # --- # name: test_details_with_errors[side_effect0] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Oops', + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details_with_errors[side_effect1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Boom!', + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -4542,12 +4621,105 @@ # --- # name: test_info_with_errors[side_effect0] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Oops', + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + ]), + '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', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info_with_errors[side_effect1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Boom!', + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + ]), + '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', + }), + 'success': True, 'type': 'result', }) # --- diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 5af6d595938..263a36570e6 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -148,7 +148,8 @@ async def test_info( @pytest.mark.parametrize( - "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] + "side_effect", + [Exception("Oops"), HomeAssistantError("Boom!"), BackupAgentUnreachableError], ) async def test_info_with_errors( hass: HomeAssistant, @@ -209,7 +210,8 @@ async def test_details( @pytest.mark.parametrize( - "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] + "side_effect", + [Exception("Oops"), HomeAssistantError("Boom!"), BackupAgentUnreachableError], ) async def test_details_with_errors( hass: HomeAssistant, diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 0dd2adc99ed..7547e3e3586 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -661,8 +661,8 @@ async def test_agent_get_backup( ( SupervisorBadRequestError("blah"), { - "success": False, - "error": {"code": "unknown_error", "message": "Unknown error"}, + "success": True, + "result": {"agent_errors": {"hassio.local": "blah"}, "backup": None}, }, ), ( @@ -733,8 +733,8 @@ async def test_agent_delete_backup( ( SupervisorBadRequestError("blah"), { - "success": False, - "error": {"code": "unknown_error", "message": "Unknown error"}, + "success": True, + "result": {"agent_errors": {"hassio.local": "blah"}}, }, ), ( From cabb4062702b55f5376fd564117eb969efc825c4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 9 Feb 2025 19:34:25 -0500 Subject: [PATCH 0343/1941] Fix user input not added to chat log from contextvar (#138173) --- .../components/conversation/chat_log.py | 26 +++++++++---------- .../components/conversation/test_chat_log.py | 23 ++++++++++++++++ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 5dbd19ba275..a060a769907 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -38,21 +38,23 @@ def async_get_chat_log( user_input: ConversationInput | None = None, ) -> Generator[ChatLog]: """Return chat log for a specific chat session.""" - if chat_log := current_chat_log.get(): - # If a chat log is already active and it's the requested conversation ID, - # return that. We won't update the last updated time in this case. - if chat_log.conversation_id == session.conversation_id: - yield chat_log - return + # If a chat log is already active and it's the requested conversation ID, + # return that. We won't update the last updated time in this case. + if ( + chat_log := current_chat_log.get() + ) and chat_log.conversation_id == session.conversation_id: + if user_input is not None: + chat_log.async_add_user_content(UserContent(content=user_input.text)) + + yield chat_log + return all_chat_logs = hass.data.get(DATA_CHAT_LOGS) if all_chat_logs is None: all_chat_logs = {} hass.data[DATA_CHAT_LOGS] = all_chat_logs - chat_log = all_chat_logs.get(session.conversation_id) - - if chat_log: + if chat_log := all_chat_logs.get(session.conversation_id): chat_log = replace(chat_log, content=chat_log.content.copy()) else: chat_log = ChatLog(hass, session.conversation_id) @@ -395,12 +397,10 @@ class ChatLog: if llm_api: prompt_parts.append(llm_api.api_prompt) - extra_system_prompt = ( + if extra_system_prompt := ( # Take new system prompt if one was given user_input.extra_system_prompt or self.extra_system_prompt - ) - - if extra_system_prompt: + ): prompt_parts.append(extra_system_prompt) prompt = "\n".join(prompt_parts) diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 090904c7063..0c11d19aab2 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -602,3 +602,26 @@ async def test_add_delta_content_stream_errors( stream([{"role": role}]), ): pass + + +async def test_chat_log_reuse( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test that we can reuse a chat log.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + assert chat_log.conversation_id == session.conversation_id + assert len(chat_log.content) == 1 + + with async_get_chat_log(hass, session) as chat_log2: + assert chat_log2 is chat_log + assert len(chat_log.content) == 1 + + with async_get_chat_log(hass, session, mock_conversation_input) as chat_log2: + assert chat_log2 is chat_log + assert len(chat_log.content) == 2 + assert chat_log.content[1].role == "user" + assert chat_log.content[1].content == mock_conversation_input.text From fa3acde684bb11f062b6198ecc0b35d0bdb36303 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 9 Feb 2025 20:19:28 -0500 Subject: [PATCH 0344/1941] Make MockChatLog reusable for other integrations (#138112) * Make MockChatLog reusable for other integrations * Update tests/components/conversation/__init__.py --- tests/components/conversation/__init__.py | 53 ++++++++++- .../snapshots/test_conversation.ambr | 14 --- .../openai_conversation/test_conversation.py | 91 ++++--------------- 3 files changed, 68 insertions(+), 90 deletions(-) diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 1ae3372968e..314188dbd82 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -2,7 +2,11 @@ from __future__ import annotations +from dataclasses import dataclass, field from typing import Literal +from unittest.mock import patch + +import pytest from homeassistant.components import conversation from homeassistant.components.conversation.models import ( @@ -14,7 +18,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( async_expose_entity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import chat_session, intent class MockAgent(conversation.AbstractConversationAgent): @@ -44,6 +48,53 @@ class MockAgent(conversation.AbstractConversationAgent): ) +@pytest.fixture +async def mock_chat_log(hass: HomeAssistant) -> MockChatLog: + """Return mock chat logs.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch( + "homeassistant.components.conversation.chat_log.ChatLog", + MockChatLog, + ), + chat_session.async_get_chat_session(hass, "mock-conversation-id") as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + yield chat_log + + +@dataclass +class MockChatLog(conversation.ChatLog): + """Mock chat log.""" + + _mock_tool_results: dict = field(default_factory=dict) + + def mock_tool_results(self, results: dict) -> None: + """Set tool results.""" + self._mock_tool_results = results + + @property + def llm_api(self): + """Return LLM API.""" + return self._llm_api + + @llm_api.setter + def llm_api(self, value): + """Set LLM API.""" + self._llm_api = value + + if not value: + return + + async def async_call_tool(tool_input): + """Call tool.""" + if tool_input.id not in self._mock_tool_results: + raise ValueError(f"Tool {tool_input.id} not found") + return self._mock_tool_results[tool_input.id] + + self._llm_api.async_call_tool = async_call_tool + + def expose_new(hass: HomeAssistant, expose_new: bool) -> None: """Enable exposing new entities to the default agent.""" exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 2db5be706ef..77c28de2773 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -1,20 +1,6 @@ # serializer version: 1 # name: test_function_call list([ - dict({ - 'content': ''' - Current time is 16:00:00. Today's date is 2024-06-03. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. - ''', - 'role': 'system', - }), - dict({ - 'content': 'hello', - 'role': 'user', - }), dict({ 'content': 'Please call the test function', 'role': 'user', diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 9afdfc6a5a2..2c956b7e63f 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,10 +1,8 @@ """Tests for the OpenAI integration.""" from collections.abc import Generator -from dataclasses import dataclass, field from unittest.mock import AsyncMock, patch -from freezegun import freeze_time from httpx import Response from openai import RateLimitError from openai.types.chat.chat_completion_chunk import ( @@ -18,14 +16,17 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation -from homeassistant.components.conversation import chat_log from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import chat_session, intent +from homeassistant.helpers import intent from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.components.conversation import ( + MockChatLog, + mock_chat_log, # noqa: F401 +) ASSIST_RESPONSE_FINISH = ( # Assistant message @@ -66,66 +67,6 @@ def mock_create_stream() -> Generator[AsyncMock]: yield mock_create -@dataclass -class MockChatLog(chat_log.ChatLog): - """Mock chat log.""" - - _mock_tool_results: dict = field(default_factory=dict) - - def mock_tool_results(self, results: dict) -> None: - """Set tool results.""" - self._mock_tool_results = results - - @property - def llm_api(self): - """Return LLM API.""" - return self._llm_api - - @llm_api.setter - def llm_api(self, value): - """Set LLM API.""" - self._llm_api = value - - if not value: - return - - async def async_call_tool(tool_input): - """Call tool.""" - if tool_input.id not in self._mock_tool_results: - raise ValueError(f"Tool {tool_input.id} not found") - return self._mock_tool_results[tool_input.id] - - self._llm_api.async_call_tool = async_call_tool - - def latest_content(self) -> list[conversation.Content]: - """Return content from latest version chat log. - - The chat log makes copies until it's committed. Helper to get latest content. - """ - with ( - chat_session.async_get_chat_session( - self.hass, self.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session) as chat_log, - ): - return chat_log.content - - -@pytest.fixture -async def mock_chat_log(hass: HomeAssistant) -> MockChatLog: - """Return mock chat logs.""" - with ( - patch( - "homeassistant.components.conversation.chat_log.ChatLog", - MockChatLog, - ), - chat_session.async_get_chat_session(hass, "mock-conversation-id") as session, - conversation.async_get_chat_log(hass, session) as chat_log, - ): - chat_log.async_add_user_content(conversation.UserContent("hello")) - return chat_log - - async def test_entity( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -189,7 +130,7 @@ async def test_function_call( mock_config_entry_with_assist: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, - mock_chat_log: MockChatLog, + mock_chat_log: MockChatLog, # noqa: F811 snapshot: SnapshotAssertion, ) -> None: """Test function call from the assistant.""" @@ -309,17 +250,17 @@ async def test_function_call( } ) - with freeze_time("2024-06-03 23:00:00"): - result = await conversation.async_converse( - hass, - "Please call the test function", - "mock-conversation-id", - Context(), - agent_id="conversation.openai", - ) + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai", + ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert mock_chat_log.latest_content() == snapshot + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot @pytest.mark.parametrize( @@ -430,7 +371,7 @@ async def test_function_call_invalid( mock_config_entry_with_assist: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, - mock_chat_log: MockChatLog, + mock_chat_log: MockChatLog, # noqa: F811 description: str, messages: tuple[ChatCompletionChunk], ) -> None: From 29c6a2ec1385208b5989a9657cc76a2d841016ab Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 9 Feb 2025 21:09:52 -0500 Subject: [PATCH 0345/1941] Add pipeline intent-progress events based on deltas (#138095) Add intent progress Assist event --- .../components/assist_pipeline/pipeline.py | 16 +++++ .../components/conversation/chat_log.py | 26 +++++++- .../assist_pipeline/test_websocket.py | 62 ++++++++++++++++++- .../components/conversation/test_chat_log.py | 30 ++++++--- 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index ef26e1a5a6d..cf9fb4c7212 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -374,6 +374,7 @@ class PipelineEventType(StrEnum): STT_VAD_END = "stt-vad-end" STT_END = "stt-end" INTENT_START = "intent-start" + INTENT_PROGRESS = "intent-progress" INTENT_END = "intent-end" TTS_START = "tts-start" TTS_END = "tts-end" @@ -1093,6 +1094,20 @@ class PipelineRun: agent_id = conversation.HOME_ASSISTANT_AGENT processed_locally = True + @callback + def chat_log_delta_listener( + chat_log: conversation.ChatLog, delta: dict + ) -> None: + """Handle chat log delta.""" + self.process_event( + PipelineEvent( + PipelineEventType.INTENT_PROGRESS, + { + "chat_log_delta": delta, + }, + ) + ) + with ( chat_session.async_get_chat_session( self.hass, user_input.conversation_id @@ -1101,6 +1116,7 @@ class PipelineRun: self.hass, session, user_input, + chat_log_delta_listener=chat_log_delta_listener, ) as chat_log, ): # It was already handled, create response and add to chat history diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index a060a769907..1ee5e9965ab 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, AsyncIterable, Generator +from collections.abc import AsyncGenerator, AsyncIterable, Callable, Generator from contextlib import contextmanager from contextvars import ContextVar -from dataclasses import dataclass, field, replace +from dataclasses import asdict, dataclass, field, replace import logging from typing import Literal, TypedDict @@ -36,6 +36,8 @@ def async_get_chat_log( hass: HomeAssistant, session: chat_session.ChatSession, user_input: ConversationInput | None = None, + *, + chat_log_delta_listener: Callable[[ChatLog, dict], None] | None = None, ) -> Generator[ChatLog]: """Return chat log for a specific chat session.""" # If a chat log is already active and it's the requested conversation ID, @@ -43,6 +45,10 @@ def async_get_chat_log( if ( chat_log := current_chat_log.get() ) and chat_log.conversation_id == session.conversation_id: + if chat_log_delta_listener is not None: + raise RuntimeError( + "Cannot attach chat log delta listener unless initial caller" + ) if user_input is not None: chat_log.async_add_user_content(UserContent(content=user_input.text)) @@ -59,6 +65,9 @@ def async_get_chat_log( else: chat_log = ChatLog(hass, session.conversation_id) + if chat_log_delta_listener: + chat_log.delta_listener = chat_log_delta_listener + if user_input is not None: chat_log.async_add_user_content(UserContent(content=user_input.text)) @@ -83,6 +92,9 @@ def async_get_chat_log( session.async_on_cleanup(do_cleanup) + if chat_log_delta_listener: + chat_log.delta_listener = None + all_chat_logs[session.conversation_id] = chat_log @@ -165,6 +177,7 @@ class ChatLog: content: list[Content] = field(default_factory=lambda: [SystemContent(content="")]) extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None + delta_listener: Callable[[ChatLog, dict], None] | None = None @property def unresponded_tool_results(self) -> bool: @@ -275,6 +288,8 @@ class ChatLog: self.llm_api.async_call_tool(tool_call), name=f"llm_tool_{tool_call.id}", ) + if self.delta_listener: + self.delta_listener(self, delta) # type: ignore[arg-type] continue # Starting a new message @@ -294,10 +309,15 @@ class ChatLog: content, tool_call_tasks=tool_call_tasks ): yield tool_result + if self.delta_listener: + self.delta_listener(self, asdict(tool_result)) current_content = delta.get("content") or "" current_tool_calls = delta.get("tool_calls") or [] + if self.delta_listener: + self.delta_listener(self, delta) # type: ignore[arg-type] + if current_content or current_tool_calls: content = AssistantContent( agent_id=agent_id, @@ -309,6 +329,8 @@ class ChatLog: content, tool_call_tasks=tool_call_tasks ): yield tool_result + if self.delta_listener: + self.delta_listener(self, asdict(tool_result)) async def async_update_llm_data( self, diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 2cd56f094dd..f856bbe7f61 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -9,6 +9,7 @@ from unittest.mock import ANY, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import conversation from homeassistant.components.assist_pipeline.const import ( DOMAIN, SAMPLE_CHANNELS, @@ -22,7 +23,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import chat_session, device_registry as dr from .conftest import ( BYTES_ONE_SECOND, @@ -2727,3 +2728,62 @@ async def test_stt_cooldown_different_ids( # Both should start stt assert {event_type_1, event_type_2} == {"stt-start"} + + +async def test_intent_progress_event( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, +) -> None: + """Test intent-progress events from a pipeline are forwarded.""" + client = await hass_ws_client(hass) + + orig_converse = conversation.async_converse + expected_delta_events = [ + {"chat_log_delta": {"role": "assistant"}}, + {"chat_log_delta": {"content": "Hello"}}, + ] + + async def mock_delta_stream(): + """Mock delta stream.""" + for d in expected_delta_events: + yield d["chat_log_delta"] + + async def mock_converse(**kwargs): + """Mock converse method.""" + with ( + chat_session.async_get_chat_session( + kwargs["hass"], kwargs["conversation_id"] + ) as session, + conversation.async_get_chat_log(hass, session) as chat_log, + ): + async for _content in chat_log.async_add_delta_content_stream( + "", mock_delta_stream() + ): + pass + + return await orig_converse(**kwargs) + + with patch("homeassistant.components.conversation.async_converse", mock_converse): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + "conversation_id": "mock-conversation-id", + "device_id": "mock-device-id", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + events = [] + for _ in range(6): + msg = await client.receive_json() + if msg["event"]["type"] == "intent-progress": + events.append(msg["event"]["data"]) + + assert events == expected_delta_events diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 0c11d19aab2..a4dc9b819c1 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -1,6 +1,7 @@ """Test the conversation session.""" from collections.abc import Generator +from dataclasses import asdict from datetime import timedelta from unittest.mock import AsyncMock, Mock, patch @@ -524,18 +525,29 @@ async def test_add_delta_content_stream( return tool_input.tool_args["param1"] mock_tool.async_call.side_effect = tool_call + expected_delta = [] async def stream(): """Yield deltas.""" for d in deltas: yield d + expected_delta.append(d) + + captured_deltas = [] with ( patch( "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] ) as mock_get_tools, 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, + chat_log_delta_listener=lambda chat_log, delta: captured_deltas.append( + delta + ), + ) as chat_log, ): mock_get_tools.return_value = [mock_tool] await chat_log.async_update_llm_data( @@ -545,13 +557,17 @@ async def test_add_delta_content_stream( user_llm_prompt=None, ) - results = [ - tool_result_content - async for tool_result_content in chat_log.async_add_delta_content_stream( - "mock-agent-id", stream() - ) - ] + results = [] + async for content in chat_log.async_add_delta_content_stream( + "mock-agent-id", stream() + ): + results.append(content) + # Interweave the tool results with the source deltas into expected_delta + if content.role == "tool_result": + expected_delta.append(asdict(content)) + + assert captured_deltas == expected_delta assert results == snapshot assert chat_log.content[2:] == results From ae38f897282a43285fa9eeee898010d67474aaab Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 9 Feb 2025 20:42:15 -0800 Subject: [PATCH 0346/1941] Update anthropic to use the new chatlog API (#138178) * Update anthropic to use the new chatlog API * Remove conversation id logging * Add back whitespace * Reduce unnecessary diffs * Revert diffs to conversation component * Replace types with union type --- .../components/anthropic/conversation.py | 258 ++++++++---------- .../snapshots/test_conversation.ambr | 4 +- .../components/anthropic/test_conversation.py | 39 +-- 3 files changed, 112 insertions(+), 189 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 259d1295809..b479ee4409c 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -16,18 +16,15 @@ from anthropic.types import ( ToolUseBlock, ToolUseBlockParam, ) -import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import conversation -from homeassistant.components.conversation import trace from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import device_registry as dr, intent, llm, template +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import chat_session, device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import ulid as ulid_util from . import AnthropicConfigEntry from .const import ( @@ -89,6 +86,44 @@ def _message_convert( return MessageParam(role=message.role, content=param_content) +def _convert_content(chat_content: conversation.Content) -> MessageParam: + """Create tool response content.""" + if isinstance(chat_content, conversation.ToolResultContent): + return MessageParam( + role="user", + content=[ + ToolResultBlockParam( + type="tool_result", + tool_use_id=chat_content.tool_call_id, + content=json.dumps(chat_content.tool_result), + ) + ], + ) + if isinstance(chat_content, conversation.AssistantContent): + return MessageParam( + role="assistant", + content=[ + TextBlockParam(type="text", text=chat_content.content or ""), + *[ + ToolUseBlockParam( + type="tool_use", + id=tool_call.id, + name=tool_call.tool_name, + input=json.dumps(tool_call.tool_args), + ) + for tool_call in chat_content.tool_calls or () + ], + ], + ) + if isinstance(chat_content, conversation.UserContent): + return MessageParam( + role="user", + content=chat_content.content, + ) + # Note: We don't pass SystemContent here as its passed to the API as the prompt + raise ValueError(f"Unexpected content type: {type(chat_content)}") + + class AnthropicConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -100,7 +135,6 @@ class AnthropicConversationEntity( def __init__(self, entry: AnthropicConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, list[MessageParam]] = {} self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -129,110 +163,43 @@ class AnthropicConversationEntity( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: """Process a sentence.""" - options = self.entry.options - intent_response = intent.IntentResponse(language=user_input.language) - llm_api: llm.APIInstance | None = None - tools: list[ToolParam] | None = None - user_name: str | None = None - llm_context = llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ) - - if options.get(CONF_LLM_HASS_API): - try: - llm_api = await llm.async_get_api( - self.hass, - options[CONF_LLM_HASS_API], - llm_context, - ) - except HomeAssistantError as err: - LOGGER.error("Error getting LLM API: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Error preparing LLM API: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=user_input.conversation_id - ) - tools = [ - _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools - ] - - if user_input.conversation_id is None: - conversation_id = ulid_util.ulid_now() - messages = [] - - elif user_input.conversation_id in self.history: - conversation_id = user_input.conversation_id - messages = self.history[conversation_id] - - else: - # Conversation IDs are ULIDs. We generate a new one if not provided. - # If an old OLID is passed in, we will generate a new one to indicate - # a new conversation was started. If the user picks their own, they - # want to track a conversation and we respect it. - try: - ulid_util.ulid_to_bytes(user_input.conversation_id) - conversation_id = ulid_util.ulid_now() - except ValueError: - conversation_id = user_input.conversation_id - - messages = [] - - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) + with ( + chat_session.async_get_chat_session( + self.hass, user_input.conversation_id + ) as session, + conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, ): - user_name = user.name + return await self._async_handle_message(user_input, chat_log) + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Call the API.""" + options = self.entry.options try: - prompt_parts = [ - template.Template( - llm.BASE_PROMPT - + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ) + await chat_log.async_update_llm_data( + DOMAIN, + user_input, + options.get(CONF_LLM_HASS_API), + options.get(CONF_PROMPT), + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + tools: list[ToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools ] - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem with my template: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - if llm_api: - prompt_parts.append(llm_api.api_prompt) - - prompt = "\n".join(prompt_parts) - - # Create a copy of the variable because we attach it to the trace - messages = [*messages, MessageParam(role="user", content=user_input.text)] - - LOGGER.debug("Prompt: %s", messages) - LOGGER.debug("Tools: %s", tools) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, - {"system": prompt, "messages": messages}, - ) + system = chat_log.content[0] + if not isinstance(system, conversation.SystemContent): + raise TypeError("First message must be a system message") + messages = [_convert_content(content) for content in chat_log.content[1:]] client = self.entry.runtime_data @@ -244,69 +211,62 @@ class AnthropicConversationEntity( messages=messages, tools=tools or NOT_GIVEN, max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - system=prompt, + system=system.content, temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), ) except anthropic.AnthropicError as err: - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem talking to Anthropic: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) + raise HomeAssistantError( + f"Sorry, I had a problem talking to Anthropic: {err}" + ) from err LOGGER.debug("Response %s", response) messages.append(_message_convert(response)) - if response.stop_reason != "tool_use" or not llm_api: - break - - tool_results: list[ToolResultBlockParam] = [] - for tool_call in response.content: - if isinstance(tool_call, TextBlock): - LOGGER.info(tool_call.text) - - if not isinstance(tool_call, ToolUseBlock): - continue - - tool_input = llm.ToolInput( + text = "".join( + [ + content.text + for content in response.content + if isinstance(content, TextBlock) + ] + ) + tool_inputs = [ + llm.ToolInput( id=tool_call.id, tool_name=tool_call.name, tool_args=cast(dict[str, Any], tool_call.input), ) - LOGGER.debug( - "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args + for tool_call in response.content + if isinstance(tool_call, ToolUseBlock) + ] + + tool_results = [ + ToolResultBlockParam( + type="tool_result", + tool_use_id=tool_response.tool_call_id, + content=json.dumps(tool_response.tool_result), ) - - try: - tool_response = await llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - tool_response = {"error": type(e).__name__} - if str(e): - tool_response["error_text"] = str(e) - - LOGGER.debug("Tool response: %s", tool_response) - tool_results.append( - ToolResultBlockParam( - type="tool_result", - tool_use_id=tool_call.id, - content=json.dumps(tool_response), + async for tool_response in chat_log.async_add_assistant_content( + conversation.AssistantContent( + agent_id=user_input.agent_id, + content=text, + tool_calls=tool_inputs or None, ) ) + ] + if tool_results: + messages.append(MessageParam(role="user", content=tool_results)) - messages.append(MessageParam(role="user", content=tool_results)) - - self.history[conversation_id] = messages - - for content in response.content: - if isinstance(content, TextBlock): - intent_response.async_set_speech(content.text) + if not tool_inputs: break + response_content = chat_log.content[-1] + if not isinstance(response_content, conversation.AssistantContent): + raise TypeError("Last message must be an assistant message") + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_speech(response_content.content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id + response=intent_response, conversation_id=chat_log.conversation_id ) async def _async_entry_update_listener( diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index e4dd7cd00bb..93f3b03d9af 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ - 'conversation_id': None, + 'conversation_id': '1234', 'response': IntentResponse( card=dict({ }), @@ -20,7 +20,7 @@ speech=dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Error preparing LLM API: API non-existing not found', + 'speech': 'Error preparing LLM API', }), }), speech_slots=dict({ diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index bb77e2ff926..2f1de3a2db9 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -10,7 +10,6 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components import conversation -from homeassistant.components.conversation import trace from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -250,42 +249,6 @@ async def test_function_call( ), ) - # Test Conversation tracing - traces = trace.async_get_traces() - assert traces - last_trace = traces[-1].as_dict() - trace_events = last_trace.get("events", []) - assert [event["event_type"] for event in trace_events] == [ - trace.ConversationTraceEventType.ASYNC_PROCESS, - trace.ConversationTraceEventType.AGENT_DETAIL, - trace.ConversationTraceEventType.TOOL_CALL, - ] - # AGENT_DETAIL event contains the raw prompt passed to the model - detail_event = trace_events[1] - assert "Answer in plain text" in detail_event["data"]["system"] - assert "Today's date is 2024-06-03." in trace_events[1]["data"]["system"] - - # Call it again, make sure we have updated prompt - with ( - patch( - "anthropic.resources.messages.AsyncMessages.create", - new_callable=AsyncMock, - side_effect=completion_result, - ) as mock_create, - freeze_time("2024-06-04 23:00:00"), - ): - result = await conversation.async_converse( - hass, - "Please call the test function", - None, - context, - agent_id=agent_id, - ) - - assert "Today's date is 2024-06-04." in mock_create.mock_calls[1][2]["system"] - # Test old assert message not updated - assert "Today's date is 2024-06-03." in trace_events[1]["data"]["system"] - @patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") async def test_function_exception( @@ -448,7 +411,7 @@ async def test_unknown_hass_api( ) result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", "1234", Context(), agent_id="conversation.claude" ) assert result == snapshot From 15223b36794a6e2399e6434b339e2c9f57a424f3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 9 Feb 2025 21:05:41 -0800 Subject: [PATCH 0347/1941] Update Ollama to use streaming API (#138177) * Update ollama to use streaming APIs * Remove unnecessary logging * Update ollama to use streaming APIs * Remove unnecessary logging * Update homeassistant/components/ollama/conversation.py Co-authored-by: Paulus Schoutsen --------- Co-authored-by: Paulus Schoutsen --- .../components/ollama/conversation.py | 87 ++++++---- tests/components/ollama/test_conversation.py | 154 +++++++++++++----- 2 files changed, 167 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 2c83720f930..8ee275865a7 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable import json import logging from typing import Any, Literal @@ -123,7 +123,47 @@ def _convert_content( role=MessageRole.SYSTEM.value, content=chat_content.content, ) - raise ValueError(f"Unexpected content type: {type(chat_content)}") + raise TypeError(f"Unexpected content type: {type(chat_content)}") + + +async def _transform_stream( + result: AsyncGenerator[ollama.Message], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + An Ollama streaming response may come in chunks like this: + + response: message=Message(role="assistant", content="Paris") + response: message=Message(role="assistant", content=".") + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + response: message=Message(role="assistant", tool_calls=[...]) + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + + This generator conforms to the chatlog delta stream expectations in that it + yields deltas, then the role only once the response is done. + """ + + new_msg = True + async for response in result: + _LOGGER.debug("Received response: %s", response) + response_message = response["message"] + chunk: conversation.AssistantContentDeltaDict = {} + if new_msg: + new_msg = False + chunk["role"] = "assistant" + if (tool_calls := response_message.get("tool_calls")) is not None: + chunk["tool_calls"] = [ + llm.ToolInput( + tool_name=tool_call["function"]["name"], + tool_args=_parse_tool_args(tool_call["function"]["arguments"]), + ) + for tool_call in tool_calls + ] + if (content := response_message.get("content")) is not None: + chunk["content"] = content + if response_message.get("done"): + new_msg = True + yield chunk class OllamaConversationEntity( @@ -216,12 +256,12 @@ class OllamaConversationEntity( # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: - response = await client.chat( + response_generator = await client.chat( model=model, # Make a copy of the messages because we mutate the list later messages=list(message_history.messages), tools=tools, - stream=False, + stream=True, # keep_alive requires specifying unit. In this case, seconds keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, @@ -232,46 +272,25 @@ class OllamaConversationEntity( f"Sorry, I had a problem talking to the Ollama server: {err}" ) from err - response_message = response["message"] - content = response_message.get("content") - tool_calls = response_message.get("tool_calls") - message_history.messages.append( - ollama.Message( - role=response_message["role"], - content=content, - tool_calls=tool_calls, - ) - ) - tool_inputs = [ - llm.ToolInput( - tool_name=tool_call["function"]["name"], - tool_args=_parse_tool_args(tool_call["function"]["arguments"]), - ) - for tool_call in tool_calls or () - ] - message_history.messages.extend( [ - ollama.Message( - role=MessageRole.TOOL.value, - content=json.dumps(tool_response.tool_result), - ) - async for tool_response in chat_log.async_add_assistant_content( - conversation.AssistantContent( - agent_id=user_input.agent_id, - content=content, - tool_calls=tool_inputs or None, - ) + _convert_content(content) + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(response_generator) ) ] ) - if not tool_calls: + if not chat_log.unresponded_tool_results: break # Create intent response intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response_message["content"]) + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise TypeError( + f"Unexpected last message type: {type(chat_log.content[-1])}" + ) + intent_response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( response=intent_response, conversation_id=chat_log.conversation_id ) diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index df7c6beca72..db641ba703b 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -1,5 +1,6 @@ """Tests for the Ollama integration.""" +from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -25,6 +26,14 @@ def mock_ulid_tools(): yield +async def stream_generator(response: dict | list[dict]) -> AsyncGenerator[dict]: + """Generate a response from the assistant.""" + if not isinstance(response, list): + response = [response] + for msg in response: + yield msg + + @pytest.mark.parametrize("agent_id", [None, "conversation.mock_title"]) async def test_chat( hass: HomeAssistant, @@ -42,7 +51,9 @@ async def test_chat( with patch( "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, + return_value=stream_generator( + {"message": {"role": "assistant", "content": "test response"}} + ), ) as mock_chat: result = await conversation.async_converse( hass, @@ -81,6 +92,53 @@ async def test_chat( assert "Current time is" in detail_event["data"]["messages"][0]["content"] +async def test_chat_stream( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test chat messages are assembled across streamed responses.""" + + entry = MockConfigEntry() + entry.add_to_hass(hass) + + with patch( + "ollama.AsyncClient.chat", + return_value=stream_generator( + [ + {"message": {"role": "assistant", "content": "test "}}, + { + "message": {"role": "assistant", "content": "response"}, + "done": True, + "done_reason": "stop", + }, + ], + ), + ) as mock_chat: + result = await conversation.async_converse( + hass, + "test message", + None, + Context(), + agent_id=mock_config_entry.entry_id, + ) + + assert mock_chat.call_count == 1 + args = mock_chat.call_args.kwargs + prompt = args["messages"][0]["content"] + + assert args["model"] == "test model" + assert args["messages"] == [ + Message(role="system", content=prompt), + Message(role="user", content="test message"), + ] + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( + result + ) + assert result.response.speech["plain"]["speech"] == "test response" + + async def test_template_variables( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: @@ -103,7 +161,9 @@ async def test_template_variables( patch("ollama.AsyncClient.list"), patch( "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, + return_value=stream_generator( + {"message": {"role": "assistant", "content": "test response"}} + ), ) as mock_chat, patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), ): @@ -170,26 +230,30 @@ async def test_function_call( def completion_result(*args, messages, **kwargs): for message in messages: if message["role"] == "tool": - return { - "message": { - "role": "assistant", - "content": "I have successfully called the function", - } - } - - return { - "message": { - "role": "assistant", - "tool_calls": [ + return stream_generator( { - "function": { - "name": "test_tool", - "arguments": tool_args, + "message": { + "role": "assistant", + "content": "I have successfully called the function", } } - ], + ) + + return stream_generator( + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "function": { + "name": "test_tool", + "arguments": tool_args, + } + } + ], + } } - } + ) with patch( "ollama.AsyncClient.chat", @@ -251,26 +315,30 @@ async def test_function_exception( def completion_result(*args, messages, **kwargs): for message in messages: if message["role"] == "tool": - return { - "message": { - "role": "assistant", - "content": "There was an error calling the function", - } - } - - return { - "message": { - "role": "assistant", - "tool_calls": [ + return stream_generator( { - "function": { - "name": "test_tool", - "arguments": {"param1": "test_value"}, + "message": { + "role": "assistant", + "content": "There was an error calling the function", } } - ], + ) + + return stream_generator( + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "function": { + "name": "test_tool", + "arguments": {"param1": "test_value"}, + } + } + ], + } } - } + ) with patch( "ollama.AsyncClient.chat", @@ -344,7 +412,9 @@ async def test_message_history_trimming( def response(*args, **kwargs) -> dict: nonlocal response_idx response_idx += 1 - return {"message": {"role": "assistant", "content": f"response {response_idx}"}} + return stream_generator( + {"message": {"role": "assistant", "content": f"response {response_idx}"}} + ) with patch( "ollama.AsyncClient.chat", @@ -438,11 +508,13 @@ async def test_message_history_unlimited( """Test that message history is not trimmed when max_history = 0.""" conversation_id = "1234" + def stream(*args, **kwargs) -> AsyncGenerator[dict]: + return stream_generator( + {"message": {"role": "assistant", "content": "test response"}} + ) + with ( - patch( - "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, - ) as mock_chat, + patch("ollama.AsyncClient.chat", side_effect=stream) as mock_chat, ): hass.config_entries.async_update_entry( mock_config_entry, options={ollama.CONF_MAX_HISTORY: 0} @@ -559,7 +631,9 @@ async def test_options( """Test that options are passed correctly to ollama client.""" with patch( "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, + return_value=stream_generator( + {"message": {"role": "assistant", "content": "test response"}} + ), ) as mock_chat: await conversation.async_converse( hass, From 8c602d74f39ed1360ba364da09d884ac84a94adb Mon Sep 17 00:00:00 2001 From: pglab-electronics <89299919+pglab-electronics@users.noreply.github.com> Date: Mon, 10 Feb 2025 08:19:42 +0100 Subject: [PATCH 0348/1941] Add pglab integration (#109725) * Add PG LAB Electronics integration * Add time from last boot sensor diagnostic * Limit the initial new pglab integration to only one platform * Update FlowHandler with the new return type ConfigFlowResult * Fix docstring file with the right integration name to PG LAB. * There is no need for default value in the callback definition. * Move all mqtt callbacks to be global and also renamed with a better name. * Removed unused member variables. * Renaming functions with a better name. * Adding miss docstring to __build_device. * Renamed CreateDiscovery with a better name. * Removing not so meaning comment. * Avoid to populate hass.data with pglab discovery information. Use hass.data[DOMAIN] instead. * Revert "Removed unused member variables." This reverts commit 4193c491ec3c31d5c589abac59028ee9be898785. * Removed unused member variables. * Refactoring of const. Be sure to have in const.py constant that are used in at least two other modules * Restoring back the process to unregister the plaform when unload the integration. * fix spelling mistake * Revert "Move all mqtt callbacks to be global and also renamed with a better name." This reverts commit d94d8010d5d11d3febfcb075859483d9e2beae3c. * Main refactoring to avoid to store PG Lab discovery in hass.data * Change class name BaseEntity in PGLabEntity. And named PyPGLab... what imported from external python module pypglab. * Avoid to use dict to create DeviceInfo * Removing unused parameter * Removing not necessary call to base class * Update entity name/id to be compatible with the new integration policy. * Upate test to new entity id * Add new line after file description * avoid to store in local variable data for calling function * Move PGLABConfigEntry in __init__.py * change function to pure callback * to avoid hang, dont' trust the split of the discovery topic... introduce a max split count * rename method with a more meaning name * use assignment operator * rename variable with a better name * removing unecessary test * Raise exception in case of unexpected error during discovery * Review comments all other the intergration. * Rename classes to be consistent in integration * Using new feature single_config_entry to allow single instance integration * rename class FlowHandler to PGLabFlowHandler * using __package__ to initialize integration logger * missing to catch the exception when for some reason is not possible to create the discovery instance. This can happen when the discovery MQTT message is not in valid json format. * using ATTR_ENTITY_ID instead of the string * using SOURCE_MQTT, SOURCE_USER instead of config_entries.SOURCE_MQTT, config_entries.SOURCE_USER * Using FlowResultType.ABORT instead of the string value * Code refactoring for tests of configuration from USER and MQTT * Remove to the user the possibility to add PGLab integration manually, and remove not needed tests. * Change test_device_update to use snapshot to check test result * Raise exeception in case of unexpected device and entity_id * Avoid to log on info channel. * Renamed _LOGGER in LOGGER * Propage the call to the base class * Remove not needed code because from the manifest it's only allows a single instance * Using specific type for result test instead of string value * Code refactoring, avoid not necessary function * update to the new way to import mqtt components * Avoid runtime check * add err variable for catching the exception * add doc string to mqtt_publish * add doc string to mqtt_subscribe * Rename DiscoverDeviceInfo.add_entity_id in add_entity * add doc string * removing not meaning documentation string * fix spelling * fix wrong case in docstring * fix spelling mistake in PyPGLab callback name * rename mqtt message received callback * Avoid to store hard coded discovery_prefix * Removing unused strings from strings.json * Give to the user more information during config_flow, and add the possibility to add manually the integration * Fix to avoid fails of auto test * update discovery test * Be sure to always subscribe to MQTT topic when entity is added to HA * Update codeowner of PGLAB integration and test * Add control to check if mqtt is available during integration setup * New test for check no state change for disable entity switch * Remore not more used file * update pypglab to version 0.0.3 and improve the symmetry to subscribe/unsubscribe to mqtt entity topic and to register/deregister the status update callback * Update codeowner of pglab integration * Adding quality_scale * removing async_setup * Fix spelling mistake * Added test to cover config_flow.async_step_user --------- Co-authored-by: Pierluigi --- CODEOWNERS | 2 + homeassistant/components/pglab/__init__.py | 85 +++++ homeassistant/components/pglab/config_flow.py | 73 ++++ homeassistant/components/pglab/const.py | 12 + homeassistant/components/pglab/discovery.py | 277 +++++++++++++++ homeassistant/components/pglab/entity.py | 70 ++++ homeassistant/components/pglab/manifest.json | 14 + .../components/pglab/quality_scale.yaml | 80 +++++ homeassistant/components/pglab/strings.json | 24 ++ homeassistant/components/pglab/switch.py | 76 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 7 + homeassistant/generated/mqtt.py | 3 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/pglab/__init__.py | 1 + tests/components/pglab/conftest.py | 41 +++ tests/components/pglab/test_config_flow.py | 133 ++++++++ tests/components/pglab/test_discovery.py | 154 +++++++++ tests/components/pglab/test_init.py | 1 + tests/components/pglab/test_switch.py | 318 ++++++++++++++++++ 21 files changed, 1378 insertions(+) create mode 100644 homeassistant/components/pglab/__init__.py create mode 100644 homeassistant/components/pglab/config_flow.py create mode 100644 homeassistant/components/pglab/const.py create mode 100644 homeassistant/components/pglab/discovery.py create mode 100644 homeassistant/components/pglab/entity.py create mode 100644 homeassistant/components/pglab/manifest.json create mode 100644 homeassistant/components/pglab/quality_scale.yaml create mode 100644 homeassistant/components/pglab/strings.json create mode 100644 homeassistant/components/pglab/switch.py create mode 100644 tests/components/pglab/__init__.py create mode 100644 tests/components/pglab/conftest.py create mode 100644 tests/components/pglab/test_config_flow.py create mode 100644 tests/components/pglab/test_discovery.py create mode 100644 tests/components/pglab/test_init.py create mode 100644 tests/components/pglab/test_switch.py diff --git a/CODEOWNERS b/CODEOWNERS index e510eec6dfa..3d8159560bc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1138,6 +1138,8 @@ build.json @home-assistant/supervisor /tests/components/permobil/ @IsakNyberg /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core +/homeassistant/components/pglab/ @pglab-electronics +/tests/components/pglab/ @pglab-electronics /homeassistant/components/philips_js/ @elupus /tests/components/philips_js/ @elupus /homeassistant/components/pi_hole/ @shenxn diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py new file mode 100644 index 00000000000..7307ac2f801 --- /dev/null +++ b/homeassistant/components/pglab/__init__.py @@ -0,0 +1,85 @@ +"""PG LAB Electronics integration.""" + +from __future__ import annotations + +from pypglab.mqtt import ( + Client as PyPGLabMqttClient, + Sub_State as PyPGLabSubState, + Subcribe_CallBack as PyPGLabSubscribeCallBack, +) + +from homeassistant.components import mqtt +from homeassistant.components.mqtt import ( + ReceiveMessage, + async_prepare_subscribe_topics, + async_subscribe_topics, + async_unsubscribe_topics, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, LOGGER +from .discovery import PGLabDiscovery + +type PGLABConfigEntry = ConfigEntry[PGLabDiscovery] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: + """Set up PG LAB Electronics integration from a config entry.""" + + async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None: + """Publish an MQTT message using the Home Assistant MQTT client.""" + await mqtt.async_publish(hass, topic, payload, qos, retain) + + async def mqtt_subscribe( + sub_state: PyPGLabSubState, topic: str, callback_func: PyPGLabSubscribeCallBack + ) -> PyPGLabSubState: + """Subscribe to MQTT topics using the Home Assistant MQTT client.""" + + @callback + def mqtt_message_received(msg: ReceiveMessage) -> None: + """Handle PGLab mqtt messages.""" + callback_func(msg.topic, msg.payload) + + topics = { + "pglab_subscribe_topic": { + "topic": topic, + "msg_callback": mqtt_message_received, + } + } + + sub_state = async_prepare_subscribe_topics(hass, sub_state, topics) + await async_subscribe_topics(hass, sub_state) + return sub_state + + async def mqtt_unsubscribe(sub_state: PyPGLabSubState) -> None: + async_unsubscribe_topics(hass, sub_state) + + if not await mqtt.async_wait_for_mqtt_client(hass): + LOGGER.error("MQTT integration not available") + raise ConfigEntryNotReady("MQTT integration not available") + + # Create an MQTT client for PGLab used for PGLab python module. + pglab_mqtt = PyPGLabMqttClient(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe) + + # Setup PGLab device discovery. + entry.runtime_data = PGLabDiscovery() + + # Start to discovery PG Lab devices. + await entry.runtime_data.start(hass, pglab_mqtt, entry) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: + """Unload a config entry.""" + + # Stop PGLab device discovery. + pglab_discovery = entry.runtime_data + await pglab_discovery.stop(hass, entry) + + return True diff --git a/homeassistant/components/pglab/config_flow.py b/homeassistant/components/pglab/config_flow.py new file mode 100644 index 00000000000..606de757622 --- /dev/null +++ b/homeassistant/components/pglab/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for PG LAB Electronics integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import DISCOVERY_TOPIC, DOMAIN + + +class PGLabFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_mqtt( + self, discovery_info: MqttServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by MQTT discovery.""" + + await self.async_set_unique_id(DOMAIN) + + # Validate the message, abort if it fails. + if not discovery_info.topic.endswith("/config"): + # Not a PGLab Electronics discovery message. + return self.async_abort(reason="invalid_discovery_info") + if not discovery_info.payload: + # Empty payload, unexpected payload. + return self.async_abort(reason="invalid_discovery_info") + + return await self.async_step_confirm_from_mqtt() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + try: + if not mqtt.is_connected(self.hass): + return self.async_abort(reason="mqtt_not_connected") + except KeyError: + return self.async_abort(reason="mqtt_not_configured") + + return await self.async_step_confirm_from_user() + + def step_confirm( + self, step_id: str, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + + if user_input is not None: + return self.async_create_entry( + title="PG LAB Electronics", + data={ + "discovery_prefix": DISCOVERY_TOPIC, + }, + ) + + return self.async_show_form(step_id=step_id) + + async def async_step_confirm_from_mqtt( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup from MQTT discovered.""" + return self.step_confirm(step_id="confirm_from_mqtt", user_input=user_input) + + async def async_step_confirm_from_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup from user add integration.""" + return self.step_confirm(step_id="confirm_from_user", user_input=user_input) diff --git a/homeassistant/components/pglab/const.py b/homeassistant/components/pglab/const.py new file mode 100644 index 00000000000..de076ac37f0 --- /dev/null +++ b/homeassistant/components/pglab/const.py @@ -0,0 +1,12 @@ +"""Constants used by PG LAB Electronics integration.""" + +import logging + +# The domain of the integration. +DOMAIN = "pglab" + +# The message logger. +LOGGER = logging.getLogger(__package__) + +# The MQTT message used to subscribe to get a new PG LAB device. +DISCOVERY_TOPIC = "pglab/discovery" diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py new file mode 100644 index 00000000000..af6bedc9bf4 --- /dev/null +++ b/homeassistant/components/pglab/discovery.py @@ -0,0 +1,277 @@ +"""Discovery PG LAB Electronics devices.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import json +from typing import TYPE_CHECKING, Any + +from pypglab.device import Device as PyPGLabDevice +from pypglab.mqtt import Client as PyPGLabMqttClient + +from homeassistant.components.mqtt import ( + EntitySubscription, + ReceiveMessage, + async_prepare_subscribe_topics, + async_subscribe_topics, + async_unsubscribe_topics, +) +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.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity + +from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER + +if TYPE_CHECKING: + from . import PGLABConfigEntry + +# Supported platforms. +PLATFORMS = [ + Platform.SWITCH, +] + +# Used to create a new component entity. +CREATE_NEW_ENTITY = { + Platform.SWITCH: "pglab_create_new_entity_switch", +} + + +class PGLabDiscoveryError(Exception): + """Raised when a discovery has failed.""" + + +def get_device_id_from_discovery_topic(topic: str) -> str | None: + """From the discovery topic get the PG LAB Electronics device id.""" + + # The discovery topic has the following format "pglab/discovery/[Device ID]/config" + split_topic = topic.split("/", 5) + + # Do a sanity check on the string. + if len(split_topic) != 4: + return None + + if split_topic[3] != "config": + return None + + return split_topic[2] + + +class DiscoverDeviceInfo: + """Keeps information of the PGLab discovered device.""" + + def __init__(self, pglab_device: PyPGLabDevice) -> None: + """Initialize the device discovery info.""" + + # Hash string represents the devices actual configuration, + # it depends on the number of available relays and shutters. + # When the hash string changes the devices entities must be rebuilt. + self._hash = pglab_device.hash + self._entities: list[tuple[str, str]] = [] + + def add_entity(self, entity: Entity) -> None: + """Add an entity.""" + + # PGLabEntity always have unique IDs + if TYPE_CHECKING: + assert entity.unique_id is not None + self._entities.append((entity.platform.domain, entity.unique_id)) + + @property + def hash(self) -> int: + """Return the hash for this configuration.""" + return self._hash + + @property + def entities(self) -> list[tuple[str, str]]: + """Return array of entities available.""" + return self._entities + + +@dataclass +class PGLabDiscovery: + """Discovery a PGLab device with the following MQTT topic format pglab/discovery/[device]/config.""" + + def __init__(self) -> None: + """Initialize the discovery class.""" + self._substate: dict[str, EntitySubscription] = {} + self._discovery_topic = DISCOVERY_TOPIC + self._mqtt_client = None + self._discovered: dict[str, DiscoverDeviceInfo] = {} + self._disconnect_platform: list = [] + + async def __build_device( + self, mqtt: PyPGLabMqttClient, msg: ReceiveMessage + ) -> PyPGLabDevice: + """Build a PGLab device.""" + + # Check if the discovery message is in valid json format. + try: + payload = json.loads(msg.payload) + except ValueError as err: + raise PGLabDiscoveryError( + f"Can't decode discovery payload: {msg.payload!r}" + ) from err + + device_id = "id" + + # Check if the key id is present in the payload. It must always be present. + if device_id not in payload: + raise PGLabDiscoveryError( + "Unexpected discovery payload format, id key not present" + ) + + # Do a sanity check: the id must match the discovery topic /pglab/discovery/[id]/config + topic = msg.topic + if not topic.endswith(f"{payload[device_id]}/config"): + raise PGLabDiscoveryError("Unexpected discovery topic format") + + # Build and configure the PGLab device. + pglab_device = PyPGLabDevice() + if not await pglab_device.config(mqtt, payload): + raise PGLabDiscoveryError("Error during setup of a new discovered device") + + return pglab_device + + def __clean_discovered_device(self, hass: HomeAssistant, device_id: str) -> None: + """Destroy the device and any entities connected to the device.""" + + if device_id not in self._discovered: + return + + discovery_info = self._discovered[device_id] + + # Destroy all entities connected to the device. + entity_registry = er.async_get(hass) + for platform, unique_id in discovery_info.entities: + if entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + + # Destroy the device. + device_registry = dr.async_get(hass) + if device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ): + device_registry.async_remove_device(device_entry.id) + + # Clean the discovery info. + del self._discovered[device_id] + + async def start( + self, hass: HomeAssistant, mqtt: PyPGLabMqttClient, entry: PGLABConfigEntry + ) -> None: + """Start discovering a PGLab devices.""" + + async def discovery_message_received(msg: ReceiveMessage) -> None: + """Received a new discovery message.""" + + # Create a PGLab device and add entities. + try: + pglab_device = await self.__build_device(mqtt, msg) + except PGLabDiscoveryError as err: + LOGGER.warning("Can't create PGLabDiscovery instance(%s) ", str(err)) + + # For some reason it's not possible to create the device with the discovery message, + # be sure that any previous device with the same topic is now destroyed. + device_id = get_device_id_from_discovery_topic(msg.topic) + + # If there is a valid topic device_id clean everything relative to the device. + if device_id: + self.__clean_discovered_device(hass, device_id) + + return + + # Create a new device. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + configuration_url=f"http://{pglab_device.ip}/", + connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, + identifiers={(DOMAIN, pglab_device.id)}, + manufacturer=pglab_device.manufactor, + model=pglab_device.type, + name=pglab_device.name, + sw_version=pglab_device.firmware_version, + hw_version=pglab_device.hardware_version, + ) + + # Do some checking if previous entities must be updated. + if pglab_device.id in self._discovered: + # The device is already been discovered, + # get the old discovery info data. + discovery_info = self._discovered[pglab_device.id] + + if discovery_info.hash == pglab_device.hash: + # Best case, there is nothing to do. + # The device is still in the same configuration. Same name, same shutters, same relay etc. + return + + LOGGER.warning( + "Changed internal configuration of device(%s). Rebuilding all entities", + pglab_device.id, + ) + + # Something has changed, all previous entities must be destroyed and re-created. + self.__clean_discovered_device(hass, pglab_device.id) + + # Add a new device. + discovery_info = DiscoverDeviceInfo(pglab_device) + self._discovered[pglab_device.id] = discovery_info + + # Create all new relay entities. + for r in pglab_device.relays: + # The HA entity is not yet created, send a message to create it. + async_dispatcher_send( + hass, CREATE_NEW_ENTITY[Platform.SWITCH], pglab_device, r + ) + + topics = { + "discovery_topic": { + "topic": f"{self._discovery_topic}/#", + "msg_callback": discovery_message_received, + } + } + + # Forward setup all HA supported platforms. + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + self._mqtt_client = mqtt + self._substate = async_prepare_subscribe_topics(hass, self._substate, topics) + await async_subscribe_topics(hass, self._substate) + + async def register_platform( + self, hass: HomeAssistant, platform: Platform, target: Callable[..., Any] + ): + """Register a callback to create entity of a specific HA platform.""" + disconnect_callback = async_dispatcher_connect( + hass, CREATE_NEW_ENTITY[platform], target + ) + self._disconnect_platform.append(disconnect_callback) + + async def stop(self, hass: HomeAssistant, entry: PGLABConfigEntry) -> None: + """Stop to discovery PG LAB devices.""" + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + # Disconnect all registered platforms. + for disconnect_callback in self._disconnect_platform: + disconnect_callback() + + async_unsubscribe_topics(hass, self._substate) + + async def add_entity(self, entity: Entity, device_id: str): + """Save a new PG LAB device entity.""" + + # Be sure that the device is been discovered. + if device_id not in self._discovered: + raise PGLabDiscoveryError("Unknown device, device_id not discovered") + + discovery_info = self._discovered[device_id] + discovery_info.add_entity(entity) diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py new file mode 100644 index 00000000000..1b8975a3bbe --- /dev/null +++ b/homeassistant/components/pglab/entity.py @@ -0,0 +1,70 @@ +"""Entity for PG LAB Electronics.""" + +from __future__ import annotations + +from pypglab.device import Device as PyPGLabDevice +from pypglab.entity import Entity as PyPGLabEntity + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .discovery import PGLabDiscovery + + +class PGLabEntity(Entity): + """Representation of a PGLab entity in Home Assistant.""" + + _attr_has_entity_name = True + + def __init__( + self, + discovery: PGLabDiscovery, + device: PyPGLabDevice, + entity: PyPGLabEntity, + ) -> None: + """Initialize the class.""" + + self._id = entity.id + self._device_id = device.id + self._entity = entity + self._discovery = discovery + + # Information about the device that is partially visible in the UI. + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.id)}, + name=device.name, + sw_version=device.firmware_version, + hw_version=device.hardware_version, + model=device.type, + manufacturer=device.manufactor, + configuration_url=f"http://{device.ip}/", + connections={(CONNECTION_NETWORK_MAC, device.mac)}, + ) + + async def async_added_to_hass(self) -> None: + """Update the device discovery info.""" + + self._entity.set_on_state_callback(self.state_updated) + await self._entity.subscribe_topics() + + await super().async_added_to_hass() + + # Inform PGLab discovery instance that a new entity is available. + # This is important to know in case the device needs to be reconfigured + # and the entity can be potentially destroyed. + await self._discovery.add_entity(self, self._device_id) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe when removed.""" + + await super().async_will_remove_from_hass() + + await self._entity.unsubscribe_topics() + self._entity.set_on_state_callback(None) + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + self.async_write_ha_state() diff --git a/homeassistant/components/pglab/manifest.json b/homeassistant/components/pglab/manifest.json new file mode 100644 index 00000000000..7f7d596be77 --- /dev/null +++ b/homeassistant/components/pglab/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "pglab", + "name": "PG LAB Electronics", + "codeowners": ["@pglab-electronics"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/pglab", + "iot_class": "local_push", + "loggers": ["pglab"], + "mqtt": ["pglab/discovery/#"], + "quality_scale": "bronze", + "requirements": ["pypglab==0.0.3"], + "single_config_entry": true +} diff --git a/homeassistant/components/pglab/quality_scale.yaml b/homeassistant/components/pglab/quality_scale.yaml new file mode 100644 index 00000000000..dda637e5833 --- /dev/null +++ b/homeassistant/components/pglab/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not provide any additional actions. + appropriate-polling: + status: exempt + comment: The 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: The integration does not provide any 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: + status: exempt + comment: The integration relies solely on auto-discovery. + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + docs-installation-parameters: + status: exempt + comment: There are no parameters. + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The integration does not require authentication. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: + status: exempt + comment: The integration has no settings. + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The integration does not make HTTP requests. + strict-typing: todo diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json new file mode 100644 index 00000000000..8f9021cdcca --- /dev/null +++ b/homeassistant/components/pglab/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "confirm_from_user": { + "description": "In order to be found PG LAB Electronics devices need to be connected to the same broker as the Home Assistant MQTT integration client. Do you want to continue?" + }, + "confirm_from_mqtt": { + "description": "Do you want to set up PG LAB Electronics?" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "mqtt_not_connected": "Home Assistant MQTT integration not connected to MQTT broker.", + "mqtt_not_configured": "Home Assistant MQTT integration not configured." + } + }, + "entity": { + "switch": { + "relay": { + "name": "Relay {relay_id}" + } + } + } +} diff --git a/homeassistant/components/pglab/switch.py b/homeassistant/components/pglab/switch.py new file mode 100644 index 00000000000..790ac7e7814 --- /dev/null +++ b/homeassistant/components/pglab/switch.py @@ -0,0 +1,76 @@ +"""Switch for PG LAB Electronics.""" + +from __future__ import annotations + +from typing import Any + +from pypglab.device import Device as PyPGLabDevice +from pypglab.relay import Relay as PyPGLabRelay + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PGLABConfigEntry +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PGLABConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches for device.""" + + @callback + def async_discover(pglab_device: PyPGLabDevice, pglab_relay: PyPGLabRelay) -> None: + """Discover and add a PGLab Relay.""" + pglab_discovery = config_entry.runtime_data + pglab_switch = PGLabSwitch(pglab_discovery, pglab_device, pglab_relay) + async_add_entities([pglab_switch]) + + # Register the callback to create the switch entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.SWITCH, async_discover) + + +class PGLabSwitch(PGLabEntity, SwitchEntity): + """A PGLab switch.""" + + _attr_translation_key = "relay" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_relay: PyPGLabRelay, + ) -> None: + """Initialize the Switch class.""" + + super().__init__( + discovery=pglab_discovery, + device=pglab_device, + entity=pglab_relay, + ) + + self._attr_unique_id = f"{pglab_device.id}_relay{pglab_relay.id}" + self._attr_translation_placeholders = {"relay_id": pglab_relay.id} + + self._relay = pglab_relay + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self._relay.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self._relay.turn_off() + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._relay.state diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d0a8e821f8d..01aa2d8f236 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -467,6 +467,7 @@ FLOWS = { "peco", "pegel_online", "permobil", + "pglab", "philips_js", "pi_hole", "picnic", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6c688e07f5c..05e6a4a78c4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4739,6 +4739,13 @@ "integration_type": "virtual", "supported_by": "opower" }, + "pglab": { + "name": "PG LAB Electronics", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "single_config_entry": true + }, "philips": { "name": "Philips", "integrations": { diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index 72f160ee2ec..c4eb8708b0e 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -16,6 +16,9 @@ MQTT = { "fully_kiosk": [ "fully/deviceInfo/+", ], + "pglab": [ + "pglab/discovery/#", + ], "qbus": [ "cloudapp/QBUSMQTTGW/state", "cloudapp/QBUSMQTTGW/config", diff --git a/requirements_all.txt b/requirements_all.txt index e0bbbf69a60..77b861ec918 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2207,6 +2207,9 @@ pypca==0.0.7 # homeassistant.components.lcn pypck==0.8.5 +# homeassistant.components.pglab +pypglab==0.0.3 + # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bcf50f76bc4..151ff550d56 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1800,6 +1800,9 @@ pypalazzetti==0.1.19 # homeassistant.components.lcn pypck==0.8.5 +# homeassistant.components.pglab +pypglab==0.0.3 + # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/tests/components/pglab/__init__.py b/tests/components/pglab/__init__.py new file mode 100644 index 00000000000..0ee9b203524 --- /dev/null +++ b/tests/components/pglab/__init__.py @@ -0,0 +1 @@ +"""Tests for the PG LAB Electronics integration.""" diff --git a/tests/components/pglab/conftest.py b/tests/components/pglab/conftest.py new file mode 100644 index 00000000000..b148cb08a15 --- /dev/null +++ b/tests/components/pglab/conftest.py @@ -0,0 +1,41 @@ +"""Common fixtures for the PG LAB Electronics tests.""" + +import pytest + +from homeassistant.components.pglab.const import DISCOVERY_TOPIC, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, mock_device_registry, mock_registry + +CONF_DISCOVERY_PREFIX = "discovery_prefix" + + +@pytest.fixture +def device_reg(hass: HomeAssistant): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass: HomeAssistant): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +async def setup_pglab(hass: HomeAssistant): + """Set up PG LAB Electronics.""" + hass.config.components.add("pglab") + + entry = MockConfigEntry( + data={CONF_DISCOVERY_PREFIX: DISCOVERY_TOPIC}, + domain=DOMAIN, + title="PG LAB Electronics", + ) + + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert "pglab" in hass.config.components diff --git a/tests/components/pglab/test_config_flow.py b/tests/components/pglab/test_config_flow.py new file mode 100644 index 00000000000..81ed010920e --- /dev/null +++ b/tests/components/pglab/test_config_flow.py @@ -0,0 +1,133 @@ +"""Test the PG LAB Electronics config flow.""" + +from homeassistant.components.mqtt import MQTT_CONNECTION_STATE +from homeassistant.components.pglab.const import DOMAIN +from homeassistant.config_entries import SOURCE_MQTT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from tests.common import MockConfigEntry +from tests.typing import MqttMockHAClient + + +async def test_mqtt_config_single_instance( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test MQTT flow aborts when an entry already exist.""" + + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT} + ) + + # Be sure that result is abort. Only single instance is allowed. + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="pglab/discovery/E-Board-DD53AC85/config", + payload=( + '{"ip":"192.168.1.16", "mac":"80:34:28:1B:18:5A", "name":"e-board-office",' + '"hw":"255.255.255", "fw":"255.255.255", "type":"E-Board", "id":"E-Board-DD53AC85",' + '"manufacturer":"PG LAB Electronics", "params":{"shutters":0, "boards":"10000000" } }' + ), + qos=0, + retain=False, + subscribed_topic="pglab/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["result"].data == {"discovery_prefix": "pglab/discovery"} + + +async def test_mqtt_abort_invalid_topic( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Check MQTT flow aborts if discovery topic is invalid.""" + discovery_info = MqttServiceInfo( + topic="pglab/discovery/E-Board-DD53AC85/wrong_topic", + payload=( + '{"ip":"192.168.1.16", "mac":"80:34:28:1B:18:5A", "name":"e-board-office",' + '"hw":"255.255.255", "fw":"255.255.255", "type":"E-Board", "id":"E-Board-DD53AC85",' + '"manufacturer":"PG LAB Electronics", "params":{"shutters":0, "boards":"10000000" } }' + ), + qos=0, + retain=False, + subscribed_topic="pglab/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + discovery_info = MqttServiceInfo( + topic="pglab/discovery/E-Board-DD53AC85/config", + payload="", + qos=0, + retain=False, + subscribed_topic="pglab/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_MQTT}, data=discovery_info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_discovery_info" + + +async def test_user_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test if the user can finish a config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].data == { + "discovery_prefix": "pglab/discovery", + } + + +async def test_user_setup_mqtt_not_connected( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test that the user setup is aborted when MQTT is not connected.""" + + mqtt_mock.connected = False + async_dispatcher_send(hass, MQTT_CONNECTION_STATE, False) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "mqtt_not_connected" + + +async def test_user_setup_mqtt_not_configured(hass: HomeAssistant) -> None: + """Test that the user setup is aborted when MQTT is not configured.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "mqtt_not_configured" diff --git a/tests/components/pglab/test_discovery.py b/tests/components/pglab/test_discovery.py new file mode 100644 index 00000000000..65716236277 --- /dev/null +++ b/tests/components/pglab/test_discovery.py @@ -0,0 +1,154 @@ +"""The tests for the PG LAB Electronics discovery device.""" + +import json + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_device_discover( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + device_reg, + entity_reg, + setup_pglab, +) -> None: + """Test setting up a device.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Verify device and registry entries are created + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is not None + assert device_entry.configuration_url == f"http://{payload['ip']}/" + assert device_entry.manufacturer == "PG LAB Electronics" + assert device_entry.model == payload["type"] + assert device_entry.name == payload["name"] + assert device_entry.sw_version == payload["fw"] + + +async def test_device_update( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + device_reg, + entity_reg, + setup_pglab, + snapshot: SnapshotAssertion, +) -> None: + """Test update a device.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Verify device is created + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is not None + + # update device + payload["fw"] = "1.0.1" + payload["hw"] = "1.0.8" + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Verify device is created + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is not None + assert device_entry.sw_version == "1.0.1" + assert device_entry.hw_version == "1.0.8" + + +async def test_device_remove( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + device_reg, + entity_reg, + setup_pglab, +) -> None: + """Test remove a device.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Verify device is created + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is not None + + async_fire_mqtt_message( + hass, + topic, + "", + ) + await hass.async_block_till_done() + + # Verify device entry is removed + device_entry = device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, payload["mac"])} + ) + assert device_entry is None diff --git a/tests/components/pglab/test_init.py b/tests/components/pglab/test_init.py new file mode 100644 index 00000000000..a6353054e8c --- /dev/null +++ b/tests/components/pglab/test_init.py @@ -0,0 +1 @@ +"""Test the PG LAB Electronics integration.""" diff --git a/tests/components/pglab/test_switch.py b/tests/components/pglab/test_switch.py new file mode 100644 index 00000000000..fef445f80f3 --- /dev/null +++ b/tests/components/pglab/test_switch.py @@ -0,0 +1,318 @@ +"""The tests for the PG LAB Electronics switch.""" + +from datetime import timedelta +import json + +from homeassistant import config_entries +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_mqtt_message, async_fire_time_changed +from tests.typing import MqttMockHAClient + + +async def call_service(hass: HomeAssistant, entity_id, service, **kwargs): + """Call a service.""" + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **kwargs}, + blocking=True, + ) + + +async def test_available_relay( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Check if relay are properly created when two E-Relay boards are connected.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + for i in range(16): + state = hass.states.get(f"switch.test_relay_{i}") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_change_state_via_mqtt( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Test state update via MQTT.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Simulate response from the device + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Turn relay OFF + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "OFF") + await hass.async_block_till_done() + state = hass.states.get("switch.test_relay_0") + assert not state.attributes.get(ATTR_ASSUMED_STATE) + assert state.state == STATE_OFF + + # Turn relay ON + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON") + await hass.async_block_till_done() + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_ON + + # Turn relay OFF + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "OFF") + await hass.async_block_till_done() + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_OFF + + # Turn relay ON + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON") + await hass.async_block_till_done() + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_ON + + +async def test_mqtt_state_by_calling_service( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Calling service to turn ON/OFF relay and check mqtt state.""" + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Turn relay ON + await call_service(hass, "switch.test_relay_0", SERVICE_TURN_ON) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/relay/0/set", "ON", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Turn relay OFF + await call_service(hass, "switch.test_relay_0", SERVICE_TURN_OFF) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/relay/0/set", "OFF", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Turn relay ON + await call_service(hass, "switch.test_relay_3", SERVICE_TURN_ON) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/relay/3/set", "ON", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Turn relay OFF + await call_service(hass, "switch.test_relay_3", SERVICE_TURN_OFF) + mqtt_mock.async_publish.assert_called_once_with( + "pglab/test/relay/3/set", "OFF", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + +async def test_discovery_update( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_pglab +) -> None: + """Update discovery message and check if relay are property updated.""" + + # publish the first discovery message + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "first_test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # test the available relay in the first configuration + for i in range(8): + state = hass.states.get(f"switch.first_test_relay_{i}") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # prepare a new message ... the same device but renamed + # and with different relay configuration + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "second_test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "11000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # be sure that old relay are been removed + for i in range(8): + assert not hass.states.get(f"switch.first_test_relay_{i}") + + # check new relay + for i in range(16): + state = hass.states.get(f"switch.second_test_relay_{i}") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_disable_entity_state_change_via_mqtt( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mqtt_mock: MqttMockHAClient, + setup_pglab, +) -> None: + """Test state update via MQTT of disable entity.""" + + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "10000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Be sure that the entity relay_0 is available + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Disable entity relay_0 + new_status = entity_registry.async_update_entity( + "switch.test_relay_0", disabled_by=er.RegistryEntryDisabler.USER + ) + + # Be sure that the entity is disabled + assert new_status.disabled is True + + # Try to change the state of the disabled relay_0 + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON") + await hass.async_block_till_done() + + # Enable entity relay_0 + new_status = entity_registry.async_update_entity( + "switch.test_relay_0", disabled_by=None + ) + + # Be sure that the entity is enabled + assert new_status.disabled is False + + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Re-send the discovery message + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + # Be sure that the state is not changed + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_UNKNOWN + + # Try again to change the state of the disabled relay_0 + async_fire_mqtt_message(hass, "pglab/test/relay/0/state", "ON") + await hass.async_block_till_done() + + # Be sure that the state is been updated + state = hass.states.get("switch.test_relay_0") + assert state.state == STATE_ON From 38b8df8f6f9fa3391272591612e5176e1b2d7648 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Mon, 10 Feb 2025 20:17:02 +1030 Subject: [PATCH 0349/1941] Prevent crash if telegram message failed and did not generate an ID (#137989) Fix #137901 - Regression introduced in 6fdccda2256f92c824a98712ef102b4a77140126 --- homeassistant/components/telegram_bot/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index fa3ec1dc4f7..b3c09049ae5 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -756,7 +756,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) - msg_ids[chat_id] = msg.id + if msg is not None: + msg_ids[chat_id] = msg.id return msg_ids async def delete_message(self, chat_id=None, context=None, **kwargs): From de86e4bd3c56138f9118ad2398c882bb66f78bee Mon Sep 17 00:00:00 2001 From: kiran Bhakre Date: Mon, 10 Feb 2025 10:39:48 +0000 Subject: [PATCH 0350/1941] Add authorities to london_air (#137349) * Update sensor.py added Hounslow and hammersmith * Update sensor.py maintain the alphabetical order * Update homeassistant/components/london_air/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/london_air/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index 81133433d05..a4d34fcb2d6 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -37,10 +37,12 @@ AUTHORITIES = [ "Enfield", "Greenwich", "Hackney", + "Hammersmith and Fulham", "Haringey", "Harrow", "Havering", "Hillingdon", + "Hounslow", "Islington", "Kensington and Chelsea", "Kingston", From b89f9a59618126fe7e9c3ffd2de7e085f5887f52 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 10 Feb 2025 12:41:28 +0100 Subject: [PATCH 0351/1941] Bump onedrive-personal-sdk to 0.0.10 (#138186) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/onedrive/const.py | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index fcc922b3e46..899a5e77b47 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.9"] + "requirements": ["onedrive-personal-sdk==0.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 77b861ec918..1f5f6fcad9f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1559,7 +1559,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.9 +onedrive-personal-sdk==0.0.10 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 151ff550d56..08331bcac87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1307,7 +1307,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.9 +onedrive-personal-sdk==0.0.10 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 3739369887d..3ba54dc40d7 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -5,10 +5,10 @@ from json import dumps from onedrive_personal_sdk.models.items import ( AppRoot, - Contributor, File, Folder, Hashes, + IdentitySet, ItemParentReference, User, ) @@ -31,7 +31,7 @@ BACKUP_METADATA = { "size": 34519040, } -CONTRIBUTOR = Contributor( +IDENTITY_SET = IdentitySet( user=User( display_name="John Doe", id="id", @@ -47,7 +47,7 @@ MOCK_APPROOT = AppRoot( parent_reference=ItemParentReference( drive_id="mock_drive_id", id="id", path="path" ), - created_by=CONTRIBUTOR, + created_by=IDENTITY_SET, ) MOCK_BACKUP_FOLDER = Folder( @@ -58,7 +58,7 @@ MOCK_BACKUP_FOLDER = Folder( parent_reference=ItemParentReference( drive_id="mock_drive_id", id="id", path="path" ), - created_by=CONTRIBUTOR, + created_by=IDENTITY_SET, ) MOCK_BACKUP_FILE = File( @@ -73,7 +73,7 @@ MOCK_BACKUP_FILE = File( ), mime_type="application/x-tar", description="", - created_by=CONTRIBUTOR, + created_by=IDENTITY_SET, ) MOCK_METADATA_FILE = File( @@ -96,5 +96,5 @@ MOCK_METADATA_FILE = File( } ) ), - created_by=CONTRIBUTOR, + created_by=IDENTITY_SET, ) From e1d3549ce3c6f4293a33690c0ce52c8935b8c828 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 10 Feb 2025 12:53:44 +0100 Subject: [PATCH 0352/1941] Improve blueprint importer typing (#138194) --- homeassistant/components/blueprint/importer.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index 8582761bafb..83afa511b68 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -6,6 +6,7 @@ from contextlib import suppress from dataclasses import dataclass import html import re +from typing import TYPE_CHECKING import voluptuous as vol import yarl @@ -195,8 +196,8 @@ async def fetch_blueprint_from_github_gist_url( ) gist = await resp.json() - blueprint = None - filename = None + blueprint: Blueprint | None = None + filename: str | None = None content: str for filename, info in gist["files"].items(): @@ -218,6 +219,8 @@ async def fetch_blueprint_from_github_gist_url( "No valid blueprint found in the gist. The blueprint file needs to end with" " '.yaml'" ) + if TYPE_CHECKING: + assert isinstance(filename, str) return ImportedBlueprint( f"{gist['owner']['login']}/{filename[:-5]}", content, blueprint From 9e04f618b81e4ff73053eb72e9439992dbff1c9f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 10 Feb 2025 13:11:33 +0100 Subject: [PATCH 0353/1941] Adjust 'Install all test requirements' task to include base requirements (#137642) --- .vscode/tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7425e7a2533..b699ed44b96 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -148,7 +148,7 @@ { "label": "Install all Test Requirements", "type": "shell", - "command": "uv pip install -r requirements_test_all.txt", + "command": "uv pip install -r requirements.txt -r requirements_test_all.txt", "group": { "kind": "build", "isDefault": true From 854af1449b0706cc6e7ce6bed0b1490b77ff6cbf Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 10 Feb 2025 05:50:35 -0700 Subject: [PATCH 0354/1941] Bump pybalboa to 1.1.2 (#138139) * Bump pybalboa to 1.1.1 * Bump pybalboa to 1.1.2 --- homeassistant/components/balboa/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index 867e277358c..61cb5bbbf69 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], - "requirements": ["pybalboa==1.0.2"] + "requirements": ["pybalboa==1.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1f5f6fcad9f..924bf5cd31b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1825,7 +1825,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.0.2 +pybalboa==1.1.2 # homeassistant.components.bbox pybbox==0.0.5-alpha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08331bcac87..a2699793935 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1505,7 +1505,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.0.2 +pybalboa==1.1.2 # homeassistant.components.blackbird pyblackbird==0.6 From 4b34d1bbb57e2183ef710d844735a4648455260c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 10 Feb 2025 16:40:07 +0100 Subject: [PATCH 0355/1941] Merge config subentry feature branch to dev (#136121) * Reapply "Add support for subentries to config entries" (#133470) (#136061) * Reapply "Add support for subentries to config entries" (#133470) This reverts commit ecb3bf79f32a2e25d141ff467e5958826ed9fc3a. * Update test snapshot * Add config subentry support to device registry (#128157) * Add config subentry support to device registry * Apply suggestions from code review * Update syrupy serializer * Update snapshots * Address review comments * Allow a device to be connected to no or a single subentry of a config entry * Update snapshots * Revert "Allow a device to be connected to no or a single subentry of a config entry" This reverts commit ec6f613151cb4a806b7961033c004b71b76510c2. * Update test snapshots * Bump release version in comments * Rename config_subentries to config_entries_subentries * Add config subentry support to entity registry (#128155) * Add config subentry support to entity registry * Update syrupy serializer * Update snapshots * Update snapshots * Accept suggested changes * Clean registries when removing subentry (#136671) * Clean up registries when removing subentry * Update tests * Clean up subentries from deleted devices when removing config entry (#136669) * Clean up subentries from deleted devices when removing config entry * Move * Add config subentry support to entity platform (#128161) * Add config subentry support to entity platform * Rename subentry_id to config_subentry_id * Store subentry type in subentry (#136687) * Add reconfigure support to config subentries (#133353) * Add reconfigure support to config subentries * Update test * Minor adjustment * Rename supported_subentry_flows to supported_subentry_types * Address review comments * Add subentry support to kitchen sink (#136755) * Add subentry support to kitchen sink * Add subentry reconfigure support to kitchen_sink * Update kitchen_sink tests with subentry type stored in config entry * Update kitchen_sink * Update kitchen_sink * Adjust kitchen sink tests * Fix hassfest * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Improve docstrings and strings.json --------- Co-authored-by: Martin Hjelmare * Update snapshots * Update snapshots * Update snapshots * Update snapshots * Update snapshots * Update snapshots * Update snapshots --------- Co-authored-by: Martin Hjelmare --- .../components/config/config_entries.py | 130 +++ .../components/kitchen_sink/__init__.py | 13 +- .../components/kitchen_sink/config_flow.py | 67 ++ .../components/kitchen_sink/sensor.py | 22 +- .../components/kitchen_sink/strings.json | 21 + homeassistant/config_entries.py | 461 +++++++++- homeassistant/helpers/data_entry_flow.py | 4 +- homeassistant/helpers/device_registry.py | 214 ++++- homeassistant/helpers/entity_platform.py | 54 +- homeassistant/helpers/entity_registry.py | 89 +- script/hassfest/translations.py | 9 + tests/common.py | 24 + .../acaia/snapshots/test_binary_sensor.ambr | 1 + .../acaia/snapshots/test_button.ambr | 3 + .../components/acaia/snapshots/test_init.ambr | 1 + .../acaia/snapshots/test_sensor.ambr | 3 + .../accuweather/snapshots/test_sensor.ambr | 131 +++ .../accuweather/snapshots/test_weather.ambr | 1 + .../aemet/snapshots/test_diagnostics.ambr | 2 + .../airgradient/snapshots/test_button.ambr | 3 + .../airgradient/snapshots/test_init.ambr | 2 + .../airgradient/snapshots/test_number.ambr | 2 + .../airgradient/snapshots/test_select.ambr | 11 + .../airgradient/snapshots/test_sensor.ambr | 29 + .../airgradient/snapshots/test_switch.ambr | 1 + .../airgradient/snapshots/test_update.ambr | 1 + .../airly/snapshots/test_diagnostics.ambr | 2 + .../airly/snapshots/test_sensor.ambr | 11 + .../airnow/snapshots/test_diagnostics.ambr | 2 + .../airtouch5/snapshots/test_cover.ambr | 2 + .../airvisual/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../airzone/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_sensor.ambr | 50 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_sensor.ambr | 7 + .../aosmith/snapshots/test_device.ambr | 1 + .../aosmith/snapshots/test_sensor.ambr | 2 + .../aosmith/snapshots/test_water_heater.ambr | 2 + .../snapshots/test_binary_sensor.ambr | 4 + .../apsystems/snapshots/test_number.ambr | 1 + .../apsystems/snapshots/test_sensor.ambr | 9 + .../apsystems/snapshots/test_switch.ambr | 1 + .../aquacell/snapshots/test_sensor.ambr | 6 + .../arve/snapshots/test_sensor.ambr | 7 + .../august/snapshots/test_binary_sensor.ambr | 1 + .../august/snapshots/test_lock.ambr | 1 + .../autarco/snapshots/test_sensor.ambr | 16 + .../axis/snapshots/test_binary_sensor.ambr | 11 + .../axis/snapshots/test_camera.ambr | 2 + .../axis/snapshots/test_diagnostics.ambr | 2 + tests/components/axis/snapshots/test_hub.ambr | 2 + .../components/axis/snapshots/test_light.ambr | 1 + .../axis/snapshots/test_switch.ambr | 4 + .../azure_devops/snapshots/test_sensor.ambr | 10 + .../balboa/snapshots/test_binary_sensor.ambr | 3 + .../balboa/snapshots/test_climate.ambr | 1 + .../components/balboa/snapshots/test_fan.ambr | 1 + .../balboa/snapshots/test_light.ambr | 1 + .../balboa/snapshots/test_select.ambr | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../blink/snapshots/test_diagnostics.ambr | 2 + .../bluemaestro/snapshots/test_sensor.ambr | 5 + .../snapshots/test_binary_sensor.ambr | 29 + .../snapshots/test_button.ambr | 19 + .../snapshots/test_lock.ambr | 4 + .../snapshots/test_number.ambr | 2 + .../snapshots/test_select.ambr | 5 + .../snapshots/test_sensor.ambr | 62 ++ .../snapshots/test_switch.ambr | 4 + .../braviatv/snapshots/test_diagnostics.ambr | 2 + .../bring/snapshots/test_event.ambr | 2 + .../bring/snapshots/test_sensor.ambr | 10 + .../components/bring/snapshots/test_todo.ambr | 2 + .../brother/snapshots/test_sensor.ambr | 28 + .../snapshots/test_climate.ambr | 1 + .../bsblan/snapshots/test_climate.ambr | 2 + .../bsblan/snapshots/test_sensor.ambr | 2 + .../bsblan/snapshots/test_water_heater.ambr | 1 + .../cambridge_audio/snapshots/test_init.ambr | 3 +- .../snapshots/test_select.ambr | 2 + .../snapshots/test_switch.ambr | 2 + .../ccm15/snapshots/test_climate.ambr | 4 + .../chacon_dio/snapshots/test_cover.ambr | 1 + .../chacon_dio/snapshots/test_switch.ambr | 1 + .../co2signal/snapshots/test_diagnostics.ambr | 2 + .../co2signal/snapshots/test_sensor.ambr | 2 + .../coinbase/snapshots/test_diagnostics.ambr | 2 + .../comelit/snapshots/test_diagnostics.ambr | 4 + .../components/config/test_config_entries.py | 531 +++++++++++ .../components/config/test_device_registry.py | 3 + .../components/config/test_entity_registry.py | 16 + .../cookidoo/snapshots/test_button.ambr | 1 + .../cookidoo/snapshots/test_sensor.ambr | 2 + .../cookidoo/snapshots/test_todo.ambr | 2 + .../deako/snapshots/test_light.ambr | 3 + .../snapshots/test_alarm_control_panel.ambr | 1 + .../deconz/snapshots/test_binary_sensor.ambr | 21 + .../deconz/snapshots/test_button.ambr | 2 + .../deconz/snapshots/test_climate.ambr | 7 + .../deconz/snapshots/test_cover.ambr | 3 + .../deconz/snapshots/test_diagnostics.ambr | 2 + .../components/deconz/snapshots/test_fan.ambr | 1 + .../components/deconz/snapshots/test_hub.ambr | 1 + .../deconz/snapshots/test_light.ambr | 19 + .../deconz/snapshots/test_number.ambr | 2 + .../deconz/snapshots/test_scene.ambr | 1 + .../deconz/snapshots/test_select.ambr | 10 + .../deconz/snapshots/test_sensor.ambr | 44 + .../snapshots/test_binary_sensor.ambr | 3 + .../snapshots/test_climate.ambr | 1 + .../snapshots/test_cover.ambr | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_light.ambr | 2 + .../snapshots/test_sensor.ambr | 5 + .../snapshots/test_siren.ambr | 3 + .../snapshots/test_switch.ambr | 1 + .../snapshots/test_binary_sensor.ambr | 1 + .../snapshots/test_button.ambr | 4 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_image.ambr | 1 + .../snapshots/test_init.ambr | 2 + .../snapshots/test_sensor.ambr | 6 + .../snapshots/test_switch.ambr | 2 + .../snapshots/test_update.ambr | 1 + .../discovergy/snapshots/test_sensor.ambr | 5 + .../snapshots/test_binary_sensor.ambr | 10 + .../snapshots/test_diagnostics.ambr | 2 + .../ecovacs/snapshots/test_binary_sensor.ambr | 1 + .../ecovacs/snapshots/test_button.ambr | 13 + .../ecovacs/snapshots/test_diagnostics.ambr | 4 + .../ecovacs/snapshots/test_event.ambr | 1 + .../ecovacs/snapshots/test_init.ambr | 1 + .../ecovacs/snapshots/test_lawn_mower.ambr | 2 + .../ecovacs/snapshots/test_number.ambr | 3 + .../ecovacs/snapshots/test_select.ambr | 1 + .../ecovacs/snapshots/test_sensor.ambr | 44 + .../ecovacs/snapshots/test_switch.ambr | 10 + .../eheimdigital/snapshots/test_climate.ambr | 2 + .../eheimdigital/snapshots/test_light.ambr | 5 + .../elgato/snapshots/test_button.ambr | 4 + .../elgato/snapshots/test_light.ambr | 6 + .../elgato/snapshots/test_sensor.ambr | 10 + .../elgato/snapshots/test_switch.ambr | 4 + .../snapshots/test_alarm_control_panel.ambr | 3 + .../elmax/snapshots/test_binary_sensor.ambr | 8 + .../elmax/snapshots/test_cover.ambr | 1 + .../elmax/snapshots/test_switch.ambr | 1 + .../emoncms/snapshots/test_sensor.ambr | 1 + .../snapshots/test_switch.ambr | 4 + .../energyzero/snapshots/test_sensor.ambr | 11 + .../snapshots/test_binary_sensor.ambr | 6 + .../snapshots/test_diagnostics.ambr | 54 ++ .../enphase_envoy/snapshots/test_number.ambr | 8 + .../enphase_envoy/snapshots/test_select.ambr | 14 + .../enphase_envoy/snapshots/test_sensor.ambr | 470 ++++++++++ .../enphase_envoy/snapshots/test_switch.ambr | 6 + .../esphome/snapshots/test_diagnostics.ambr | 2 + tests/components/esphome/test_diagnostics.py | 1 + .../filesize/snapshots/test_sensor.ambr | 4 + .../snapshots/test_binary_sensor.ambr | 1 + .../flexit_bacnet/snapshots/test_climate.ambr | 1 + .../flexit_bacnet/snapshots/test_number.ambr | 11 + .../flexit_bacnet/snapshots/test_sensor.ambr | 15 + .../flexit_bacnet/snapshots/test_switch.ambr | 2 + .../folder_watcher/snapshots/test_event.ambr | 1 + .../forecast_solar/snapshots/test_init.ambr | 2 + .../fritz/snapshots/test_button.ambr | 5 + .../fritz/snapshots/test_diagnostics.ambr | 2 + .../fritz/snapshots/test_sensor.ambr | 16 + .../fritz/snapshots/test_switch.ambr | 12 + .../fritz/snapshots/test_update.ambr | 3 + .../fronius/snapshots/test_diagnostics.ambr | 2 + .../fronius/snapshots/test_sensor.ambr | 181 ++++ .../snapshots/test_climate.ambr | 2 + .../fujitsu_fglair/snapshots/test_sensor.ambr | 2 + .../fyta/snapshots/test_binary_sensor.ambr | 16 + .../fyta/snapshots/test_diagnostics.ambr | 2 + .../components/fyta/snapshots/test_image.ambr | 2 + .../fyta/snapshots/test_sensor.ambr | 30 + .../snapshots/test_binary_sensor.ambr | 1 + .../snapshots/test_sensor.ambr | 4 + .../snapshots/test_config_flow.ambr | 8 + .../snapshots/test_init.ambr | 1 + .../snapshots/test_binary_sensor.ambr | 1 + .../geniushub/snapshots/test_climate.ambr | 7 + .../geniushub/snapshots/test_sensor.ambr | 18 + .../geniushub/snapshots/test_switch.ambr | 3 + .../gios/snapshots/test_diagnostics.ambr | 2 + .../gios/snapshots/test_sensor.ambr | 13 + .../glances/snapshots/test_sensor.ambr | 34 + .../goodwe/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../gree/snapshots/test_climate.ambr | 1 + .../gree/snapshots/test_switch.ambr | 5 + tests/components/guardian/test_diagnostics.py | 1 + .../snapshots/test_binary_sensor.ambr | 1 + .../habitica/snapshots/test_button.ambr | 28 + .../habitica/snapshots/test_calendar.ambr | 4 + .../habitica/snapshots/test_sensor.ambr | 23 + .../habitica/snapshots/test_switch.ambr | 1 + .../habitica/snapshots/test_todo.ambr | 2 + .../heos/snapshots/test_diagnostics.ambr | 5 + tests/components/heos/test_diagnostics.py | 1 + .../snapshots/test_init.ambr | 864 ++++++++++++++++++ .../homewizard/snapshots/test_button.ambr | 2 + .../snapshots/test_config_flow.ambr | 16 + .../homewizard/snapshots/test_number.ambr | 4 + .../homewizard/snapshots/test_sensor.ambr | 462 ++++++++++ .../homewizard/snapshots/test_switch.ambr | 22 + .../snapshots/test_binary_sensor.ambr | 6 + .../snapshots/test_button.ambr | 3 + .../snapshots/test_device_tracker.ambr | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_init.ambr | 1 + .../snapshots/test_number.ambr | 4 + .../snapshots/test_sensor.ambr | 23 + .../snapshots/test_switch.ambr | 7 + .../snapshots/test_init.ambr | 1 + .../snapshots/test_binary_sensor.ambr | 4 + .../hydrawise/snapshots/test_sensor.ambr | 12 + .../hydrawise/snapshots/test_switch.ambr | 4 + .../hydrawise/snapshots/test_valve.ambr | 2 + .../igloohome/snapshots/test_sensor.ambr | 1 + .../imgw_pib/snapshots/test_diagnostics.ambr | 2 + .../imgw_pib/snapshots/test_sensor.ambr | 2 + .../snapshots/test_binary_sensor.ambr | 20 + .../incomfort/snapshots/test_climate.ambr | 4 + .../incomfort/snapshots/test_sensor.ambr | 3 + .../snapshots/test_water_heater.ambr | 1 + .../snapshots/test_binary_sensor.ambr | 17 + .../intellifire/snapshots/test_climate.ambr | 1 + .../intellifire/snapshots/test_sensor.ambr | 10 + .../iotty/snapshots/test_switch.ambr | 2 + .../components/ipp/snapshots/test_sensor.ambr | 7 + .../iqvia/snapshots/test_diagnostics.ambr | 2 + .../iron_os/snapshots/test_binary_sensor.ambr | 1 + .../iron_os/snapshots/test_button.ambr | 2 + .../iron_os/snapshots/test_number.ambr | 19 + .../iron_os/snapshots/test_select.ambr | 9 + .../iron_os/snapshots/test_sensor.ambr | 13 + .../iron_os/snapshots/test_switch.ambr | 7 + .../iron_os/snapshots/test_update.ambr | 1 + .../israel_rail/snapshots/test_sensor.ambr | 6 + .../ista_ecotrend/snapshots/test_init.ambr | 2 + .../ista_ecotrend/snapshots/test_sensor.ambr | 16 + .../ituran/snapshots/test_device_tracker.ambr | 1 + .../ituran/snapshots/test_init.ambr | 1 + .../ituran/snapshots/test_sensor.ambr | 6 + .../kitchen_sink/snapshots/test_sensor.ambr | 81 ++ .../kitchen_sink/snapshots/test_switch.ambr | 6 + .../kitchen_sink/test_config_flow.py | 82 ++ tests/components/kitchen_sink/test_sensor.py | 32 +- .../knocki/snapshots/test_event.ambr | 1 + .../kostal_plenticore/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_binary_sensor.ambr | 4 + .../lamarzocco/snapshots/test_button.ambr | 1 + .../lamarzocco/snapshots/test_calendar.ambr | 2 + .../lamarzocco/snapshots/test_init.ambr | 2 + .../lamarzocco/snapshots/test_number.ambr | 14 + .../lamarzocco/snapshots/test_select.ambr | 6 + .../lamarzocco/snapshots/test_sensor.ambr | 10 + .../lamarzocco/snapshots/test_switch.ambr | 7 + .../lamarzocco/snapshots/test_update.ambr | 2 + .../lcn/snapshots/test_binary_sensor.ambr | 3 + .../lcn/snapshots/test_climate.ambr | 1 + .../components/lcn/snapshots/test_cover.ambr | 2 + .../components/lcn/snapshots/test_light.ambr | 3 + .../components/lcn/snapshots/test_scene.ambr | 2 + .../components/lcn/snapshots/test_sensor.ambr | 4 + .../components/lcn/snapshots/test_switch.ambr | 7 + .../snapshots/test_binary_sensor.ambr | 10 + .../lektrico/snapshots/test_button.ambr | 3 + .../lektrico/snapshots/test_init.ambr | 1 + .../lektrico/snapshots/test_number.ambr | 2 + .../lektrico/snapshots/test_select.ambr | 1 + .../lektrico/snapshots/test_sensor.ambr | 10 + .../lektrico/snapshots/test_switch.ambr | 2 + .../letpot/snapshots/test_switch.ambr | 4 + .../letpot/snapshots/test_time.ambr | 2 + .../lg_thinq/snapshots/test_climate.ambr | 1 + .../lg_thinq/snapshots/test_event.ambr | 1 + .../lg_thinq/snapshots/test_number.ambr | 2 + .../lg_thinq/snapshots/test_sensor.ambr | 10 +- .../snapshots/test_cover.ambr | 4 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_light.ambr | 4 + .../madvr/snapshots/test_binary_sensor.ambr | 4 + .../madvr/snapshots/test_diagnostics.ambr | 2 + .../madvr/snapshots/test_remote.ambr | 1 + .../madvr/snapshots/test_sensor.ambr | 26 + .../mastodon/snapshots/test_init.ambr | 1 + .../mastodon/snapshots/test_sensor.ambr | 3 + .../matter/snapshots/test_binary_sensor.ambr | 14 + .../matter/snapshots/test_button.ambr | 40 + .../matter/snapshots/test_climate.ambr | 4 + .../matter/snapshots/test_cover.ambr | 5 + .../matter/snapshots/test_event.ambr | 6 + .../components/matter/snapshots/test_fan.ambr | 4 + .../matter/snapshots/test_light.ambr | 10 + .../matter/snapshots/test_lock.ambr | 2 + .../matter/snapshots/test_number.ambr | 29 + .../matter/snapshots/test_select.ambr | 32 + .../matter/snapshots/test_sensor.ambr | 68 ++ .../matter/snapshots/test_switch.ambr | 10 + .../matter/snapshots/test_vacuum.ambr | 1 + .../matter/snapshots/test_valve.ambr | 1 + .../mealie/snapshots/test_calendar.ambr | 4 + .../mealie/snapshots/test_init.ambr | 1 + .../mealie/snapshots/test_sensor.ambr | 5 + .../mealie/snapshots/test_todo.ambr | 3 + .../melcloud/snapshots/test_diagnostics.ambr | 2 + .../meteo_france/snapshots/test_sensor.ambr | 15 + .../meteo_france/snapshots/test_weather.ambr | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_binary_sensor.ambr | 1 + .../snapshots/test_button.ambr | 1 + .../snapshots/test_climate.ambr | 1 + .../snapshots/test_sensor.ambr | 1 + .../monarch_money/snapshots/test_sensor.ambr | 22 + .../monzo/snapshots/test_sensor.ambr | 5 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_media_player.ambr | 3 + .../snapshots/test_binary_sensor.ambr | 7 + .../myuplink/snapshots/test_init.ambr | 3 + .../myuplink/snapshots/test_number.ambr | 8 + .../myuplink/snapshots/test_select.ambr | 2 + .../myuplink/snapshots/test_sensor.ambr | 94 ++ .../myuplink/snapshots/test_switch.ambr | 4 + .../components/nam/snapshots/test_sensor.ambr | 32 + .../netatmo/snapshots/test_binary_sensor.ambr | 11 + .../netatmo/snapshots/test_button.ambr | 2 + .../netatmo/snapshots/test_camera.ambr | 3 + .../netatmo/snapshots/test_climate.ambr | 5 + .../netatmo/snapshots/test_cover.ambr | 2 + .../netatmo/snapshots/test_diagnostics.ambr | 2 + .../netatmo/snapshots/test_fan.ambr | 1 + .../netatmo/snapshots/test_init.ambr | 39 + .../netatmo/snapshots/test_light.ambr | 3 + .../netatmo/snapshots/test_select.ambr | 1 + .../netatmo/snapshots/test_sensor.ambr | 143 +++ .../netatmo/snapshots/test_switch.ambr | 1 + .../netgear_lte/snapshots/test_init.ambr | 1 + .../snapshots/test_binary_sensor.ambr | 6 + .../nextcloud/snapshots/test_sensor.ambr | 80 ++ .../nextcloud/snapshots/test_update.ambr | 1 + .../nextdns/snapshots/test_binary_sensor.ambr | 2 + .../nextdns/snapshots/test_button.ambr | 1 + .../nextdns/snapshots/test_diagnostics.ambr | 2 + .../nextdns/snapshots/test_sensor.ambr | 25 + .../nextdns/snapshots/test_switch.ambr | 73 ++ .../nice_go/snapshots/test_cover.ambr | 4 + .../nice_go/snapshots/test_diagnostics.ambr | 2 + .../nice_go/snapshots/test_light.ambr | 2 + .../snapshots/test_cover.ambr | 1 + .../snapshots/test_light.ambr | 2 + .../nordpool/snapshots/test_sensor.ambr | 48 + tests/components/notion/test_diagnostics.py | 1 + .../nuki/snapshots/test_binary_sensor.ambr | 5 + .../components/nuki/snapshots/test_lock.ambr | 2 + .../nuki/snapshots/test_sensor.ambr | 1 + .../nyt_games/snapshots/test_init.ambr | 3 + .../nyt_games/snapshots/test_sensor.ambr | 12 + .../ohme/snapshots/test_button.ambr | 1 + .../components/ohme/snapshots/test_init.ambr | 1 + .../ohme/snapshots/test_number.ambr | 1 + .../ohme/snapshots/test_select.ambr | 1 + .../ohme/snapshots/test_sensor.ambr | 6 + .../ohme/snapshots/test_switch.ambr | 3 + .../components/ohme/snapshots/test_time.ambr | 1 + .../omnilogic/snapshots/test_sensor.ambr | 2 + .../omnilogic/snapshots/test_switch.ambr | 2 + .../ondilo_ico/snapshots/test_init.ambr | 2 + .../ondilo_ico/snapshots/test_sensor.ambr | 14 + .../onewire/snapshots/test_binary_sensor.ambr | 16 + .../onewire/snapshots/test_init.ambr | 22 + .../onewire/snapshots/test_select.ambr | 1 + .../onewire/snapshots/test_sensor.ambr | 58 ++ .../onewire/snapshots/test_switch.ambr | 37 + .../onvif/snapshots/test_diagnostics.ambr | 2 + tests/components/openuv/test_diagnostics.py | 1 + .../snapshots/test_water_heater.ambr | 1 + .../overseerr/snapshots/test_event.ambr | 1 + .../overseerr/snapshots/test_init.ambr | 1 + .../overseerr/snapshots/test_sensor.ambr | 7 + .../p1_monitor/snapshots/test_init.ambr | 4 + .../palazzetti/snapshots/test_button.ambr | 1 + .../palazzetti/snapshots/test_climate.ambr | 1 + .../palazzetti/snapshots/test_init.ambr | 1 + .../palazzetti/snapshots/test_number.ambr | 3 + .../palazzetti/snapshots/test_sensor.ambr | 9 + .../peblar/snapshots/test_binary_sensor.ambr | 2 + .../peblar/snapshots/test_button.ambr | 2 + .../peblar/snapshots/test_init.ambr | 1 + .../peblar/snapshots/test_number.ambr | 1 + .../peblar/snapshots/test_select.ambr | 1 + .../peblar/snapshots/test_sensor.ambr | 16 + .../peblar/snapshots/test_switch.ambr | 2 + .../peblar/snapshots/test_update.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../components/philips_js/test_config_flow.py | 1 + .../pi_hole/snapshots/test_diagnostics.ambr | 2 + .../ping/snapshots/test_binary_sensor.ambr | 1 + .../ping/snapshots/test_sensor.ambr | 3 + .../plaato/snapshots/test_binary_sensor.ambr | 2 + .../plaato/snapshots/test_sensor.ambr | 12 + .../snapshots/test_binary_sensor.ambr | 2 + .../poolsense/snapshots/test_sensor.ambr | 9 + .../powerfox/snapshots/test_sensor.ambr | 11 + .../proximity/snapshots/test_diagnostics.ambr | 2 + tests/components/ps4/test_init.py | 1 + .../components/purpleair/test_diagnostics.py | 1 + .../pyload/snapshots/test_button.ambr | 4 + .../pyload/snapshots/test_sensor.ambr | 20 + .../pyload/snapshots/test_switch.ambr | 2 + .../snapshots/test_diagnostics.ambr | 4 + .../rainforest_raven/snapshots/test_init.ambr | 1 + .../snapshots/test_sensor.ambr | 5 + .../snapshots/test_binary_sensor.ambr | 6 + .../rainmachine/snapshots/test_button.ambr | 1 + .../snapshots/test_diagnostics.ambr | 4 + .../rainmachine/snapshots/test_select.ambr | 1 + .../rainmachine/snapshots/test_sensor.ambr | 15 + .../rainmachine/snapshots/test_switch.ambr | 30 + .../recollect_waste/test_diagnostics.py | 1 + .../renault/snapshots/test_binary_sensor.ambr | 60 ++ .../renault/snapshots/test_button.ambr | 28 + .../snapshots/test_device_tracker.ambr | 14 + .../renault/snapshots/test_select.ambr | 14 + .../renault/snapshots/test_sensor.ambr | 112 +++ .../ridwell/snapshots/test_diagnostics.ambr | 2 + .../ring/snapshots/test_binary_sensor.ambr | 5 + .../ring/snapshots/test_button.ambr | 1 + .../ring/snapshots/test_camera.ambr | 6 + .../components/ring/snapshots/test_event.ambr | 6 + .../components/ring/snapshots/test_light.ambr | 2 + .../ring/snapshots/test_number.ambr | 7 + .../ring/snapshots/test_sensor.ambr | 29 + .../components/ring/snapshots/test_siren.ambr | 3 + .../ring/snapshots/test_switch.ambr | 6 + .../components/rova/snapshots/test_init.ambr | 1 + .../rova/snapshots/test_sensor.ambr | 4 + .../russound_rio/snapshots/test_init.ambr | 1 + .../sabnzbd/snapshots/test_binary_sensor.ambr | 1 + .../sabnzbd/snapshots/test_button.ambr | 2 + .../sabnzbd/snapshots/test_number.ambr | 1 + .../sabnzbd/snapshots/test_sensor.ambr | 11 + .../samsungtv/snapshots/test_init.ambr | 3 + .../components/samsungtv/test_diagnostics.py | 3 + .../sanix/snapshots/test_sensor.ambr | 6 + .../schlage/snapshots/test_init.ambr | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../sense/snapshots/test_binary_sensor.ambr | 2 + .../sense/snapshots/test_sensor.ambr | 51 ++ .../sensibo/snapshots/test_binary_sensor.ambr | 15 + .../sensibo/snapshots/test_button.ambr | 3 + .../sensibo/snapshots/test_climate.ambr | 3 + .../sensibo/snapshots/test_entity.ambr | 4 + .../sensibo/snapshots/test_number.ambr | 6 + .../sensibo/snapshots/test_select.ambr | 2 + .../sensibo/snapshots/test_sensor.ambr | 16 + .../sensibo/snapshots/test_switch.ambr | 4 + .../sensibo/snapshots/test_update.ambr | 3 + .../sfr_box/snapshots/test_binary_sensor.ambr | 6 + .../sfr_box/snapshots/test_button.ambr | 2 + .../sfr_box/snapshots/test_sensor.ambr | 16 + .../shelly/snapshots/test_binary_sensor.ambr | 3 + .../shelly/snapshots/test_event.ambr | 1 + .../shelly/snapshots/test_number.ambr | 2 + .../shelly/snapshots/test_sensor.ambr | 3 + .../snapshots/test_binary_sensor.ambr | 8 + .../simplefin/snapshots/test_sensor.ambr | 16 + .../components/simplisafe/test_diagnostics.py | 1 + .../slide_local/snapshots/test_button.ambr | 1 + .../slide_local/snapshots/test_cover.ambr | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../slide_local/snapshots/test_init.ambr | 1 + .../slide_local/snapshots/test_switch.ambr | 1 + .../sma/snapshots/test_diagnostics.ambr | 2 + .../smarty/snapshots/test_binary_sensor.ambr | 3 + .../smarty/snapshots/test_button.ambr | 1 + .../components/smarty/snapshots/test_fan.ambr | 1 + .../smarty/snapshots/test_init.ambr | 1 + .../smarty/snapshots/test_sensor.ambr | 6 + .../smarty/snapshots/test_switch.ambr | 1 + .../smlight/snapshots/test_binary_sensor.ambr | 4 + .../smlight/snapshots/test_init.ambr | 1 + .../smlight/snapshots/test_sensor.ambr | 9 + .../smlight/snapshots/test_switch.ambr | 4 + .../smlight/snapshots/test_update.ambr | 2 + .../solarlog/snapshots/test_diagnostics.ambr | 2 + .../solarlog/snapshots/test_sensor.ambr | 27 + .../sonos/snapshots/test_media_player.ambr | 1 + .../spotify/snapshots/test_media_player.ambr | 2 + .../snapshots/test_media_player.ambr | 2 + .../stookwijzer/snapshots/test_sensor.ambr | 3 + .../snapshots/test_binary_sensor.ambr | 1 + .../snapshots/test_sensor.ambr | 3 + tests/components/subaru/test_config_flow.py | 2 + .../suez_water/snapshots/test_sensor.ambr | 2 + .../snapshots/test_sensor.ambr | 8 + .../snapshots/test_sensor.ambr | 6 + .../switcher_kis/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 4 + .../snapshots/test_binary_sensor.ambr | 4 + .../tailwind/snapshots/test_button.ambr | 2 + .../tailwind/snapshots/test_cover.ambr | 4 + .../tailwind/snapshots/test_number.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../tasmota/snapshots/test_sensor.ambr | 25 + .../snapshots/test_binary_sensor.ambr | 5 + .../technove/snapshots/test_number.ambr | 1 + .../technove/snapshots/test_sensor.ambr | 9 + .../technove/snapshots/test_switch.ambr | 2 + .../tedee/snapshots/test_binary_sensor.ambr | 8 + .../components/tedee/snapshots/test_init.ambr | 2 + .../components/tedee/snapshots/test_lock.ambr | 4 + .../tedee/snapshots/test_sensor.ambr | 4 + .../snapshots/test_binary_sensor.ambr | 27 + .../tesla_fleet/snapshots/test_button.ambr | 6 + .../tesla_fleet/snapshots/test_climate.ambr | 6 + .../tesla_fleet/snapshots/test_cover.ambr | 15 + .../snapshots/test_device_tracker.ambr | 2 + .../tesla_fleet/snapshots/test_init.ambr | 4 + .../tesla_fleet/snapshots/test_lock.ambr | 2 + .../snapshots/test_media_player.ambr | 2 + .../tesla_fleet/snapshots/test_number.ambr | 4 + .../tesla_fleet/snapshots/test_select.ambr | 10 + .../tesla_fleet/snapshots/test_sensor.ambr | 71 ++ .../tesla_fleet/snapshots/test_switch.ambr | 8 + .../snapshots/test_binary_sensor.ambr | 51 ++ .../teslemetry/snapshots/test_button.ambr | 6 + .../teslemetry/snapshots/test_climate.ambr | 6 + .../teslemetry/snapshots/test_cover.ambr | 14 + .../snapshots/test_device_tracker.ambr | 2 + .../teslemetry/snapshots/test_init.ambr | 4 + .../teslemetry/snapshots/test_lock.ambr | 4 + .../snapshots/test_media_player.ambr | 2 + .../teslemetry/snapshots/test_number.ambr | 4 + .../teslemetry/snapshots/test_select.ambr | 8 + .../teslemetry/snapshots/test_sensor.ambr | 71 ++ .../teslemetry/snapshots/test_switch.ambr | 8 + .../teslemetry/snapshots/test_update.ambr | 2 + .../tessie/snapshots/test_binary_sensor.ambr | 30 + .../tessie/snapshots/test_button.ambr | 6 + .../tessie/snapshots/test_climate.ambr | 1 + .../tessie/snapshots/test_cover.ambr | 5 + .../tessie/snapshots/test_device_tracker.ambr | 2 + .../tessie/snapshots/test_lock.ambr | 2 + .../tessie/snapshots/test_media_player.ambr | 1 + .../tessie/snapshots/test_number.ambr | 5 + .../tessie/snapshots/test_select.ambr | 9 + .../tessie/snapshots/test_sensor.ambr | 44 + .../tessie/snapshots/test_switch.ambr | 7 + .../tessie/snapshots/test_update.ambr | 1 + .../tile/snapshots/test_binary_sensor.ambr | 1 + .../tile/snapshots/test_device_tracker.ambr | 1 + .../components/tile/snapshots/test_init.ambr | 1 + .../snapshots/test_alarm_control_panel.ambr | 2 + .../snapshots/test_binary_sensor.ambr | 25 + .../totalconnect/snapshots/test_button.ambr | 6 + .../tplink/snapshots/test_binary_sensor.ambr | 10 + .../tplink/snapshots/test_button.ambr | 15 + .../tplink/snapshots/test_camera.ambr | 2 + .../tplink/snapshots/test_climate.ambr | 2 + .../components/tplink/snapshots/test_fan.ambr | 4 + .../tplink/snapshots/test_number.ambr | 9 + .../tplink/snapshots/test_select.ambr | 4 + .../tplink/snapshots/test_sensor.ambr | 39 + .../tplink/snapshots/test_siren.ambr | 2 + .../tplink/snapshots/test_switch.ambr | 14 + .../tplink/snapshots/test_vacuum.ambr | 2 + .../tplink_omada/snapshots/test_sensor.ambr | 6 + .../tplink_omada/snapshots/test_switch.ambr | 2 + .../snapshots/test_binary_sensor.ambr | 1 + .../snapshots/test_device_tracker.ambr | 1 + .../tractive/snapshots/test_diagnostics.ambr | 2 + .../tractive/snapshots/test_sensor.ambr | 10 + .../tractive/snapshots/test_switch.ambr | 3 + .../tuya/snapshots/test_config_flow.ambr | 8 + .../twentemilieu/snapshots/test_calendar.ambr | 2 + .../twentemilieu/snapshots/test_sensor.ambr | 10 + .../twinkly/snapshots/test_diagnostics.ambr | 2 + .../twinkly/snapshots/test_light.ambr | 1 + .../twinkly/snapshots/test_select.ambr | 1 + .../unifi/snapshots/test_button.ambr | 3 + .../unifi/snapshots/test_device_tracker.ambr | 3 + .../unifi/snapshots/test_diagnostics.ambr | 2 + .../unifi/snapshots/test_image.ambr | 1 + .../unifi/snapshots/test_sensor.ambr | 39 + .../unifi/snapshots/test_switch.ambr | 11 + .../unifi/snapshots/test_update.ambr | 4 + .../uptime/snapshots/test_config_flow.ambr | 4 + .../uptime/snapshots/test_sensor.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../v2c/snapshots/test_diagnostics.ambr | 2 + .../components/v2c/snapshots/test_sensor.ambr | 11 + .../velbus/snapshots/test_binary_sensor.ambr | 1 + .../velbus/snapshots/test_button.ambr | 1 + .../velbus/snapshots/test_climate.ambr | 1 + .../velbus/snapshots/test_cover.ambr | 2 + .../velbus/snapshots/test_diagnostics.ambr | 2 + .../velbus/snapshots/test_init.ambr | 8 + .../velbus/snapshots/test_light.ambr | 2 + .../velbus/snapshots/test_select.ambr | 1 + .../velbus/snapshots/test_sensor.ambr | 5 + .../velbus/snapshots/test_switch.ambr | 1 + .../components/vesync/snapshots/test_fan.ambr | 17 + .../vesync/snapshots/test_light.ambr | 15 + .../vesync/snapshots/test_sensor.ambr | 29 + .../vesync/snapshots/test_switch.ambr | 14 + .../vicare/snapshots/test_binary_sensor.ambr | 9 + .../vicare/snapshots/test_button.ambr | 1 + .../vicare/snapshots/test_climate.ambr | 2 + .../vicare/snapshots/test_diagnostics.ambr | 2 + .../components/vicare/snapshots/test_fan.ambr | 1 + .../vicare/snapshots/test_number.ambr | 125 +-- .../vicare/snapshots/test_sensor.ambr | 56 ++ .../vicare/snapshots/test_water_heater.ambr | 2 + .../snapshots/test_button.ambr | 1 + .../snapshots/test_device_tracker.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_sensor.ambr | 5 + .../watergate/snapshots/test_sensor.ambr | 10 + .../watttime/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_sensor.ambr | 15 + .../snapshots/test_weather.ambr | 1 + .../webmin/snapshots/test_diagnostics.ambr | 2 + .../webmin/snapshots/test_sensor.ambr | 34 + .../webostv/snapshots/test_diagnostics.ambr | 2 + .../webostv/snapshots/test_media_player.ambr | 1 + .../weheat/snapshots/test_binary_sensor.ambr | 4 + .../weheat/snapshots/test_sensor.ambr | 17 + .../whirlpool/snapshots/test_diagnostics.ambr | 2 + .../whois/snapshots/test_config_flow.ambr | 20 + .../whois/snapshots/test_sensor.ambr | 19 + .../withings/snapshots/test_init.ambr | 2 + .../withings/snapshots/test_sensor.ambr | 76 ++ .../wled/snapshots/test_button.ambr | 2 + .../wled/snapshots/test_number.ambr | 4 + .../wled/snapshots/test_select.ambr | 8 + .../wled/snapshots/test_switch.ambr | 8 + .../wmspro/snapshots/test_cover.ambr | 1 + .../wmspro/snapshots/test_light.ambr | 1 + .../wmspro/snapshots/test_scene.ambr | 1 + .../workday/snapshots/test_diagnostics.ambr | 2 + .../wyoming/snapshots/test_config_flow.ambr | 12 + .../yale/snapshots/test_binary_sensor.ambr | 1 + .../components/yale/snapshots/test_lock.ambr | 1 + .../snapshots/test_alarm_control_panel.ambr | 1 + .../snapshots/test_binary_sensor.ambr | 10 + .../snapshots/test_button.ambr | 1 + .../yale_smart_alarm/snapshots/test_lock.ambr | 6 + .../snapshots/test_select.ambr | 6 + .../snapshots/test_switch.ambr | 6 + .../youless/snapshots/test_sensor.ambr | 22 + .../zeversolar/snapshots/test_sensor.ambr | 2 + .../zha/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_entity_platform.ambr | 38 + tests/helpers/test_device_registry.py | 863 ++++++++++++++++- tests/helpers/test_entity_platform.py | 109 ++- tests/helpers/test_entity_registry.py | 643 ++++++++++++- tests/snapshots/test_config_entries.ambr | 2 + tests/syrupy.py | 2 + tests/test_config_entries.py | 608 +++++++++++- 668 files changed, 10982 insertions(+), 153 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 52e3346002e..74c9b5a9d0c 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -46,6 +46,13 @@ def async_setup(hass: HomeAssistant) -> bool: hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) + hass.http.register_view( + SubentryManagerFlowIndexView(hass.config_entries.subentries) + ) + hass.http.register_view( + SubentryManagerFlowResourceView(hass.config_entries.subentries) + ) + websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) websocket_api.async_register_command(hass, config_entry_get_single) @@ -54,6 +61,9 @@ def async_setup(hass: HomeAssistant) -> bool: websocket_api.async_register_command(hass, config_entries_progress) websocket_api.async_register_command(hass, ignore_config_flow) + websocket_api.async_register_command(hass, config_subentry_delete) + websocket_api.async_register_command(hass, config_subentry_list) + return True @@ -285,6 +295,66 @@ class OptionManagerFlowResourceView( return await super().post(request, flow_id) +class SubentryManagerFlowIndexView( + FlowManagerIndexView[config_entries.ConfigSubentryFlowManager] +): + """View to create subentry flows.""" + + url = "/api/config/config_entries/subentries/flow" + name = "api:config:config_entries:subentries:flow" + + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) + @RequestDataValidator( + vol.Schema( + { + vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)), + vol.Optional("show_advanced_options", default=False): cv.boolean, + }, + extra=vol.ALLOW_EXTRA, + ) + ) + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: + """Handle a POST request. + + handler in request is [entry_id, subentry_type]. + """ + return await super()._post_impl(request, data) + + def get_context(self, data: dict[str, Any]) -> dict[str, Any]: + """Return context.""" + context = super().get_context(data) + context["source"] = config_entries.SOURCE_USER + if subentry_id := data.get("subentry_id"): + context["source"] = config_entries.SOURCE_RECONFIGURE + context["subentry_id"] = subentry_id + return context + + +class SubentryManagerFlowResourceView( + FlowManagerResourceView[config_entries.ConfigSubentryFlowManager] +): + """View to interact with the subentry flow manager.""" + + url = "/api/config/config_entries/subentries/flow/{flow_id}" + name = "api:config:config_entries:subentries:flow:resource" + + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) + async def get(self, request: web.Request, /, flow_id: str) -> web.Response: + """Get the current state of a data_entry_flow.""" + return await super().get(request, flow_id) + + @require_admin( + error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT) + ) + async def post(self, request: web.Request, flow_id: str) -> web.Response: + """Handle a POST request.""" + return await super().post(request, flow_id) + + @websocket_api.require_admin @websocket_api.websocket_command({"type": "config_entries/flow/progress"}) def config_entries_progress( @@ -589,3 +659,63 @@ async def _async_matching_config_entries_json_fragments( ) or (filter_is_not_helper and entry.domain not in integrations) ] + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + "type": "config_entries/subentries/list", + "entry_id": str, + } +) +@websocket_api.async_response +async def config_subentry_list( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """List subentries of a config entry.""" + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) + if entry is None: + return + + result = [ + { + "subentry_id": subentry.subentry_id, + "subentry_type": subentry.subentry_type, + "title": subentry.title, + "unique_id": subentry.unique_id, + } + for subentry in entry.subentries.values() + ] + connection.send_result(msg["id"], result) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + "type": "config_entries/subentries/delete", + "entry_id": str, + "subentry_id": str, + } +) +@websocket_api.async_response +async def config_subentry_delete( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Delete a subentry of a config entry.""" + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) + if entry is None: + return + + try: + hass.config_entries.async_remove_subentry(entry, msg["subentry_id"]) + except config_entries.UnknownSubEntry: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found" + ) + return + + connection.send_result(msg["id"]) diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 09a72fc529c..eff1a1ba8b2 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -70,11 +70,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set the config entry up.""" # Set up demo platforms with config entry await hass.config_entries.async_forward_entry_setups( - config_entry, COMPONENTS_WITH_DEMO_PLATFORM + entry, COMPONENTS_WITH_DEMO_PLATFORM ) # Create issues @@ -85,7 +85,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await _insert_statistics(hass) # Start a reauth flow - config_entry.async_start_reauth(hass) + entry.async_start_reauth(hass) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) # Notify backup listeners hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) @@ -93,6 +95,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" # Notify backup listeners diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 019d1dddcad..e1ffe334038 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -12,7 +12,9 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, + ConfigSubentryFlow, OptionsFlow, + SubentryFlowResult, ) from homeassistant.core import callback @@ -35,6 +37,14 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler() + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"entity": SubentryFlowHandler} + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Set the config entry up from yaml.""" return self.async_create_entry(title="Kitchen Sink", data=import_data) @@ -94,3 +104,60 @@ class OptionsFlowHandler(OptionsFlow): } ), ) + + +class SubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a sensor subentry.""" + return await self.async_step_add_sensor() + + async def async_step_add_sensor( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a new sensor.""" + if user_input is not None: + title = user_input.pop("name") + return self.async_create_entry(data=user_input, title=title) + + return self.async_show_form( + step_id="add_sensor", + data_schema=vol.Schema( + { + vol.Required("name"): str, + vol.Required("state"): int, + } + ), + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reconfigure a sensor subentry.""" + return await self.async_step_reconfigure_sensor() + + async def async_step_reconfigure_sensor( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reconfigure a sensor.""" + if user_input is not None: + title = user_input.pop("name") + return self.async_update_and_abort( + self._get_reconfigure_entry(), + self._get_reconfigure_subentry(), + data=user_input, + title=title, + ) + + return self.async_show_form( + step_id="reconfigure_sensor", + data_schema=vol.Schema( + { + vol.Required("name"): str, + vol.Required("state"): int, + } + ), + ) diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 95e56c276e4..f8f82758732 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType from . import DOMAIN @@ -21,7 +21,8 @@ from .device import async_create_device async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + # pylint: disable-next=hass-argument-type + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Everything but the Kitchen Sink config entry.""" async_create_device( @@ -90,6 +91,23 @@ async def async_setup_entry( ] ) + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [ + DemoSensor( + device_unique_id=subentry_id, + unique_id=subentry_id, + device_name=subentry.title, + entity_name=None, + state=subentry.data["state"], + device_class=None, + state_class=None, + unit_of_measurement=None, + ) + ], + config_subentry_id=subentry_id, + ) + class DemoSensor(SensorEntity): """Representation of a Demo sensor.""" diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index c03f909e617..e2fbb99c89f 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -9,6 +9,27 @@ } } }, + "config_subentries": { + "entity": { + "title": "Add entity", + "step": { + "add_sensor": { + "description": "Configure the new sensor", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "state": "Initial state" + } + }, + "reconfigure_sensor": { + "description": "Reconfigure the sensor", + "data": { + "name": "[%key:component::kitchen_sink::config_subentries::entity::step::reconfigure_sensor::data::state%]", + "state": "Initial state" + } + } + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 620e4bc8197..b4de9749250 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -15,6 +15,7 @@ from collections.abc import ( ) from contextvars import ContextVar from copy import deepcopy +from dataclasses import dataclass, field from datetime import datetime from enum import Enum, StrEnum import functools @@ -22,7 +23,7 @@ from functools import cache import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Self, cast +from typing import TYPE_CHECKING, Any, Self, TypedDict, cast from async_interrupt import interrupt from propcache.api import cached_property @@ -127,7 +128,7 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry() STORAGE_KEY = "core.config_entries" STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 4 +STORAGE_VERSION_MINOR = 5 SAVE_DELAY = 1 @@ -253,6 +254,10 @@ class UnknownEntry(ConfigError): """Unknown entry specified.""" +class UnknownSubEntry(ConfigError): + """Unknown subentry specified.""" + + class OperationNotAllowed(ConfigError): """Raised when a config entry operation is not allowed.""" @@ -297,6 +302,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): minor_version: int options: Mapping[str, Any] + subentries: Iterable[ConfigSubentryData] version: int @@ -310,6 +316,61 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N ) +class ConfigSubentryData(TypedDict): + """Container for configuration subentry data. + + Returned by integrations, a subentry_id will be assigned automatically. + """ + + data: Mapping[str, Any] + subentry_type: str + title: str + unique_id: str | None + + +class ConfigSubentryDataWithId(ConfigSubentryData): + """Container for configuration subentry data. + + This type is used when loading existing subentries from storage. + """ + + subentry_id: str + + +class SubentryFlowContext(FlowContext, total=False): + """Typed context dict for subentry flow.""" + + entry_id: str + subentry_id: str + + +class SubentryFlowResult(FlowResult[SubentryFlowContext, tuple[str, str]], total=False): + """Typed result dict for subentry flow.""" + + unique_id: str | None + + +@dataclass(frozen=True, kw_only=True) +class ConfigSubentry: + """Container for a configuration subentry.""" + + data: MappingProxyType[str, Any] + subentry_id: str = field(default_factory=ulid_util.ulid_now) + subentry_type: str + title: str + unique_id: str | None + + def as_dict(self) -> ConfigSubentryDataWithId: + """Return dictionary version of this subentry.""" + return { + "data": dict(self.data), + "subentry_id": self.subentry_id, + "subentry_type": self.subentry_type, + "title": self.title, + "unique_id": self.unique_id, + } + + class ConfigEntry[_DataT = Any]: """Hold a configuration entry.""" @@ -319,6 +380,7 @@ class ConfigEntry[_DataT = Any]: data: MappingProxyType[str, Any] runtime_data: _DataT options: MappingProxyType[str, Any] + subentries: MappingProxyType[str, ConfigSubentry] unique_id: str | None state: ConfigEntryState reason: str | None @@ -334,6 +396,7 @@ class ConfigEntry[_DataT = Any]: supports_remove_device: bool | None _supports_options: bool | None _supports_reconfigure: bool | None + _supported_subentry_types: dict[str, dict[str, bool]] | None update_listeners: list[UpdateListenerType] _async_cancel_retry_setup: Callable[[], Any] | None _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None @@ -363,6 +426,7 @@ class ConfigEntry[_DataT = Any]: pref_disable_polling: bool | None = None, source: str, state: ConfigEntryState = ConfigEntryState.NOT_LOADED, + subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None, title: str, unique_id: str | None, version: int, @@ -388,6 +452,25 @@ class ConfigEntry[_DataT = Any]: # Entry options _setter(self, "options", MappingProxyType(options or {})) + # Subentries + subentries_data = subentries_data or () + subentries = {} + for subentry_data in subentries_data: + subentry_kwargs = {} + if "subentry_id" in subentry_data: + # If subentry_data has key "subentry_id", we're loading from storage + subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] # type: ignore[typeddict-item] + subentry = ConfigSubentry( + data=MappingProxyType(subentry_data["data"]), + subentry_type=subentry_data["subentry_type"], + title=subentry_data["title"], + unique_id=subentry_data.get("unique_id"), + **subentry_kwargs, + ) + subentries[subentry.subentry_id] = subentry + + _setter(self, "subentries", MappingProxyType(subentries)) + # Entry system options if pref_disable_new_entities is None: pref_disable_new_entities = False @@ -424,6 +507,9 @@ class ConfigEntry[_DataT = Any]: # Supports reconfigure _setter(self, "_supports_reconfigure", None) + # Supports subentries + _setter(self, "_supported_subentry_types", None) + # Listeners to call on update _setter(self, "update_listeners", []) @@ -496,6 +582,28 @@ class ConfigEntry[_DataT = Any]: ) return self._supports_reconfigure or False + @property + def supported_subentry_types(self) -> dict[str, dict[str, bool]]: + """Return supported subentry types.""" + if self._supported_subentry_types is None and ( + handler := HANDLERS.get(self.domain) + ): + # work out sub entries supported by the handler + supported_flows = handler.async_get_supported_subentry_types(self) + object.__setattr__( + self, + "_supported_subentry_types", + { + subentry_flow_type: { + "supports_reconfigure": hasattr( + subentry_flow_handler, "async_step_reconfigure" + ) + } + for subentry_flow_type, subentry_flow_handler in supported_flows.items() + }, + ) + return self._supported_subentry_types or {} + def clear_state_cache(self) -> None: """Clear cached properties that are included in as_json_fragment.""" self.__dict__.pop("as_json_fragment", None) @@ -515,12 +623,14 @@ class ConfigEntry[_DataT = Any]: "supports_remove_device": self.supports_remove_device or False, "supports_unload": self.supports_unload or False, "supports_reconfigure": self.supports_reconfigure, + "supported_subentry_types": self.supported_subentry_types, "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "disabled_by": self.disabled_by, "reason": self.reason, "error_reason_translation_key": self.error_reason_translation_key, "error_reason_translation_placeholders": self.error_reason_translation_placeholders, + "num_subentries": len(self.subentries), } return json_fragment(json_bytes(json_repr)) @@ -1012,6 +1122,7 @@ class ConfigEntry[_DataT = Any]: "pref_disable_new_entities": self.pref_disable_new_entities, "pref_disable_polling": self.pref_disable_polling, "source": self.source, + "subentries": [subentry.as_dict() for subentry in self.subentries.values()], "title": self.title, "unique_id": self.unique_id, "version": self.version, @@ -1497,6 +1608,7 @@ class ConfigEntriesFlowManager( minor_version=result["minor_version"], options=result["options"], source=flow.context["source"], + subentries_data=result["subentries"], title=result["title"], unique_id=flow.unique_id, version=result["version"], @@ -1787,6 +1899,11 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entry in data["entries"]: entry["discovery_keys"] = {} + if old_minor_version < 5: + # Version 1.4 adds config subentries + for entry in data["entries"]: + entry.setdefault("subentries", entry.get("subentries", {})) + if old_major_version > 1: raise NotImplementedError return data @@ -1803,6 +1920,7 @@ class ConfigEntries: self.hass = hass self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.options = OptionsFlowManager(hass) + self.subentries = ConfigSubentryFlowManager(hass) self._hass_config = hass_config self._entries = ConfigEntryItems(hass) self._store = ConfigEntryStore(hass) @@ -2005,6 +2123,7 @@ class ConfigEntries: pref_disable_new_entities=entry["pref_disable_new_entities"], pref_disable_polling=entry["pref_disable_polling"], source=entry["source"], + subentries_data=entry["subentries"], title=entry["title"], unique_id=entry["unique_id"], version=entry["version"], @@ -2164,6 +2283,44 @@ class ConfigEntries: If the entry was changed, the update_listeners are fired and this function returns True + If the entry was not changed, the update_listeners are + not fired and this function returns False + """ + return self._async_update_entry( + entry, + data=data, + discovery_keys=discovery_keys, + minor_version=minor_version, + options=options, + pref_disable_new_entities=pref_disable_new_entities, + pref_disable_polling=pref_disable_polling, + title=title, + unique_id=unique_id, + version=version, + ) + + @callback + def _async_update_entry( + self, + entry: ConfigEntry, + *, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]] + | UndefinedType = UNDEFINED, + minor_version: int | UndefinedType = UNDEFINED, + options: Mapping[str, Any] | UndefinedType = UNDEFINED, + pref_disable_new_entities: bool | UndefinedType = UNDEFINED, + pref_disable_polling: bool | UndefinedType = UNDEFINED, + subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + unique_id: str | None | UndefinedType = UNDEFINED, + version: int | UndefinedType = UNDEFINED, + ) -> bool: + """Update a config entry. + + If the entry was changed, the update_listeners are + fired and this function returns True + If the entry was not changed, the update_listeners are not fired and this function returns False """ @@ -2226,11 +2383,21 @@ class ConfigEntries: changed = True _setter(entry, "options", MappingProxyType(options)) + if subentries is not UNDEFINED: + if entry.subentries != subentries: + changed = True + _setter(entry, "subentries", MappingProxyType(subentries)) + if not changed: return False _setter(entry, "modified_at", utcnow()) + self._async_save_and_notify(entry) + return True + + @callback + def _async_save_and_notify(self, entry: ConfigEntry) -> None: for listener in entry.update_listeners: self.hass.async_create_task( listener(self.hass, entry), @@ -2241,8 +2408,92 @@ class ConfigEntries: entry.clear_state_cache() entry.clear_storage_cache() self._async_dispatch(ConfigEntryChange.UPDATED, entry) + + @callback + def async_add_subentry(self, entry: ConfigEntry, subentry: ConfigSubentry) -> bool: + """Add a subentry to a config entry.""" + self._raise_if_subentry_unique_id_exists(entry, subentry.unique_id) + + return self._async_update_entry( + entry, + subentries=entry.subentries | {subentry.subentry_id: subentry}, + ) + + @callback + def async_remove_subentry(self, entry: ConfigEntry, subentry_id: str) -> bool: + """Remove a subentry from a config entry.""" + subentries = dict(entry.subentries) + try: + subentries.pop(subentry_id) + except KeyError as err: + raise UnknownSubEntry from err + + result = self._async_update_entry(entry, subentries=subentries) + dev_reg = dr.async_get(self.hass) + ent_reg = er.async_get(self.hass) + + dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id) + ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id) + return result + + @callback + def async_update_subentry( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + unique_id: str | None | UndefinedType = UNDEFINED, + ) -> bool: + """Update a config subentry. + + If the subentry was changed, the update_listeners are + fired and this function returns True + + If the subentry was not changed, the update_listeners are + not fired and this function returns False + """ + if entry.entry_id not in self._entries: + raise UnknownEntry(entry.entry_id) + if subentry.subentry_id not in entry.subentries: + raise UnknownSubEntry(subentry.subentry_id) + + self.hass.verify_event_loop_thread("hass.config_entries.async_update_subentry") + changed = False + _setter = object.__setattr__ + + if unique_id is not UNDEFINED and subentry.unique_id != unique_id: + self._raise_if_subentry_unique_id_exists(entry, unique_id) + changed = True + _setter(subentry, "unique_id", unique_id) + + if title is not UNDEFINED and subentry.title != title: + changed = True + _setter(subentry, "title", title) + + if data is not UNDEFINED and subentry.data != data: + changed = True + _setter(subentry, "data", MappingProxyType(data)) + + if not changed: + return False + + _setter(entry, "modified_at", utcnow()) + + self._async_save_and_notify(entry) return True + def _raise_if_subentry_unique_id_exists( + self, entry: ConfigEntry, unique_id: str | None + ) -> None: + """Raise if a subentry with the same unique_id exists.""" + if unique_id is None: + return + for existing_subentry in entry.subentries.values(): + if existing_subentry.unique_id == unique_id: + raise data_entry_flow.AbortFlow("already_configured") + @callback def _async_dispatch( self, change_type: ConfigEntryChange, entry: ConfigEntry @@ -2579,6 +2830,14 @@ class ConfigFlow(ConfigEntryBaseFlow): """Return options flow support for this handler.""" return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {} + @callback def _async_abort_entries_match( self, match_dict: dict[str, Any] | None = None @@ -2887,6 +3146,7 @@ class ConfigFlow(ConfigEntryBaseFlow): description: str | None = None, description_placeholders: Mapping[str, str] | None = None, options: Mapping[str, Any] | None = None, + subentries: Iterable[ConfigSubentryData] | None = None, ) -> ConfigFlowResult: """Finish config flow and create a config entry.""" if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}: @@ -2906,6 +3166,7 @@ class ConfigFlow(ConfigEntryBaseFlow): result["minor_version"] = self.MINOR_VERSION result["options"] = options or {} + result["subentries"] = subentries or () result["version"] = self.VERSION return result @@ -3020,17 +3281,199 @@ class ConfigFlow(ConfigEntryBaseFlow): ) -class OptionsFlowManager( - data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult] -): - """Flow to set options for a configuration entry.""" +class _ConfigSubFlowManager: + """Mixin class for flow managers which manage flows tied to a config entry.""" - _flow_result = ConfigFlowResult + hass: HomeAssistant def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry: """Return config entry or raise if not found.""" return self.hass.config_entries.async_get_known_entry(config_entry_id) + +class ConfigSubentryFlowManager( + data_entry_flow.FlowManager[ + SubentryFlowContext, SubentryFlowResult, tuple[str, str] + ], + _ConfigSubFlowManager, +): + """Manage all the config subentry flows that are in progress.""" + + _flow_result = SubentryFlowResult + + async def async_create_flow( + self, + handler_key: tuple[str, str], + *, + context: FlowContext | None = None, + data: dict[str, Any] | None = None, + ) -> ConfigSubentryFlow: + """Create a subentry flow for a config entry. + + The entry_id and flow.handler[0] is the same thing to map entry with flow. + """ + if not context or "source" not in context: + raise KeyError("Context not set or doesn't have a source set") + + entry_id, subentry_type = handler_key + entry = self._async_get_config_entry(entry_id) + handler = await _async_get_flow_handler(self.hass, entry.domain, {}) + subentry_types = handler.async_get_supported_subentry_types(entry) + if subentry_type not in subentry_types: + raise data_entry_flow.UnknownHandler( + f"Config entry '{entry.domain}' does not support subentry '{subentry_type}'" + ) + subentry_flow = subentry_types[subentry_type]() + subentry_flow.init_step = context["source"] + return subentry_flow + + async def async_finish_flow( + self, + flow: data_entry_flow.FlowHandler[ + SubentryFlowContext, SubentryFlowResult, tuple[str, str] + ], + result: SubentryFlowResult, + ) -> SubentryFlowResult: + """Finish a subentry flow and add a new subentry to the configuration entry. + + The flow.handler[0] and entry_id is the same thing to map flow with entry. + """ + flow = cast(ConfigSubentryFlow, flow) + + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: + return result + + entry_id, subentry_type = flow.handler + entry = self.hass.config_entries.async_get_entry(entry_id) + if entry is None: + raise UnknownEntry(entry_id) + + unique_id = result.get("unique_id") + if unique_id is not None and not isinstance(unique_id, str): + raise HomeAssistantError("unique_id must be a string") + + self.hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(result["data"]), + subentry_type=subentry_type, + title=result["title"], + unique_id=unique_id, + ), + ) + + result["result"] = True + return result + + +class ConfigSubentryFlow( + data_entry_flow.FlowHandler[ + SubentryFlowContext, SubentryFlowResult, tuple[str, str] + ] +): + """Base class for config subentry flows.""" + + _flow_result = SubentryFlowResult + handler: tuple[str, str] + + @callback + def async_create_entry( + self, + *, + title: str | None = None, + data: Mapping[str, Any], + description: str | None = None, + description_placeholders: Mapping[str, str] | None = None, + unique_id: str | None = None, + ) -> SubentryFlowResult: + """Finish config flow and create a config entry.""" + if self.source != SOURCE_USER: + raise ValueError(f"Source is {self.source}, expected {SOURCE_USER}") + + result = super().async_create_entry( + title=title, + data=data, + description=description, + description_placeholders=description_placeholders, + ) + + result["unique_id"] = unique_id + + return result + + @callback + def async_update_and_abort( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + ) -> SubentryFlowResult: + """Update config subentry and finish subentry flow. + + :param data: replace the subentry data with new data + :param data_updates: add items from data_updates to subentry data - existing + keys are overridden + :param title: replace the title of the subentry + :param unique_id: replace the unique_id of the subentry + """ + if data_updates is not UNDEFINED: + if data is not UNDEFINED: + raise ValueError("Cannot set both data and data_updates") + data = entry.data | data_updates + self.hass.config_entries.async_update_subentry( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + ) + return self.async_abort(reason="reconfigure_successful") + + @property + def _reconfigure_entry_id(self) -> str: + """Return reconfigure entry id.""" + if self.source != SOURCE_RECONFIGURE: + raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}") + return self.handler[0] + + @callback + def _get_reconfigure_entry(self) -> ConfigEntry: + """Return the reconfigure config entry linked to the current context.""" + return self.hass.config_entries.async_get_known_entry( + self._reconfigure_entry_id + ) + + @property + def _reconfigure_subentry_id(self) -> str: + """Return reconfigure subentry id.""" + if self.source != SOURCE_RECONFIGURE: + raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}") + return self.context["subentry_id"] + + @callback + def _get_reconfigure_subentry(self) -> ConfigSubentry: + """Return the reconfigure config subentry linked to the current context.""" + entry = self.hass.config_entries.async_get_known_entry( + self._reconfigure_entry_id + ) + subentry_id = self._reconfigure_subentry_id + if subentry_id not in entry.subentries: + raise UnknownEntry + return entry.subentries[subentry_id] + + +class OptionsFlowManager( + data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult], + _ConfigSubFlowManager, +): + """Manage all the config entry option flows that are in progress.""" + + _flow_result = ConfigFlowResult + async def async_create_flow( self, handler_key: str, @@ -3040,7 +3483,7 @@ class OptionsFlowManager( ) -> OptionsFlow: """Create an options flow for a config entry. - Entry_id and flow.handler is the same thing to map entry with flow. + The entry_id and the flow.handler is the same thing to map entry with flow. """ entry = self._async_get_config_entry(handler_key) handler = await _async_get_flow_handler(self.hass, entry.domain, {}) @@ -3056,7 +3499,7 @@ class OptionsFlowManager( This method is called when a flow step returns FlowResultType.ABORT or FlowResultType.CREATE_ENTRY. - Flow.handler and entry_id is the same thing to map flow with entry. + The flow.handler and the entry_id is the same thing to map flow with entry. """ flow = cast(OptionsFlow, flow) diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index b15d8b9e607..65eb2786aaf 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -17,7 +17,7 @@ from . import config_validation as cv _FlowManagerT = TypeVar( "_FlowManagerT", - bound=data_entry_flow.FlowManager[Any, Any], + bound=data_entry_flow.FlowManager[Any, Any, Any], default=data_entry_flow.FlowManager, ) @@ -70,7 +70,7 @@ class FlowManagerIndexView(_BaseFlowManagerView[_FlowManagerT]): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Initialize a POST request. - Override `_post_impl` in subclasses which need + Override `post` and call `_post_impl` in subclasses which need to implement their own `RequestDataValidator` """ return await self._post_impl(request, data) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 92101dd0e21..991a6cf5a57 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -56,7 +56,7 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event ) STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 8 +STORAGE_VERSION_MINOR = 9 CLEANUP_DELAY = 10 @@ -272,6 +272,7 @@ class DeviceEntry: area_id: str | None = attr.ib(default=None) config_entries: set[str] = attr.ib(converter=set, factory=set) + config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict) configuration_url: str | None = attr.ib(default=None) connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set) created_at: datetime = attr.ib(factory=utcnow) @@ -311,6 +312,10 @@ class DeviceEntry: "area_id": self.area_id, "configuration_url": self.configuration_url, "config_entries": list(self.config_entries), + "config_entries_subentries": { + config_entry_id: list(subentries) + for config_entry_id, subentries in self.config_entries_subentries.items() + }, "connections": list(self.connections), "created_at": self.created_at.timestamp(), "disabled_by": self.disabled_by, @@ -354,7 +359,13 @@ class DeviceEntry: json_bytes( { "area_id": self.area_id, + # The config_entries list can be removed from the storage + # representation in HA Core 2026.2 "config_entries": list(self.config_entries), + "config_entries_subentries": { + config_entry_id: list(subentries) + for config_entry_id, subentries in self.config_entries_subentries.items() + }, "configuration_url": self.configuration_url, "connections": list(self.connections), "created_at": self.created_at, @@ -384,6 +395,7 @@ class DeletedDeviceEntry: """Deleted Device Registry Entry.""" config_entries: set[str] = attr.ib() + config_entries_subentries: dict[str, set[str | None]] = attr.ib() connections: set[tuple[str, str]] = attr.ib() identifiers: set[tuple[str, str]] = attr.ib() id: str = attr.ib() @@ -395,6 +407,7 @@ class DeletedDeviceEntry: def to_device_entry( self, config_entry_id: str, + config_subentry_id: str | None, connections: set[tuple[str, str]], identifiers: set[tuple[str, str]], ) -> DeviceEntry: @@ -402,6 +415,7 @@ class DeletedDeviceEntry: return DeviceEntry( # type ignores: likely https://github.com/python/mypy/issues/8625 config_entries={config_entry_id}, # type: ignore[arg-type] + config_entries_subentries={config_entry_id: {config_subentry_id}}, connections=self.connections & connections, # type: ignore[arg-type] created_at=self.created_at, identifiers=self.identifiers & identifiers, # type: ignore[arg-type] @@ -415,7 +429,13 @@ class DeletedDeviceEntry: return json_fragment( json_bytes( { + # The config_entries list can be removed from the storage + # representation in HA Core 2026.2 "config_entries": list(self.config_entries), + "config_entries_subentries": { + config_entry_id: list(subentries) + for config_entry_id, subentries in self.config_entries_subentries.items() + }, "connections": list(self.connections), "created_at": self.created_at, "identifiers": list(self.identifiers), @@ -458,7 +478,10 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): old_data: dict[str, list[dict[str, Any]]], ) -> dict[str, Any]: """Migrate to the new version.""" - if old_major_version < 2: + # Support for a future major version bump to 2 added in HA Core 2025.2. + # Major versions 1 and 2 will be the same, except that version 2 will no + # longer store a list of config_entries. + if old_major_version < 3: if old_minor_version < 2: # Version 1.2 implements migration and freezes the available keys, # populate keys which were introduced before version 1.2 @@ -505,8 +528,20 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): device["created_at"] = device["modified_at"] = created_at for device in old_data["deleted_devices"]: device["created_at"] = device["modified_at"] = created_at + if old_minor_version < 9: + # Introduced in 2025.2 + for device in old_data["devices"]: + device["config_entries_subentries"] = { + config_entry_id: {None} + for config_entry_id in device["config_entries"] + } + for device in old_data["deleted_devices"]: + device["config_entries_subentries"] = { + config_entry_id: {None} + for config_entry_id in device["config_entries"] + } - if old_major_version > 1: + if old_major_version > 2: raise NotImplementedError return old_data @@ -722,6 +757,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self, *, config_entry_id: str, + config_subentry_id: str | None | UndefinedType = UNDEFINED, configuration_url: str | URL | None | UndefinedType = UNDEFINED, connections: set[tuple[str, str]] | None | UndefinedType = UNDEFINED, created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored @@ -812,7 +848,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): else: self.deleted_devices.pop(deleted_device.id) device = deleted_device.to_device_entry( - config_entry_id, connections, identifiers + config_entry_id, + # Interpret not specifying a subentry as None + config_subentry_id if config_subentry_id is not UNDEFINED else None, + connections, + identifiers, ) self.devices[device.id] = device # If creating a new device, default to the config entry name @@ -846,6 +886,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): device.id, allow_collisions=True, add_config_entry_id=config_entry_id, + add_config_subentry_id=config_subentry_id, configuration_url=configuration_url, device_info_type=device_info_type, disabled_by=disabled_by, @@ -874,6 +915,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): device_id: str, *, add_config_entry_id: str | UndefinedType = UNDEFINED, + add_config_subentry_id: str | None | UndefinedType = UNDEFINED, # Temporary flag so we don't blow up when collisions are implicitly introduced # by calls to async_get_or_create. Must not be set by integrations. allow_collisions: bool = False, @@ -894,25 +936,58 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, + remove_config_subentry_id: str | None | UndefinedType = UNDEFINED, serial_number: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, ) -> DeviceEntry | None: - """Update device attributes.""" + """Update device attributes. + + :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id + :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id + """ old = self.devices[device_id] new_values: dict[str, Any] = {} # Dict with new key/value pairs old_values: dict[str, Any] = {} # Dict with old key/value pairs config_entries = old.config_entries + config_entries_subentries = old.config_entries_subentries if add_config_entry_id is not UNDEFINED: - if self.hass.config_entries.async_get_entry(add_config_entry_id) is None: + if ( + add_config_entry := self.hass.config_entries.async_get_entry( + add_config_entry_id + ) + ) is None: raise HomeAssistantError( f"Can't link device to unknown config entry {add_config_entry_id}" ) + if add_config_subentry_id is not UNDEFINED: + if add_config_entry_id is UNDEFINED: + raise HomeAssistantError( + "Can't add config subentry without specifying config entry" + ) + if ( + add_config_subentry_id + # mypy says add_config_entry can be None. That's impossible, because we + # raise above if that happens + and add_config_subentry_id not in add_config_entry.subentries # type: ignore[union-attr] + ): + raise HomeAssistantError( + f"Config entry {add_config_entry_id} has no subentry {add_config_subentry_id}" + ) + + if ( + remove_config_subentry_id is not UNDEFINED + and remove_config_entry_id is UNDEFINED + ): + raise HomeAssistantError( + "Can't remove config subentry without specifying config entry" + ) + if not new_connections and not new_identifiers: raise HomeAssistantError( "A device must have at least one of identifiers or connections" @@ -943,6 +1018,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): area_id = area.id if add_config_entry_id is not UNDEFINED: + if add_config_subentry_id is UNDEFINED: + # Interpret not specifying a subentry as None (the main entry) + add_config_subentry_id = None + primary_entry_id = old.primary_config_entry if ( device_info_type == "primary" @@ -962,25 +1041,59 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if add_config_entry_id not in old.config_entries: config_entries = old.config_entries | {add_config_entry_id} + config_entries_subentries = old.config_entries_subentries | { + add_config_entry_id: {add_config_subentry_id} + } + elif ( + add_config_subentry_id + not in old.config_entries_subentries[add_config_entry_id] + ): + config_entries_subentries = old.config_entries_subentries | { + add_config_entry_id: old.config_entries_subentries[ + add_config_entry_id + ] + | {add_config_subentry_id} + } if ( remove_config_entry_id is not UNDEFINED and remove_config_entry_id in config_entries ): - if config_entries == {remove_config_entry_id}: - self.async_remove_device(device_id) - return None + if remove_config_subentry_id is UNDEFINED: + config_entries_subentries = dict(old.config_entries_subentries) + del config_entries_subentries[remove_config_entry_id] + elif ( + remove_config_subentry_id + in old.config_entries_subentries[remove_config_entry_id] + ): + config_entries_subentries = old.config_entries_subentries | { + remove_config_entry_id: old.config_entries_subentries[ + remove_config_entry_id + ] + - {remove_config_subentry_id} + } + if not config_entries_subentries[remove_config_entry_id]: + del config_entries_subentries[remove_config_entry_id] - if remove_config_entry_id == old.primary_config_entry: - new_values["primary_config_entry"] = None - old_values["primary_config_entry"] = old.primary_config_entry + if remove_config_entry_id not in config_entries_subentries: + if config_entries == {remove_config_entry_id}: + self.async_remove_device(device_id) + return None - config_entries = config_entries - {remove_config_entry_id} + if remove_config_entry_id == old.primary_config_entry: + new_values["primary_config_entry"] = None + old_values["primary_config_entry"] = old.primary_config_entry + + config_entries = config_entries - {remove_config_entry_id} if config_entries != old.config_entries: new_values["config_entries"] = config_entries old_values["config_entries"] = old.config_entries + if config_entries_subentries != old.config_entries_subentries: + new_values["config_entries_subentries"] = config_entries_subentries + old_values["config_entries_subentries"] = old.config_entries_subentries + added_connections: set[tuple[str, str]] | None = None added_identifiers: set[tuple[str, str]] | None = None @@ -1138,6 +1251,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): device = self.devices.pop(device_id) self.deleted_devices[device_id] = DeletedDeviceEntry( config_entries=device.config_entries, + config_entries_subentries=device.config_entries_subentries, connections=device.connections, created_at=device.created_at, identifiers=device.identifiers, @@ -1168,7 +1282,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for device in data["devices"]: devices[device["id"]] = DeviceEntry( area_id=device["area_id"], - config_entries=set(device["config_entries"]), + config_entries=set(device["config_entries_subentries"]), + config_entries_subentries={ + config_entry_id: set(subentries) + for config_entry_id, subentries in device[ + "config_entries_subentries" + ].items() + }, configuration_url=device["configuration_url"], # type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625 connections={ @@ -1208,6 +1328,12 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( config_entries=set(device["config_entries"]), + config_entries_subentries={ + config_entry_id: set(subentries) + for config_entry_id, subentries in device[ + "config_entries_subentries" + ].items() + }, connections={tuple(conn) for conn in device["connections"]}, created_at=datetime.fromisoformat(device["created_at"]), identifiers={tuple(iden) for iden in device["identifiers"]}, @@ -1243,14 +1369,70 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if config_entries == {config_entry_id}: # Add a time stamp when the deleted device became orphaned self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, orphaned_timestamp=now_time, config_entries=set() + deleted_device, + orphaned_timestamp=now_time, + config_entries=set(), + config_entries_subentries={}, ) else: config_entries = config_entries - {config_entry_id} + config_entries_subentries = dict( + deleted_device.config_entries_subentries + ) + del config_entries_subentries[config_entry_id] # No need to reindex here since we currently # do not have a lookup by config entry self.deleted_devices[deleted_device.id] = attr.evolve( - deleted_device, config_entries=config_entries + deleted_device, + config_entries=config_entries, + config_entries_subentries=config_entries_subentries, + ) + self.async_schedule_save() + + @callback + def async_clear_config_subentry( + self, config_entry_id: str, config_subentry_id: str + ) -> None: + """Clear config entry from registry entries.""" + now_time = time.time() + now_time = time.time() + for device in self.devices.get_devices_for_config_entry_id(config_entry_id): + self.async_update_device( + device.id, + remove_config_entry_id=config_entry_id, + remove_config_subentry_id=config_subentry_id, + ) + for deleted_device in list(self.deleted_devices.values()): + config_entries = deleted_device.config_entries + config_entries_subentries = deleted_device.config_entries_subentries + if ( + config_entry_id not in config_entries_subentries + or config_subentry_id not in config_entries_subentries[config_entry_id] + ): + continue + if config_entries_subentries == {config_entry_id: {config_subentry_id}}: + # We're removing the last config subentry from the last config + # entry, add a time stamp when the deleted device became orphaned + self.deleted_devices[deleted_device.id] = attr.evolve( + deleted_device, + orphaned_timestamp=now_time, + config_entries=set(), + config_entries_subentries={}, + ) + else: + config_entries_subentries = config_entries_subentries | { + config_entry_id: config_entries_subentries[config_entry_id] + - {config_subentry_id} + } + if not config_entries_subentries[config_entry_id]: + del config_entries_subentries[config_entry_id] + config_entries = config_entries - {config_entry_id} + # No need to reindex here since we currently + # do not have a lookup by config entry + self.deleted_devices[deleted_device.id] = attr.evolve( + deleted_device, + config_entries=config_entries, + config_entries_subentries=config_entries_subentries, ) self.async_schedule_save() diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index c8cc6979226..adf34f3b285 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -80,6 +80,22 @@ class AddEntitiesCallback(Protocol): """Define add_entities type.""" +class AddConfigEntryEntitiesCallback(Protocol): + """Protocol type for EntityPlatform.add_entities callback.""" + + def __call__( + self, + new_entities: Iterable[Entity], + update_before_add: bool = False, + *, + config_subentry_id: str | None = None, + ) -> None: + """Define add_entities type. + + :param config_subentry_id: subentry which the entities should be added to + """ + + class EntityPlatformModule(Protocol): """Protocol type for entity platform modules.""" @@ -105,7 +121,7 @@ class EntityPlatformModule(Protocol): self, hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an integration platform from a config entry.""" @@ -517,13 +533,21 @@ class EntityPlatform: @callback def _async_schedule_add_entities_for_entry( - self, new_entities: Iterable[Entity], update_before_add: bool = False + self, + new_entities: Iterable[Entity], + update_before_add: bool = False, + *, + config_subentry_id: str | None = None, ) -> None: """Schedule adding entities for a single platform async and track the task.""" assert self.config_entry task = self.config_entry.async_create_task( self.hass, - self.async_add_entities(new_entities, update_before_add=update_before_add), + self.async_add_entities( + new_entities, + update_before_add=update_before_add, + config_subentry_id=config_subentry_id, + ), f"EntityPlatform async_add_entities_for_entry {self.domain}.{self.platform_name}", eager_start=True, ) @@ -625,12 +649,27 @@ class EntityPlatform: ) async def async_add_entities( - self, new_entities: Iterable[Entity], update_before_add: bool = False + self, + new_entities: Iterable[Entity], + update_before_add: bool = False, + *, + config_subentry_id: str | None = None, ) -> None: """Add entities for a single platform async. This method must be run in the event loop. + + :param subentry_id: subentry which the entities should be added to """ + if config_subentry_id and ( + not self.config_entry + or config_subentry_id not in self.config_entry.subentries + ): + raise HomeAssistantError( + f"Can't add entities to unknown subentry {config_subentry_id} of config " + f"entry {self.config_entry.entry_id if self.config_entry else None}" + ) + # handle empty list from component/platform if not new_entities: # type: ignore[truthy-iterable] return @@ -641,7 +680,9 @@ class EntityPlatform: entities: list[Entity] = [] for entity in new_entities: coros.append( - self._async_add_entity(entity, update_before_add, entity_registry) + self._async_add_entity( + entity, update_before_add, entity_registry, config_subentry_id + ) ) entities.append(entity) @@ -720,6 +761,7 @@ class EntityPlatform: entity: Entity, update_before_add: bool, entity_registry: EntityRegistry, + config_subentry_id: str | None, ) -> None: """Add an entity to the platform.""" if entity is None: @@ -779,6 +821,7 @@ class EntityPlatform: try: device = dev_reg.async_get(self.hass).async_get_or_create( config_entry_id=self.config_entry.entry_id, + config_subentry_id=config_subentry_id, **device_info, ) except dev_reg.DeviceInfoError as exc: @@ -825,6 +868,7 @@ class EntityPlatform: entity.unique_id, capabilities=entity.capability_attributes, config_entry=self.config_entry, + config_subentry_id=config_subentry_id, device_id=device.id if device else None, disabled_by=disabled_by, entity_category=entity.entity_category, diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 95a32696228..684d00fe344 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -79,7 +79,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 15 +STORAGE_VERSION_MINOR = 16 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -177,6 +177,7 @@ class RegistryEntry: categories: dict[str, str] = attr.ib(factory=dict) capabilities: Mapping[str, Any] | None = attr.ib(default=None) config_entry_id: str | None = attr.ib(default=None) + config_subentry_id: str | None = attr.ib(default=None) created_at: datetime = attr.ib(factory=utcnow) device_class: str | None = attr.ib(default=None) device_id: str | None = attr.ib(default=None) @@ -280,6 +281,7 @@ class RegistryEntry: "area_id": self.area_id, "categories": self.categories, "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "created_at": self.created_at.timestamp(), "device_id": self.device_id, "disabled_by": self.disabled_by, @@ -341,6 +343,7 @@ class RegistryEntry: "categories": self.categories, "capabilities": self.capabilities, "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "created_at": self.created_at, "device_class": self.device_class, "device_id": self.device_id, @@ -405,6 +408,7 @@ class DeletedRegistryEntry: unique_id: str = attr.ib() platform: str = attr.ib() config_entry_id: str | None = attr.ib() + config_subentry_id: str | None = attr.ib() domain: str = attr.ib(init=False, repr=False) id: str = attr.ib() orphaned_timestamp: float | None = attr.ib() @@ -424,6 +428,7 @@ class DeletedRegistryEntry: json_bytes( { "config_entry_id": self.config_entry_id, + "config_subentry_id": self.config_subentry_id, "created_at": self.created_at, "entity_id": self.entity_id, "id": self.id, @@ -539,6 +544,13 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entity in data["deleted_entities"]: entity["created_at"] = entity["modified_at"] = created_at + if old_minor_version < 16: + # Version 1.16 adds config_subentry_id + for entity in data["entities"]: + entity["config_subentry_id"] = None + for entity in data["deleted_entities"]: + entity["config_subentry_id"] = None + if old_major_version > 1: raise NotImplementedError return data @@ -647,10 +659,12 @@ def _validate_item( platform: str, *, config_entry_id: str | None | UndefinedType = None, + config_subentry_id: str | None | UndefinedType = None, device_id: str | None | UndefinedType = None, disabled_by: RegistryEntryDisabler | None | UndefinedType = None, entity_category: EntityCategory | None | UndefinedType = None, hidden_by: RegistryEntryHider | None | UndefinedType = None, + old_config_subentry_id: str | None = None, report_non_string_unique_id: bool = True, unique_id: str | Hashable | UndefinedType | Any, ) -> None: @@ -676,6 +690,26 @@ def _validate_item( raise ValueError( f"Can't link entity to unknown config entry {config_entry_id}" ) + if ( + config_entry_id + and config_entry_id is not UNDEFINED + and old_config_subentry_id + and config_subentry_id is UNDEFINED + ): + raise ValueError("Can't change config entry without changing subentry") + if ( + config_entry_id + and config_entry_id is not UNDEFINED + and config_subentry_id + and config_subentry_id is not UNDEFINED + ): + if ( + not (config_entry := hass.config_entries.async_get_entry(config_entry_id)) + or config_subentry_id not in config_entry.subentries + ): + raise ValueError( + f"Config entry {config_entry_id} has no subentry {config_subentry_id}" + ) if device_id and device_id is not UNDEFINED: device_registry = dr.async_get(hass) if not device_registry.async_get(device_id): @@ -826,6 +860,7 @@ class EntityRegistry(BaseRegistry): # Data that we want entry to have capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, config_entry: ConfigEntry | None | UndefinedType = UNDEFINED, + config_subentry_id: str | None | UndefinedType = UNDEFINED, device_id: str | None | UndefinedType = UNDEFINED, entity_category: EntityCategory | UndefinedType | None = UNDEFINED, has_entity_name: bool | UndefinedType = UNDEFINED, @@ -852,6 +887,7 @@ class EntityRegistry(BaseRegistry): entity_id, capabilities=capabilities, config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, device_id=device_id, entity_category=entity_category, has_entity_name=has_entity_name, @@ -869,6 +905,7 @@ class EntityRegistry(BaseRegistry): domain, platform, config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, @@ -907,6 +944,7 @@ class EntityRegistry(BaseRegistry): entry = RegistryEntry( capabilities=none_if_undefined(capabilities), config_entry_id=none_if_undefined(config_entry_id), + config_subentry_id=none_if_undefined(config_subentry_id), created_at=created_at, device_id=none_if_undefined(device_id), disabled_by=disabled_by, @@ -949,6 +987,7 @@ class EntityRegistry(BaseRegistry): orphaned_timestamp = None if config_entry_id else time.time() self.deleted_entities[key] = DeletedRegistryEntry( config_entry_id=config_entry_id, + config_subentry_id=entity.config_subentry_id, created_at=entity.created_at, entity_id=entity_id, id=entity.id, @@ -1008,6 +1047,20 @@ class EntityRegistry(BaseRegistry): ): self.async_remove(entity.entity_id) + # Remove entities which belong to config subentries no longer associated with the + # device + entities = async_entries_for_device( + self, event.data["device_id"], include_disabled_entities=True + ) + for entity in entities: + if ( + (config_entry_id := entity.config_entry_id) is not None + and config_entry_id in device.config_entries + and entity.config_subentry_id + not in device.config_entries_subentries[config_entry_id] + ): + self.async_remove(entity.entity_id) + # Re-enable disabled entities if the device is no longer disabled if not device.disabled: entities = async_entries_for_device( @@ -1041,6 +1094,7 @@ class EntityRegistry(BaseRegistry): categories: dict[str, str] | UndefinedType = UNDEFINED, capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, config_entry_id: str | None | UndefinedType = UNDEFINED, + config_subentry_id: str | None | UndefinedType = UNDEFINED, device_class: str | None | UndefinedType = UNDEFINED, device_id: str | None | UndefinedType = UNDEFINED, disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, @@ -1073,6 +1127,7 @@ class EntityRegistry(BaseRegistry): ("categories", categories), ("capabilities", capabilities), ("config_entry_id", config_entry_id), + ("config_subentry_id", config_subentry_id), ("device_class", device_class), ("device_id", device_id), ("disabled_by", disabled_by), @@ -1102,10 +1157,12 @@ class EntityRegistry(BaseRegistry): old.domain, old.platform, config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, device_id=device_id, disabled_by=disabled_by, entity_category=entity_category, hidden_by=hidden_by, + old_config_subentry_id=old.config_subentry_id, unique_id=new_unique_id, ) @@ -1170,6 +1227,7 @@ class EntityRegistry(BaseRegistry): categories: dict[str, str] | UndefinedType = UNDEFINED, capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED, config_entry_id: str | None | UndefinedType = UNDEFINED, + config_subentry_id: str | None | UndefinedType = UNDEFINED, device_class: str | None | UndefinedType = UNDEFINED, device_id: str | None | UndefinedType = UNDEFINED, disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED, @@ -1196,6 +1254,7 @@ class EntityRegistry(BaseRegistry): categories=categories, capabilities=capabilities, config_entry_id=config_entry_id, + config_subentry_id=config_subentry_id, device_class=device_class, device_id=device_id, disabled_by=disabled_by, @@ -1222,6 +1281,7 @@ class EntityRegistry(BaseRegistry): new_platform: str, *, new_config_entry_id: str | UndefinedType = UNDEFINED, + new_config_subentry_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, new_device_id: str | None | UndefinedType = UNDEFINED, ) -> RegistryEntry: @@ -1246,6 +1306,7 @@ class EntityRegistry(BaseRegistry): entity_id, new_unique_id=new_unique_id, config_entry_id=new_config_entry_id, + config_subentry_id=new_config_subentry_id, device_id=new_device_id, platform=new_platform, ) @@ -1308,6 +1369,7 @@ class EntityRegistry(BaseRegistry): categories=entity["categories"], capabilities=entity["capabilities"], config_entry_id=entity["config_entry_id"], + config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), device_class=entity["device_class"], device_id=entity["device_id"], @@ -1357,6 +1419,7 @@ class EntityRegistry(BaseRegistry): ) deleted_entities[key] = DeletedRegistryEntry( config_entry_id=entity["config_entry_id"], + config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), entity_id=entity["entity_id"], id=entity["id"], @@ -1415,6 +1478,30 @@ class EntityRegistry(BaseRegistry): ) self.async_schedule_save() + @callback + def async_clear_config_subentry( + self, config_entry_id: str, config_subentry_id: str + ) -> None: + """Clear config subentry from registry entries.""" + now_time = time.time() + for entity_id in [ + entry.entity_id + for entry in self.entities.get_entries_for_config_entry_id(config_entry_id) + if entry.config_subentry_id == config_subentry_id + ]: + self.async_remove(entity_id) + for key, deleted_entity in list(self.deleted_entities.items()): + if config_subentry_id != deleted_entity.config_subentry_id: + continue + # Add a time stamp when the deleted entity became orphaned + self.deleted_entities[key] = attr.evolve( + deleted_entity, + orphaned_timestamp=now_time, + config_entry_id=None, + config_subentry_id=None, + ) + self.async_schedule_save() + @callback def async_purge_expired_orphaned_entities(self) -> None: """Purge expired orphaned entities from the registry. diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index b3d397dbd55..2e5ec3e8ba0 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -285,6 +285,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: "user" if integration.integration_type == "helper" else None ), ), + vol.Optional("config_subentries"): cv.schema_with_slug_keys( + gen_data_entry_schema( + config=config, + integration=integration, + flow_title=REQUIRED, + require_step_title=False, + ), + slug_validator=vol.Any("_", cv.slug), + ), vol.Optional("options"): gen_data_entry_schema( config=config, integration=integration, diff --git a/tests/common.py b/tests/common.py index 0315ee6d845..b88f261e83c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1004,6 +1004,7 @@ class MockConfigEntry(config_entries.ConfigEntry): reason=None, source=config_entries.SOURCE_USER, state=None, + subentries_data=None, title="Mock Title", unique_id=None, version=1, @@ -1020,6 +1021,7 @@ class MockConfigEntry(config_entries.ConfigEntry): "options": options or {}, "pref_disable_new_entities": pref_disable_new_entities, "pref_disable_polling": pref_disable_polling, + "subentries_data": subentries_data or (), "title": title, "unique_id": unique_id, "version": version, @@ -1092,6 +1094,28 @@ class MockConfigEntry(config_entries.ConfigEntry): }, ) + async def start_subentry_reconfigure_flow( + self, + hass: HomeAssistant, + subentry_flow_type: str, + subentry_id: str, + *, + show_advanced_options: bool = False, + ) -> ConfigFlowResult: + """Start a subentry reconfiguration flow.""" + if self.entry_id not in hass.config_entries._entries: + raise ValueError( + "Config entry must be added to hass to start reconfiguration flow" + ) + return await hass.config_entries.subentries.async_init( + (self.entry_id, subentry_flow_type), + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "subentry_id": subentry_id, + "show_advanced_options": show_advanced_options, + }, + ) + async def start_reauth_flow( hass: HomeAssistant, diff --git a/tests/components/acaia/snapshots/test_binary_sensor.ambr b/tests/components/acaia/snapshots/test_binary_sensor.ambr index 113b5f1501e..a9c52c052a3 100644 --- a/tests/components/acaia/snapshots/test_binary_sensor.ambr +++ b/tests/components/acaia/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr index cd91ca1a17a..11827c0997f 100644 --- a/tests/components/acaia/snapshots/test_button.ambr +++ b/tests/components/acaia/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr index 7011b20f68c..c7a11cb58df 100644 --- a/tests/components/acaia/snapshots/test_init.ambr +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/acaia/snapshots/test_sensor.ambr b/tests/components/acaia/snapshots/test_sensor.ambr index c3c8ce966ee..9214db4f102 100644 --- a/tests/components/acaia/snapshots/test_sensor.ambr +++ b/tests/components/acaia/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index 3468d638bc0..257d29ae844 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -15,6 +15,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -80,6 +81,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +147,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,6 +213,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +279,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +338,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -385,6 +391,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -440,6 +447,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -489,6 +497,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -537,6 +546,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -585,6 +595,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -633,6 +644,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -681,6 +693,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -729,6 +742,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -777,6 +791,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -825,6 +840,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -873,6 +889,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -921,6 +938,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -969,6 +987,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1016,6 +1035,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1063,6 +1083,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1110,6 +1131,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1157,6 +1179,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1204,6 +1227,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1251,6 +1275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1298,6 +1323,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1345,6 +1371,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1392,6 +1419,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1441,6 +1469,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1491,6 +1520,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1540,6 +1570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1589,6 +1620,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1638,6 +1670,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1687,6 +1720,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1736,6 +1770,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1784,6 +1819,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1832,6 +1868,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1880,6 +1917,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1928,6 +1966,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1978,6 +2017,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2028,6 +2068,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2077,6 +2118,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2126,6 +2168,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2175,6 +2218,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2224,6 +2268,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2275,6 +2320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2328,6 +2374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2387,6 +2434,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2440,6 +2488,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2489,6 +2538,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2538,6 +2588,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2587,6 +2638,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2636,6 +2688,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2687,6 +2740,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2737,6 +2791,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2786,6 +2841,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2835,6 +2891,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2884,6 +2941,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2933,6 +2991,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2982,6 +3041,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3031,6 +3091,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3080,6 +3141,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3129,6 +3191,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3178,6 +3241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3229,6 +3293,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3279,6 +3344,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3328,6 +3394,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3377,6 +3444,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3426,6 +3494,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3475,6 +3544,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3524,6 +3594,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3573,6 +3644,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3622,6 +3694,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3671,6 +3744,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3720,6 +3794,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3769,6 +3844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3818,6 +3894,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3867,6 +3944,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3916,6 +3994,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3965,6 +4044,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4014,6 +4094,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4063,6 +4144,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4112,6 +4194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4161,6 +4244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4210,6 +4294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4261,6 +4346,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4311,6 +4397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4359,6 +4446,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4407,6 +4495,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4455,6 +4544,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4503,6 +4593,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4551,6 +4642,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4599,6 +4691,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4647,6 +4740,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4695,6 +4789,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4743,6 +4838,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4791,6 +4887,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4840,6 +4937,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4889,6 +4987,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4938,6 +5037,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4987,6 +5087,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5038,6 +5139,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5088,6 +5190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5137,6 +5240,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5186,6 +5290,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5235,6 +5340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5284,6 +5390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5335,6 +5442,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5387,6 +5495,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5439,6 +5548,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5489,6 +5599,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5539,6 +5650,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5589,6 +5701,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5639,6 +5752,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5689,6 +5803,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5739,6 +5854,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5789,6 +5905,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5839,6 +5956,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5889,6 +6007,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5939,6 +6058,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5991,6 +6111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6041,6 +6162,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6091,6 +6213,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6141,6 +6264,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6191,6 +6315,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6241,6 +6366,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6291,6 +6417,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6341,6 +6468,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6391,6 +6519,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6441,6 +6570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6491,6 +6621,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/accuweather/snapshots/test_weather.ambr b/tests/components/accuweather/snapshots/test_weather.ambr index cbe1891d216..862d79c2fde 100644 --- a/tests/components/accuweather/snapshots/test_weather.ambr +++ b/tests/components/accuweather/snapshots/test_weather.ambr @@ -247,6 +247,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/aemet/snapshots/test_diagnostics.ambr b/tests/components/aemet/snapshots/test_diagnostics.ambr index 0e40cce1b86..165e682de68 100644 --- a/tests/components/aemet/snapshots/test_diagnostics.ambr +++ b/tests/components/aemet/snapshots/test_diagnostics.ambr @@ -22,6 +22,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airgradient/snapshots/test_button.ambr b/tests/components/airgradient/snapshots/test_button.ambr index fa3f8994c3c..85ad29f98f2 100644 --- a/tests/components/airgradient/snapshots/test_button.ambr +++ b/tests/components/airgradient/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index 72cb12535f1..4e0c8027b43 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/airgradient/snapshots/test_number.ambr b/tests/components/airgradient/snapshots/test_number.ambr index 87df8757eeb..f847a4a472d 100644 --- a/tests/components/airgradient/snapshots/test_number.ambr +++ b/tests/components/airgradient/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_select.ambr b/tests/components/airgradient/snapshots/test_select.ambr index b8fca4a110b..cc080560ae5 100644 --- a/tests/components/airgradient/snapshots/test_select.ambr +++ b/tests/components/airgradient/snapshots/test_select.ambr @@ -15,6 +15,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -74,6 +75,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -129,6 +131,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -184,6 +187,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +244,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -299,6 +304,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -360,6 +366,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +429,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,6 +489,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -539,6 +548,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -600,6 +610,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 3db188bed95..38a6774b6db 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -157,6 +160,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -213,6 +217,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -266,6 +271,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -315,6 +321,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -368,6 +375,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +430,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -469,6 +478,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -519,6 +529,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -620,6 +632,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -671,6 +684,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -722,6 +736,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -772,6 +787,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -823,6 +839,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -873,6 +890,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -924,6 +942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -975,6 +994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1022,6 +1042,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1070,6 +1091,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1120,6 +1142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1167,6 +1190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1217,6 +1241,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1267,6 +1292,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1317,6 +1343,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1368,6 +1395,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1415,6 +1443,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_switch.ambr b/tests/components/airgradient/snapshots/test_switch.ambr index 752355dbe97..ae2116d5b29 100644 --- a/tests/components/airgradient/snapshots/test_switch.ambr +++ b/tests/components/airgradient/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airgradient/snapshots/test_update.ambr b/tests/components/airgradient/snapshots/test_update.ambr index 1f944bb528b..53c815629f2 100644 --- a/tests/components/airgradient/snapshots/test_update.ambr +++ b/tests/components/airgradient/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index ec501b2fd7e..1c760eaec52 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Home', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airly/snapshots/test_sensor.ambr b/tests/components/airly/snapshots/test_sensor.ambr index 23a4d13cd00..134023f34e0 100644 --- a/tests/components/airly/snapshots/test_sensor.ambr +++ b/tests/components/airly/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -62,6 +63,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -118,6 +120,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -173,6 +176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -230,6 +234,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +292,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +348,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -399,6 +406,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -456,6 +464,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -511,6 +520,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -568,6 +578,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 3dd4788dc61..73ba6a7123f 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -35,6 +35,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/airtouch5/snapshots/test_cover.ambr b/tests/components/airtouch5/snapshots/test_cover.ambr index a8e57f69527..d2ae3cddc7f 100644 --- a/tests/components/airtouch5/snapshots/test_cover.ambr +++ b/tests/components/airtouch5/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr index 606d6082351..0dbdef1d508 100644 --- a/tests/components/airvisual/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 3, diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr index cb1d3a7aee7..113db6e3b96 100644 --- a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -101,6 +101,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'XXXXXXX', 'version': 1, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 0c3c0ba7c7a..b4976c07e1b 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -289,6 +289,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index c6ad36916bf..4bd7bfaccdd 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -101,6 +101,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'installation1', 'version': 1, diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr index fd48184ca0b..7266afcfd96 100644 --- a/tests/components/ambient_network/snapshots/test_sensor.ambr +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -126,6 +128,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +185,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -238,6 +242,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +302,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -353,6 +359,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -407,6 +414,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +466,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -517,6 +526,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -576,6 +586,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -635,6 +646,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -691,6 +703,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -746,6 +759,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -803,6 +817,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -857,6 +872,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -916,6 +932,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -975,6 +992,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1034,6 +1052,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1093,6 +1112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1149,6 +1169,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1205,6 +1226,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1264,6 +1286,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1320,6 +1343,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1374,6 +1398,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1425,6 +1450,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1484,6 +1510,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1543,6 +1570,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1602,6 +1630,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1658,6 +1687,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1713,6 +1743,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1770,6 +1801,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1824,6 +1856,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1883,6 +1916,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1942,6 +1976,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2000,6 +2035,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2058,6 +2094,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2113,6 +2150,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2168,6 +2206,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2226,6 +2265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2281,6 +2321,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2336,6 +2377,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2394,6 +2436,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2452,6 +2495,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2510,6 +2554,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2565,6 +2610,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2619,6 +2665,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2675,6 +2722,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2728,6 +2776,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2786,6 +2835,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr index 2f90b09d39f..07db19101ab 100644 --- a/tests/components/ambient_station/snapshots/test_diagnostics.ambr +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/analytics_insights/snapshots/test_sensor.ambr b/tests/components/analytics_insights/snapshots/test_sensor.ambr index 6e11b344b0b..799738eb677 100644 --- a/tests/components/analytics_insights/snapshots/test_sensor.ambr +++ b/tests/components/analytics_insights/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr index dec33a92fe2..e647b7fa6a5 100644 --- a/tests/components/aosmith/snapshots/test_device.ambr +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'basement', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/aosmith/snapshots/test_sensor.ambr b/tests/components/aosmith/snapshots/test_sensor.ambr index 563b52f6df7..c422e8fdab5 100644 --- a/tests/components/aosmith/snapshots/test_sensor.ambr +++ b/tests/components/aosmith/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -60,6 +61,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr index deb079570f1..43db89807b6 100644 --- a/tests/components/aosmith/snapshots/test_water_heater.ambr +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -9,6 +9,7 @@ 'min_temp': 95, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -71,6 +72,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/apsystems/snapshots/test_binary_sensor.ambr b/tests/components/apsystems/snapshots/test_binary_sensor.ambr index 0875c88976b..381fc1864fc 100644 --- a/tests/components/apsystems/snapshots/test_binary_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/apsystems/snapshots/test_number.ambr b/tests/components/apsystems/snapshots/test_number.ambr index a2b82e23596..21141de7d64 100644 --- a/tests/components/apsystems/snapshots/test_number.ambr +++ b/tests/components/apsystems/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/apsystems/snapshots/test_sensor.ambr b/tests/components/apsystems/snapshots/test_sensor.ambr index 669e89fda17..251a8d8428c 100644 --- a/tests/components/apsystems/snapshots/test_sensor.ambr +++ b/tests/components/apsystems/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +372,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -416,6 +424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/apsystems/snapshots/test_switch.ambr b/tests/components/apsystems/snapshots/test_switch.ambr index 6daa9fd6e14..a9f74ee5517 100644 --- a/tests/components/apsystems/snapshots/test_switch.ambr +++ b/tests/components/apsystems/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/aquacell/snapshots/test_sensor.ambr b/tests/components/aquacell/snapshots/test_sensor.ambr index a237f59881a..eeac14c000d 100644 --- a/tests/components/aquacell/snapshots/test_sensor.ambr +++ b/tests/components/aquacell/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -154,6 +157,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -202,6 +206,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +261,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/arve/snapshots/test_sensor.ambr b/tests/components/arve/snapshots/test_sensor.ambr index 5c7888c41de..ed2494c3197 100644 --- a/tests/components/arve/snapshots/test_sensor.ambr +++ b/tests/components/arve/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -43,6 +44,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -78,6 +80,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +116,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -148,6 +152,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -183,6 +188,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +224,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr index 6e95b0ce552..be5947372f5 100644 --- a/tests/components/august/snapshots/test_binary_sensor.ambr +++ b/tests/components/august/snapshots/test_binary_sensor.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://account.august.com', 'connections': set({ }), diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr index 6aad3a140ca..0a594fed1ee 100644 --- a/tests/components/august/snapshots/test_lock.ambr +++ b/tests/components/august/snapshots/test_lock.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://account.august.com', 'connections': set({ tuple( diff --git a/tests/components/autarco/snapshots/test_sensor.ambr b/tests/components/autarco/snapshots/test_sensor.ambr index dbbd8e9b47d..d57f4be5da0 100644 --- a/tests/components/autarco/snapshots/test_sensor.ambr +++ b/tests/components/autarco/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +372,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -416,6 +424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -467,6 +476,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -518,6 +528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -620,6 +632,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -671,6 +684,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -722,6 +736,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -773,6 +788,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/axis/snapshots/test_binary_sensor.ambr b/tests/components/axis/snapshots/test_binary_sensor.ambr index ab860489d55..6c0f3ead473 100644 --- a/tests/components/axis/snapshots/test_binary_sensor.ambr +++ b/tests/components/axis/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +486,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/axis/snapshots/test_camera.ambr b/tests/components/axis/snapshots/test_camera.ambr index 564ff96b3d8..1e70e2a799f 100644 --- a/tests/components/axis/snapshots/test_camera.ambr +++ b/tests/components/axis/snapshots/test_camera.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index ebd0061f416..b475c796d2b 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 3, diff --git a/tests/components/axis/snapshots/test_hub.ambr b/tests/components/axis/snapshots/test_hub.ambr index 16579287f09..9e407bfef0b 100644 --- a/tests/components/axis/snapshots/test_hub.ambr +++ b/tests/components/axis/snapshots/test_hub.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( @@ -39,6 +40,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ tuple( diff --git a/tests/components/axis/snapshots/test_light.ambr b/tests/components/axis/snapshots/test_light.ambr index b37da39fe27..d8d01543ee5 100644 --- a/tests/components/axis/snapshots/test_light.ambr +++ b/tests/components/axis/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/axis/snapshots/test_switch.ambr b/tests/components/axis/snapshots/test_switch.ambr index dc4c75371cf..fa6091550e5 100644 --- a/tests/components/axis/snapshots/test_switch.ambr +++ b/tests/components/axis/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/azure_devops/snapshots/test_sensor.ambr b/tests/components/azure_devops/snapshots/test_sensor.ambr index aa8d1d9e7e0..0b8f35497c6 100644 --- a/tests/components/azure_devops/snapshots/test_sensor.ambr +++ b/tests/components/azure_devops/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -157,6 +160,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -204,6 +208,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -250,6 +255,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +302,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -388,6 +396,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -435,6 +444,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_binary_sensor.ambr b/tests/components/balboa/snapshots/test_binary_sensor.ambr index c37c8a20d4b..4aa0f1d71fe 100644 --- a/tests/components/balboa/snapshots/test_binary_sensor.ambr +++ b/tests/components/balboa/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_climate.ambr b/tests/components/balboa/snapshots/test_climate.ambr index d3060077341..70e33c4065f 100644 --- a/tests/components/balboa/snapshots/test_climate.ambr +++ b/tests/components/balboa/snapshots/test_climate.ambr @@ -17,6 +17,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_fan.ambr b/tests/components/balboa/snapshots/test_fan.ambr index 8d35ab6de7c..4df73c3178c 100644 --- a/tests/components/balboa/snapshots/test_fan.ambr +++ b/tests/components/balboa/snapshots/test_fan.ambr @@ -8,6 +8,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_light.ambr b/tests/components/balboa/snapshots/test_light.ambr index 31777744740..fdfd7af1d0c 100644 --- a/tests/components/balboa/snapshots/test_light.ambr +++ b/tests/components/balboa/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/balboa/snapshots/test_select.ambr b/tests/components/balboa/snapshots/test_select.ambr index a0cfd68d009..68368bf3602 100644 --- a/tests/components/balboa/snapshots/test_select.ambr +++ b/tests/components/balboa/snapshots/test_select.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index e9540b5cec6..d7f9a045921 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Beosound Balance-11111111', 'unique_id': '11111111', 'version': 1, diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index edc2879a66b..54df2b48cdb 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -48,6 +48,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 3, diff --git a/tests/components/bluemaestro/snapshots/test_sensor.ambr b/tests/components/bluemaestro/snapshots/test_sensor.ambr index 2b777ec6f09..48f20aa97b5 100644 --- a/tests/components/bluemaestro/snapshots/test_sensor.ambr +++ b/tests/components/bluemaestro/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr index c0462279e59..569d39c1a5a 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -200,6 +204,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -248,6 +253,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -302,6 +308,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -348,6 +355,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -397,6 +405,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -444,6 +453,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -492,6 +502,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -550,6 +561,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -597,6 +609,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -645,6 +658,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -698,6 +712,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -744,6 +759,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -796,6 +812,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -843,6 +860,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -891,6 +909,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -949,6 +968,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -996,6 +1016,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1044,6 +1065,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1098,6 +1120,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1144,6 +1167,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1196,6 +1220,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1245,6 +1270,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1306,6 +1332,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1354,6 +1381,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1407,6 +1435,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_button.ambr b/tests/components/bmw_connected_drive/snapshots/test_button.ambr index f38441125ce..5072b918d2e 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_button.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -374,6 +382,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -466,6 +476,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -512,6 +523,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -604,6 +617,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -650,6 +664,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -696,6 +711,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -742,6 +758,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -788,6 +805,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -834,6 +852,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_lock.ambr b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr index 395c6e56dda..3dc4e59b7b1 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_lock.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr index 71dbc46b454..866e52e7982 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_number.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index b827dfe478a..de76b07057e 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -79,6 +80,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +149,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -214,6 +217,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +286,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr index 624b2c6007f..230025fc865 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -166,6 +169,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,6 +231,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -279,6 +284,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +339,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -387,6 +394,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -441,6 +449,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -494,6 +503,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -548,6 +558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -602,6 +613,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -654,6 +666,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -705,6 +718,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -752,6 +766,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -814,6 +829,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -875,6 +891,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -933,6 +950,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -989,6 +1007,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1046,6 +1065,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1103,6 +1123,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1160,6 +1181,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1217,6 +1239,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1271,6 +1294,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1328,6 +1352,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1385,6 +1410,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1442,6 +1468,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1499,6 +1526,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1553,6 +1581,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1607,6 +1636,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1659,6 +1689,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1710,6 +1741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1757,6 +1789,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1819,6 +1852,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1880,6 +1914,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1938,6 +1973,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1994,6 +2030,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2051,6 +2088,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2108,6 +2146,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2165,6 +2204,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2222,6 +2262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2276,6 +2317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2333,6 +2375,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2390,6 +2433,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2447,6 +2491,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2504,6 +2549,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2558,6 +2604,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2612,6 +2659,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2672,6 +2720,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2728,6 +2777,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2785,6 +2835,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2842,6 +2893,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2899,6 +2951,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2956,6 +3009,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3010,6 +3064,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3067,6 +3122,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3124,6 +3180,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3181,6 +3238,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3238,6 +3296,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3292,6 +3351,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3345,6 +3405,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3399,6 +3460,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr index 5b60a32c3be..ce6ebc21f51 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr index cd29c647df7..de76c00cd23 100644 --- a/tests/components/braviatv/snapshots/test_diagnostics.ambr +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'very_unique_string', 'version': 1, diff --git a/tests/components/bring/snapshots/test_event.ambr b/tests/components/bring/snapshots/test_event.ambr index 907467bd6bb..0bcdcb5b565 100644 --- a/tests/components/bring/snapshots/test_event.ambr +++ b/tests/components/bring/snapshots/test_event.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -95,6 +96,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bring/snapshots/test_sensor.ambr b/tests/components/bring/snapshots/test_sensor.ambr index 97e1d1b4bd9..eb307d31396 100644 --- a/tests/components/bring/snapshots/test_sensor.ambr +++ b/tests/components/bring/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -181,6 +184,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -250,6 +254,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +302,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -350,6 +356,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -402,6 +409,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -472,6 +480,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -541,6 +550,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bring/snapshots/test_todo.ambr b/tests/components/bring/snapshots/test_todo.ambr index 6a7104727a1..46146415bf6 100644 --- a/tests/components/bring/snapshots/test_todo.ambr +++ b/tests/components/bring/snapshots/test_todo.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/brother/snapshots/test_sensor.ambr b/tests/components/brother/snapshots/test_sensor.ambr index 4de85859461..847ea0a2c6b 100644 --- a/tests/components/brother/snapshots/test_sensor.ambr +++ b/tests/components/brother/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -358,6 +365,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -408,6 +416,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -508,6 +518,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +569,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -608,6 +620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -658,6 +671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -708,6 +722,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -758,6 +773,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -806,6 +822,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -855,6 +872,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -905,6 +923,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -955,6 +974,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1005,6 +1025,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1055,6 +1076,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1105,6 +1127,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1153,6 +1176,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1201,6 +1225,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1251,6 +1276,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1301,6 +1327,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1351,6 +1378,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bryant_evolution/snapshots/test_climate.ambr b/tests/components/bryant_evolution/snapshots/test_climate.ambr index 4f6c1f2bbc4..3aeaf66329f 100644 --- a/tests/components/bryant_evolution/snapshots/test_climate.ambr +++ b/tests/components/bryant_evolution/snapshots/test_climate.ambr @@ -21,6 +21,7 @@ 'min_temp': 45, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr index 16828fea752..70d13f1cb95 100644 --- a/tests/components/bsblan/snapshots/test_climate.ambr +++ b/tests/components/bsblan/snapshots/test_climate.ambr @@ -18,6 +18,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -91,6 +92,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bsblan/snapshots/test_sensor.ambr b/tests/components/bsblan/snapshots/test_sensor.ambr index 0146dd23b3d..df7ceecc957 100644 --- a/tests/components/bsblan/snapshots/test_sensor.ambr +++ b/tests/components/bsblan/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/bsblan/snapshots/test_water_heater.ambr b/tests/components/bsblan/snapshots/test_water_heater.ambr index c1a13b764c0..37fdb14aca9 100644 --- a/tests/components/bsblan/snapshots/test_water_heater.ambr +++ b/tests/components/bsblan/snapshots/test_water_heater.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr index 64182ee2188..7f4bbed36f7 100644 --- a/tests/components/cambridge_audio/snapshots/test_init.ambr +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.20.218', 'connections': set({ }), @@ -30,4 +31,4 @@ 'sw_version': None, 'via_device_id': None, }) -# --- \ No newline at end of file +# --- diff --git a/tests/components/cambridge_audio/snapshots/test_select.ambr b/tests/components/cambridge_audio/snapshots/test_select.ambr index b40c8a8d5c4..8c9801b101b 100644 --- a/tests/components/cambridge_audio/snapshots/test_select.ambr +++ b/tests/components/cambridge_audio/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/cambridge_audio/snapshots/test_switch.ambr b/tests/components/cambridge_audio/snapshots/test_switch.ambr index 9bfcd7c6da7..cd4326fdcc3 100644 --- a/tests/components/cambridge_audio/snapshots/test_switch.ambr +++ b/tests/components/cambridge_audio/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr index 27dcbcb3405..a3cda75463f 100644 --- a/tests/components/ccm15/snapshots/test_climate.ambr +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -28,6 +28,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -83,6 +84,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +220,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -273,6 +276,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/chacon_dio/snapshots/test_cover.ambr b/tests/components/chacon_dio/snapshots/test_cover.ambr index b2febe20070..afac3359410 100644 --- a/tests/components/chacon_dio/snapshots/test_cover.ambr +++ b/tests/components/chacon_dio/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/chacon_dio/snapshots/test_switch.ambr b/tests/components/chacon_dio/snapshots/test_switch.ambr index 7a65dad5445..a2620005531 100644 --- a/tests/components/chacon_dio/snapshots/test_switch.ambr +++ b/tests/components/chacon_dio/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index 9218e7343ec..4159c8ec1a1 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/co2signal/snapshots/test_sensor.ambr b/tests/components/co2signal/snapshots/test_sensor.ambr index 3702521e4c3..1e241735102 100644 --- a/tests/components/co2signal/snapshots/test_sensor.ambr +++ b/tests/components/co2signal/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -60,6 +61,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index 51bd946f140..3eab18fb9f3 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -44,6 +44,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 58ce74035f9..877f48a4611 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -71,6 +71,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, @@ -135,6 +137,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index f5241f65200..24b775ccd90 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -139,11 +139,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentry_types": {}, "supports_options": True, "supports_reconfigure": False, "supports_remove_device": False, @@ -157,11 +159,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": core_ce.ConfigEntryState.SETUP_ERROR.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -175,11 +179,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -193,11 +199,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -211,11 +219,13 @@ async def test_get_entries(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -573,11 +583,13 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": core_ce.SOURCE_USER, "state": core_ce.ConfigEntryState.LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -588,6 +600,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, + "subentries": [], } @@ -656,11 +669,13 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": core_ce.SOURCE_USER, "state": core_ce.ConfigEntryState.LOADED.value, + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -671,6 +686,7 @@ async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: "description_placeholders": None, "options": {}, "minor_version": 1, + "subentries": [], } @@ -1125,6 +1141,326 @@ async def test_options_flow_with_invalid_data( assert data == {"errors": {"choices": "invalid is not a valid option"}} +async def test_subentry_flow(hass: HomeAssistant, client) -> None: + """Test we can start a subentry flow.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + raise NotImplementedError + + async def async_step_user(self, user_input=None): + schema = {} + schema[vol.Required("enabled")] = bool + return self.async_show_form( + step_id="user", + data_schema=schema, + description_placeholders={"enabled": "Set to true to be true"}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "user", + "data_schema": [{"name": "enabled", "required": True, "type": "boolean"}], + "description_placeholders": {"enabled": "Set to true to be true"}, + "errors": None, + "last_step": None, + "preview": None, + } + + +async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: + """Test we can start a subentry reconfigure flow.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + raise NotImplementedError + + async def async_step_user(self, user_input=None): + raise NotImplementedError + + async def async_step_reconfigure(self, user_input=None): + schema = {} + schema[vol.Required("enabled")] = bool + return self.async_show_form( + step_id="reconfigure", + data_schema=schema, + description_placeholders={"enabled": "Set to true to be true"}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + subentries_data=[ + core_ce.ConfigSubentryData( + data={}, + subentry_id="mock_id", + subentry_type="test", + title="Title", + unique_id=None, + ) + ], + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post( + url, json={"handler": [entry.entry_id, "test"], "subentry_id": "mock_id"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "reconfigure", + "data_schema": [{"name": "enabled", "required": True, "type": "boolean"}], + "description_placeholders": {"enabled": "Set to true to be true"}, + "errors": None, + "last_step": None, + "preview": None, + } + + +@pytest.mark.parametrize( + ("endpoint", "method"), + [ + ("/api/config/config_entries/subentries/flow", "post"), + ("/api/config/config_entries/subentries/flow/1", "get"), + ("/api/config/config_entries/subentries/flow/1", "post"), + ], +) +async def test_subentry_flow_unauth( + hass: HomeAssistant, client, hass_admin_user: MockUser, endpoint: str, method: str +) -> None: + """Test unauthorized on subentry flow.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + schema = {} + schema[vol.Required("enabled")] = bool + return self.async_show_form( + step_id="user", + data_schema=schema, + description_placeholders={"enabled": "Set to true to be true"}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + hass_admin_user.groups = [] + + with mock_config_flow("test", TestFlow): + resp = await getattr(client, method)(endpoint, json={"handler": entry.entry_id}) + + assert resp.status == HTTPStatus.UNAUTHORIZED + + +async def test_two_step_subentry_flow(hass: HomeAssistant, client) -> None: + """Test we can finish a two step subentry flow.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + return await self.async_step_finish() + + async def async_step_finish(self, user_input=None): + if user_input: + return self.async_create_entry( + title="Mock title", data=user_input, unique_id="test" + ) + + return self.async_show_form( + step_id="finish", data_schema=vol.Schema({"enabled": bool}) + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data["flow_id"] + expected_data = { + "data_schema": [{"name": "enabled", "type": "boolean"}], + "description_placeholders": None, + "errors": None, + "flow_id": flow_id, + "handler": ["test1", "test"], + "last_step": None, + "preview": None, + "step_id": "finish", + "type": "form", + } + assert data == expected_data + + resp = await client.get(f"/api/config/config_entries/subentries/flow/{flow_id}") + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == expected_data + + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"enabled": True}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data == { + "description_placeholders": None, + "description": None, + "flow_id": flow_id, + "handler": ["test1", "test"], + "title": "Mock title", + "type": "create_entry", + "unique_id": "test", + } + + +async def test_subentry_flow_with_invalid_data(hass: HomeAssistant, client) -> None: + """Test a subentry flow with invalid_data.""" + mock_integration( + hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) + ) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + return self.async_show_form( + step_id="finish", + data_schema=vol.Schema( + { + vol.Required( + "choices", default=["invalid", "valid"] + ): cv.multi_select({"valid": "Valid"}) + } + ), + ) + + async def async_step_finish(self, user_input=None): + return self.async_create_entry(title="Enable disable", data=user_input) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + flow_id = data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "finish", + "data_schema": [ + { + "default": ["invalid", "valid"], + "name": "choices", + "options": {"valid": "Valid"}, + "required": True, + "type": "multi_select", + } + ], + "description_placeholders": None, + "errors": None, + "last_step": None, + "preview": None, + } + + with mock_config_flow("test", TestFlow): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"choices": ["valid", "invalid"]}, + ) + assert resp.status == HTTPStatus.BAD_REQUEST + data = await resp.json() + assert data == {"errors": {"choices": "invalid is not a valid option"}} + + @pytest.mark.usefixtures("freezer") async def test_get_single( hass: HomeAssistant, hass_ws_client: WebSocketGenerator @@ -1157,11 +1493,13 @@ async def test_get_single( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "user", "state": "loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1517,11 +1855,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1536,11 +1876,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": "setup_error", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1555,11 +1897,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1574,11 +1918,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1593,11 +1939,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1623,11 +1971,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1652,11 +2002,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1671,11 +2023,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1700,11 +2054,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1719,11 +2075,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1754,11 +2112,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1773,11 +2133,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": "setup_error", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1792,11 +2154,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1811,11 +2175,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla4", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1830,11 +2196,13 @@ async def test_get_matching_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": timestamp, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla5", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1937,11 +2305,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1959,11 +2329,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": "Unsupported API", "source": "bla2", "state": "setup_error", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -1981,11 +2353,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2009,11 +2383,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2038,11 +2414,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2066,11 +2444,13 @@ async def test_subscribe_entries_ws( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": entry.modified_at.timestamp(), + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2156,11 +2536,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2178,11 +2560,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": created, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2208,11 +2592,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2234,11 +2620,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla3", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2264,11 +2652,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": modified, + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2292,11 +2682,13 @@ async def test_subscribe_entries_ws_filtered( "error_reason_translation_key": None, "error_reason_translation_placeholders": None, "modified_at": entry.modified_at.timestamp(), + "num_subentries": 0, "pref_disable_new_entities": False, "pref_disable_polling": False, "reason": None, "source": "bla", "state": "not_loaded", + "supported_subentry_types": {}, "supports_options": False, "supports_reconfigure": False, "supports_remove_device": False, @@ -2507,3 +2899,142 @@ async def test_does_not_support_reconfigure( response == '{"message":"Handler ConfigEntriesFlowManager doesn\'t support step reconfigure"}' ) + + +async def test_list_subentries( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that we can list subentries.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry( + domain="test", + state=core_ce.ConfigEntryState.LOADED, + subentries_data=[ + core_ce.ConfigSubentryData( + data={"test": "test"}, + subentry_id="mock_id", + subentry_type="test", + title="Mock title", + unique_id="test", + ) + ], + ) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == [ + { + "subentry_id": "mock_id", + "subentry_type": "test", + "title": "Mock title", + "unique_id": "test", + }, + ] + + # Try listing subentries for an unknown entry + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": "no_such_entry", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } + + +async def test_delete_subentry( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that we can delete a subentry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry( + domain="test", + state=core_ce.ConfigEntryState.LOADED, + subentries_data=[ + core_ce.ConfigSubentryData( + data={"test": "test"}, + subentry_id="mock_id", + subentry_type="test", + title="Mock title", + ) + ], + ) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": entry.entry_id, + "subentry_id": "mock_id", + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] is None + + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/list", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == [] + + # Try deleting the subentry again + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": entry.entry_id, + "subentry_id": "mock_id", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config subentry not found", + } + + # Try deleting subentry from an unknown entry + await ws_client.send_json_auto_id( + { + "type": "config_entries/subentries/delete", + "entry_id": "no_such_entry", + "subentry_id": "mock_id", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index c840ce2bed2..8a4e1ef234f 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -65,6 +65,7 @@ async def test_list_devices( { "area_id": None, "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), @@ -87,6 +88,7 @@ async def test_list_devices( { "area_id": None, "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, "configuration_url": None, "connections": [], "created_at": utcnow().timestamp(), @@ -121,6 +123,7 @@ async def test_list_devices( { "area_id": None, "config_entries": [entry.entry_id], + "config_entries_subentries": {entry.entry_id: [None]}, "configuration_url": None, "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], "created_at": utcnow().timestamp(), diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index bfbd69ec9bd..2e3de33d808 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -67,6 +67,7 @@ async def test_list_entities( "area_id": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": utcnow().timestamp(), "device_id": None, "disabled_by": None, @@ -89,6 +90,7 @@ async def test_list_entities( "area_id": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": utcnow().timestamp(), "device_id": None, "disabled_by": None, @@ -138,6 +140,7 @@ async def test_list_entities( "area_id": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": utcnow().timestamp(), "device_id": None, "disabled_by": None, @@ -374,6 +377,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": name_created_at.timestamp(), "device_class": None, "device_id": None, @@ -410,6 +414,7 @@ async def test_get_entity(hass: HomeAssistant, client: MockHAClientWebSocket) -> "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": no_name_created_at.timestamp(), "device_class": None, "device_id": None, @@ -477,6 +482,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": name_created_at.timestamp(), "device_class": None, "device_id": None, @@ -504,6 +510,7 @@ async def test_get_entities(hass: HomeAssistant, client: MockHAClientWebSocket) "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": no_name_created_at.timestamp(), "device_class": None, "device_id": None, @@ -586,6 +593,7 @@ async def test_update_entity( "categories": {"scope1": "id", "scope2": "id"}, "created_at": created.timestamp(), "config_entry_id": None, + "config_subentry_id": None, "device_class": "custom_device_class", "device_id": None, "disabled_by": None, @@ -668,6 +676,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -714,6 +723,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -759,6 +769,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id", "scope3": "id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -804,6 +815,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope2": "id", "scope3": "other_id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -849,6 +861,7 @@ async def test_update_entity( "capabilities": None, "categories": {"scope1": "id", "scope3": "other_id"}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": "custom_device_class", "device_id": None, @@ -911,6 +924,7 @@ async def test_update_entity_require_restart( "capabilities": None, "categories": {}, "config_entry_id": config_entry.entry_id, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": None, "device_id": None, @@ -1032,6 +1046,7 @@ async def test_update_entity_no_changes( "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": None, "device_id": None, @@ -1129,6 +1144,7 @@ async def test_update_entity_id( "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": created.timestamp(), "device_class": None, "device_id": None, diff --git a/tests/components/cookidoo/snapshots/test_button.ambr b/tests/components/cookidoo/snapshots/test_button.ambr index a6223059aa1..f316b0cfc82 100644 --- a/tests/components/cookidoo/snapshots/test_button.ambr +++ b/tests/components/cookidoo/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/cookidoo/snapshots/test_sensor.ambr b/tests/components/cookidoo/snapshots/test_sensor.ambr index 568b0baf688..ca861241971 100644 --- a/tests/components/cookidoo/snapshots/test_sensor.ambr +++ b/tests/components/cookidoo/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/cookidoo/snapshots/test_todo.ambr b/tests/components/cookidoo/snapshots/test_todo.ambr index be641432929..5b2c7552548 100644 --- a/tests/components/cookidoo/snapshots/test_todo.ambr +++ b/tests/components/cookidoo/snapshots/test_todo.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deako/snapshots/test_light.ambr b/tests/components/deako/snapshots/test_light.ambr index 7bc170654e1..f5ef5fd19e8 100644 --- a/tests/components/deako/snapshots/test_light.ambr +++ b/tests/components/deako/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -121,6 +123,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_alarm_control_panel.ambr b/tests/components/deconz/snapshots/test_alarm_control_panel.ambr index 86b97a62dfe..e1a6126498c 100644 --- a/tests/components/deconz/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/deconz/snapshots/test_alarm_control_panel.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_binary_sensor.ambr b/tests/components/deconz/snapshots/test_binary_sensor.ambr index 584575c23af..6b348d3ed0a 100644 --- a/tests/components/deconz/snapshots/test_binary_sensor.ambr +++ b/tests/components/deconz/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -197,6 +201,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -247,6 +252,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -341,6 +348,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -436,6 +445,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -484,6 +494,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -531,6 +542,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -578,6 +590,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -627,6 +640,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -676,6 +690,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -725,6 +740,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -772,6 +788,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -819,6 +836,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -875,6 +893,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -925,6 +944,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -972,6 +992,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_button.ambr b/tests/components/deconz/snapshots/test_button.ambr index 1ef5248ebc3..b7ad00cdacd 100644 --- a/tests/components/deconz/snapshots/test_button.ambr +++ b/tests/components/deconz/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_climate.ambr b/tests/components/deconz/snapshots/test_climate.ambr index 4e33e11534e..f8d572ab2ca 100644 --- a/tests/components/deconz/snapshots/test_climate.ambr +++ b/tests/components/deconz/snapshots/test_climate.ambr @@ -24,6 +24,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +112,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -207,6 +209,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +297,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -360,6 +364,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -425,6 +430,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -491,6 +497,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_cover.ambr b/tests/components/deconz/snapshots/test_cover.ambr index 5c50923453c..41ff4e950a8 100644 --- a/tests/components/deconz/snapshots/test_cover.ambr +++ b/tests/components/deconz/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr index 1ca674a4fbe..20558b4bbbd 100644 --- a/tests/components/deconz/snapshots/test_diagnostics.ambr +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -21,6 +21,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/deconz/snapshots/test_fan.ambr b/tests/components/deconz/snapshots/test_fan.ambr index 8b7dbba64e4..6a260c39673 100644 --- a/tests/components/deconz/snapshots/test_fan.ambr +++ b/tests/components/deconz/snapshots/test_fan.ambr @@ -8,6 +8,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_hub.ambr b/tests/components/deconz/snapshots/test_hub.ambr index f3aa9a5e65d..06067b69c17 100644 --- a/tests/components/deconz/snapshots/test_hub.ambr +++ b/tests/components/deconz/snapshots/test_hub.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://1.2.3.4:80', 'connections': set({ }), diff --git a/tests/components/deconz/snapshots/test_light.ambr b/tests/components/deconz/snapshots/test_light.ambr index b73bbcca216..212ccd84d0c 100644 --- a/tests/components/deconz/snapshots/test_light.ambr +++ b/tests/components/deconz/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -75,6 +76,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +162,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -238,6 +241,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +318,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -379,6 +384,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -464,6 +470,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -542,6 +549,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -618,6 +626,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -683,6 +692,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -768,6 +778,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -846,6 +857,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -931,6 +943,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1022,6 +1035,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1122,6 +1136,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1215,6 +1230,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1291,6 +1307,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1348,6 +1365,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1418,6 +1436,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_number.ambr b/tests/components/deconz/snapshots/test_number.ambr index 26e044e1d31..173d5e87043 100644 --- a/tests/components/deconz/snapshots/test_number.ambr +++ b/tests/components/deconz/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_scene.ambr b/tests/components/deconz/snapshots/test_scene.ambr index 85a5ab92c5c..21456afaea1 100644 --- a/tests/components/deconz/snapshots/test_scene.ambr +++ b/tests/components/deconz/snapshots/test_scene.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_select.ambr b/tests/components/deconz/snapshots/test_select.ambr index 997eab0901f..7fa2aaf11cb 100644 --- a/tests/components/deconz/snapshots/test_select.ambr +++ b/tests/components/deconz/snapshots/test_select.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -180,6 +183,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +240,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -293,6 +298,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +355,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -405,6 +412,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -462,6 +470,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -523,6 +532,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/deconz/snapshots/test_sensor.ambr b/tests/components/deconz/snapshots/test_sensor.ambr index 0b76366b5d1..be397f0e22a 100644 --- a/tests/components/deconz/snapshots/test_sensor.ambr +++ b/tests/components/deconz/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -257,6 +262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -305,6 +311,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -353,6 +360,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -401,6 +409,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -450,6 +459,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -505,6 +515,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -557,6 +568,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -611,6 +623,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +676,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -717,6 +731,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -771,6 +786,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -822,6 +838,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -876,6 +893,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -928,6 +946,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -980,6 +999,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1035,6 +1055,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1085,6 +1106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1134,6 +1156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1186,6 +1209,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1239,6 +1263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1290,6 +1315,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1341,6 +1367,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1392,6 +1419,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1443,6 +1471,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1493,6 +1522,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1544,6 +1574,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1600,6 +1631,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1651,6 +1683,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1702,6 +1735,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1753,6 +1787,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1803,6 +1838,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1854,6 +1890,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1905,6 +1942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1956,6 +1994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2006,6 +2045,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2058,6 +2098,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2109,6 +2150,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2160,6 +2202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2211,6 +2254,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr index c5daed73b33..659420c1590 100644 --- a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_climate.ambr b/tests/components/devolo_home_control/snapshots/test_climate.ambr index be7d6f78142..96ffe45c4a4 100644 --- a/tests/components/devolo_home_control/snapshots/test_climate.ambr +++ b/tests/components/devolo_home_control/snapshots/test_climate.ambr @@ -35,6 +35,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_cover.ambr b/tests/components/devolo_home_control/snapshots/test_cover.ambr index 7d88d42d5c2..44bff626923 100644 --- a/tests/components/devolo_home_control/snapshots/test_cover.ambr +++ b/tests/components/devolo_home_control/snapshots/test_cover.ambr @@ -22,6 +22,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index abedc128756..0e507ca0b28 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -47,6 +47,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '123456', 'version': 1, diff --git a/tests/components/devolo_home_control/snapshots/test_light.ambr b/tests/components/devolo_home_control/snapshots/test_light.ambr index 959656b52a4..11dc768a519 100644 --- a/tests/components/devolo_home_control/snapshots/test_light.ambr +++ b/tests/components/devolo_home_control/snapshots/test_light.ambr @@ -29,6 +29,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -85,6 +86,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_sensor.ambr index 3c23385594a..7cca8b23e77 100644 --- a/tests/components/devolo_home_control/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_sensor.ambr @@ -24,6 +24,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -74,6 +75,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -125,6 +127,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -176,6 +179,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,6 +231,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_siren.ambr b/tests/components/devolo_home_control/snapshots/test_siren.ambr index 5c94674998c..41b68574065 100644 --- a/tests/components/devolo_home_control/snapshots/test_siren.ambr +++ b/tests/components/devolo_home_control/snapshots/test_siren.ambr @@ -27,6 +27,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -81,6 +82,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -135,6 +137,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_control/snapshots/test_switch.ambr b/tests/components/devolo_home_control/snapshots/test_switch.ambr index 3e2f6f705d3..d3097716092 100644 --- a/tests/components/devolo_home_control/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_control/snapshots/test_switch.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr index c0df0d5d5a5..a33fdf084dd 100644 --- a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_button.ambr b/tests/components/devolo_home_network/snapshots/test_button.ambr index 126ac4e7cdb..31d8ebf31a0 100644 --- a/tests/components/devolo_home_network/snapshots/test_button.ambr +++ b/tests/components/devolo_home_network/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 53940bf5119..1288b7f3ef6 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -32,6 +32,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '1234567890', 'version': 1, diff --git a/tests/components/devolo_home_network/snapshots/test_image.ambr b/tests/components/devolo_home_network/snapshots/test_image.ambr index b3924a508cf..3772672d8cb 100644 --- a/tests/components/devolo_home_network/snapshots/test_image.ambr +++ b/tests/components/devolo_home_network/snapshots/test_image.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index 297c9a25183..bdc597819a7 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ tuple( @@ -39,6 +40,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.0.2.1', 'connections': set({ }), diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 2e6730cdb21..9e2d8879ac9 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -115,6 +117,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +265,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_switch.ambr b/tests/components/devolo_home_network/snapshots/test_switch.ambr index a2df5d2579f..6499bb9a17b 100644 --- a/tests/components/devolo_home_network/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_network/snapshots/test_switch.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/devolo_home_network/snapshots/test_update.ambr b/tests/components/devolo_home_network/snapshots/test_update.ambr index 8a1065f9a60..f4d1c0480cf 100644 --- a/tests/components/devolo_home_network/snapshots/test_update.ambr +++ b/tests/components/devolo_home_network/snapshots/test_update.ambr @@ -32,6 +32,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/discovergy/snapshots/test_sensor.ambr b/tests/components/discovergy/snapshots/test_sensor.ambr index b4831d81bda..866a57c8dda 100644 --- a/tests/components/discovergy/snapshots/test_sensor.ambr +++ b/tests/components/discovergy/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -44,6 +45,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -188,6 +192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/drop_connect/snapshots/test_binary_sensor.ambr b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr index 9b0cc201573..8d83482e208 100644 --- a/tests/components/drop_connect/snapshots/test_binary_sensor.ambr +++ b/tests/components/drop_connect/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +293,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -334,6 +341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -427,6 +436,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr index d407fe2dc5b..0a46dd7f476 100644 --- a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr +++ b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'dsmr_reader', 'unique_id': 'UNIQUE_TEST_ID', 'version': 1, diff --git a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr index 62b356e379d..59e2f5a24b7 100644 --- a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_button.ambr b/tests/components/ecovacs/snapshots/test_button.ambr index f21d019a7b1..2c657080c12 100644 --- a/tests/components/ecovacs/snapshots/test_button.ambr +++ b/tests/components/ecovacs/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -374,6 +382,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -466,6 +476,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -512,6 +523,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr index 38c8a9a5ab9..f9540e06038 100644 --- a/tests/components/ecovacs/snapshots/test_diagnostics.ambr +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, @@ -70,6 +72,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/ecovacs/snapshots/test_event.ambr b/tests/components/ecovacs/snapshots/test_event.ambr index 8f433560cd1..d29bf8dd57a 100644 --- a/tests/components/ecovacs/snapshots/test_event.ambr +++ b/tests/components/ecovacs/snapshots/test_event.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr index 9113445cc31..e403c937394 100644 --- a/tests/components/ecovacs/snapshots/test_init.ambr +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/ecovacs/snapshots/test_lawn_mower.ambr b/tests/components/ecovacs/snapshots/test_lawn_mower.ambr index 29c710a5cb7..6367872c7f7 100644 --- a/tests/components/ecovacs/snapshots/test_lawn_mower.ambr +++ b/tests/components/ecovacs/snapshots/test_lawn_mower.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -39,6 +40,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_number.ambr b/tests/components/ecovacs/snapshots/test_number.ambr index c80132784e1..952fa4556b0 100644 --- a/tests/components/ecovacs/snapshots/test_number.ambr +++ b/tests/components/ecovacs/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr index 125e7f0cee8..354afca1178 100644 --- a/tests/components/ecovacs/snapshots/test_select.ambr +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index 755fcda9e7d..c4e5a5b1966 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -154,6 +157,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -201,6 +205,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -249,6 +254,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +302,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -347,6 +354,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -394,6 +402,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -440,6 +449,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -489,6 +499,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -539,6 +550,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -593,6 +605,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -640,6 +653,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -686,6 +700,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -732,6 +747,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -779,6 +795,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -827,6 +844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -878,6 +896,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -925,6 +944,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -972,6 +992,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1018,6 +1039,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1065,6 +1087,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1112,6 +1135,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1164,6 +1188,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1217,6 +1242,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1267,6 +1293,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1321,6 +1348,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1368,6 +1396,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1415,6 +1444,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1461,6 +1491,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1507,6 +1538,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1554,6 +1586,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1602,6 +1635,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1653,6 +1687,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1700,6 +1735,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1747,6 +1783,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1793,6 +1830,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1840,6 +1878,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1889,6 +1928,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1939,6 +1979,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1993,6 +2034,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2040,6 +2082,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2086,6 +2129,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ecovacs/snapshots/test_switch.ambr b/tests/components/ecovacs/snapshots/test_switch.ambr index 59e891bea5e..48aa9d8fc17 100644 --- a/tests/components/ecovacs/snapshots/test_switch.ambr +++ b/tests/components/ecovacs/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -374,6 +382,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/eheimdigital/snapshots/test_climate.ambr b/tests/components/eheimdigital/snapshots/test_climate.ambr index 171d3d427fc..73c7cf638e8 100644 --- a/tests/components/eheimdigital/snapshots/test_climate.ambr +++ b/tests/components/eheimdigital/snapshots/test_climate.ambr @@ -19,6 +19,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -95,6 +96,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/eheimdigital/snapshots/test_light.ambr b/tests/components/eheimdigital/snapshots/test_light.ambr index 8df4745997e..a8b454f416e 100644 --- a/tests/components/eheimdigital/snapshots/test_light.ambr +++ b/tests/components/eheimdigital/snapshots/test_light.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -76,6 +77,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -139,6 +141,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -202,6 +205,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -265,6 +269,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index dcf9d1c87d0..81a817f2738 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -133,6 +136,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 4bb4644ab86..84f7ca45843 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -52,6 +52,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -82,6 +83,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -169,6 +171,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -199,6 +202,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -286,6 +290,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -316,6 +321,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index be0ec0a56c5..f64893798e9 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -24,6 +24,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -114,6 +116,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -207,6 +211,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -243,6 +248,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -300,6 +306,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +340,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -390,6 +398,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -426,6 +435,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index ba95160d28a..254e4deb7d9 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -49,6 +50,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -131,6 +134,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/elmax/snapshots/test_alarm_control_panel.ambr b/tests/components/elmax/snapshots/test_alarm_control_panel.ambr index f175fc707bb..2bf3aa48430 100644 --- a/tests/components/elmax/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/elmax/snapshots/test_alarm_control_panel.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/elmax/snapshots/test_binary_sensor.ambr b/tests/components/elmax/snapshots/test_binary_sensor.ambr index 3c3f63b44ca..7515547406e 100644 --- a/tests/components/elmax/snapshots/test_binary_sensor.ambr +++ b/tests/components/elmax/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/elmax/snapshots/test_cover.ambr b/tests/components/elmax/snapshots/test_cover.ambr index 0dbea416934..8cb230e1523 100644 --- a/tests/components/elmax/snapshots/test_cover.ambr +++ b/tests/components/elmax/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/elmax/snapshots/test_switch.ambr b/tests/components/elmax/snapshots/test_switch.ambr index 0ae1942e7e0..f5845223717 100644 --- a/tests/components/elmax/snapshots/test_switch.ambr +++ b/tests/components/elmax/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr index 210196ce414..6dc19155863 100644 --- a/tests/components/emoncms/snapshots/test_sensor.ambr +++ b/tests/components/emoncms/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/energenie_power_sockets/snapshots/test_switch.ambr b/tests/components/energenie_power_sockets/snapshots/test_switch.ambr index d462d6ca6d4..99595168157 100644 --- a/tests/components/energenie_power_sockets/snapshots/test_switch.ambr +++ b/tests/components/energenie_power_sockets/snapshots/test_switch.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -114,6 +116,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index 452f4ae748e..5407ac8f0e9 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -201,6 +205,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -249,6 +254,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +303,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -345,6 +352,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -393,6 +401,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -443,6 +452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -492,6 +502,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr index e9bf8378d79..e4810c21226 100644 --- a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 4254ffe961a..152cf803258 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -20,6 +20,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -31,6 +33,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -68,6 +75,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -119,6 +127,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -168,6 +177,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -218,6 +228,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -266,6 +277,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -303,6 +319,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -346,6 +363,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': 'integration', 'domain': 'sensor', @@ -449,6 +467,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -460,6 +480,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -497,6 +522,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -548,6 +574,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -597,6 +624,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -647,6 +675,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -695,6 +724,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -732,6 +766,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -775,6 +810,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': 'integration', 'domain': 'sensor', @@ -918,6 +954,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, @@ -929,6 +967,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -966,6 +1009,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1017,6 +1061,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1066,6 +1111,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1116,6 +1162,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1164,6 +1211,11 @@ 'config_entries': list([ '45a36e55aaddb2007c5f6602e0c38e72', ]), + 'config_entries_subentries': dict({ + '45a36e55aaddb2007c5f6602e0c38e72': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1201,6 +1253,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1244,6 +1297,7 @@ 'categories': dict({ }), 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': 'integration', 'domain': 'sensor', diff --git a/tests/components/enphase_envoy/snapshots/test_number.ambr b/tests/components/enphase_envoy/snapshots/test_number.ambr index b7e799c9ac8..eb8f5266f32 100644 --- a/tests/components/enphase_envoy/snapshots/test_number.ambr +++ b/tests/components/enphase_envoy/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -125,6 +127,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -181,6 +184,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -237,6 +241,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -293,6 +298,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +355,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -405,6 +412,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/enphase_envoy/snapshots/test_select.ambr b/tests/components/enphase_envoy/snapshots/test_select.ambr index f091879d9fc..d8238926dfd 100644 --- a/tests/components/enphase_envoy/snapshots/test_select.ambr +++ b/tests/components/enphase_envoy/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -186,6 +189,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -245,6 +249,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -302,6 +307,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -359,6 +365,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -418,6 +425,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -477,6 +485,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +543,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -591,6 +601,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -650,6 +661,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -709,6 +721,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -766,6 +779,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 0f251b5e859..c1e2c9270e2 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -63,6 +64,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -119,6 +121,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -176,6 +179,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -233,6 +237,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +287,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -331,6 +337,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -388,6 +395,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -445,6 +453,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -502,6 +511,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -557,6 +567,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -613,6 +624,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -668,6 +680,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -724,6 +737,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -781,6 +795,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -835,6 +850,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -889,6 +905,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -946,6 +963,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1003,6 +1021,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1060,6 +1079,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1117,6 +1137,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1172,6 +1193,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1218,6 +1240,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1270,6 +1293,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1328,6 +1352,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1382,6 +1407,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1439,6 +1465,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1492,6 +1519,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1545,6 +1573,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1602,6 +1631,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1659,6 +1689,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1716,6 +1747,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1765,6 +1797,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1812,6 +1845,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1867,6 +1901,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1920,6 +1955,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1968,6 +2004,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2016,6 +2053,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2064,6 +2102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2111,6 +2150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2159,6 +2199,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2207,6 +2248,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2255,6 +2297,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2303,6 +2346,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2351,6 +2395,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2399,6 +2444,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2449,6 +2495,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2504,6 +2551,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2552,6 +2600,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2602,6 +2651,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2659,6 +2709,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2716,6 +2767,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2773,6 +2825,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2830,6 +2883,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2887,6 +2941,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2942,6 +2997,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2998,6 +3054,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3053,6 +3110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3109,6 +3167,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3166,6 +3225,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3220,6 +3280,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3274,6 +3335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3328,6 +3390,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3382,6 +3445,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3436,6 +3500,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3490,6 +3555,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3544,6 +3610,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3598,6 +3665,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3655,6 +3723,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3712,6 +3781,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3769,6 +3839,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3826,6 +3897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3883,6 +3955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3940,6 +4013,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3997,6 +4071,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4054,6 +4129,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4111,6 +4187,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4168,6 +4245,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4223,6 +4301,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4269,6 +4348,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4315,6 +4395,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4361,6 +4442,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4407,6 +4489,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4453,6 +4536,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4499,6 +4583,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4545,6 +4630,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4597,6 +4683,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4655,6 +4742,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4713,6 +4801,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4771,6 +4860,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4829,6 +4919,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4887,6 +4978,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4945,6 +5037,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5003,6 +5096,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5057,6 +5151,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5114,6 +5209,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5171,6 +5267,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5228,6 +5325,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5285,6 +5383,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5338,6 +5437,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5391,6 +5491,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5444,6 +5545,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5497,6 +5599,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5550,6 +5653,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5603,6 +5707,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5656,6 +5761,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5709,6 +5815,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5766,6 +5873,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5823,6 +5931,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5880,6 +5989,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5935,6 +6045,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5983,6 +6094,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6033,6 +6145,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6090,6 +6203,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6147,6 +6261,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6204,6 +6319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6261,6 +6377,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6318,6 +6435,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6375,6 +6493,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6432,6 +6551,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6489,6 +6609,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6538,6 +6659,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6585,6 +6707,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6633,6 +6756,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6681,6 +6805,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6728,6 +6853,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6776,6 +6902,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6824,6 +6951,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6874,6 +7002,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6929,6 +7058,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6977,6 +7107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7027,6 +7158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7084,6 +7216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7141,6 +7274,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7198,6 +7332,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7255,6 +7390,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7312,6 +7448,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7367,6 +7504,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7423,6 +7561,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7478,6 +7617,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7534,6 +7674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7591,6 +7732,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7645,6 +7787,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7699,6 +7842,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7753,6 +7897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7807,6 +7952,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7861,6 +8007,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7915,6 +8062,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7969,6 +8117,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8023,6 +8172,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8080,6 +8230,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8137,6 +8288,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8194,6 +8346,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8251,6 +8404,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8308,6 +8462,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8365,6 +8520,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8422,6 +8578,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8479,6 +8636,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8536,6 +8694,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8593,6 +8752,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8648,6 +8808,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8694,6 +8855,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8740,6 +8902,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8786,6 +8949,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8832,6 +8996,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8878,6 +9043,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8924,6 +9090,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8970,6 +9137,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9022,6 +9190,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9080,6 +9249,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9138,6 +9308,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9196,6 +9367,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9254,6 +9426,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9312,6 +9485,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9370,6 +9544,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9428,6 +9603,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9482,6 +9658,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9539,6 +9716,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9596,6 +9774,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9653,6 +9832,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9710,6 +9890,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9763,6 +9944,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9816,6 +9998,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9869,6 +10052,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9922,6 +10106,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9975,6 +10160,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10028,6 +10214,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10081,6 +10268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10134,6 +10322,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10191,6 +10380,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10248,6 +10438,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10305,6 +10496,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10360,6 +10552,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10408,6 +10601,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10458,6 +10652,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10515,6 +10710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10572,6 +10768,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10629,6 +10826,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10686,6 +10884,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10743,6 +10942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10800,6 +11000,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10857,6 +11058,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10914,6 +11116,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10963,6 +11166,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11010,6 +11214,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11058,6 +11263,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11106,6 +11312,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11153,6 +11360,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11201,6 +11409,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11249,6 +11458,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11296,6 +11506,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11344,6 +11555,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11394,6 +11606,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11451,6 +11664,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11508,6 +11722,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11565,6 +11780,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11620,6 +11836,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11668,6 +11885,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11718,6 +11936,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11775,6 +11994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11832,6 +12052,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11889,6 +12110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11946,6 +12168,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12003,6 +12226,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12060,6 +12284,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12117,6 +12342,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12174,6 +12400,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12231,6 +12458,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12288,6 +12516,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12345,6 +12574,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12402,6 +12632,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12459,6 +12690,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12516,6 +12748,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12573,6 +12806,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12628,6 +12862,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12682,6 +12917,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12736,6 +12972,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12790,6 +13027,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12846,6 +13084,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12903,6 +13142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12960,6 +13200,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13017,6 +13258,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13072,6 +13314,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13126,6 +13369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13180,6 +13424,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13234,6 +13479,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13290,6 +13536,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13347,6 +13594,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13404,6 +13652,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13461,6 +13710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13518,6 +13768,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13572,6 +13823,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13626,6 +13878,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13680,6 +13933,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13734,6 +13988,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13788,6 +14043,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13842,6 +14098,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13896,6 +14153,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13950,6 +14208,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14004,6 +14263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14058,6 +14318,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14112,6 +14373,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14166,6 +14428,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14223,6 +14486,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14280,6 +14544,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14337,6 +14602,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14394,6 +14660,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14451,6 +14718,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14508,6 +14776,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14565,6 +14834,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14622,6 +14892,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14679,6 +14950,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14736,6 +15008,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14793,6 +15066,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14850,6 +15124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14907,6 +15182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14964,6 +15240,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15021,6 +15298,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15078,6 +15356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15135,6 +15414,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15192,6 +15472,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15249,6 +15530,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15306,6 +15588,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15363,6 +15646,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15420,6 +15704,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15477,6 +15762,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15534,6 +15820,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15591,6 +15878,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15648,6 +15936,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15705,6 +15994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15760,6 +16050,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15806,6 +16097,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15852,6 +16144,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15898,6 +16191,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15944,6 +16238,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15990,6 +16285,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16036,6 +16332,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16082,6 +16379,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16128,6 +16426,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16174,6 +16473,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16220,6 +16520,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16266,6 +16567,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16318,6 +16620,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16376,6 +16679,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16434,6 +16738,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16492,6 +16797,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16550,6 +16856,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16608,6 +16915,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16666,6 +16974,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16724,6 +17033,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16782,6 +17092,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16840,6 +17151,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16898,6 +17210,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16956,6 +17269,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17010,6 +17324,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17067,6 +17382,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17124,6 +17440,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17181,6 +17498,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17238,6 +17556,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17291,6 +17610,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17344,6 +17664,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17397,6 +17718,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17450,6 +17772,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17503,6 +17826,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17556,6 +17880,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17609,6 +17934,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17662,6 +17988,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17715,6 +18042,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17768,6 +18096,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17821,6 +18150,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17874,6 +18204,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17931,6 +18262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17988,6 +18320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18045,6 +18378,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18100,6 +18434,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18148,6 +18483,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18198,6 +18534,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18255,6 +18592,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18312,6 +18650,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18369,6 +18708,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18426,6 +18766,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18483,6 +18824,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18540,6 +18882,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18597,6 +18940,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18654,6 +18998,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18711,6 +19056,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18768,6 +19114,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18825,6 +19172,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18882,6 +19230,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18939,6 +19288,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18996,6 +19346,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19053,6 +19404,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19110,6 +19462,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19159,6 +19512,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19208,6 +19562,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19265,6 +19620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19322,6 +19678,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19379,6 +19736,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19436,6 +19794,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19493,6 +19852,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19550,6 +19910,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19607,6 +19968,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19664,6 +20026,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19721,6 +20084,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19778,6 +20142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19835,6 +20200,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19892,6 +20258,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19949,6 +20316,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20006,6 +20374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20063,6 +20432,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20118,6 +20488,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20172,6 +20543,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20226,6 +20598,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20280,6 +20653,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20336,6 +20710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20393,6 +20768,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20450,6 +20826,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20507,6 +20884,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20562,6 +20940,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20616,6 +20995,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20670,6 +21050,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20724,6 +21105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20780,6 +21162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20837,6 +21220,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20894,6 +21278,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -20951,6 +21336,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21008,6 +21394,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21062,6 +21449,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21116,6 +21504,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21170,6 +21559,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21224,6 +21614,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21278,6 +21669,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21332,6 +21724,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21386,6 +21779,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21440,6 +21834,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21497,6 +21892,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21554,6 +21950,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21611,6 +22008,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21668,6 +22066,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21725,6 +22124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21782,6 +22182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21839,6 +22240,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21896,6 +22298,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -21953,6 +22356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22010,6 +22414,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22067,6 +22472,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22124,6 +22530,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22181,6 +22588,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22238,6 +22646,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22295,6 +22704,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22352,6 +22762,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22409,6 +22820,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22466,6 +22878,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22523,6 +22936,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22578,6 +22992,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22624,6 +23039,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22670,6 +23086,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22716,6 +23133,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22762,6 +23180,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22808,6 +23227,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22854,6 +23274,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22900,6 +23321,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -22952,6 +23374,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23010,6 +23433,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23068,6 +23492,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23126,6 +23551,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23184,6 +23610,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23242,6 +23669,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23300,6 +23728,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23358,6 +23787,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23412,6 +23842,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23469,6 +23900,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23526,6 +23958,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23583,6 +24016,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23640,6 +24074,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23693,6 +24128,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23746,6 +24182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23799,6 +24236,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23852,6 +24290,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23905,6 +24344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -23958,6 +24398,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24011,6 +24452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24064,6 +24506,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24121,6 +24564,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24178,6 +24622,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24235,6 +24680,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24292,6 +24738,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24349,6 +24796,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24406,6 +24854,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24463,6 +24912,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24520,6 +24970,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24577,6 +25028,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24634,6 +25086,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24691,6 +25144,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24748,6 +25202,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24797,6 +25252,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24846,6 +25302,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24903,6 +25360,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -24958,6 +25416,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25014,6 +25473,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25071,6 +25531,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25125,6 +25586,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25182,6 +25644,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25237,6 +25700,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25289,6 +25753,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25343,6 +25808,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25396,6 +25862,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25453,6 +25920,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25510,6 +25978,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -25559,6 +26028,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/enphase_envoy/snapshots/test_switch.ambr b/tests/components/enphase_envoy/snapshots/test_switch.ambr index a022e476d5c..77b682cb948 100644 --- a/tests/components/enphase_envoy/snapshots/test_switch.ambr +++ b/tests/components/enphase_envoy/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 4f7ea679b20..8f1711e829e 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -20,6 +20,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'ESPHome Device', 'unique_id': '11:22:33:44:55:aa', 'version': 1, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 832e7d6572f..0beeae71df3 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -79,6 +79,7 @@ async def test_diagnostics_with_bluetooth( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "11:22:33:44:55:aa", "version": 1, diff --git a/tests/components/filesize/snapshots/test_sensor.ambr b/tests/components/filesize/snapshots/test_sensor.ambr index 339d64acf91..e7f6f9d042b 100644 --- a/tests/components/filesize/snapshots/test_sensor.ambr +++ b/tests/components/filesize/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr index f983d834927..0b45e1f19be 100644 --- a/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/flexit_bacnet/snapshots/test_climate.ambr b/tests/components/flexit_bacnet/snapshots/test_climate.ambr index 790c377b1f2..d15fc291a16 100644 --- a/tests/components/flexit_bacnet/snapshots/test_climate.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_climate.ambr @@ -19,6 +19,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/flexit_bacnet/snapshots/test_number.ambr b/tests/components/flexit_bacnet/snapshots/test_number.ambr index e2875c140cc..622ec81e45d 100644 --- a/tests/components/flexit_bacnet/snapshots/test_number.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -125,6 +127,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +185,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +243,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +301,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -353,6 +359,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -410,6 +417,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -467,6 +475,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -524,6 +533,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -581,6 +591,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr index 2c65bd53a6e..b265a4402dc 100644 --- a/tests/components/flexit_bacnet/snapshots/test_sensor.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -112,6 +114,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,6 +214,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -412,6 +420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -460,6 +469,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -510,6 +520,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -562,6 +573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -612,6 +624,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -662,6 +675,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -710,6 +724,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/flexit_bacnet/snapshots/test_switch.ambr b/tests/components/flexit_bacnet/snapshots/test_switch.ambr index 1df1c12e791..3d931dd7753 100644 --- a/tests/components/flexit_bacnet/snapshots/test_switch.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/folder_watcher/snapshots/test_event.ambr b/tests/components/folder_watcher/snapshots/test_event.ambr index 04405e0694b..1101380703a 100644 --- a/tests/components/folder_watcher/snapshots/test_event.ambr +++ b/tests/components/folder_watcher/snapshots/test_event.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr index 6ae4c2f6198..c0db54c2d4e 100644 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -23,6 +23,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Green House', 'unique_id': 'unique', 'version': 2, diff --git a/tests/components/fritz/snapshots/test_button.ambr b/tests/components/fritz/snapshots/test_button.ambr index ed0b0e72160..748d8c1ba29 100644 --- a/tests/components/fritz/snapshots/test_button.ambr +++ b/tests/components/fritz/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 53f7093a21b..9b5b8c9353a 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -61,6 +61,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index 50744815aa5..5ff0e448b15 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -249,6 +254,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -298,6 +304,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -345,6 +352,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -392,6 +400,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -439,6 +448,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -487,6 +497,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +545,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -581,6 +593,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -629,6 +642,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -677,6 +691,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -727,6 +742,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fritz/snapshots/test_switch.ambr b/tests/components/fritz/snapshots/test_switch.ambr index b34a3626fe2..a1097d3333b 100644 --- a/tests/components/fritz/snapshots/test_switch.ambr +++ b/tests/components/fritz/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -482,6 +492,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -529,6 +540,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fritz/snapshots/test_update.ambr b/tests/components/fritz/snapshots/test_update.ambr index 3c7880d01e7..746823e9dc9 100644 --- a/tests/components/fritz/snapshots/test_update.ambr +++ b/tests/components/fritz/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fronius/snapshots/test_diagnostics.ambr b/tests/components/fronius/snapshots/test_diagnostics.ambr index 010de06e276..b112839835a 100644 --- a/tests/components/fronius/snapshots/test_diagnostics.ambr +++ b/tests/components/fronius/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/fronius/snapshots/test_sensor.ambr b/tests/components/fronius/snapshots/test_sensor.ambr index 81770893273..5384e9c6389 100644 --- a/tests/components/fronius/snapshots/test_sensor.ambr +++ b/tests/components/fronius/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +370,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -508,6 +516,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -655,6 +664,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +714,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -750,6 +761,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -807,6 +819,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -866,6 +879,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -917,6 +931,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -968,6 +983,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1019,6 +1035,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1070,6 +1087,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1121,6 +1139,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1172,6 +1191,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1223,6 +1243,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1274,6 +1295,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1323,6 +1345,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1377,6 +1400,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1433,6 +1457,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1483,6 +1508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1533,6 +1559,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1583,6 +1610,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1633,6 +1661,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1683,6 +1712,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1733,6 +1763,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1784,6 +1815,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1835,6 +1867,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1886,6 +1919,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1937,6 +1971,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1988,6 +2023,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2039,6 +2075,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2090,6 +2127,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2141,6 +2179,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2192,6 +2231,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2243,6 +2283,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2294,6 +2335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2345,6 +2387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2396,6 +2439,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2447,6 +2491,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2498,6 +2543,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2549,6 +2595,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2600,6 +2647,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2649,6 +2697,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2697,6 +2746,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2748,6 +2798,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2799,6 +2850,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2850,6 +2902,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2901,6 +2954,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2952,6 +3006,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3003,6 +3058,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3054,6 +3110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3104,6 +3161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3154,6 +3212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3205,6 +3264,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3256,6 +3316,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3305,6 +3366,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3352,6 +3414,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3401,6 +3464,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3452,6 +3516,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3503,6 +3568,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3554,6 +3620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3605,6 +3672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3656,6 +3724,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3707,6 +3776,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3758,6 +3828,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3809,6 +3880,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3858,6 +3930,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4003,6 +4076,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4150,6 +4224,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4199,6 +4274,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4245,6 +4321,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4302,6 +4379,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4361,6 +4439,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4412,6 +4491,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4463,6 +4543,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4512,6 +4593,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4567,6 +4649,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4624,6 +4707,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4675,6 +4759,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4726,6 +4811,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4777,6 +4863,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4828,6 +4915,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4879,6 +4967,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4930,6 +5019,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4981,6 +5071,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5032,6 +5123,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5081,6 +5173,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5135,6 +5228,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5191,6 +5285,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5241,6 +5336,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5291,6 +5387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5341,6 +5438,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5391,6 +5489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5441,6 +5540,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5491,6 +5591,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5542,6 +5643,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5593,6 +5695,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5644,6 +5747,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5695,6 +5799,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5746,6 +5851,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5797,6 +5903,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5848,6 +5955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5899,6 +6007,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5950,6 +6059,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6001,6 +6111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6052,6 +6163,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6103,6 +6215,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6154,6 +6267,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6205,6 +6319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6256,6 +6371,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6307,6 +6423,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6358,6 +6475,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6407,6 +6525,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6455,6 +6574,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6506,6 +6626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6557,6 +6678,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6608,6 +6730,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6659,6 +6782,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6710,6 +6834,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6761,6 +6886,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6812,6 +6938,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6863,6 +6990,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6914,6 +7042,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6965,6 +7094,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7015,6 +7145,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7065,6 +7196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7116,6 +7248,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7167,6 +7300,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7218,6 +7352,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7269,6 +7404,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7320,6 +7456,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7371,6 +7508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7422,6 +7560,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7471,6 +7610,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7616,6 +7756,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7763,6 +7904,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7812,6 +7954,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7858,6 +8001,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7904,6 +8048,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7961,6 +8106,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8020,6 +8166,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8071,6 +8218,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8122,6 +8270,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8173,6 +8322,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8224,6 +8374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8275,6 +8426,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8326,6 +8478,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8377,6 +8530,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8426,6 +8580,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8571,6 +8726,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8718,6 +8874,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8767,6 +8924,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8813,6 +8971,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8859,6 +9018,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8916,6 +9076,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8975,6 +9136,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9024,6 +9186,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9078,6 +9241,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9134,6 +9298,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9185,6 +9350,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9235,6 +9401,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9286,6 +9453,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9337,6 +9505,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9387,6 +9556,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9435,6 +9605,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9483,6 +9654,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9534,6 +9706,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9585,6 +9758,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9636,6 +9810,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9687,6 +9862,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9738,6 +9914,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9789,6 +9966,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9840,6 +10018,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9890,6 +10069,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9940,6 +10120,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fujitsu_fglair/snapshots/test_climate.ambr b/tests/components/fujitsu_fglair/snapshots/test_climate.ambr index 31b143c6f95..21c5b3429f4 100644 --- a/tests/components/fujitsu_fglair/snapshots/test_climate.ambr +++ b/tests/components/fujitsu_fglair/snapshots/test_climate.ambr @@ -28,6 +28,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +123,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr index 89738cc4a66..751ad3cd2d9 100644 --- a/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr +++ b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fyta/snapshots/test_binary_sensor.ambr b/tests/components/fyta/snapshots/test_binary_sensor.ambr index c90db22bc7f..1218a3da71c 100644 --- a/tests/components/fyta/snapshots/test_binary_sensor.ambr +++ b/tests/components/fyta/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -191,6 +195,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -237,6 +242,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -283,6 +289,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -330,6 +337,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -376,6 +384,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -423,6 +432,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -469,6 +479,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -515,6 +526,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -561,6 +573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -607,6 +620,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -653,6 +667,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -700,6 +715,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index a252e81952c..24206fbb875 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'fyta_user', 'unique_id': None, 'version': 1, diff --git a/tests/components/fyta/snapshots/test_image.ambr b/tests/components/fyta/snapshots/test_image.ambr index 95e25e0a4d7..cb39efb4500 100644 --- a/tests/components/fyta/snapshots/test_image.ambr +++ b/tests/components/fyta/snapshots/test_image.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr index 8b75579f557..c43a7446f11 100644 --- a/tests/components/fyta/snapshots/test_sensor.ambr +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -163,6 +166,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -220,6 +224,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -278,6 +283,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +339,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +396,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -446,6 +454,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -501,6 +510,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -556,6 +566,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +625,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -669,6 +681,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -717,6 +730,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -775,6 +789,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -832,6 +847,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -881,6 +897,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -930,6 +947,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -987,6 +1005,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1044,6 +1063,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1102,6 +1122,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1157,6 +1178,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1213,6 +1235,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1270,6 +1293,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1325,6 +1349,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1380,6 +1405,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1438,6 +1464,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1493,6 +1520,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1541,6 +1569,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1599,6 +1628,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr index 5f6511090ee..b93a8656ecc 100644 --- a/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr +++ b/tests/components/garages_amsterdam/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr index 2c579631bae..3453817da10 100644 --- a/tests/components/garages_amsterdam/snapshots/test_sensor.ambr +++ b/tests/components/garages_amsterdam/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -155,6 +158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 6d521b1f2c8..10f23759fae 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -66,10 +66,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'bluetooth', + 'subentries': list([ + ]), 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Gardena Water Computer', 'type': , 'version': 1, @@ -223,10 +227,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Gardena Water Computer', 'type': , 'version': 1, diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index 71195918bb1..8dc9d220e85 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/geniushub/snapshots/test_binary_sensor.ambr b/tests/components/geniushub/snapshots/test_binary_sensor.ambr index fcc256b5232..c295ab8d10a 100644 --- a/tests/components/geniushub/snapshots/test_binary_sensor.ambr +++ b/tests/components/geniushub/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/geniushub/snapshots/test_climate.ambr b/tests/components/geniushub/snapshots/test_climate.ambr index eb372de784e..8f897c84559 100644 --- a/tests/components/geniushub/snapshots/test_climate.ambr +++ b/tests/components/geniushub/snapshots/test_climate.ambr @@ -16,6 +16,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -96,6 +97,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +180,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +263,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +346,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -423,6 +428,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -503,6 +509,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/geniushub/snapshots/test_sensor.ambr b/tests/components/geniushub/snapshots/test_sensor.ambr index 874f24cff95..aaf3030d4a4 100644 --- a/tests/components/geniushub/snapshots/test_sensor.ambr +++ b/tests/components/geniushub/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -203,6 +207,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +261,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -309,6 +315,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -415,6 +423,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -468,6 +477,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -521,6 +531,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -574,6 +585,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -629,6 +641,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -684,6 +697,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -739,6 +753,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -794,6 +809,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -849,6 +865,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -904,6 +921,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/geniushub/snapshots/test_switch.ambr b/tests/components/geniushub/snapshots/test_switch.ambr index 6c3c95af477..cc0451b4e94 100644 --- a/tests/components/geniushub/snapshots/test_switch.ambr +++ b/tests/components/geniushub/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -61,6 +62,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -116,6 +118,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 71e0afdc495..890edc00482 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Home', 'unique_id': '123', 'version': 1, diff --git a/tests/components/gios/snapshots/test_sensor.ambr b/tests/components/gios/snapshots/test_sensor.ambr index c67cc3e4d7c..ab8a2359d0c 100644 --- a/tests/components/gios/snapshots/test_sensor.ambr +++ b/tests/components/gios/snapshots/test_sensor.ambr @@ -15,6 +15,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -73,6 +74,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -181,6 +184,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -243,6 +247,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -301,6 +306,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +369,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -421,6 +428,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -483,6 +491,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -541,6 +550,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -603,6 +613,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -661,6 +672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -723,6 +735,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/glances/snapshots/test_sensor.ambr b/tests/components/glances/snapshots/test_sensor.ambr index 662e95c6a1c..baac4c5b056 100644 --- a/tests/components/glances/snapshots/test_sensor.ambr +++ b/tests/components/glances/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -107,6 +109,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -317,6 +323,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -368,6 +375,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +430,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +485,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -530,6 +540,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -584,6 +595,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -633,6 +645,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -682,6 +695,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -731,6 +745,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -780,6 +795,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -831,6 +847,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -881,6 +898,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -932,6 +950,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -983,6 +1002,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1033,6 +1053,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1084,6 +1105,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1135,6 +1157,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1185,6 +1208,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1235,6 +1259,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1288,6 +1313,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1339,6 +1365,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1393,6 +1420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1447,6 +1475,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1501,6 +1530,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1555,6 +1585,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1606,6 +1637,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1656,6 +1688,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1705,6 +1738,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr index f52e47688e8..40ed22195d5 100644 --- a/tests/components/goodwe/snapshots/test_diagnostics.ambr +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index edbbdb1ba28..1ecedbd1173 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -15,6 +15,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'import', + 'subentries': list([ + ]), 'title': '1234', 'unique_id': '1234', 'version': 1, diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 4f62be5cded..9111b909f04 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -93,6 +93,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index 71c6d3ea71d..836641cb2ab 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -71,6 +71,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -133,6 +135,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -164,6 +167,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -195,6 +199,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index faba2103000..4487d0b6ac6 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -42,6 +42,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "valve_controller": { diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr index 0a4076a6135..ffe4ce83d0e 100644 --- a/tests/components/habitica/snapshots/test_binary_sensor.ambr +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_button.ambr b/tests/components/habitica/snapshots/test_button.ambr index 76a0198d5b2..5c6ad640039 100644 --- a/tests/components/habitica/snapshots/test_button.ambr +++ b/tests/components/habitica/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -286,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -379,6 +387,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -425,6 +434,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -472,6 +482,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -518,6 +529,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -564,6 +576,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -611,6 +624,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -658,6 +672,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -751,6 +767,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -798,6 +815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -845,6 +863,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -891,6 +910,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -937,6 +957,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -984,6 +1005,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1030,6 +1052,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1077,6 +1100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1124,6 +1148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1171,6 +1196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1218,6 +1244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1264,6 +1291,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 8be45ccc0fd..2948f31f1cf 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -906,6 +906,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -959,6 +960,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1011,6 +1013,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1063,6 +1066,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 9050db1946d..110bde5e60d 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -168,6 +171,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -219,6 +223,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -267,6 +272,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -318,6 +324,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -369,6 +376,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -575,6 +583,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -626,6 +635,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -677,6 +687,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -732,6 +743,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -778,6 +790,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -829,6 +842,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -876,6 +890,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -924,6 +939,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -975,6 +991,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1023,6 +1040,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1078,6 +1096,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1129,6 +1148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1181,6 +1201,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1267,6 +1288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1315,6 +1337,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_switch.ambr b/tests/components/habitica/snapshots/test_switch.ambr index a865df3a4f4..e8122f77c6e 100644 --- a/tests/components/habitica/snapshots/test_switch.ambr +++ b/tests/components/habitica/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/habitica/snapshots/test_todo.ambr b/tests/components/habitica/snapshots/test_todo.ambr index 9cd6d9a540f..88204d53ded 100644 --- a/tests/components/habitica/snapshots/test_todo.ambr +++ b/tests/components/habitica/snapshots/test_todo.ambr @@ -113,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +161,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 1df2d172142..9526e21ee94 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -15,6 +15,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'HEOS System (via 127.0.0.1)', 'unique_id': 'heos', 'version': 1, @@ -156,6 +158,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'HEOS System (via 127.0.0.1)', 'unique_id': 'heos', 'version': 1, @@ -276,6 +280,7 @@ 'area_id': None, 'categories': dict({ }), + 'config_subentry_id': None, 'disabled_by': None, 'entity_category': None, 'entity_id': 'media_player.test_player', diff --git a/tests/components/heos/test_diagnostics.py b/tests/components/heos/test_diagnostics.py index 2a7deccfb33..fb71682fb48 100644 --- a/tests/components/heos/test_diagnostics.py +++ b/tests/components/heos/test_diagnostics.py @@ -88,6 +88,7 @@ async def test_device_diagnostics( "created_at", "modified_at", "config_entries", + "config_entries_subentries", "id", "primary_config_entry", "config_entry_id", diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 2bd5e7faf75..a41964d98cc 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -7,6 +7,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -42,6 +47,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -85,6 +91,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -135,6 +142,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -185,6 +193,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -233,6 +242,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -277,6 +287,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -321,6 +332,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -373,6 +385,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -432,6 +445,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -482,6 +496,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -522,6 +537,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -562,6 +578,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -605,6 +622,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -640,6 +662,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -680,6 +703,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -715,6 +743,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -756,6 +785,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -797,6 +827,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -840,6 +871,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -884,6 +916,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -923,6 +956,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -958,6 +996,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -999,6 +1038,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1040,6 +1080,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -1083,6 +1124,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1127,6 +1169,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -1166,6 +1209,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1201,6 +1249,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -1242,6 +1291,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1283,6 +1333,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -1326,6 +1377,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1370,6 +1422,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -1413,6 +1466,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1448,6 +1506,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'alarm_control_panel', @@ -1492,6 +1551,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1538,6 +1598,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'number', @@ -1582,6 +1643,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -1621,6 +1683,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1656,6 +1723,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -1697,6 +1765,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1740,6 +1809,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -1787,6 +1857,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -1822,6 +1897,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'alarm_control_panel', @@ -1866,6 +1942,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -1916,6 +1993,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -1977,6 +2055,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'number', @@ -2021,6 +2100,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -2064,6 +2144,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -2099,6 +2184,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -2142,6 +2228,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2189,6 +2276,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -2224,6 +2316,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -2265,6 +2358,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -2306,6 +2400,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -2356,6 +2451,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -2414,6 +2510,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2458,6 +2555,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2504,6 +2602,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2549,6 +2648,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2592,6 +2692,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -2632,6 +2733,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -2675,6 +2777,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -2710,6 +2817,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -2753,6 +2861,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2798,6 +2907,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2843,6 +2953,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2888,6 +2999,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2933,6 +3045,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -2978,6 +3091,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3021,6 +3135,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -3062,6 +3177,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -3106,6 +3222,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -3141,6 +3262,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3182,6 +3304,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -3225,6 +3348,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3267,6 +3391,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -3302,6 +3431,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3343,6 +3473,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3384,6 +3515,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -3424,6 +3556,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -3476,6 +3609,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -3540,6 +3674,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -3590,6 +3725,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -3636,6 +3772,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3681,6 +3818,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3723,6 +3861,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -3758,6 +3901,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3799,6 +3943,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -3842,6 +3987,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -3884,6 +4030,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -3919,6 +4070,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -3960,6 +4112,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4003,6 +4156,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4049,6 +4203,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -4084,6 +4243,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -4125,6 +4285,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -4166,6 +4327,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4206,6 +4368,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4258,6 +4421,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -4322,6 +4486,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -4372,6 +4537,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -4418,6 +4584,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4463,6 +4630,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4509,6 +4677,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -4544,6 +4717,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -4585,6 +4759,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4625,6 +4800,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -4660,6 +4840,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -4712,6 +4893,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -4775,6 +4957,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -4821,6 +5004,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4866,6 +5050,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -4908,6 +5093,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -4943,6 +5133,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -4984,6 +5175,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5027,6 +5219,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5069,6 +5262,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -5104,6 +5302,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5145,6 +5344,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5188,6 +5388,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5234,6 +5435,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -5269,6 +5475,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5310,6 +5517,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5351,6 +5559,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5391,6 +5600,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5447,6 +5657,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -5516,6 +5727,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -5566,6 +5778,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -5612,6 +5825,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5657,6 +5871,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5703,6 +5918,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -5738,6 +5958,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5779,6 +6000,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -5820,6 +6042,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -5863,6 +6086,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5908,6 +6132,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -5951,6 +6176,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -5994,6 +6220,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -6029,6 +6260,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6075,6 +6307,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'number', @@ -6124,6 +6357,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -6170,6 +6404,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6215,6 +6450,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6261,6 +6497,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6306,6 +6543,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6352,6 +6590,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -6387,6 +6630,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6430,6 +6674,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6475,6 +6720,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6520,6 +6766,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6565,6 +6812,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -6608,6 +6856,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -6649,6 +6898,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -6692,6 +6942,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -6727,6 +6982,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6768,6 +7024,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6808,6 +7065,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6851,6 +7109,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -6899,6 +7158,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -6934,6 +7198,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -6975,6 +7240,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -7018,6 +7284,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7053,6 +7324,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7094,6 +7366,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -7138,6 +7411,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -7181,6 +7455,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7216,6 +7495,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7256,6 +7536,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7291,6 +7576,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7332,6 +7618,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -7376,6 +7663,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -7423,6 +7711,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7458,6 +7751,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7501,6 +7795,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -7545,6 +7840,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7580,6 +7880,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7620,6 +7921,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7655,6 +7961,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7698,6 +8005,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -7747,6 +8055,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -7782,6 +8095,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -7833,6 +8147,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -7887,6 +8202,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -7938,6 +8254,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -7984,6 +8301,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8029,6 +8347,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8071,6 +8390,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8106,6 +8430,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8150,6 +8475,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8185,6 +8515,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8225,6 +8556,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8260,6 +8596,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8305,6 +8642,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -8353,6 +8691,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8400,6 +8739,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8435,6 +8779,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8476,6 +8821,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -8520,6 +8866,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8563,6 +8910,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8598,6 +8950,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8638,6 +8991,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8673,6 +9031,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8714,6 +9073,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -8758,6 +9118,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -8805,6 +9166,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8840,6 +9206,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -8883,6 +9250,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -8927,6 +9295,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -8962,6 +9335,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9002,6 +9376,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9037,6 +9416,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9080,6 +9460,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -9130,6 +9511,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9165,6 +9551,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9205,6 +9592,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9240,6 +9632,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9283,6 +9676,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -9333,6 +9727,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9368,6 +9767,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9423,6 +9823,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -9482,6 +9883,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -9533,6 +9935,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -9579,6 +9982,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -9624,6 +10028,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -9666,6 +10071,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9701,6 +10111,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9745,6 +10156,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9780,6 +10196,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9820,6 +10237,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -9855,6 +10277,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -9903,6 +10326,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'humidifier', @@ -9956,6 +10380,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -10002,6 +10427,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10037,6 +10467,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10077,6 +10508,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10112,6 +10548,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10160,6 +10597,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'humidifier', @@ -10213,6 +10651,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -10259,6 +10698,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10294,6 +10738,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10334,6 +10779,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10369,6 +10819,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10419,6 +10870,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -10477,6 +10929,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -10524,6 +10977,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10559,6 +11017,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10616,6 +11075,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -10678,6 +11138,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -10724,6 +11185,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10759,6 +11225,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10808,6 +11275,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -10862,6 +11330,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -10897,6 +11370,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -10946,6 +11420,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11000,6 +11475,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11035,6 +11515,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11084,6 +11565,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11138,6 +11620,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11173,6 +11660,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11222,6 +11710,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11276,6 +11765,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11311,6 +11805,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11360,6 +11855,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11424,6 +11920,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11459,6 +11960,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11508,6 +12010,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -11572,6 +12075,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11607,6 +12115,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11652,6 +12161,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -11701,6 +12211,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -11750,6 +12261,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -11799,6 +12311,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -11846,6 +12359,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -11889,6 +12403,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -11924,6 +12443,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -11969,6 +12489,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12014,6 +12535,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12049,6 +12575,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12094,6 +12621,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12139,6 +12667,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12174,6 +12707,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12219,6 +12753,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12264,6 +12799,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12299,6 +12839,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12344,6 +12885,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12389,6 +12931,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12424,6 +12971,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12469,6 +13017,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12514,6 +13063,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12549,6 +13103,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12594,6 +13149,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12639,6 +13195,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12674,6 +13235,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12719,6 +13281,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12764,6 +13327,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12799,6 +13367,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12843,6 +13412,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -12878,6 +13452,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -12928,6 +13503,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -12987,6 +13563,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13022,6 +13603,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13065,6 +13647,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -13108,6 +13691,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -13152,6 +13736,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13187,6 +13776,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13230,6 +13820,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -13273,6 +13864,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -13313,6 +13905,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -13356,6 +13949,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13391,6 +13989,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13441,6 +14040,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -13501,6 +14101,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -13547,6 +14148,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -13592,6 +14194,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -13638,6 +14241,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13673,6 +14281,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13724,6 +14333,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'media_player', @@ -13776,6 +14386,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -13819,6 +14430,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13854,6 +14470,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -13897,6 +14514,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -13941,6 +14559,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -13976,6 +14599,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14020,6 +14644,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -14055,6 +14684,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14096,6 +14726,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14136,6 +14767,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14176,6 +14808,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14216,6 +14849,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14256,6 +14890,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -14299,6 +14934,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -14334,6 +14974,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14379,6 +15020,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -14428,6 +15070,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -14463,6 +15110,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14513,6 +15161,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -14570,6 +15219,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -14621,6 +15271,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'select', @@ -14667,6 +15318,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -14712,6 +15364,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -14758,6 +15411,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -14793,6 +15451,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -14843,6 +15502,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -14918,6 +15578,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -14977,6 +15638,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15030,6 +15692,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -15065,6 +15732,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -15106,6 +15774,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -15147,6 +15816,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'camera', @@ -15194,6 +15864,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'event', @@ -15241,6 +15912,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15281,6 +15953,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15324,6 +15997,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -15359,6 +16037,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -15400,6 +16079,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'binary_sensor', @@ -15441,6 +16121,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -15485,6 +16166,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -15520,6 +16206,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -15563,6 +16250,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15607,6 +16295,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15652,6 +16341,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15697,6 +16387,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15742,6 +16433,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -15788,6 +16480,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -15823,6 +16520,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -15864,6 +16562,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15907,6 +16606,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15950,6 +16650,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -15993,6 +16694,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16036,6 +16738,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16079,6 +16782,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16122,6 +16826,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16165,6 +16870,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -16211,6 +16917,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16246,6 +16957,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16287,6 +16999,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -16331,6 +17044,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -16374,6 +17088,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16409,6 +17128,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16449,6 +17169,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16484,6 +17209,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16525,6 +17251,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -16569,6 +17296,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -16616,6 +17344,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16651,6 +17384,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16692,6 +17426,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -16736,6 +17471,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -16779,6 +17515,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16814,6 +17555,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -16855,6 +17597,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -16899,6 +17642,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -16942,6 +17686,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -16977,6 +17726,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17018,6 +17768,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -17062,6 +17813,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -17105,6 +17857,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17140,6 +17897,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17180,6 +17938,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17215,6 +17978,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17256,6 +18020,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -17300,6 +18065,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -17347,6 +18113,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17382,6 +18153,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17423,6 +18195,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'lock', @@ -17467,6 +18240,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17502,6 +18280,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17545,6 +18324,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'fan', @@ -17595,6 +18375,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -17644,6 +18425,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17679,6 +18465,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17720,6 +18507,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -17766,6 +18554,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -17801,6 +18594,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -17852,6 +18646,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'climate', @@ -17907,6 +18702,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -17950,6 +18746,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -17990,6 +18787,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -18031,6 +18829,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -18072,6 +18871,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -18113,6 +18913,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', @@ -18157,6 +18958,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18192,6 +18998,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18235,6 +19042,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18280,6 +19088,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18325,6 +19134,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18371,6 +19181,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18406,6 +19221,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18446,6 +19262,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18481,6 +19302,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18524,6 +19346,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18569,6 +19392,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18614,6 +19438,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -18656,6 +19481,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18691,6 +19521,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18732,6 +19563,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -18778,6 +19610,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18813,6 +19650,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18854,6 +19692,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -18900,6 +19739,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -18935,6 +19779,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -18976,6 +19821,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'cover', @@ -19021,6 +19867,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -19056,6 +19907,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -19104,6 +19956,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'humidifier', @@ -19164,6 +20017,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'light', @@ -19235,6 +20089,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'number', @@ -19281,6 +20136,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -19327,6 +20183,11 @@ 'config_entries': list([ 'TestData', ]), + 'config_entries_subentries': dict({ + 'TestData': list([ + None, + ]), + }), 'configuration_url': None, 'connections': list([ ]), @@ -19362,6 +20223,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'button', @@ -19405,6 +20267,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'sensor', @@ -19448,6 +20311,7 @@ 'categories': dict({ }), 'config_entry_id': 'TestData', + 'config_subentry_id': None, 'device_class': None, 'disabled_by': None, 'domain': 'switch', diff --git a/tests/components/homewizard/snapshots/test_button.ambr b/tests/components/homewizard/snapshots/test_button.ambr index 6dd7fcc45d2..16cc62ad726 100644 --- a/tests/components/homewizard/snapshots/test_button.ambr +++ b/tests/components/homewizard/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index 0a301fc3941..71e70f3a153 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -30,10 +30,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, @@ -74,10 +78,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, @@ -118,10 +126,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'Energy Socket', 'unique_id': 'HWE-SKT_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Energy Socket', 'type': , 'version': 1, @@ -158,10 +170,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'P1 meter', 'unique_id': 'HWE-P1_5c2fafabcdef', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'P1 meter', 'type': , 'version': 1, diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index b14028cd97c..1c901bda6f6 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -29,6 +29,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -121,6 +123,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -151,6 +154,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index 91b1e30e4f8..f68b5a57d2e 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -44,6 +45,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -88,6 +90,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -129,6 +132,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -175,6 +179,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -216,6 +221,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -262,6 +268,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -303,6 +310,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +357,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -390,6 +399,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -436,6 +446,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -477,6 +488,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -526,6 +538,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -567,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -616,6 +630,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -655,6 +670,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -699,6 +715,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -740,6 +757,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -786,6 +804,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -827,6 +846,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -872,6 +892,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -911,6 +932,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -954,6 +976,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -995,6 +1018,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1041,6 +1065,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1082,6 +1107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1128,6 +1154,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1169,6 +1196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1215,6 +1243,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1256,6 +1285,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1302,6 +1332,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1343,6 +1374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1389,6 +1421,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1430,6 +1463,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1479,6 +1513,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1520,6 +1555,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1566,6 +1602,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1607,6 +1644,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1653,6 +1691,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1694,6 +1733,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1740,6 +1780,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1779,6 +1820,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1822,6 +1864,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1863,6 +1906,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1908,6 +1952,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -1949,6 +1994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1995,6 +2041,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2036,6 +2083,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2082,6 +2130,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2123,6 +2172,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2169,6 +2219,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2210,6 +2261,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2256,6 +2308,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2297,6 +2350,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2343,6 +2397,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2384,6 +2439,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2430,6 +2486,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2471,6 +2528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2517,6 +2575,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2558,6 +2617,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2604,6 +2664,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2645,6 +2706,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2691,6 +2753,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2732,6 +2795,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2778,6 +2842,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2819,6 +2884,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2865,6 +2931,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2906,6 +2973,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2955,6 +3023,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -2996,6 +3065,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3042,6 +3112,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3083,6 +3154,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3129,6 +3201,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3170,6 +3243,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3216,6 +3290,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3257,6 +3332,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3306,6 +3382,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3347,6 +3424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3396,6 +3474,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3437,6 +3516,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3486,6 +3566,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3527,6 +3608,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3573,6 +3655,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3614,6 +3697,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3660,6 +3744,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3701,6 +3786,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3747,6 +3833,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3788,6 +3875,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3834,6 +3922,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3875,6 +3964,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3921,6 +4011,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -3962,6 +4053,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4008,6 +4100,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4049,6 +4142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4095,6 +4189,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4134,6 +4229,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4177,6 +4273,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4218,6 +4315,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4263,6 +4361,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4302,6 +4401,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4347,6 +4447,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4388,6 +4489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4434,6 +4536,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4475,6 +4578,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4521,6 +4625,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4562,6 +4667,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4608,6 +4714,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4647,6 +4754,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4690,6 +4798,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4731,6 +4840,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4777,6 +4887,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4818,6 +4929,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4864,6 +4976,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4905,6 +5018,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4951,6 +5065,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -4992,6 +5107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5038,6 +5154,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5079,6 +5196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5125,6 +5243,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5166,6 +5285,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5212,6 +5332,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5253,6 +5374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5299,6 +5421,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5340,6 +5463,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5386,6 +5510,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5427,6 +5552,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5473,6 +5599,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5514,6 +5641,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5560,6 +5688,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5601,6 +5730,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5647,6 +5777,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5686,6 +5817,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5729,6 +5861,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5768,6 +5901,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5813,6 +5947,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5854,6 +5989,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5903,6 +6039,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -5942,6 +6079,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5985,6 +6123,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6026,6 +6165,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6075,6 +6215,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6116,6 +6257,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6165,6 +6307,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6206,6 +6349,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6255,6 +6399,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6294,6 +6439,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6337,6 +6483,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6376,6 +6523,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6419,6 +6567,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6465,6 +6614,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6515,6 +6665,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6556,6 +6707,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6602,6 +6754,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6643,6 +6796,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6689,6 +6843,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6730,6 +6885,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6776,6 +6932,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6817,6 +6974,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6863,6 +7021,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6902,6 +7061,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6945,6 +7105,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -6984,6 +7145,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7027,6 +7189,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7066,6 +7229,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7109,6 +7273,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7148,6 +7313,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7191,6 +7357,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7230,6 +7397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7273,6 +7441,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7312,6 +7481,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7355,6 +7525,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7396,6 +7567,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7441,6 +7613,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7480,6 +7653,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7523,6 +7697,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -7564,6 +7739,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7609,6 +7785,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -7646,6 +7823,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7692,6 +7870,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -7729,6 +7908,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7775,6 +7955,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -7812,6 +7993,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7857,6 +8039,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -7894,6 +8077,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7940,6 +8124,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -7977,6 +8162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8023,6 +8209,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8062,6 +8249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8107,6 +8295,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8148,6 +8337,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8194,6 +8384,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8235,6 +8426,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8281,6 +8473,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8322,6 +8515,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8368,6 +8562,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8407,6 +8602,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8450,6 +8646,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8491,6 +8688,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8537,6 +8735,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8578,6 +8777,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8624,6 +8824,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8665,6 +8866,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8711,6 +8913,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8752,6 +8955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8798,6 +9002,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8839,6 +9044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8885,6 +9091,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -8926,6 +9133,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -8972,6 +9180,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9013,6 +9222,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9059,6 +9269,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9100,6 +9311,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9146,6 +9358,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9187,6 +9400,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9233,6 +9447,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9274,6 +9489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9320,6 +9536,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9361,6 +9578,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9407,6 +9625,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9446,6 +9665,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9489,6 +9709,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9528,6 +9749,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9573,6 +9795,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9614,6 +9837,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9663,6 +9887,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9702,6 +9927,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9745,6 +9971,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9786,6 +10013,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9835,6 +10063,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9876,6 +10105,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -9925,6 +10155,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -9966,6 +10197,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10015,6 +10247,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10054,6 +10287,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10097,6 +10331,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10136,6 +10371,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10179,6 +10415,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10225,6 +10462,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10275,6 +10513,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10316,6 +10555,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10362,6 +10602,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10403,6 +10644,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10449,6 +10691,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10490,6 +10733,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10536,6 +10780,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10577,6 +10822,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10623,6 +10869,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10662,6 +10909,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10705,6 +10953,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10744,6 +10993,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10787,6 +11037,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10826,6 +11077,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10869,6 +11121,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10908,6 +11161,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -10951,6 +11205,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -10990,6 +11245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11033,6 +11289,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11072,6 +11329,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11115,6 +11373,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11156,6 +11415,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11201,6 +11461,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11240,6 +11501,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11283,6 +11545,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11324,6 +11587,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11369,6 +11633,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -11406,6 +11671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11452,6 +11718,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -11489,6 +11756,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11535,6 +11803,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -11572,6 +11841,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11617,6 +11887,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -11654,6 +11925,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11700,6 +11972,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -11737,6 +12010,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11783,6 +12057,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11822,6 +12097,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11867,6 +12143,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11908,6 +12185,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -11954,6 +12232,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -11995,6 +12274,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12041,6 +12321,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12082,6 +12363,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12128,6 +12410,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12169,6 +12452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12215,6 +12499,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12256,6 +12541,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12302,6 +12588,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12343,6 +12630,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12389,6 +12677,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12430,6 +12719,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12476,6 +12766,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12517,6 +12808,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12563,6 +12855,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12604,6 +12897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12650,6 +12944,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12691,6 +12986,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12737,6 +13033,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12778,6 +13075,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12824,6 +13122,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12865,6 +13164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12911,6 +13211,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -12952,6 +13253,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -12998,6 +13300,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13039,6 +13342,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13085,6 +13389,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13124,6 +13429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13167,6 +13473,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13208,6 +13515,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13257,6 +13565,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13296,6 +13605,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13339,6 +13649,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13380,6 +13691,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13429,6 +13741,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13470,6 +13783,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13519,6 +13833,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13560,6 +13875,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13609,6 +13925,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13650,6 +13967,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13696,6 +14014,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13737,6 +14056,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13783,6 +14103,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13824,6 +14145,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13870,6 +14192,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13911,6 +14234,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -13957,6 +14281,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -13996,6 +14321,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14039,6 +14365,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14078,6 +14405,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14121,6 +14449,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14160,6 +14489,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14203,6 +14533,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14242,6 +14573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14285,6 +14617,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14324,6 +14657,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14367,6 +14701,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14406,6 +14741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14449,6 +14785,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14490,6 +14827,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14535,6 +14873,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14574,6 +14913,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14617,6 +14957,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14658,6 +14999,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14703,6 +15045,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14744,6 +15087,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14790,6 +15134,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14831,6 +15176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14877,6 +15223,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -14918,6 +15265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -14967,6 +15315,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15008,6 +15357,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15057,6 +15407,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15096,6 +15447,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15139,6 +15491,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15180,6 +15533,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15225,6 +15579,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15266,6 +15621,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15312,6 +15668,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15353,6 +15710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15399,6 +15757,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15440,6 +15799,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15486,6 +15846,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15527,6 +15888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15573,6 +15935,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15614,6 +15977,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15660,6 +16024,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15701,6 +16066,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15750,6 +16116,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15791,6 +16158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15837,6 +16205,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15878,6 +16247,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -15927,6 +16297,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -15968,6 +16339,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16014,6 +16386,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16055,6 +16428,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16101,6 +16475,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16140,6 +16515,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16183,6 +16559,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16224,6 +16601,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16269,6 +16647,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16310,6 +16689,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16356,6 +16736,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16397,6 +16778,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16442,6 +16824,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16481,6 +16864,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16524,6 +16908,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16565,6 +16950,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16610,6 +16996,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16651,6 +17038,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16697,6 +17085,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16738,6 +17127,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16784,6 +17174,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16825,6 +17216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16871,6 +17263,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16912,6 +17305,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -16958,6 +17352,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -16999,6 +17394,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17045,6 +17441,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17086,6 +17483,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17135,6 +17533,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17176,6 +17575,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17222,6 +17622,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17263,6 +17664,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17309,6 +17711,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17350,6 +17753,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17396,6 +17800,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17435,6 +17840,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17478,6 +17884,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17519,6 +17926,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17564,6 +17972,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17605,6 +18014,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17651,6 +18061,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17692,6 +18103,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17738,6 +18150,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17779,6 +18192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17825,6 +18239,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17866,6 +18281,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17912,6 +18328,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -17953,6 +18370,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -17999,6 +18417,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18040,6 +18459,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18086,6 +18506,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18127,6 +18548,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18173,6 +18595,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18214,6 +18637,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18260,6 +18684,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18301,6 +18726,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18347,6 +18773,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18388,6 +18815,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18434,6 +18862,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18475,6 +18904,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18521,6 +18951,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18562,6 +18993,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18611,6 +19043,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18652,6 +19085,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18698,6 +19132,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18739,6 +19174,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18785,6 +19221,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18826,6 +19263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18872,6 +19310,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -18913,6 +19352,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -18962,6 +19402,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19003,6 +19444,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19052,6 +19494,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19093,6 +19536,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19142,6 +19586,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19183,6 +19628,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19229,6 +19675,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19270,6 +19717,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19316,6 +19764,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19357,6 +19806,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19403,6 +19853,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19444,6 +19895,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19490,6 +19942,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19531,6 +19984,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19577,6 +20031,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19618,6 +20073,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19664,6 +20120,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19705,6 +20162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19751,6 +20209,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19790,6 +20249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -19833,6 +20293,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -19874,6 +20335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/homewizard/snapshots/test_switch.ambr b/tests/components/homewizard/snapshots/test_switch.ambr index 8f6af16068d..cd21cb92819 100644 --- a/tests/components/homewizard/snapshots/test_switch.ambr +++ b/tests/components/homewizard/snapshots/test_switch.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -49,6 +50,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -131,6 +134,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -184,6 +188,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -214,6 +219,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -266,6 +272,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +303,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -348,6 +356,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -378,6 +387,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -431,6 +441,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -461,6 +472,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -513,6 +525,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -543,6 +556,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -595,6 +609,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -625,6 +640,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -677,6 +693,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -707,6 +724,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -759,6 +777,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -789,6 +808,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -841,6 +861,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -871,6 +892,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr index 16d9452e847..a077eb134d4 100644 --- a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -238,6 +243,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr index 2ce3aae3065..088850c1e07 100644 --- a/tests/components/husqvarna_automower/snapshots/test_button.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr index 156eee9b8df..e94eea4087c 100644 --- a/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index a4dc986c2f9..2dab82451a6 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -183,6 +183,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Husqvarna Automower of Erika Mustermann', 'unique_id': '123', 'version': 1, diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index 036783dd6d0..1428a75d7b4 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'garden', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/husqvarna_automower/snapshots/test_number.ambr b/tests/components/husqvarna_automower/snapshots/test_number.ambr index b0ccce5800a..291aef83dbf 100644 --- a/tests/components/husqvarna_automower/snapshots/test_number.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +181,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index d57a829a997..02a64718276 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +262,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -455,6 +458,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -504,6 +508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -560,6 +565,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +620,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +670,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -711,6 +719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -760,6 +769,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -809,6 +819,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -869,6 +880,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -930,6 +942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -984,6 +997,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1038,6 +1052,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1092,6 +1107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1146,6 +1162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1205,6 +1222,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1265,6 +1283,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1463,6 +1482,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1666,6 +1686,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1720,6 +1741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1780,6 +1802,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_switch.ambr b/tests/components/husqvarna_automower/snapshots/test_switch.ambr index 8f8f6b367c0..5e01694e924 100644 --- a/tests/components/husqvarna_automower/snapshots/test_switch.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr index 1cc54020195..b7aa14ef0bf 100644 --- a/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower_ble/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/hydrawise/snapshots/test_binary_sensor.ambr b/tests/components/hydrawise/snapshots/test_binary_sensor.ambr index 9886345595d..84e52a7f966 100644 --- a/tests/components/hydrawise/snapshots/test_binary_sensor.ambr +++ b/tests/components/hydrawise/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/hydrawise/snapshots/test_sensor.ambr b/tests/components/hydrawise/snapshots/test_sensor.ambr index dadf3c44789..3e475b1eeb1 100644 --- a/tests/components/hydrawise/snapshots/test_sensor.ambr +++ b/tests/components/hydrawise/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -61,6 +62,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -165,6 +168,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -220,6 +224,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +280,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -324,6 +330,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -372,6 +379,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +428,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +485,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -525,6 +535,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -573,6 +584,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/hydrawise/snapshots/test_switch.ambr b/tests/components/hydrawise/snapshots/test_switch.ambr index 977bd15f004..9ad37ddbfbf 100644 --- a/tests/components/hydrawise/snapshots/test_switch.ambr +++ b/tests/components/hydrawise/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/hydrawise/snapshots/test_valve.ambr b/tests/components/hydrawise/snapshots/test_valve.ambr index cac08893324..197e7796a07 100644 --- a/tests/components/hydrawise/snapshots/test_valve.ambr +++ b/tests/components/hydrawise/snapshots/test_valve.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/igloohome/snapshots/test_sensor.ambr b/tests/components/igloohome/snapshots/test_sensor.ambr index f65baa484a0..9e17343d4fa 100644 --- a/tests/components/igloohome/snapshots/test_sensor.ambr +++ b/tests/components/igloohome/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index a98f60a2b3e..97453930c1e 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -15,6 +15,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'River Name (Station Name)', 'unique_id': '123', 'version': 1, diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index c7779f5d850..ccc6e46befa 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -63,6 +64,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/incomfort/snapshots/test_binary_sensor.ambr b/tests/components/incomfort/snapshots/test_binary_sensor.ambr index fe0d8edd0f0..518ea230705 100644 --- a/tests/components/incomfort/snapshots/test_binary_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -148,6 +151,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -195,6 +199,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -242,6 +247,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +296,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -337,6 +344,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -384,6 +392,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -431,6 +440,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -479,6 +489,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -526,6 +537,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -573,6 +585,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -620,6 +633,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -668,6 +682,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -715,6 +730,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -762,6 +778,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -809,6 +826,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -857,6 +875,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -904,6 +923,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index e0e8b9562dd..df3fe3f710b 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -12,6 +12,7 @@ 'min_temp': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -78,6 +79,7 @@ 'min_temp': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +146,7 @@ 'min_temp': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,6 +213,7 @@ 'min_temp': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/incomfort/snapshots/test_sensor.ambr b/tests/components/incomfort/snapshots/test_sensor.ambr index a69a64d964e..294a6094164 100644 --- a/tests/components/incomfort/snapshots/test_sensor.ambr +++ b/tests/components/incomfort/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/incomfort/snapshots/test_water_heater.ambr b/tests/components/incomfort/snapshots/test_water_heater.ambr index d2cd955a9fc..d3fc2b057fc 100644 --- a/tests/components/incomfort/snapshots/test_water_heater.ambr +++ b/tests/components/incomfort/snapshots/test_water_heater.ambr @@ -9,6 +9,7 @@ 'min_temp': 30.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/intellifire/snapshots/test_binary_sensor.ambr b/tests/components/intellifire/snapshots/test_binary_sensor.ambr index 1b85db51d68..afa3c1fa8a9 100644 --- a/tests/components/intellifire/snapshots/test_binary_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -341,6 +348,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -437,6 +446,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -485,6 +495,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -533,6 +544,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -581,6 +593,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -629,6 +642,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -676,6 +690,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -724,6 +739,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -771,6 +787,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/intellifire/snapshots/test_climate.ambr b/tests/components/intellifire/snapshots/test_climate.ambr index 36f719d2264..d0744424cff 100644 --- a/tests/components/intellifire/snapshots/test_climate.ambr +++ b/tests/components/intellifire/snapshots/test_climate.ambr @@ -14,6 +14,7 @@ 'target_temp_step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/intellifire/snapshots/test_sensor.ambr b/tests/components/intellifire/snapshots/test_sensor.ambr index d749da216ac..548c8d5a8aa 100644 --- a/tests/components/intellifire/snapshots/test_sensor.ambr +++ b/tests/components/intellifire/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -200,6 +204,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -248,6 +253,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +303,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -401,6 +409,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -450,6 +459,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iotty/snapshots/test_switch.ambr b/tests/components/iotty/snapshots/test_switch.ambr index c6e8764cf37..16913d340f0 100644 --- a/tests/components/iotty/snapshots/test_switch.ambr +++ b/tests/components/iotty/snapshots/test_switch.ambr @@ -15,6 +15,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ipp/snapshots/test_sensor.ambr b/tests/components/ipp/snapshots/test_sensor.ambr index 3f910399ad8..f8e0578a6b9 100644 --- a/tests/components/ipp/snapshots/test_sensor.ambr +++ b/tests/components/ipp/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -73,6 +74,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -126,6 +128,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -232,6 +236,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -283,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -332,6 +338,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr index f2fa656cb0f..41cfedb0e29 100644 --- a/tests/components/iqvia/snapshots/test_diagnostics.ambr +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -358,6 +358,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/iron_os/snapshots/test_binary_sensor.ambr b/tests/components/iron_os/snapshots/test_binary_sensor.ambr index 17b49c1d687..c36c1cc42ff 100644 --- a/tests/components/iron_os/snapshots/test_binary_sensor.ambr +++ b/tests/components/iron_os/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_button.ambr b/tests/components/iron_os/snapshots/test_button.ambr index 64a71f5e424..c9ff9181515 100644 --- a/tests/components/iron_os/snapshots/test_button.ambr +++ b/tests/components/iron_os/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index fc4fe96d746..62fcd120201 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -234,6 +238,7 @@ 'step': 2.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +295,7 @@ 'step': 250, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -346,6 +352,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -402,6 +409,7 @@ 'step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +466,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -514,6 +523,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +579,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -626,6 +637,7 @@ 'step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -682,6 +694,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -739,6 +752,7 @@ 'step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -796,6 +810,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -852,6 +867,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -909,6 +925,7 @@ 'step': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -966,6 +983,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1022,6 +1040,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_select.ambr b/tests/components/iron_os/snapshots/test_select.ambr index e3989fbf863..10aacc838df 100644 --- a/tests/components/iron_os/snapshots/test_select.ambr +++ b/tests/components/iron_os/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -75,6 +76,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -136,6 +138,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +196,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -249,6 +253,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -307,6 +312,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +371,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +429,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -479,6 +487,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_sensor.ambr b/tests/components/iron_os/snapshots/test_sensor.ambr index 0eb8e81fb4f..6a30aa6632b 100644 --- a/tests/components/iron_os/snapshots/test_sensor.ambr +++ b/tests/components/iron_os/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,6 +214,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -259,6 +264,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -325,6 +331,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -391,6 +398,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -450,6 +458,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -505,6 +514,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -559,6 +569,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -609,6 +620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -660,6 +672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_switch.ambr b/tests/components/iron_os/snapshots/test_switch.ambr index f13cdcfe666..a3d28e58d63 100644 --- a/tests/components/iron_os/snapshots/test_switch.ambr +++ b/tests/components/iron_os/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/iron_os/snapshots/test_update.ambr b/tests/components/iron_os/snapshots/test_update.ambr index e0872d032ec..f2db3246158 100644 --- a/tests/components/iron_os/snapshots/test_update.ambr +++ b/tests/components/iron_os/snapshots/test_update.ambr @@ -9,6 +9,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/israel_rail/snapshots/test_sensor.ambr b/tests/components/israel_rail/snapshots/test_sensor.ambr index f851f1cd726..610c2c53e22 100644 --- a/tests/components/israel_rail/snapshots/test_sensor.ambr +++ b/tests/components/israel_rail/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -197,6 +201,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -244,6 +249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ista_ecotrend/snapshots/test_init.ambr b/tests/components/ista_ecotrend/snapshots/test_init.ambr index c84d55c059c..7329eec7f70 100644 --- a/tests/components/ista_ecotrend/snapshots/test_init.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://ecotrend.ista.de/', 'connections': set({ }), diff --git a/tests/components/ista_ecotrend/snapshots/test_sensor.ambr b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr index b5056019c74..296ce26c7f2 100644 --- a/tests/components/ista_ecotrend/snapshots/test_sensor.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -61,6 +62,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -115,6 +117,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -169,6 +172,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -223,6 +227,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -277,6 +282,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -331,6 +337,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -385,6 +392,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -439,6 +447,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -492,6 +501,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -546,6 +556,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -600,6 +611,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -654,6 +666,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -708,6 +721,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -762,6 +776,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -816,6 +831,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ituran/snapshots/test_device_tracker.ambr b/tests/components/ituran/snapshots/test_device_tracker.ambr index 3b650f7927f..e73f0cfee24 100644 --- a/tests/components/ituran/snapshots/test_device_tracker.ambr +++ b/tests/components/ituran/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ituran/snapshots/test_init.ambr b/tests/components/ituran/snapshots/test_init.ambr index 1e64ef9e850..b97aef6027b 100644 --- a/tests/components/ituran/snapshots/test_init.ambr +++ b/tests/components/ituran/snapshots/test_init.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/ituran/snapshots/test_sensor.ambr b/tests/components/ituran/snapshots/test_sensor.ambr index c1512de912f..f96190fdbc2 100644 --- a/tests/components/ituran/snapshots/test_sensor.ambr +++ b/tests/components/ituran/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -200,6 +204,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -251,6 +256,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/kitchen_sink/snapshots/test_sensor.ambr b/tests/components/kitchen_sink/snapshots/test_sensor.ambr index bbf88c84eca..7b433c40170 100644 --- a/tests/components/kitchen_sink/snapshots/test_sensor.ambr +++ b/tests/components/kitchen_sink/snapshots/test_sensor.ambr @@ -69,3 +69,84 @@ }), }) # --- +# name: test_states_with_subentry + set({ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Outlet 1 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Outlet 2 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.outlet_2_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sensor test', + }), + 'context': , + 'entity_id': 'sensor.sensor_test', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issues Issue 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issues Issue 2', + 'state_class': , + 'unit_of_measurement': 'dogs', + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Statistics issues Issue 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.statistics_issues_issue_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }), + }) +# --- diff --git a/tests/components/kitchen_sink/snapshots/test_switch.ambr b/tests/components/kitchen_sink/snapshots/test_switch.ambr index fe4311ad711..5535554017f 100644 --- a/tests/components/kitchen_sink/snapshots/test_switch.ambr +++ b/tests/components/kitchen_sink/snapshots/test_switch.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -49,6 +50,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -81,6 +83,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -129,6 +132,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +163,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -191,6 +196,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 5f163d1342e..1eea1c8036b 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -104,3 +104,85 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert config_entry.options == {"section_1": {"bool": True, "int": 15}} await hass.async_block_till_done() + + +@pytest.mark.usefixtures("no_platforms") +async def test_subentry_flow(hass: HomeAssistant) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "entity"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "add_sensor" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"name": "Sensor 1", "state": 15}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(config_entry.subentries)[0] + assert config_entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"state": 15}, + subentry_id=subentry_id, + subentry_type="entity", + title="Sensor 1", + unique_id=None, + ) + } + + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("no_platforms") +async def test_subentry_reconfigure_flow(hass: HomeAssistant) -> None: + """Test config flow options.""" + subentry_id = "mock_id" + config_entry = MockConfigEntry( + domain=DOMAIN, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"state": 15}, + subentry_id="mock_id", + subentry_type="entity", + title="Sensor 1", + unique_id=None, + ) + ], + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await config_entry.start_subentry_reconfigure_flow( + hass, "entity", subentry_id + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_sensor" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"name": "Renamed sensor 1", "state": 5}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"state": 5}, + subentry_id=subentry_id, + subentry_type="entity", + title="Renamed sensor 1", + unique_id=None, + ) + } + + await hass.async_block_till_done() diff --git a/tests/components/kitchen_sink/test_sensor.py b/tests/components/kitchen_sink/test_sensor.py index c4b5f03499e..f980e39f652 100644 --- a/tests/components/kitchen_sink/test_sensor.py +++ b/tests/components/kitchen_sink/test_sensor.py @@ -5,11 +5,14 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant import config_entries from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + @pytest.fixture async def sensor_only() -> None: @@ -21,14 +24,41 @@ async def sensor_only() -> None: yield -@pytest.fixture(autouse=True) +@pytest.fixture async def setup_comp(hass: HomeAssistant, sensor_only): """Set up demo component.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() +@pytest.mark.usefixtures("setup_comp") async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test the expected sensor entities are added.""" states = hass.states.async_all() assert set(states) == snapshot + + +@pytest.mark.usefixtures("sensor_only") +async def test_states_with_subentry( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test the expected sensor entities are added.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"state": 15}, + subentry_id="blabla", + subentry_type="entity", + title="Sensor test", + unique_id=None, + ) + ], + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + states = hass.states.async_all() + assert set(states) == snapshot diff --git a/tests/components/knocki/snapshots/test_event.ambr b/tests/components/knocki/snapshots/test_event.ambr index fba1c90b45d..65fecd59739 100644 --- a/tests/components/knocki/snapshots/test_event.ambr +++ b/tests/components/knocki/snapshots/test_event.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 08f06684d9a..3a99a7f681d 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -57,6 +57,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "client": { "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index bfbfa2901a6..0975704b680 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -25,6 +25,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index 47bca8dcb63..6cd4e8cd5ae 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_button.ambr b/tests/components/lamarzocco/snapshots/test_button.ambr index 64d47a11072..33aace5f97a 100644 --- a/tests/components/lamarzocco/snapshots/test_button.ambr +++ b/tests/components/lamarzocco/snapshots/test_button.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_calendar.ambr b/tests/components/lamarzocco/snapshots/test_calendar.ambr index 729eed5879a..74847892cfa 100644 --- a/tests/components/lamarzocco/snapshots/test_calendar.ambr +++ b/tests/components/lamarzocco/snapshots/test_calendar.ambr @@ -90,6 +90,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -123,6 +124,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_init.ambr b/tests/components/lamarzocco/snapshots/test_init.ambr index 67aa0b8bea8..4c210136bd2 100644 --- a/tests/components/lamarzocco/snapshots/test_init.ambr +++ b/tests/components/lamarzocco/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -43,6 +44,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index 49e4713aab1..0748c9384a9 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -30,6 +30,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -87,6 +88,7 @@ 'step': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +146,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -201,6 +204,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +262,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -315,6 +320,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -672,6 +678,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -729,6 +736,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -786,6 +794,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -843,6 +852,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -900,6 +910,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -957,6 +968,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1012,6 +1024,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1067,6 +1080,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_select.ambr b/tests/components/lamarzocco/snapshots/test_select.ambr index 325409a0b7f..2e88688652a 100644 --- a/tests/components/lamarzocco/snapshots/test_select.ambr +++ b/tests/components/lamarzocco/snapshots/test_select.ambr @@ -28,6 +28,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -85,6 +86,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -142,6 +144,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -199,6 +202,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -254,6 +258,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -311,6 +316,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index be2b1672cb9..996dff93433 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -24,6 +24,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -109,6 +111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -259,6 +264,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -313,6 +319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -367,6 +374,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -418,6 +426,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -468,6 +477,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index 72fe41c1392..085d9a16125 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -39,6 +40,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index 40f47a783d7..17d0528c3d8 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_binary_sensor.ambr b/tests/components/lcn/snapshots/test_binary_sensor.ambr index 0ad31437dd1..d2d697569d1 100644 --- a/tests/components/lcn/snapshots/test_binary_sensor.ambr +++ b/tests/components/lcn/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_climate.ambr b/tests/components/lcn/snapshots/test_climate.ambr index 443b13312d1..81745ca8515 100644 --- a/tests/components/lcn/snapshots/test_climate.ambr +++ b/tests/components/lcn/snapshots/test_climate.ambr @@ -13,6 +13,7 @@ 'min_temp': 0.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_cover.ambr b/tests/components/lcn/snapshots/test_cover.ambr index 82a19060d73..d399626537d 100644 --- a/tests/components/lcn/snapshots/test_cover.ambr +++ b/tests/components/lcn/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_light.ambr b/tests/components/lcn/snapshots/test_light.ambr index f53d1fdf2dc..638cddc15cd 100644 --- a/tests/components/lcn/snapshots/test_light.ambr +++ b/tests/components/lcn/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -121,6 +123,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_scene.ambr b/tests/components/lcn/snapshots/test_scene.ambr index c039c4ef951..a5576158621 100644 --- a/tests/components/lcn/snapshots/test_scene.ambr +++ b/tests/components/lcn/snapshots/test_scene.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_sensor.ambr b/tests/components/lcn/snapshots/test_sensor.ambr index 56776e3e0f6..f8d57ed8904 100644 --- a/tests/components/lcn/snapshots/test_sensor.ambr +++ b/tests/components/lcn/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lcn/snapshots/test_switch.ambr b/tests/components/lcn/snapshots/test_switch.ambr index 36145b8d4fd..bc69b0ed483 100644 --- a/tests/components/lcn/snapshots/test_switch.ambr +++ b/tests/components/lcn/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_binary_sensor.ambr b/tests/components/lektrico/snapshots/test_binary_sensor.ambr index 6a28e7c60de..b365ff84187 100644 --- a/tests/components/lektrico/snapshots/test_binary_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_button.ambr b/tests/components/lektrico/snapshots/test_button.ambr index 5070cd484c4..f9cb7189237 100644 --- a/tests/components/lektrico/snapshots/test_button.ambr +++ b/tests/components/lektrico/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_init.ambr b/tests/components/lektrico/snapshots/test_init.ambr index 63739e1c9d8..35183bf5d75 100644 --- a/tests/components/lektrico/snapshots/test_init.ambr +++ b/tests/components/lektrico/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/lektrico/snapshots/test_number.ambr b/tests/components/lektrico/snapshots/test_number.ambr index 30a37a25a09..57cf40567e7 100644 --- a/tests/components/lektrico/snapshots/test_number.ambr +++ b/tests/components/lektrico/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_select.ambr b/tests/components/lektrico/snapshots/test_select.ambr index 5a964f52ada..0f564abb146 100644 --- a/tests/components/lektrico/snapshots/test_select.ambr +++ b/tests/components/lektrico/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_sensor.ambr b/tests/components/lektrico/snapshots/test_sensor.ambr index 73ec88e6fa1..aa146f55776 100644 --- a/tests/components/lektrico/snapshots/test_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -203,6 +207,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -266,6 +271,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +334,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -392,6 +399,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -452,6 +460,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -501,6 +510,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lektrico/snapshots/test_switch.ambr b/tests/components/lektrico/snapshots/test_switch.ambr index 3f4a1693315..c55e96ac9a9 100644 --- a/tests/components/lektrico/snapshots/test_switch.ambr +++ b/tests/components/lektrico/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/letpot/snapshots/test_switch.ambr b/tests/components/letpot/snapshots/test_switch.ambr index 28ca9603760..1a36e555dd1 100644 --- a/tests/components/letpot/snapshots/test_switch.ambr +++ b/tests/components/letpot/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/letpot/snapshots/test_time.ambr b/tests/components/letpot/snapshots/test_time.ambr index 66f6648c202..9ca75003e56 100644 --- a/tests/components/letpot/snapshots/test_time.ambr +++ b/tests/components/letpot/snapshots/test_time.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr index 9369367a1f7..e2fcc2540f3 100644 --- a/tests/components/lg_thinq/snapshots/test_climate.ambr +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -23,6 +23,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lg_thinq/snapshots/test_event.ambr b/tests/components/lg_thinq/snapshots/test_event.ambr index 025f4496aeb..dbb43ce0bb9 100644 --- a/tests/components/lg_thinq/snapshots/test_event.ambr +++ b/tests/components/lg_thinq/snapshots/test_event.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lg_thinq/snapshots/test_number.ambr b/tests/components/lg_thinq/snapshots/test_number.ambr index 68f01854501..ef4d9a21b86 100644 --- a/tests/components/lg_thinq/snapshots/test_number.ambr +++ b/tests/components/lg_thinq/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index fe1929944f9..5e6eb98ac42 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -157,6 +160,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -257,6 +262,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -305,6 +311,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -353,6 +360,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -392,4 +400,4 @@ 'last_updated': , 'state': '2024-10-10T13:14:00+00:00', }) -# --- \ No newline at end of file +# --- diff --git a/tests/components/linear_garage_door/snapshots/test_cover.ambr b/tests/components/linear_garage_door/snapshots/test_cover.ambr index 96745e1d92a..a09156c53e0 100644 --- a/tests/components/linear_garage_door/snapshots/test_cover.ambr +++ b/tests/components/linear_garage_door/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr index c689d04949a..db82f41eb73 100644 --- a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr +++ b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr @@ -73,6 +73,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'test-site-name', 'unique_id': None, 'version': 1, diff --git a/tests/components/linear_garage_door/snapshots/test_light.ambr b/tests/components/linear_garage_door/snapshots/test_light.ambr index ba64a2b0a04..9e27efc02ec 100644 --- a/tests/components/linear_garage_door/snapshots/test_light.ambr +++ b/tests/components/linear_garage_door/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +181,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/madvr/snapshots/test_binary_sensor.ambr b/tests/components/madvr/snapshots/test_binary_sensor.ambr index 7fd54a7c240..7d665210a6f 100644 --- a/tests/components/madvr/snapshots/test_binary_sensor.ambr +++ b/tests/components/madvr/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/madvr/snapshots/test_diagnostics.ambr b/tests/components/madvr/snapshots/test_diagnostics.ambr index 3a281391860..92d0578dba8 100644 --- a/tests/components/madvr/snapshots/test_diagnostics.ambr +++ b/tests/components/madvr/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'envy', 'unique_id': '00:11:22:33:44:55', 'version': 1, diff --git a/tests/components/madvr/snapshots/test_remote.ambr b/tests/components/madvr/snapshots/test_remote.ambr index 1157496a93e..c90270674c8 100644 --- a/tests/components/madvr/snapshots/test_remote.ambr +++ b/tests/components/madvr/snapshots/test_remote.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/madvr/snapshots/test_sensor.ambr b/tests/components/madvr/snapshots/test_sensor.ambr index 7b0dd254f77..115f6a3f5d7 100644 --- a/tests/components/madvr/snapshots/test_sensor.ambr +++ b/tests/components/madvr/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -243,6 +248,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -348,6 +355,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -405,6 +413,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -462,6 +471,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -520,6 +530,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -583,6 +594,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -639,6 +651,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -685,6 +698,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -736,6 +750,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -789,6 +804,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -838,6 +854,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -884,6 +901,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -930,6 +948,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -982,6 +1001,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1039,6 +1059,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1097,6 +1118,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1160,6 +1182,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1216,6 +1239,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1262,6 +1286,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1313,6 +1338,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 37fa765acea..28157b9e6eb 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/mastodon/snapshots/test_sensor.ambr b/tests/components/mastodon/snapshots/test_sensor.ambr index c8df8cdab19..22ac2671c36 100644 --- a/tests/components/mastodon/snapshots/test_sensor.ambr +++ b/tests/components/mastodon/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index 82dcc166f13..c8de905d03f 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +486,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -523,6 +534,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +581,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -616,6 +629,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index dbbc984ab2f..448136eeed2 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -286,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -427,6 +436,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -474,6 +484,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -521,6 +532,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -568,6 +580,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +627,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -660,6 +674,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -706,6 +721,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -752,6 +768,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -799,6 +816,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -846,6 +864,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -893,6 +912,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -940,6 +960,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -987,6 +1008,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1034,6 +1056,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1081,6 +1104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1128,6 +1152,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1175,6 +1200,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1222,6 +1248,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1268,6 +1295,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1314,6 +1342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1360,6 +1389,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1407,6 +1437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1453,6 +1484,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1499,6 +1531,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1545,6 +1578,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1591,6 +1625,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1638,6 +1673,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1685,6 +1721,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1732,6 +1769,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1779,6 +1817,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1826,6 +1865,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 25f5ca06f62..8aeb1aaafdd 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -13,6 +13,7 @@ 'min_temp': 5.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -75,6 +76,7 @@ 'min_temp': 10.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -141,6 +143,7 @@ 'min_temp': 16.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +212,7 @@ 'min_temp': 7, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_cover.ambr b/tests/components/matter/snapshots/test_cover.ambr index 7d036d35983..c83dcf63c6b 100644 --- a/tests/components/matter/snapshots/test_cover.ambr +++ b/tests/components/matter/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -153,6 +156,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -202,6 +206,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_event.ambr b/tests/components/matter/snapshots/test_event.ambr index 031e8e9d24f..b0ddfaed8bf 100644 --- a/tests/components/matter/snapshots/test_event.ambr +++ b/tests/components/matter/snapshots/test_event.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -74,6 +75,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -135,6 +137,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -199,6 +202,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -266,6 +270,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +338,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_fan.ambr b/tests/components/matter/snapshots/test_fan.ambr index 7f1fe7d42db..e4dc14967e5 100644 --- a/tests/components/matter/snapshots/test_fan.ambr +++ b/tests/components/matter/snapshots/test_fan.ambr @@ -15,6 +15,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -84,6 +85,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +152,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -214,6 +217,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_light.ambr b/tests/components/matter/snapshots/test_light.ambr index eff5820d27d..a56f8f891e9 100644 --- a/tests/components/matter/snapshots/test_light.ambr +++ b/tests/components/matter/snapshots/test_light.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -89,6 +90,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +147,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -207,6 +210,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -284,6 +288,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -346,6 +351,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -413,6 +419,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -474,6 +481,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -547,6 +555,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +623,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_lock.ambr b/tests/components/matter/snapshots/test_lock.ambr index bf34ac267d7..10ba84dd49b 100644 --- a/tests/components/matter/snapshots/test_lock.ambr +++ b/tests/components/matter/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 7e06b6f501d..dc35f6f2a69 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -177,6 +180,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -233,6 +237,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -289,6 +294,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -344,6 +350,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -400,6 +407,7 @@ 'step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -457,6 +465,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -514,6 +523,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +579,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -625,6 +636,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -680,6 +692,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -735,6 +748,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -791,6 +805,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -847,6 +862,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -903,6 +919,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -958,6 +975,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1014,6 +1032,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1070,6 +1089,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1126,6 +1146,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1181,6 +1202,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1237,6 +1259,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1293,6 +1316,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1349,6 +1373,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1404,6 +1429,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1460,6 +1486,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1516,6 +1543,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1571,6 +1599,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_select.ambr b/tests/components/matter/snapshots/test_select.ambr index d7ddf636ff9..772ee297e13 100644 --- a/tests/components/matter/snapshots/test_select.ambr +++ b/tests/components/matter/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -70,6 +71,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -138,6 +140,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -206,6 +209,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -265,6 +269,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -324,6 +329,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -383,6 +389,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -442,6 +449,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -501,6 +509,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +567,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +624,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -672,6 +683,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -729,6 +741,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -797,6 +810,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -876,6 +890,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -944,6 +959,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1003,6 +1019,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1060,6 +1077,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1115,6 +1133,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1175,6 +1194,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1237,6 +1257,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1296,6 +1317,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1355,6 +1377,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1414,6 +1437,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1473,6 +1497,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1530,6 +1555,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1587,6 +1613,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1645,6 +1672,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1703,6 +1731,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1760,6 +1789,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1818,6 +1848,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1878,6 +1909,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 541f1bc178f..9caa84bbf96 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -173,6 +176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -224,6 +228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -274,6 +279,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -325,6 +331,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -376,6 +383,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -427,6 +435,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -478,6 +487,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -529,6 +539,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -580,6 +591,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -631,6 +643,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -682,6 +695,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -740,6 +754,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -797,6 +812,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -848,6 +864,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -899,6 +916,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -950,6 +968,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1001,6 +1020,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1052,6 +1072,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1103,6 +1124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1154,6 +1176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1205,6 +1228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1256,6 +1280,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1310,6 +1335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1364,6 +1390,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1418,6 +1445,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1472,6 +1500,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1526,6 +1555,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1583,6 +1613,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1640,6 +1671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1697,6 +1729,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1754,6 +1787,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1805,6 +1839,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1854,6 +1889,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1903,6 +1939,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1957,6 +1994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2008,6 +2046,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2059,6 +2098,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2113,6 +2153,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2164,6 +2205,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2218,6 +2260,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2268,6 +2311,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2319,6 +2363,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2375,6 +2420,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2430,6 +2476,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2481,6 +2528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2532,6 +2580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2589,6 +2638,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2652,6 +2702,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2708,6 +2759,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2765,6 +2817,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2822,6 +2875,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2883,6 +2937,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2937,6 +2992,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2999,6 +3055,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3054,6 +3111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3111,6 +3169,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3168,6 +3227,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3217,6 +3277,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3265,6 +3326,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3319,6 +3381,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3370,6 +3433,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3429,6 +3493,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3487,6 +3552,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3541,6 +3607,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3595,6 +3662,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 8277ee28838..ebf43117846 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +293,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -334,6 +341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -381,6 +389,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index 9e6b52ed572..0703a1af4c7 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/matter/snapshots/test_valve.ambr b/tests/components/matter/snapshots/test_valve.ambr index 98634635476..99da4c2d0f6 100644 --- a/tests/components/matter/snapshots/test_valve.ambr +++ b/tests/components/matter/snapshots/test_valve.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/mealie/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr index e5a0a697157..7587a7a55b7 100644 --- a/tests/components/mealie/snapshots/test_calendar.ambr +++ b/tests/components/mealie/snapshots/test_calendar.ambr @@ -170,6 +170,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -222,6 +223,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -274,6 +276,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -326,6 +329,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/mealie/snapshots/test_init.ambr b/tests/components/mealie/snapshots/test_init.ambr index 98ca52dd15e..aada173ffc3 100644 --- a/tests/components/mealie/snapshots/test_init.ambr +++ b/tests/components/mealie/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/mealie/snapshots/test_sensor.ambr b/tests/components/mealie/snapshots/test_sensor.ambr index e645cf4c45f..19219c01c1c 100644 --- a/tests/components/mealie/snapshots/test_sensor.ambr +++ b/tests/components/mealie/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/mealie/snapshots/test_todo.ambr b/tests/components/mealie/snapshots/test_todo.ambr index 4c58a839f57..88c677de581 100644 --- a/tests/components/mealie/snapshots/test_todo.ambr +++ b/tests/components/mealie/snapshots/test_todo.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/melcloud/snapshots/test_diagnostics.ambr b/tests/components/melcloud/snapshots/test_diagnostics.ambr index e6a432de07e..671f5afcc52 100644 --- a/tests/components/melcloud/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'melcloud', 'unique_id': 'UNIQUE_TEST_ID', 'version': 1, diff --git a/tests/components/meteo_france/snapshots/test_sensor.ambr b/tests/components/meteo_france/snapshots/test_sensor.ambr index 85fdec0fcea..35b6a9d19f7 100644 --- a/tests/components/meteo_france/snapshots/test_sensor.ambr +++ b/tests/components/meteo_france/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -60,6 +61,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -109,6 +111,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +159,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -205,6 +209,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +261,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -306,6 +312,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -355,6 +362,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -405,6 +413,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -454,6 +463,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -505,6 +515,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -555,6 +566,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -606,6 +618,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -659,6 +672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -709,6 +723,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/meteo_france/snapshots/test_weather.ambr b/tests/components/meteo_france/snapshots/test_weather.ambr index 9e7d7631479..7c64ee86671 100644 --- a/tests/components/meteo_france/snapshots/test_weather.ambr +++ b/tests/components/meteo_france/snapshots/test_weather.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr index f8897a4a47f..1b4090ca5a4 100644 --- a/tests/components/modern_forms/snapshots/test_diagnostics.ambr +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'AA:BB:CC:DD:EE:FF', 'version': 1, diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr index dc6680ff99a..461cb33d776 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr index 7dfb9edb2e8..27244d781df 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr index c1a63271a33..0708137e1cf 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_climate.ambr @@ -19,6 +19,7 @@ 'target_temp_step': 0.2, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr b/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr index 3fee26a6ed5..4b1c702591d 100644 --- a/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr +++ b/tests/components/moehlenhoff_alpha2/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/monarch_money/snapshots/test_sensor.ambr b/tests/components/monarch_money/snapshots/test_sensor.ambr index cf7e0cb7b2f..b70302188ed 100644 --- a/tests/components/monarch_money/snapshots/test_sensor.ambr +++ b/tests/components/monarch_money/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -211,6 +215,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -261,6 +266,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -311,6 +317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -412,6 +420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -463,6 +472,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -513,6 +523,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -564,6 +575,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -665,6 +678,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -715,6 +729,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -766,6 +781,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -816,6 +832,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -867,6 +884,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -915,6 +933,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -965,6 +984,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1018,6 +1038,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1069,6 +1090,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/monzo/snapshots/test_sensor.ambr b/tests/components/monzo/snapshots/test_sensor.ambr index 9be5943d35c..8d3f83ed4f1 100644 --- a/tests/components/monzo/snapshots/test_sensor.ambr +++ b/tests/components/monzo/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -214,6 +218,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr index 5b4b169c0fe..d042dc02ac3 100644 --- a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr +++ b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr @@ -28,6 +28,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index 6c5389dbd6a..a07bde4b29d 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -72,6 +73,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -142,6 +144,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/snapshots/test_binary_sensor.ambr b/tests/components/myuplink/snapshots/test_binary_sensor.ambr index 755cae3c623..478c5a55b80 100644 --- a/tests/components/myuplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/myuplink/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -285,6 +291,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/snapshots/test_init.ambr b/tests/components/myuplink/snapshots/test_init.ambr index 42ed9c20669..14be11c36ec 100644 --- a/tests/components/myuplink/snapshots/test_init.ambr +++ b/tests/components/myuplink/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/myuplink/snapshots/test_number.ambr b/tests/components/myuplink/snapshots/test_number.ambr index c47d3c60295..f2c89663879 100644 --- a/tests/components/myuplink/snapshots/test_number.ambr +++ b/tests/components/myuplink/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -123,6 +125,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +181,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -233,6 +237,7 @@ 'step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +293,7 @@ 'step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -343,6 +349,7 @@ 'step': 10.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -399,6 +406,7 @@ 'step': 10.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/snapshots/test_select.ambr b/tests/components/myuplink/snapshots/test_select.ambr index eff06bc7f2d..032fd2ef455 100644 --- a/tests/components/myuplink/snapshots/test_select.ambr +++ b/tests/components/myuplink/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -72,6 +73,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/snapshots/test_sensor.ambr b/tests/components/myuplink/snapshots/test_sensor.ambr index 34acbbb8785..f9249651208 100644 --- a/tests/components/myuplink/snapshots/test_sensor.ambr +++ b/tests/components/myuplink/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +372,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -416,6 +424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -467,6 +476,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -518,6 +528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -620,6 +632,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -671,6 +684,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -720,6 +734,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -766,6 +781,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -812,6 +828,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -859,6 +876,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -908,6 +926,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -959,6 +978,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1008,6 +1028,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1055,6 +1076,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1104,6 +1126,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1158,6 +1181,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1210,6 +1234,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1257,6 +1282,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1304,6 +1330,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1351,6 +1378,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1398,6 +1426,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1445,6 +1474,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1494,6 +1524,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1545,6 +1576,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1596,6 +1628,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1647,6 +1680,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1698,6 +1732,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1749,6 +1784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1800,6 +1836,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1851,6 +1888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1902,6 +1940,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1953,6 +1992,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2002,6 +2042,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2049,6 +2090,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2098,6 +2140,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2149,6 +2192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2200,6 +2244,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2251,6 +2296,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2302,6 +2348,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2353,6 +2400,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2404,6 +2452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2455,6 +2504,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2514,6 +2564,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2580,6 +2631,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2636,6 +2688,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2682,6 +2735,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2730,6 +2784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2781,6 +2836,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2832,6 +2888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2883,6 +2940,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2934,6 +2992,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2985,6 +3044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3036,6 +3096,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3087,6 +3148,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3138,6 +3200,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3189,6 +3252,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3240,6 +3304,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3291,6 +3356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3350,6 +3416,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3416,6 +3483,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3472,6 +3540,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3518,6 +3587,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3564,6 +3634,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3611,6 +3682,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3660,6 +3732,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3711,6 +3784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3762,6 +3836,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3813,6 +3888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3864,6 +3940,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3915,6 +3992,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3966,6 +4044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4017,6 +4096,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4073,6 +4153,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4133,6 +4214,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4186,6 +4268,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4232,6 +4315,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4280,6 +4364,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4331,6 +4416,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4382,6 +4468,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4433,6 +4520,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4484,6 +4572,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4535,6 +4624,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4584,6 +4674,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4631,6 +4722,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4678,6 +4770,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4725,6 +4818,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/myuplink/snapshots/test_switch.ambr b/tests/components/myuplink/snapshots/test_switch.ambr index 5d621e661ee..142d4caa455 100644 --- a/tests/components/myuplink/snapshots/test_switch.ambr +++ b/tests/components/myuplink/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nam/snapshots/test_sensor.ambr b/tests/components/nam/snapshots/test_sensor.ambr index 16129c5d7ce..429d069b741 100644 --- a/tests/components/nam/snapshots/test_sensor.ambr +++ b/tests/components/nam/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -62,6 +63,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -116,6 +118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -170,6 +173,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -224,6 +228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -278,6 +283,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -332,6 +338,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -386,6 +393,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -440,6 +448,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -494,6 +503,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -548,6 +558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -602,6 +613,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -654,6 +666,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -703,6 +716,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -755,6 +769,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -809,6 +824,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -865,6 +881,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -919,6 +936,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -973,6 +991,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1025,6 +1044,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1079,6 +1099,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1135,6 +1156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1189,6 +1211,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1243,6 +1266,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1297,6 +1321,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1351,6 +1376,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1403,6 +1429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1457,6 +1484,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1513,6 +1541,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1567,6 +1596,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1621,6 +1651,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1675,6 +1706,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_binary_sensor.ambr b/tests/components/netatmo/snapshots/test_binary_sensor.ambr index 6a90b4dd77a..3066c999655 100644 --- a/tests/components/netatmo/snapshots/test_binary_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +159,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -206,6 +210,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +261,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +310,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -352,6 +359,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -402,6 +410,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -450,6 +459,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -498,6 +508,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_button.ambr b/tests/components/netatmo/snapshots/test_button.ambr index 6ad1b9e78ba..086403c3b69 100644 --- a/tests/components/netatmo/snapshots/test_button.ambr +++ b/tests/components/netatmo/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_camera.ambr b/tests/components/netatmo/snapshots/test_camera.ambr index 94a5ded5031..9bd10ed9b5f 100644 --- a/tests/components/netatmo/snapshots/test_camera.ambr +++ b/tests/components/netatmo/snapshots/test_camera.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -128,6 +130,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr index aeae1fd71c7..506e0fb5590 100644 --- a/tests/components/netatmo/snapshots/test_climate.ambr +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -20,6 +20,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -95,6 +96,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -176,6 +178,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +259,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -338,6 +342,7 @@ 'target_temp_step': 0.5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_cover.ambr b/tests/components/netatmo/snapshots/test_cover.ambr index 7ea016f5ae8..46aafb32e8e 100644 --- a/tests/components/netatmo/snapshots/test_cover.ambr +++ b/tests/components/netatmo/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 463556ec657..4ea7e30bcf9 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -646,6 +646,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'netatmo', 'version': 1, diff --git a/tests/components/netatmo/snapshots/test_fan.ambr b/tests/components/netatmo/snapshots/test_fan.ambr index ba882d68e50..f850f7ada3b 100644 --- a/tests/components/netatmo/snapshots/test_fan.ambr +++ b/tests/components/netatmo/snapshots/test_fan.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_init.ambr b/tests/components/netatmo/snapshots/test_init.ambr index 60cb22d74f2..35e7f7efc29 100644 --- a/tests/components/netatmo/snapshots/test_init.ambr +++ b/tests/components/netatmo/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -99,6 +102,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'corridor', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -131,6 +135,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -163,6 +168,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/control', 'connections': set({ }), @@ -195,6 +201,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -227,6 +234,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -259,6 +267,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -291,6 +300,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -323,6 +333,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -355,6 +366,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -387,6 +399,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -419,6 +432,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -451,6 +465,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -483,6 +498,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -515,6 +531,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -547,6 +564,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -579,6 +597,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -611,6 +630,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.netatmo.com/security', 'connections': set({ }), @@ -643,6 +663,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -675,6 +696,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -707,6 +729,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -739,6 +762,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -771,6 +795,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -803,6 +828,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -835,6 +861,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -867,6 +894,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -899,6 +927,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -931,6 +960,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -963,6 +993,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/weather', 'connections': set({ }), @@ -995,6 +1026,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'bureau', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1027,6 +1059,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'livingroom', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1059,6 +1092,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'entrada', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1091,6 +1125,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'cocina', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1123,6 +1158,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://my.netatmo.com/app/energy', 'connections': set({ }), @@ -1155,6 +1191,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1187,6 +1224,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), @@ -1219,6 +1257,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://weathermap.netatmo.com/', 'connections': set({ }), diff --git a/tests/components/netatmo/snapshots/test_light.ambr b/tests/components/netatmo/snapshots/test_light.ambr index fe5a8aac7d0..cc7da6e8712 100644 --- a/tests/components/netatmo/snapshots/test_light.ambr +++ b/tests/components/netatmo/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -121,6 +123,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_select.ambr b/tests/components/netatmo/snapshots/test_select.ambr index ff68fc71c09..d98d9adb87f 100644 --- a/tests/components/netatmo/snapshots/test_select.ambr +++ b/tests/components/netatmo/snapshots/test_select.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index ba18c2ca21a..b149e80fa5b 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -128,6 +130,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -187,6 +190,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +245,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -293,6 +298,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +348,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -393,6 +400,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -448,6 +456,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -497,6 +506,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -548,6 +558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -606,6 +617,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -664,6 +676,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -721,6 +734,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -773,6 +787,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -823,6 +838,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -870,6 +886,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -921,6 +938,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -974,6 +992,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1021,6 +1040,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1072,6 +1092,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1122,6 +1143,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1169,6 +1191,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1218,6 +1241,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1270,6 +1294,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1320,6 +1345,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1367,6 +1393,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1416,6 +1443,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1476,6 +1504,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1529,6 +1558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1583,6 +1613,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1637,6 +1668,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1690,6 +1722,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1744,6 +1777,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1801,6 +1835,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1855,6 +1890,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1912,6 +1948,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1966,6 +2003,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2026,6 +2064,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2079,6 +2118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2133,6 +2173,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2187,6 +2228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2240,6 +2282,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2294,6 +2337,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2351,6 +2395,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2405,6 +2450,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2462,6 +2508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2516,6 +2563,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2576,6 +2624,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2629,6 +2678,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2683,6 +2733,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2737,6 +2788,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2790,6 +2842,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2844,6 +2897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2901,6 +2955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2955,6 +3010,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3012,6 +3068,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3064,6 +3121,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3113,6 +3171,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3173,6 +3232,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3233,6 +3293,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3292,6 +3353,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3346,6 +3408,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3398,6 +3461,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3447,6 +3511,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3498,6 +3563,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3553,6 +3619,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3602,6 +3669,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3651,6 +3719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3698,6 +3767,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3745,6 +3815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3792,6 +3863,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3839,6 +3911,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3888,6 +3961,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3948,6 +4022,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4000,6 +4075,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4060,6 +4136,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4119,6 +4196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4173,6 +4251,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4225,6 +4304,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4274,6 +4354,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4325,6 +4406,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4380,6 +4462,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4429,6 +4512,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4480,6 +4564,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4540,6 +4625,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4600,6 +4686,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4659,6 +4746,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4713,6 +4801,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4765,6 +4854,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4814,6 +4904,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4865,6 +4956,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4920,6 +5012,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4969,6 +5062,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5018,6 +5112,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5067,6 +5162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5117,6 +5213,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5166,6 +5263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5218,6 +5316,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5270,6 +5369,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5330,6 +5430,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5382,6 +5483,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5434,6 +5536,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5484,6 +5587,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5531,6 +5635,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5580,6 +5685,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5633,6 +5739,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5682,6 +5789,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5734,6 +5842,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5786,6 +5895,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5836,6 +5946,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5883,6 +5994,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5932,6 +6044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -5985,6 +6098,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6034,6 +6148,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6088,6 +6203,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6140,6 +6256,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6200,6 +6317,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6260,6 +6378,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6310,6 +6429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6357,6 +6477,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6406,6 +6527,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6466,6 +6588,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6526,6 +6649,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6578,6 +6702,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6632,6 +6757,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6686,6 +6812,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6738,6 +6865,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6788,6 +6916,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6835,6 +6964,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6884,6 +7014,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6937,6 +7068,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -6984,6 +7116,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7035,6 +7168,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7087,6 +7221,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7139,6 +7274,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7194,6 +7330,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7244,6 +7381,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7291,6 +7429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7338,6 +7477,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7389,6 +7529,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7444,6 +7585,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -7493,6 +7635,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netatmo/snapshots/test_switch.ambr b/tests/components/netatmo/snapshots/test_switch.ambr index 4244917d86f..f44cbcd22a5 100644 --- a/tests/components/netatmo/snapshots/test_switch.ambr +++ b/tests/components/netatmo/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/netgear_lte/snapshots/test_init.ambr b/tests/components/netgear_lte/snapshots/test_init.ambr index ca65c17cc8e..2a806be8ae1 100644 --- a/tests/components/netgear_lte/snapshots/test_init.ambr +++ b/tests/components/netgear_lte/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.5.1', 'connections': set({ }), diff --git a/tests/components/nextcloud/snapshots/test_binary_sensor.ambr b/tests/components/nextcloud/snapshots/test_binary_sensor.ambr index 1831419af52..578659d411d 100644 --- a/tests/components/nextcloud/snapshots/test_binary_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextcloud/snapshots/test_sensor.ambr b/tests/components/nextcloud/snapshots/test_sensor.ambr index c49ba3496da..d01bcc112bf 100644 --- a/tests/components/nextcloud/snapshots/test_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -155,6 +158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -204,6 +208,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -253,6 +258,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -302,6 +308,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -351,6 +358,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -400,6 +408,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -449,6 +458,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -498,6 +508,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -547,6 +558,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -596,6 +608,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -645,6 +658,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -694,6 +708,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -743,6 +758,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -792,6 +808,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -841,6 +858,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -890,6 +908,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -939,6 +958,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -986,6 +1006,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +1053,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1088,6 +1110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1137,6 +1160,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1186,6 +1210,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1235,6 +1260,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1284,6 +1310,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1331,6 +1358,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1378,6 +1406,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1424,6 +1453,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1474,6 +1504,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1524,6 +1555,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1574,6 +1606,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1628,6 +1661,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1674,6 +1708,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1720,6 +1755,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1774,6 +1810,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1828,6 +1865,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1882,6 +1920,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1936,6 +1975,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1992,6 +2032,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2039,6 +2080,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2093,6 +2135,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2147,6 +2190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2201,6 +2245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2247,6 +2292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2293,6 +2339,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2341,6 +2388,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2391,6 +2439,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2440,6 +2489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2489,6 +2539,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2536,6 +2587,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2586,6 +2638,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2642,6 +2695,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2689,6 +2743,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2741,6 +2796,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2788,6 +2844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2837,6 +2894,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2886,6 +2944,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2935,6 +2994,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2984,6 +3044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3031,6 +3092,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3078,6 +3140,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3132,6 +3195,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3186,6 +3250,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3234,6 +3299,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3288,6 +3354,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3342,6 +3409,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3388,6 +3456,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3444,6 +3513,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3491,6 +3561,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3545,6 +3616,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3591,6 +3663,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3637,6 +3710,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3683,6 +3757,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3729,6 +3804,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3775,6 +3851,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3829,6 +3906,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3885,6 +3963,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3932,6 +4011,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextcloud/snapshots/test_update.ambr b/tests/components/nextcloud/snapshots/test_update.ambr index 484106580b1..a8acd2f5294 100644 --- a/tests/components/nextcloud/snapshots/test_update.ambr +++ b/tests/components/nextcloud/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextdns/snapshots/test_binary_sensor.ambr b/tests/components/nextdns/snapshots/test_binary_sensor.ambr index 814b4c1ac16..65a477f50f3 100644 --- a/tests/components/nextdns/snapshots/test_binary_sensor.ambr +++ b/tests/components/nextdns/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextdns/snapshots/test_button.ambr b/tests/components/nextdns/snapshots/test_button.ambr index 32dc31eea19..3f1f75d1783 100644 --- a/tests/components/nextdns/snapshots/test_button.ambr +++ b/tests/components/nextdns/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 827d6aeb6e5..23f42fee077 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Fake Profile', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/nextdns/snapshots/test_sensor.ambr b/tests/components/nextdns/snapshots/test_sensor.ambr index 14bebea53f8..48c3b0894db 100644 --- a/tests/components/nextdns/snapshots/test_sensor.ambr +++ b/tests/components/nextdns/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -358,6 +365,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -408,6 +416,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -508,6 +518,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +569,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -608,6 +620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -658,6 +671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -708,6 +722,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -758,6 +773,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -808,6 +824,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -858,6 +875,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -908,6 +926,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -958,6 +977,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1008,6 +1028,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1058,6 +1079,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1108,6 +1130,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1158,6 +1181,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1208,6 +1232,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nextdns/snapshots/test_switch.ambr b/tests/components/nextdns/snapshots/test_switch.ambr index 3328e341a2e..e6d63b7f542 100644 --- a/tests/components/nextdns/snapshots/test_switch.ambr +++ b/tests/components/nextdns/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -374,6 +382,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -420,6 +429,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -466,6 +476,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -512,6 +523,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +570,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -604,6 +617,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -650,6 +664,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -696,6 +711,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -742,6 +758,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -788,6 +805,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -834,6 +852,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -880,6 +899,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -926,6 +946,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -972,6 +993,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1018,6 +1040,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1064,6 +1087,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1110,6 +1134,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1156,6 +1181,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1202,6 +1228,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1248,6 +1275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1294,6 +1322,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1340,6 +1369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1386,6 +1416,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1432,6 +1463,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1478,6 +1510,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1524,6 +1557,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1570,6 +1604,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1616,6 +1651,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1662,6 +1698,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1708,6 +1745,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1754,6 +1792,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1800,6 +1839,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1846,6 +1886,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1892,6 +1933,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1938,6 +1980,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1984,6 +2027,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2030,6 +2074,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2076,6 +2121,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2122,6 +2168,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2168,6 +2215,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2214,6 +2262,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2260,6 +2309,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2306,6 +2356,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2352,6 +2403,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2398,6 +2450,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2444,6 +2497,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2490,6 +2544,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2536,6 +2591,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2582,6 +2638,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2628,6 +2685,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2674,6 +2732,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2720,6 +2779,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2766,6 +2826,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2812,6 +2873,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2858,6 +2920,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2904,6 +2967,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2950,6 +3014,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2996,6 +3061,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3042,6 +3108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3088,6 +3155,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3134,6 +3202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3180,6 +3249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3226,6 +3296,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3272,6 +3343,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3318,6 +3390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nice_go/snapshots/test_cover.ambr b/tests/components/nice_go/snapshots/test_cover.ambr index 49b5267df56..0e1f9013a94 100644 --- a/tests/components/nice_go/snapshots/test_cover.ambr +++ b/tests/components/nice_go/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nice_go/snapshots/test_diagnostics.ambr b/tests/components/nice_go/snapshots/test_diagnostics.ambr index f4ba363a421..b33726d2b72 100644 --- a/tests/components/nice_go/snapshots/test_diagnostics.ambr +++ b/tests/components/nice_go/snapshots/test_diagnostics.ambr @@ -60,6 +60,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/nice_go/snapshots/test_light.ambr b/tests/components/nice_go/snapshots/test_light.ambr index 529df95a570..2b88b7d8d74 100644 --- a/tests/components/nice_go/snapshots/test_light.ambr +++ b/tests/components/nice_go/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/niko_home_control/snapshots/test_cover.ambr b/tests/components/niko_home_control/snapshots/test_cover.ambr index 6f99c1adb8c..5fe89497298 100644 --- a/tests/components/niko_home_control/snapshots/test_cover.ambr +++ b/tests/components/niko_home_control/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/niko_home_control/snapshots/test_light.ambr b/tests/components/niko_home_control/snapshots/test_light.ambr index 702b7326ee2..adb0e743786 100644 --- a/tests/components/niko_home_control/snapshots/test_light.ambr +++ b/tests/components/niko_home_control/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index 9b328c3a71d..86aa49357c5 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -107,6 +109,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -207,6 +211,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -259,6 +264,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -306,6 +312,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -358,6 +365,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -410,6 +418,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -463,6 +472,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -516,6 +526,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -567,6 +578,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +626,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +676,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -716,6 +730,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -769,6 +784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -820,6 +836,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -867,6 +884,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -916,6 +934,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -969,6 +988,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1022,6 +1042,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1073,6 +1094,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1120,6 +1142,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1167,6 +1190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1217,6 +1241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1265,6 +1290,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1318,6 +1344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1371,6 +1398,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1418,6 +1446,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1470,6 +1499,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1517,6 +1547,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1569,6 +1600,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1621,6 +1653,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1674,6 +1707,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1727,6 +1761,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1778,6 +1813,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1825,6 +1861,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1874,6 +1911,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1927,6 +1965,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1980,6 +2019,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2031,6 +2071,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2078,6 +2119,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2127,6 +2169,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2180,6 +2223,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2233,6 +2277,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2284,6 +2329,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2331,6 +2377,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2378,6 +2425,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 890ce2dfc4a..c1d1bd1bb2e 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -37,6 +37,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "bridges": [ diff --git a/tests/components/nuki/snapshots/test_binary_sensor.ambr b/tests/components/nuki/snapshots/test_binary_sensor.ambr index 55976bcb433..e48cc55bfb3 100644 --- a/tests/components/nuki/snapshots/test_binary_sensor.ambr +++ b/tests/components/nuki/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nuki/snapshots/test_lock.ambr b/tests/components/nuki/snapshots/test_lock.ambr index 24c80e7b487..2d80110a5cc 100644 --- a/tests/components/nuki/snapshots/test_lock.ambr +++ b/tests/components/nuki/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nuki/snapshots/test_sensor.ambr b/tests/components/nuki/snapshots/test_sensor.ambr index a319104fbc3..5be025727be 100644 --- a/tests/components/nuki/snapshots/test_sensor.ambr +++ b/tests/components/nuki/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index 383bed0e106..d9ce6f15a4d 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index 84b74a26f0d..8201c26739c 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -157,6 +160,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -207,6 +211,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -257,6 +262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -307,6 +313,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -357,6 +364,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -407,6 +415,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -458,6 +467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -509,6 +519,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -559,6 +570,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ohme/snapshots/test_button.ambr b/tests/components/ohme/snapshots/test_button.ambr index 32de16208f4..b276e8c3c42 100644 --- a/tests/components/ohme/snapshots/test_button.ambr +++ b/tests/components/ohme/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ohme/snapshots/test_init.ambr b/tests/components/ohme/snapshots/test_init.ambr index e3ed339b78a..ccf09f546cf 100644 --- a/tests/components/ohme/snapshots/test_init.ambr +++ b/tests/components/ohme/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/ohme/snapshots/test_number.ambr b/tests/components/ohme/snapshots/test_number.ambr index 580082635df..dbcf6134252 100644 --- a/tests/components/ohme/snapshots/test_number.ambr +++ b/tests/components/ohme/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ohme/snapshots/test_select.ambr b/tests/components/ohme/snapshots/test_select.ambr index 04770397098..8eec0556889 100644 --- a/tests/components/ohme/snapshots/test_select.ambr +++ b/tests/components/ohme/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index 6415d720419..b5c3c3b96d5 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -221,6 +225,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +280,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ohme/snapshots/test_switch.ambr b/tests/components/ohme/snapshots/test_switch.ambr index 76066b6e658..49bf5d5709a 100644 --- a/tests/components/ohme/snapshots/test_switch.ambr +++ b/tests/components/ohme/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ohme/snapshots/test_time.ambr b/tests/components/ohme/snapshots/test_time.ambr index 4d9fab20e0b..8c85fc2298e 100644 --- a/tests/components/ohme/snapshots/test_time.ambr +++ b/tests/components/ohme/snapshots/test_time.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/omnilogic/snapshots/test_sensor.ambr b/tests/components/omnilogic/snapshots/test_sensor.ambr index a4ea7f02a03..b6eb07dbe26 100644 --- a/tests/components/omnilogic/snapshots/test_sensor.ambr +++ b/tests/components/omnilogic/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/omnilogic/snapshots/test_switch.ambr b/tests/components/omnilogic/snapshots/test_switch.ambr index a5d77f1adcf..cc1a2e226fc 100644 --- a/tests/components/omnilogic/snapshots/test_switch.ambr +++ b/tests/components/omnilogic/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ondilo_ico/snapshots/test_init.ambr b/tests/components/ondilo_ico/snapshots/test_init.ambr index 44008ac907e..07e56a78fae 100644 --- a/tests/components/ondilo_ico/snapshots/test_init.ambr +++ b/tests/components/ondilo_ico/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/ondilo_ico/snapshots/test_sensor.ambr b/tests/components/ondilo_ico/snapshots/test_sensor.ambr index 56e30cd904a..84a2d3da4cb 100644 --- a/tests/components/ondilo_ico/snapshots/test_sensor.ambr +++ b/tests/components/ondilo_ico/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -109,6 +111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -259,6 +264,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -309,6 +315,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -360,6 +367,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -411,6 +419,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -461,6 +470,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -511,6 +521,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -561,6 +572,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -611,6 +623,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -661,6 +674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index d94eda5b7c3..10122ba8685 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -390,6 +398,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -438,6 +447,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -486,6 +496,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +545,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -582,6 +594,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -631,6 +644,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -680,6 +694,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -729,6 +744,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/onewire/snapshots/test_init.ambr b/tests/components/onewire/snapshots/test_init.ambr index 5666dab6383..9b2a0e00a62 100644 --- a/tests/components/onewire/snapshots/test_init.ambr +++ b/tests/components/onewire/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -99,6 +102,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -131,6 +135,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -163,6 +168,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -195,6 +201,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -227,6 +234,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -259,6 +267,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -291,6 +300,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -323,6 +333,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -355,6 +366,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -387,6 +399,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -419,6 +432,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -451,6 +465,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -483,6 +498,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -515,6 +531,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -547,6 +564,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -579,6 +597,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -611,6 +630,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -643,6 +663,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -675,6 +696,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr index 7c4027cd046..a896d946841 100644 --- a/tests/components/onewire/snapshots/test_select.ambr +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index b963e29d160..eca459b4c57 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -61,6 +62,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -114,6 +116,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -167,6 +170,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +222,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -269,6 +274,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -322,6 +328,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -375,6 +382,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +436,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,6 +490,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +544,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -587,6 +598,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -640,6 +652,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -693,6 +706,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -746,6 +760,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -799,6 +814,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -852,6 +868,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -905,6 +922,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -958,6 +976,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1011,6 +1030,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1064,6 +1084,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1117,6 +1138,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1170,6 +1192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1223,6 +1246,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1276,6 +1300,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1329,6 +1354,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1382,6 +1408,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1435,6 +1462,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1488,6 +1516,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1541,6 +1570,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1594,6 +1624,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1647,6 +1678,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1700,6 +1732,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1753,6 +1786,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1806,6 +1840,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1859,6 +1894,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1912,6 +1948,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1965,6 +2002,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2018,6 +2056,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2071,6 +2110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2124,6 +2164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2177,6 +2218,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2230,6 +2272,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2283,6 +2326,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2336,6 +2380,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2389,6 +2434,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2442,6 +2488,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2495,6 +2542,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2548,6 +2596,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2601,6 +2650,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2654,6 +2704,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2707,6 +2758,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2760,6 +2812,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2813,6 +2866,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2866,6 +2920,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2919,6 +2974,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2972,6 +3028,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3025,6 +3082,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index cb752982bec..8be414c7c1e 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -390,6 +398,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -438,6 +447,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -486,6 +496,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +545,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -582,6 +594,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -630,6 +643,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -678,6 +692,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -726,6 +741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -774,6 +790,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -822,6 +839,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -870,6 +888,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -918,6 +937,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -966,6 +986,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1014,6 +1035,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1062,6 +1084,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1110,6 +1133,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1158,6 +1182,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1206,6 +1231,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1254,6 +1280,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1302,6 +1329,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1350,6 +1378,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1398,6 +1427,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1446,6 +1476,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1494,6 +1525,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1542,6 +1574,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1590,6 +1623,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1638,6 +1672,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1686,6 +1721,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1734,6 +1770,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr index c8a9ff75d62..c3938efcbb6 100644 --- a/tests/components/onvif/snapshots/test_diagnostics.ambr +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -24,6 +24,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'aa:bb:cc:dd:ee:ff', 'version': 1, diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 61b68b5ad90..03b392b3e7b 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -39,6 +39,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "protection_window": { diff --git a/tests/components/osoenergy/snapshots/test_water_heater.ambr b/tests/components/osoenergy/snapshots/test_water_heater.ambr index 5ebac405144..92b3a7aa099 100644 --- a/tests/components/osoenergy/snapshots/test_water_heater.ambr +++ b/tests/components/osoenergy/snapshots/test_water_heater.ambr @@ -9,6 +9,7 @@ 'min_temp': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/overseerr/snapshots/test_event.ambr b/tests/components/overseerr/snapshots/test_event.ambr index 1002bc4cdad..8a7be6c463d 100644 --- a/tests/components/overseerr/snapshots/test_event.ambr +++ b/tests/components/overseerr/snapshots/test_event.ambr @@ -15,6 +15,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/overseerr/snapshots/test_init.ambr b/tests/components/overseerr/snapshots/test_init.ambr index 21b4b215ac5..2709f532ef6 100644 --- a/tests/components/overseerr/snapshots/test_init.ambr +++ b/tests/components/overseerr/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://overseerr.test', 'connections': set({ }), diff --git a/tests/components/overseerr/snapshots/test_sensor.ambr b/tests/components/overseerr/snapshots/test_sensor.ambr index 53a9b3dd82a..bbee260b782 100644 --- a/tests/components/overseerr/snapshots/test_sensor.ambr +++ b/tests/components/overseerr/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -258,6 +263,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -308,6 +314,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/p1_monitor/snapshots/test_init.ambr b/tests/components/p1_monitor/snapshots/test_init.ambr index d0a676fce1b..83684e153c9 100644 --- a/tests/components/p1_monitor/snapshots/test_init.ambr +++ b/tests/components/p1_monitor/snapshots/test_init.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'unique_thingy', 'version': 2, @@ -38,6 +40,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'unique_thingy', 'version': 2, diff --git a/tests/components/palazzetti/snapshots/test_button.ambr b/tests/components/palazzetti/snapshots/test_button.ambr index 6827c9a1f22..8130f0a0ec7 100644 --- a/tests/components/palazzetti/snapshots/test_button.ambr +++ b/tests/components/palazzetti/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/palazzetti/snapshots/test_climate.ambr b/tests/components/palazzetti/snapshots/test_climate.ambr index aa637039df9..cf23cb87ccb 100644 --- a/tests/components/palazzetti/snapshots/test_climate.ambr +++ b/tests/components/palazzetti/snapshots/test_climate.ambr @@ -23,6 +23,7 @@ 'target_temp_step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/palazzetti/snapshots/test_init.ambr b/tests/components/palazzetti/snapshots/test_init.ambr index abdee6b7f6f..fc96cab4fad 100644 --- a/tests/components/palazzetti/snapshots/test_init.ambr +++ b/tests/components/palazzetti/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/palazzetti/snapshots/test_number.ambr b/tests/components/palazzetti/snapshots/test_number.ambr index 7ace1149e0a..1d40e9e4b6b 100644 --- a/tests/components/palazzetti/snapshots/test_number.ambr +++ b/tests/components/palazzetti/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -123,6 +125,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/palazzetti/snapshots/test_sensor.ambr b/tests/components/palazzetti/snapshots/test_sensor.ambr index aa98f3a4f59..6bf4f68c1fa 100644 --- a/tests/components/palazzetti/snapshots/test_sensor.ambr +++ b/tests/components/palazzetti/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +368,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -460,6 +467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -511,6 +519,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_binary_sensor.ambr b/tests/components/peblar/snapshots/test_binary_sensor.ambr index 72c3ac78a12..9ad9c877ed2 100644 --- a/tests/components/peblar/snapshots/test_binary_sensor.ambr +++ b/tests/components/peblar/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_button.ambr b/tests/components/peblar/snapshots/test_button.ambr index 96aab5c93ef..6d31da0ae52 100644 --- a/tests/components/peblar/snapshots/test_button.ambr +++ b/tests/components/peblar/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_init.ambr b/tests/components/peblar/snapshots/test_init.ambr index ba79093b3ec..8a7cefc523d 100644 --- a/tests/components/peblar/snapshots/test_init.ambr +++ b/tests/components/peblar/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.127', 'connections': set({ tuple( diff --git a/tests/components/peblar/snapshots/test_number.ambr b/tests/components/peblar/snapshots/test_number.ambr index d78067849f3..d8e9c756c50 100644 --- a/tests/components/peblar/snapshots/test_number.ambr +++ b/tests/components/peblar/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_select.ambr b/tests/components/peblar/snapshots/test_select.ambr index 62e09325601..3a600653a84 100644 --- a/tests/components/peblar/snapshots/test_select.ambr +++ b/tests/components/peblar/snapshots/test_select.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_sensor.ambr b/tests/components/peblar/snapshots/test_sensor.ambr index bb1a3eb34d6..5a1d1663ba2 100644 --- a/tests/components/peblar/snapshots/test_sensor.ambr +++ b/tests/components/peblar/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +240,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -311,6 +316,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -379,6 +385,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -430,6 +437,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,6 +489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -532,6 +541,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -583,6 +593,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -648,6 +659,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +716,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -753,6 +766,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -804,6 +818,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -855,6 +870,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_switch.ambr b/tests/components/peblar/snapshots/test_switch.ambr index 426b48b6838..46051974339 100644 --- a/tests/components/peblar/snapshots/test_switch.ambr +++ b/tests/components/peblar/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/peblar/snapshots/test_update.ambr b/tests/components/peblar/snapshots/test_update.ambr index de8bb63150d..0a6b2bf069f 100644 --- a/tests/components/peblar/snapshots/test_update.ambr +++ b/tests/components/peblar/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/pegel_online/snapshots/test_diagnostics.ambr b/tests/components/pegel_online/snapshots/test_diagnostics.ambr index 1e55805f867..d0fdc81acb4 100644 --- a/tests/components/pegel_online/snapshots/test_diagnostics.ambr +++ b/tests/components/pegel_online/snapshots/test_diagnostics.ambr @@ -31,6 +31,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '70272185-xxxx-xxxx-xxxx-43bea330dcae', 'version': 1, diff --git a/tests/components/philips_js/snapshots/test_diagnostics.ambr b/tests/components/philips_js/snapshots/test_diagnostics.ambr index 4f7a6176634..53db95f0534 100644 --- a/tests/components/philips_js/snapshots/test_diagnostics.ambr +++ b/tests/components/philips_js/snapshots/test_diagnostics.ambr @@ -94,6 +94,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 80d05961813..4b8048a8ebe 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -155,6 +155,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) "version": 1, "options": {}, "minor_version": 1, + "subentries": (), } await hass.async_block_till_done() diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 3094fcef24b..2d6f6687d04 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -33,6 +33,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr index 0196c2cbbfb..bb28432841f 100644 --- a/tests/components/ping/snapshots/test_binary_sensor.ambr +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ping/snapshots/test_sensor.ambr b/tests/components/ping/snapshots/test_sensor.ambr index d1548f7559c..bb811af6a34 100644 --- a/tests/components/ping/snapshots/test_sensor.ambr +++ b/tests/components/ping/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -114,6 +116,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/plaato/snapshots/test_binary_sensor.ambr b/tests/components/plaato/snapshots/test_binary_sensor.ambr index e8db3bf32d8..76c0a299c5e 100644 --- a/tests/components/plaato/snapshots/test_binary_sensor.ambr +++ b/tests/components/plaato/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/plaato/snapshots/test_sensor.ambr b/tests/components/plaato/snapshots/test_sensor.ambr index 110ffb04ba9..24ba62e28ca 100644 --- a/tests/components/plaato/snapshots/test_sensor.ambr +++ b/tests/components/plaato/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -286,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -333,6 +340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -379,6 +387,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -478,6 +488,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -528,6 +539,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/poolsense/snapshots/test_binary_sensor.ambr b/tests/components/poolsense/snapshots/test_binary_sensor.ambr index 8a6d39332d4..b3d99b95308 100644 --- a/tests/components/poolsense/snapshots/test_binary_sensor.ambr +++ b/tests/components/poolsense/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/poolsense/snapshots/test_sensor.ambr b/tests/components/poolsense/snapshots/test_sensor.ambr index 9029f1f24aa..c0066ba9396 100644 --- a/tests/components/poolsense/snapshots/test_sensor.ambr +++ b/tests/components/poolsense/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -151,6 +154,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -199,6 +203,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -247,6 +252,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -295,6 +301,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/powerfox/snapshots/test_sensor.ambr b/tests/components/powerfox/snapshots/test_sensor.ambr index a2aa8a9c72c..bae306ccabc 100644 --- a/tests/components/powerfox/snapshots/test_sensor.ambr +++ b/tests/components/powerfox/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -107,6 +109,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -209,6 +213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -311,6 +317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -413,6 +421,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -464,6 +473,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -515,6 +525,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index 3d9673ffd90..42ec74710f9 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -102,6 +102,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'home', 'unique_id': 'proximity_home', 'version': 1, diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index ede6b3b5147..4cde723a28f 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -52,6 +52,7 @@ MOCK_FLOW_RESULT = { "title": "test_ps4", "data": MOCK_DATA, "options": {}, + "subentries": (), } MOCK_ENTRY_ID = "SomeID" diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index ae4b28567be..6271a63d652 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -38,6 +38,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": { "fields": [ diff --git a/tests/components/pyload/snapshots/test_button.ambr b/tests/components/pyload/snapshots/test_button.ambr index bf1e1f59c98..57a0358da42 100644 --- a/tests/components/pyload/snapshots/test_button.ambr +++ b/tests/components/pyload/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/pyload/snapshots/test_sensor.ambr b/tests/components/pyload/snapshots/test_sensor.ambr index 69d0387fc8f..25abe62017d 100644 --- a/tests/components/pyload/snapshots/test_sensor.ambr +++ b/tests/components/pyload/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -216,6 +220,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -266,6 +271,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -316,6 +322,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -364,6 +371,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -418,6 +426,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -474,6 +483,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -524,6 +534,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -574,6 +585,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -622,6 +634,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -676,6 +689,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -732,6 +746,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -782,6 +797,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -832,6 +848,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -880,6 +897,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -934,6 +952,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -990,6 +1009,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/pyload/snapshots/test_switch.ambr b/tests/components/pyload/snapshots/test_switch.ambr index 0fcc45f8586..479013b09e4 100644 --- a/tests/components/pyload/snapshots/test_switch.ambr +++ b/tests/components/pyload/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr b/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr index e131bf3d952..abf8e380916 100644 --- a/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr +++ b/tests/components/rainforest_raven/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, @@ -84,6 +86,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/rainforest_raven/snapshots/test_init.ambr b/tests/components/rainforest_raven/snapshots/test_init.ambr index 768bbc729d4..8a143f9963f 100644 --- a/tests/components/rainforest_raven/snapshots/test_init.ambr +++ b/tests/components/rainforest_raven/snapshots/test_init.ambr @@ -8,6 +8,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/rainforest_raven/snapshots/test_sensor.ambr b/tests/components/rainforest_raven/snapshots/test_sensor.ambr index 34a5e031885..618766c1613 100644 --- a/tests/components/rainforest_raven/snapshots/test_sensor.ambr +++ b/tests/components/rainforest_raven/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -213,6 +217,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_binary_sensor.ambr b/tests/components/rainmachine/snapshots/test_binary_sensor.ambr index 9c930736fe3..c4d6f2eeae1 100644 --- a/tests/components/rainmachine/snapshots/test_binary_sensor.ambr +++ b/tests/components/rainmachine/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_button.ambr b/tests/components/rainmachine/snapshots/test_button.ambr index 609079bb0d8..68f83d9286a 100644 --- a/tests/components/rainmachine/snapshots/test_button.ambr +++ b/tests/components/rainmachine/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_diagnostics.ambr b/tests/components/rainmachine/snapshots/test_diagnostics.ambr index acd5fd165b4..681805996f1 100644 --- a/tests/components/rainmachine/snapshots/test_diagnostics.ambr +++ b/tests/components/rainmachine/snapshots/test_diagnostics.ambr @@ -1144,6 +1144,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 2, @@ -2275,6 +2277,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/rainmachine/snapshots/test_select.ambr b/tests/components/rainmachine/snapshots/test_select.ambr index 651a709d2fa..d150f8c31b5 100644 --- a/tests/components/rainmachine/snapshots/test_select.ambr +++ b/tests/components/rainmachine/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_sensor.ambr b/tests/components/rainmachine/snapshots/test_sensor.ambr index e93d0645030..2475abecb51 100644 --- a/tests/components/rainmachine/snapshots/test_sensor.ambr +++ b/tests/components/rainmachine/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -242,6 +247,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -289,6 +295,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -336,6 +343,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -383,6 +391,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -430,6 +439,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -477,6 +487,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -524,6 +535,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -571,6 +583,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -618,6 +631,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -665,6 +679,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rainmachine/snapshots/test_switch.ambr b/tests/components/rainmachine/snapshots/test_switch.ambr index b803ff994d4..d40913a7eb0 100644 --- a/tests/components/rainmachine/snapshots/test_switch.ambr +++ b/tests/components/rainmachine/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -78,6 +79,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -126,6 +128,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -173,6 +176,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -234,6 +238,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +287,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -329,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -390,6 +397,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -438,6 +446,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -510,6 +519,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +568,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -619,6 +630,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -667,6 +679,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -728,6 +741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -776,6 +790,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -837,6 +852,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -885,6 +901,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -946,6 +963,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -994,6 +1012,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1055,6 +1074,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1103,6 +1123,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1164,6 +1185,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1212,6 +1234,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1273,6 +1296,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1321,6 +1345,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1382,6 +1407,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1430,6 +1456,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1491,6 +1518,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1539,6 +1567,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1600,6 +1629,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index 24c690bcb37..a57e289ec04 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -34,6 +34,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "data": [ { diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index 7142608b977..b62cfb4d1b1 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -72,6 +74,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -134,6 +138,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -165,6 +170,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -196,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +311,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -341,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -372,6 +381,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -403,6 +413,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -434,6 +445,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -465,6 +477,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -496,6 +509,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -527,6 +541,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -558,6 +573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -690,6 +706,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -727,6 +744,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -758,6 +776,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -789,6 +808,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -860,6 +880,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -897,6 +918,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -928,6 +950,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -959,6 +982,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -990,6 +1014,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1021,6 +1046,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1052,6 +1078,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1083,6 +1110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1114,6 +1142,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1145,6 +1174,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1288,6 +1318,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1325,6 +1356,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1356,6 +1388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1387,6 +1420,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1418,6 +1452,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1449,6 +1484,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1480,6 +1516,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1588,6 +1625,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1625,6 +1663,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1656,6 +1695,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1687,6 +1727,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1718,6 +1759,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1749,6 +1791,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1780,6 +1823,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1811,6 +1855,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1842,6 +1887,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1974,6 +2020,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -2011,6 +2058,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2042,6 +2090,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2073,6 +2122,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2144,6 +2194,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -2181,6 +2232,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2212,6 +2264,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2243,6 +2296,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2274,6 +2328,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2305,6 +2360,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2336,6 +2392,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2367,6 +2424,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2398,6 +2456,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2429,6 +2488,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index e61255372c1..58789c7aa47 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -88,6 +90,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -125,6 +128,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +160,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -187,6 +192,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +262,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -293,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -324,6 +332,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -355,6 +364,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -424,6 +434,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -461,6 +472,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -492,6 +504,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -523,6 +536,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -592,6 +606,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -629,6 +644,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -676,6 +692,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -713,6 +730,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -744,6 +762,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -775,6 +794,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -844,6 +864,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -881,6 +902,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -912,6 +934,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -943,6 +966,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1012,6 +1036,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1049,6 +1074,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1080,6 +1106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1111,6 +1138,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index f90cb92cc63..119defca4ac 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -89,6 +91,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -126,6 +129,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -174,6 +178,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -216,6 +221,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -253,6 +259,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -301,6 +308,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -338,6 +346,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -389,6 +398,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -426,6 +436,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -477,6 +488,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -519,6 +531,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -556,6 +569,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index 9974e21be75..526c8af5bc4 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -46,6 +47,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -90,6 +92,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -143,6 +146,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -187,6 +191,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +245,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -284,6 +290,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -337,6 +344,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -379,6 +387,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -423,6 +432,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +486,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -520,6 +531,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -573,6 +585,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -617,6 +630,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index b092222c9f3..175ad2422ed 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -43,6 +44,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -76,6 +78,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -109,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -140,6 +144,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -171,6 +176,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -202,6 +208,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +321,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -353,6 +361,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -395,6 +404,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +438,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -461,6 +472,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -500,6 +512,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -533,6 +546,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -566,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -599,6 +614,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -630,6 +646,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +680,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -696,6 +714,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -729,6 +748,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -760,6 +780,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -791,6 +812,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -822,6 +844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1071,6 +1094,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1110,6 +1134,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1152,6 +1177,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1185,6 +1211,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1218,6 +1245,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1257,6 +1285,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1290,6 +1319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1323,6 +1353,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1356,6 +1387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1387,6 +1419,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1420,6 +1453,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1453,6 +1487,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1484,6 +1519,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1515,6 +1551,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1546,6 +1583,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1577,6 +1615,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1824,6 +1863,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1863,6 +1903,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1905,6 +1946,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1938,6 +1980,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1971,6 +2014,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2010,6 +2054,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2043,6 +2088,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2076,6 +2122,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2109,6 +2156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2140,6 +2188,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2173,6 +2222,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2206,6 +2256,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2237,6 +2288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2268,6 +2320,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2299,6 +2352,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2330,6 +2384,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2361,6 +2416,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2620,6 +2676,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -2659,6 +2716,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2692,6 +2750,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2725,6 +2784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2756,6 +2816,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -2787,6 +2848,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2818,6 +2880,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -2930,6 +2993,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -2969,6 +3033,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3011,6 +3076,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3044,6 +3110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3077,6 +3144,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3116,6 +3184,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3149,6 +3218,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3182,6 +3252,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3215,6 +3286,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3246,6 +3318,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -3279,6 +3352,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3312,6 +3386,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3345,6 +3420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3376,6 +3452,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -3407,6 +3484,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3438,6 +3516,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -3687,6 +3766,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -3726,6 +3806,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3768,6 +3849,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3801,6 +3883,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3834,6 +3917,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3873,6 +3957,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3906,6 +3991,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3939,6 +4025,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3972,6 +4059,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4003,6 +4091,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4036,6 +4125,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4069,6 +4159,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4100,6 +4191,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4131,6 +4223,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4162,6 +4255,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4193,6 +4287,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4440,6 +4535,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -4479,6 +4575,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4521,6 +4618,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4554,6 +4652,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4587,6 +4686,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4626,6 +4726,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4659,6 +4760,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4692,6 +4794,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4725,6 +4828,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4756,6 +4860,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4789,6 +4894,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4822,6 +4928,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4853,6 +4960,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4884,6 +4992,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4915,6 +5024,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -4946,6 +5056,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4977,6 +5088,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr index b03d87c7a89..4b4dda7227d 100644 --- a/tests/components/ridwell/snapshots/test_diagnostics.ambr +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -44,6 +44,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 2, diff --git a/tests/components/ring/snapshots/test_binary_sensor.ambr b/tests/components/ring/snapshots/test_binary_sensor.ambr index 84c727e6340..09dab9b0ecc 100644 --- a/tests/components/ring/snapshots/test_binary_sensor.ambr +++ b/tests/components/ring/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_button.ambr b/tests/components/ring/snapshots/test_button.ambr index 01f6525450b..7da11d66194 100644 --- a/tests/components/ring/snapshots/test_button.ambr +++ b/tests/components/ring/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_camera.ambr b/tests/components/ring/snapshots/test_camera.ambr index ec285b438b3..8c3b8a083b0 100644 --- a/tests/components/ring/snapshots/test_camera.ambr +++ b/tests/components/ring/snapshots/test_camera.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -112,6 +114,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -164,6 +167,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -217,6 +221,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -270,6 +275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_event.ambr b/tests/components/ring/snapshots/test_event.ambr index e97a01516bb..9c0fee906a0 100644 --- a/tests/components/ring/snapshots/test_event.ambr +++ b/tests/components/ring/snapshots/test_event.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -178,6 +181,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -234,6 +238,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +295,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_light.ambr b/tests/components/ring/snapshots/test_light.ambr index 73874fda259..6c6effb93c1 100644 --- a/tests/components/ring/snapshots/test_light.ambr +++ b/tests/components/ring/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_number.ambr b/tests/components/ring/snapshots/test_number.ambr index 0873319b837..abc63051f6a 100644 --- a/tests/components/ring/snapshots/test_number.ambr +++ b/tests/components/ring/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -123,6 +125,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -235,6 +239,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -291,6 +296,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -347,6 +353,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_sensor.ambr b/tests/components/ring/snapshots/test_sensor.ambr index a90bb3fe5f6..615bd1df018 100644 --- a/tests/components/ring/snapshots/test_sensor.ambr +++ b/tests/components/ring/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -151,6 +154,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -203,6 +207,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -253,6 +258,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -301,6 +307,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +356,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -397,6 +405,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -444,6 +453,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -491,6 +501,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -540,6 +551,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -588,6 +600,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -636,6 +649,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -684,6 +698,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -731,6 +746,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -782,6 +798,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -832,6 +849,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -879,6 +897,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -927,6 +946,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -974,6 +994,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1021,6 +1042,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1068,6 +1090,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1119,6 +1142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1169,6 +1193,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1217,6 +1242,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1265,6 +1291,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1313,6 +1340,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1360,6 +1388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_siren.ambr b/tests/components/ring/snapshots/test_siren.ambr index c49ab2cb30f..8ef08815a1e 100644 --- a/tests/components/ring/snapshots/test_siren.ambr +++ b/tests/components/ring/snapshots/test_siren.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -63,6 +64,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/ring/snapshots/test_switch.ambr b/tests/components/ring/snapshots/test_switch.ambr index 57c27cfedfa..8c7c55d5169 100644 --- a/tests/components/ring/snapshots/test_switch.ambr +++ b/tests/components/ring/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/rova/snapshots/test_init.ambr b/tests/components/rova/snapshots/test_init.ambr index 5e607e6a8df..8eb77006061 100644 --- a/tests/components/rova/snapshots/test_init.ambr +++ b/tests/components/rova/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/rova/snapshots/test_sensor.ambr b/tests/components/rova/snapshots/test_sensor.ambr index 866f1c735c1..90cf29a1b89 100644 --- a/tests/components/rova/snapshots/test_sensor.ambr +++ b/tests/components/rova/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr index c92f06c4bc0..e3185a06b24 100644 --- a/tests/components/russound_rio/snapshots/test_init.ambr +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.20.75', 'connections': set({ tuple( diff --git a/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr index 9f3087df3d1..1feaece1c3e 100644 --- a/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr +++ b/tests/components/sabnzbd/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sabnzbd/snapshots/test_button.ambr b/tests/components/sabnzbd/snapshots/test_button.ambr index 9b965e10518..f09bb44e8e4 100644 --- a/tests/components/sabnzbd/snapshots/test_button.ambr +++ b/tests/components/sabnzbd/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sabnzbd/snapshots/test_number.ambr b/tests/components/sabnzbd/snapshots/test_number.ambr index 6a370797264..623002470b7 100644 --- a/tests/components/sabnzbd/snapshots/test_number.ambr +++ b/tests/components/sabnzbd/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sabnzbd/snapshots/test_sensor.ambr b/tests/components/sabnzbd/snapshots/test_sensor.ambr index 8b977e69aa6..893d270a569 100644 --- a/tests/components/sabnzbd/snapshots/test_sensor.ambr +++ b/tests/components/sabnzbd/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -62,6 +63,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -164,6 +167,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +222,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -272,6 +277,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -323,6 +329,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -375,6 +382,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -430,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -478,6 +487,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -529,6 +539,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index 017a2bc3e60..ad01b5454ff 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -46,6 +47,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -115,6 +117,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 0319d5dd8dd..e8e0b699a7e 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -51,6 +51,7 @@ async def test_entry_diagnostics( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, @@ -91,6 +92,7 @@ async def test_entry_diagnostics_encrypted( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, @@ -130,6 +132,7 @@ async def test_entry_diagnostics_encrypte_offline( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", + "subentries": [], "title": "Mock Title", "unique_id": "any", "version": 2, diff --git a/tests/components/sanix/snapshots/test_sensor.ambr b/tests/components/sanix/snapshots/test_sensor.ambr index 84c97ce68b1..6cf0254b66b 100644 --- a/tests/components/sanix/snapshots/test_sensor.ambr +++ b/tests/components/sanix/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -105,6 +107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +159,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -204,6 +208,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -251,6 +256,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/schlage/snapshots/test_init.ambr b/tests/components/schlage/snapshots/test_init.ambr index c7049443ab7..a7f94b80038 100644 --- a/tests/components/schlage/snapshots/test_init.ambr +++ b/tests/components/schlage/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index 237d3eab257..c7db7a33959 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Pentair: DD-EE-FF', 'unique_id': 'aa:bb:cc:dd:ee:ff', 'version': 1, diff --git a/tests/components/sense/snapshots/test_binary_sensor.ambr b/tests/components/sense/snapshots/test_binary_sensor.ambr index 339830b16d3..7221a0bc518 100644 --- a/tests/components/sense/snapshots/test_binary_sensor.ambr +++ b/tests/components/sense/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sense/snapshots/test_sensor.ambr b/tests/components/sense/snapshots/test_sensor.ambr index 4a3507880a1..0a68553cf04 100644 --- a/tests/components/sense/snapshots/test_sensor.ambr +++ b/tests/components/sense/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -120,6 +122,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -176,6 +179,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -229,6 +233,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -285,6 +290,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -341,6 +347,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -397,6 +404,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -453,6 +461,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -509,6 +518,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -562,6 +572,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -618,6 +629,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -674,6 +686,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -727,6 +740,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -780,6 +794,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -831,6 +846,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -881,6 +897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -932,6 +949,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -982,6 +1000,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1035,6 +1054,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1088,6 +1108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1141,6 +1162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1192,6 +1214,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1242,6 +1265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1293,6 +1317,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1343,6 +1368,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1396,6 +1422,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1448,6 +1475,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1500,6 +1528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1552,6 +1581,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1605,6 +1635,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1658,6 +1689,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1709,6 +1741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1759,6 +1792,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1810,6 +1844,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1860,6 +1895,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1913,6 +1949,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1965,6 +2002,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2018,6 +2056,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2071,6 +2110,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2122,6 +2162,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2172,6 +2213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2223,6 +2265,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2273,6 +2316,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2326,6 +2370,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2379,6 +2424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2432,6 +2478,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2483,6 +2530,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2533,6 +2581,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2584,6 +2633,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2634,6 +2684,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_binary_sensor.ambr b/tests/components/sensibo/snapshots/test_binary_sensor.ambr index 110a6ae8174..2e62c73acb4 100644 --- a/tests/components/sensibo/snapshots/test_binary_sensor.ambr +++ b/tests/components/sensibo/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -381,6 +389,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -475,6 +485,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -522,6 +533,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +581,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -616,6 +629,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -663,6 +677,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_button.ambr b/tests/components/sensibo/snapshots/test_button.ambr index 7ef6d56c714..6bfc4a5a44f 100644 --- a/tests/components/sensibo/snapshots/test_button.ambr +++ b/tests/components/sensibo/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_climate.ambr b/tests/components/sensibo/snapshots/test_climate.ambr index 5bcfae0917e..e3bd456ad23 100644 --- a/tests/components/sensibo/snapshots/test_climate.ambr +++ b/tests/components/sensibo/snapshots/test_climate.ambr @@ -13,6 +13,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -94,6 +95,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -185,6 +187,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_entity.ambr b/tests/components/sensibo/snapshots/test_entity.ambr index 23ead2f6d96..80ee847cb55 100644 --- a/tests/components/sensibo/snapshots/test_entity.ambr +++ b/tests/components/sensibo/snapshots/test_entity.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'hallway', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -38,6 +39,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'kitchen', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -72,6 +74,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'bedroom', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ tuple( @@ -106,6 +109,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://home.sensibo.com/', 'connections': set({ }), diff --git a/tests/components/sensibo/snapshots/test_number.ambr b/tests/components/sensibo/snapshots/test_number.ambr index b632b95f1be..458c7ca7183 100644 --- a/tests/components/sensibo/snapshots/test_number.ambr +++ b/tests/components/sensibo/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -125,6 +127,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +185,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +243,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +301,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_select.ambr b/tests/components/sensibo/snapshots/test_select.ambr index 7438fb70140..05582a1ea16 100644 --- a/tests/components/sensibo/snapshots/test_select.ambr +++ b/tests/components/sensibo/snapshots/test_select.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_sensor.ambr b/tests/components/sensibo/snapshots/test_sensor.ambr index 31e579d9929..bfd5f2d3e9a 100644 --- a/tests/components/sensibo/snapshots/test_sensor.ambr +++ b/tests/components/sensibo/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +222,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +280,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -321,6 +327,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -370,6 +377,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -421,6 +429,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -472,6 +481,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -523,6 +533,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -574,6 +585,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -623,6 +635,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -672,6 +685,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -725,6 +739,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -777,6 +792,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_switch.ambr b/tests/components/sensibo/snapshots/test_switch.ambr index 13cb73cef7a..e0ea140eb37 100644 --- a/tests/components/sensibo/snapshots/test_switch.ambr +++ b/tests/components/sensibo/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sensibo/snapshots/test_update.ambr b/tests/components/sensibo/snapshots/test_update.ambr index 3eb69c9a812..c113d5615b1 100644 --- a/tests/components/sensibo/snapshots/test_update.ambr +++ b/tests/components/sensibo/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index 15308fad91f..4718abc02b5 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -72,6 +74,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -132,6 +135,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ }), @@ -169,6 +173,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -200,6 +205,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index 67b2198fd2b..68a1e7f7227 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ }), @@ -41,6 +42,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 7645a4ad8bf..6376ef24ce2 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.1', 'connections': set({ }), @@ -48,6 +49,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -79,6 +81,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -110,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -149,6 +153,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -180,6 +185,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -211,6 +217,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -242,6 +249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -275,6 +283,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -308,6 +317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -341,6 +351,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -374,6 +385,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -407,6 +419,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -440,6 +453,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -480,6 +494,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -524,6 +539,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , diff --git a/tests/components/shelly/snapshots/test_binary_sensor.ambr b/tests/components/shelly/snapshots/test_binary_sensor.ambr index 942bcaad8ab..fcc6377837e 100644 --- a/tests/components/shelly/snapshots/test_binary_sensor.ambr +++ b/tests/components/shelly/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/shelly/snapshots/test_event.ambr b/tests/components/shelly/snapshots/test_event.ambr index 51129b7e249..ae719774aee 100644 --- a/tests/components/shelly/snapshots/test_event.ambr +++ b/tests/components/shelly/snapshots/test_event.ambr @@ -11,6 +11,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/shelly/snapshots/test_number.ambr b/tests/components/shelly/snapshots/test_number.ambr index 811101abe21..07fda999556 100644 --- a/tests/components/shelly/snapshots/test_number.ambr +++ b/tests/components/shelly/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr index 8ab767ca889..cb39b148c8a 100644 --- a/tests/components/shelly/snapshots/test_sensor.ambr +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/simplefin/snapshots/test_binary_sensor.ambr b/tests/components/simplefin/snapshots/test_binary_sensor.ambr index 44fe2a10b78..3123100205e 100644 --- a/tests/components/simplefin/snapshots/test_binary_sensor.ambr +++ b/tests/components/simplefin/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/simplefin/snapshots/test_sensor.ambr b/tests/components/simplefin/snapshots/test_sensor.ambr index c7dced9300e..dd305f7528f 100644 --- a/tests/components/simplefin/snapshots/test_sensor.ambr +++ b/tests/components/simplefin/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -109,6 +111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -160,6 +163,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -210,6 +214,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -261,6 +266,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -311,6 +317,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -362,6 +369,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -412,6 +420,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -463,6 +472,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -513,6 +523,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -564,6 +575,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -614,6 +626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -665,6 +678,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -715,6 +729,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -766,6 +781,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index d5479f00b06..13c1e28aa36 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -32,6 +32,7 @@ async def test_entry_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, "subscription_data": { "12345": { diff --git a/tests/components/slide_local/snapshots/test_button.ambr b/tests/components/slide_local/snapshots/test_button.ambr index 549538f1361..7b363f4d9ba 100644 --- a/tests/components/slide_local/snapshots/test_button.ambr +++ b/tests/components/slide_local/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/slide_local/snapshots/test_cover.ambr b/tests/components/slide_local/snapshots/test_cover.ambr index d9283618a47..172f5411a94 100644 --- a/tests/components/slide_local/snapshots/test_cover.ambr +++ b/tests/components/slide_local/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/slide_local/snapshots/test_diagnostics.ambr b/tests/components/slide_local/snapshots/test_diagnostics.ambr index 63dab3f5a66..7606c2a399b 100644 --- a/tests/components/slide_local/snapshots/test_diagnostics.ambr +++ b/tests/components/slide_local/snapshots/test_diagnostics.ambr @@ -19,6 +19,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'slide', 'unique_id': '12:34:56:78:90:ab', 'version': 1, diff --git a/tests/components/slide_local/snapshots/test_init.ambr b/tests/components/slide_local/snapshots/test_init.ambr index d90f72e4b05..5b1a9f5ce2f 100644 --- a/tests/components/slide_local/snapshots/test_init.ambr +++ b/tests/components/slide_local/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.2', 'connections': set({ tuple( diff --git a/tests/components/slide_local/snapshots/test_switch.ambr b/tests/components/slide_local/snapshots/test_switch.ambr index e19467c283e..9b1a7969539 100644 --- a/tests/components/slide_local/snapshots/test_switch.ambr +++ b/tests/components/slide_local/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sma/snapshots/test_diagnostics.ambr b/tests/components/sma/snapshots/test_diagnostics.ambr index c7de3851b5f..14b0d120190 100644 --- a/tests/components/sma/snapshots/test_diagnostics.ambr +++ b/tests/components/sma/snapshots/test_diagnostics.ambr @@ -21,6 +21,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'import', + 'subentries': list([ + ]), 'title': 'SMA Device Name', 'unique_id': '123456789', 'version': 1, diff --git a/tests/components/smarty/snapshots/test_binary_sensor.ambr b/tests/components/smarty/snapshots/test_binary_sensor.ambr index 2f943a25012..ad4b61f5070 100644 --- a/tests/components/smarty/snapshots/test_binary_sensor.ambr +++ b/tests/components/smarty/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smarty/snapshots/test_button.ambr b/tests/components/smarty/snapshots/test_button.ambr index 38849bd2b2e..b5b86c80beb 100644 --- a/tests/components/smarty/snapshots/test_button.ambr +++ b/tests/components/smarty/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smarty/snapshots/test_fan.ambr b/tests/components/smarty/snapshots/test_fan.ambr index 8ca95beeb86..2502bd6f09f 100644 --- a/tests/components/smarty/snapshots/test_fan.ambr +++ b/tests/components/smarty/snapshots/test_fan.ambr @@ -8,6 +8,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smarty/snapshots/test_init.ambr b/tests/components/smarty/snapshots/test_init.ambr index b25cdb9dc3a..a292cc97f47 100644 --- a/tests/components/smarty/snapshots/test_init.ambr +++ b/tests/components/smarty/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/smarty/snapshots/test_sensor.ambr b/tests/components/smarty/snapshots/test_sensor.ambr index 2f713db7f83..c32740fa38c 100644 --- a/tests/components/smarty/snapshots/test_sensor.ambr +++ b/tests/components/smarty/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -148,6 +151,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -196,6 +200,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -244,6 +249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smarty/snapshots/test_switch.ambr b/tests/components/smarty/snapshots/test_switch.ambr index be1da7c6961..33c829adf31 100644 --- a/tests/components/smarty/snapshots/test_switch.ambr +++ b/tests/components/smarty/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smlight/snapshots/test_binary_sensor.ambr b/tests/components/smlight/snapshots/test_binary_sensor.ambr index 8becf5b2567..edb2a914a5d 100644 --- a/tests/components/smlight/snapshots/test_binary_sensor.ambr +++ b/tests/components/smlight/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 457a529065c..ba374199254 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.1.161', 'connections': set({ tuple( diff --git a/tests/components/smlight/snapshots/test_sensor.ambr b/tests/components/smlight/snapshots/test_sensor.ambr index 262ecfe1544..542338e4dbf 100644 --- a/tests/components/smlight/snapshots/test_sensor.ambr +++ b/tests/components/smlight/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -118,6 +120,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -165,6 +168,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -218,6 +222,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -269,6 +274,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -319,6 +325,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -377,6 +384,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smlight/snapshots/test_switch.ambr b/tests/components/smlight/snapshots/test_switch.ambr index 733d002be0f..b748202a557 100644 --- a/tests/components/smlight/snapshots/test_switch.ambr +++ b/tests/components/smlight/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/smlight/snapshots/test_update.ambr b/tests/components/smlight/snapshots/test_update.ambr index 8c6757d5b91..dc6b8f46ca5 100644 --- a/tests/components/smlight/snapshots/test_update.ambr +++ b/tests/components/smlight/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index e0f1bc2623c..6aef72ebbd5 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'solarlog', 'unique_id': None, 'version': 1, diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index 06bc01f9d39..c51f7627efc 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -116,6 +118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -173,6 +176,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -224,6 +228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +280,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -329,6 +335,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -437,6 +445,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -494,6 +503,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -551,6 +561,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -606,6 +617,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -662,6 +674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -716,6 +729,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -765,6 +779,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -814,6 +829,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -865,6 +881,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -916,6 +933,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -967,6 +985,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1018,6 +1037,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1072,6 +1092,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1123,6 +1144,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1174,6 +1196,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1231,6 +1254,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1288,6 +1312,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1345,6 +1370,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1397,6 +1423,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/sonos/snapshots/test_media_player.ambr b/tests/components/sonos/snapshots/test_media_player.ambr index 8ef298de3db..7f4681d8915 100644 --- a/tests/components/sonos/snapshots/test_media_player.ambr +++ b/tests/components/sonos/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/spotify/snapshots/test_media_player.ambr b/tests/components/spotify/snapshots/test_media_player.ambr index 9692d59cfd1..74dbcb50f92 100644 --- a/tests/components/spotify/snapshots/test_media_player.ambr +++ b/tests/components/spotify/snapshots/test_media_player.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -79,6 +80,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index ddd5b9868a1..fd663c5eb63 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -43,6 +44,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/stookwijzer/snapshots/test_sensor.ambr b/tests/components/stookwijzer/snapshots/test_sensor.ambr index f6751a84f22..ff1f6a12b8a 100644 --- a/tests/components/stookwijzer/snapshots/test_sensor.ambr +++ b/tests/components/stookwijzer/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -67,6 +68,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -118,6 +120,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr index c74df76e71b..d13a19bc656 100644 --- a/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr +++ b/tests/components/streamlabswater/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/streamlabswater/snapshots/test_sensor.ambr b/tests/components/streamlabswater/snapshots/test_sensor.ambr index d54cdcafb93..c1248f2c0a0 100644 --- a/tests/components/streamlabswater/snapshots/test_sensor.ambr +++ b/tests/components/streamlabswater/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -108,6 +110,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index 6abc544c92a..0b45546902b 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -136,6 +136,7 @@ async def test_user_form_pin_not_required( "data": deepcopy(TEST_CONFIG), "options": {}, "minor_version": 1, + "subentries": (), } expected["data"][CONF_PIN] = None @@ -341,6 +342,7 @@ async def test_pin_form_success(hass: HomeAssistant, pin_form) -> None: "data": TEST_CONFIG, "options": {}, "minor_version": 1, + "subentries": (), } result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID assert result == expected diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr index da0ed3df7dd..536e79df606 100644 --- a/tests/components/suez_water/snapshots/test_sensor.ambr +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr index b8ad82c7b79..5ba65b2bd70 100644 --- a/tests/components/swiss_public_transport/snapshots/test_sensor.ambr +++ b/tests/components/swiss_public_transport/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -151,6 +154,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -199,6 +203,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -293,6 +299,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -340,6 +347,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr index a9b6fb20bfb..2446add959b 100644 --- a/tests/components/switchbot_cloud/snapshots/test_sensor.ambr +++ b/tests/components/switchbot_cloud/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index 53572085f9b..f59958420c4 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -69,5 +69,6 @@ async def test_diagnostics( "created_at": ANY, "modified_at": ANY, "discovery_keys": {}, + "subentries": [], }, } diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index 75d942fc601..afa508cc004 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -56,6 +56,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'System Monitor', 'unique_id': None, 'version': 1, @@ -111,6 +113,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'System Monitor', 'unique_id': None, 'version': 1, diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr index 064b391c43a..d04f2e726b5 100644 --- a/tests/components/tailwind/snapshots/test_binary_sensor.ambr +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -129,6 +132,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr index 17b656ec5fd..7d3d10aa609 100644 --- a/tests/components/tailwind/snapshots/test_button.ambr +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr index b69bd9e6410..1a26a6c98a7 100644 --- a/tests/components/tailwind/snapshots/test_cover.ambr +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -21,6 +21,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -51,6 +52,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -101,6 +103,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -131,6 +134,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr index 3e2e0577ad5..7b906ef1976 100644 --- a/tests/components/tailwind/snapshots/test_number.ambr +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -29,6 +29,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index 3180c7c0b1d..b5b33d7c246 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -37,6 +37,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/tasmota/snapshots/test_sensor.ambr b/tests/components/tasmota/snapshots/test_sensor.ambr index be011e595b9..8a5a78cd366 100644 --- a/tests/components/tasmota/snapshots/test_sensor.ambr +++ b/tests/components/tasmota/snapshots/test_sensor.ambr @@ -24,6 +24,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -103,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -149,6 +151,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -254,6 +257,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -401,6 +405,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -452,6 +457,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -503,6 +509,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -586,6 +593,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -632,6 +640,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -741,6 +750,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -824,6 +834,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -875,6 +886,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -990,6 +1002,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1041,6 +1054,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1156,6 +1170,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1239,6 +1254,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1290,6 +1306,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1405,6 +1422,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1552,6 +1570,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1603,6 +1622,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1654,6 +1674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1732,6 +1753,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1860,6 +1882,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1909,6 +1932,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1958,6 +1982,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/technove/snapshots/test_binary_sensor.ambr b/tests/components/technove/snapshots/test_binary_sensor.ambr index f08dd6970fe..5d9bcd2175a 100644 --- a/tests/components/technove/snapshots/test_binary_sensor.ambr +++ b/tests/components/technove/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/technove/snapshots/test_number.ambr b/tests/components/technove/snapshots/test_number.ambr index 622c04d542a..eea4b0cb64c 100644 --- a/tests/components/technove/snapshots/test_number.ambr +++ b/tests/components/technove/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/technove/snapshots/test_sensor.ambr b/tests/components/technove/snapshots/test_sensor.ambr index 149155519d4..dec671b0f34 100644 --- a/tests/components/technove/snapshots/test_sensor.ambr +++ b/tests/components/technove/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -320,6 +326,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -376,6 +383,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -425,6 +433,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/technove/snapshots/test_switch.ambr b/tests/components/technove/snapshots/test_switch.ambr index 6febc8c768c..a5f8411747b 100644 --- a/tests/components/technove/snapshots/test_switch.ambr +++ b/tests/components/technove/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr index e3238dacda1..c2210a7ca5d 100644 --- a/tests/components/tedee/snapshots/test_binary_sensor.ambr +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -286,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -332,6 +339,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tedee/snapshots/test_init.ambr b/tests/components/tedee/snapshots/test_init.ambr index af559f561b2..28b5ef7a7ed 100644 --- a/tests/components/tedee/snapshots/test_init.ambr +++ b/tests/components/tedee/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tedee/snapshots/test_lock.ambr b/tests/components/tedee/snapshots/test_lock.ambr index cca988663d2..432c3ebd19f 100644 --- a/tests/components/tedee/snapshots/test_lock.ambr +++ b/tests/components/tedee/snapshots/test_lock.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -85,6 +87,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -132,6 +135,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tedee/snapshots/test_sensor.ambr b/tests/components/tedee/snapshots/test_sensor.ambr index 297fe9b0d37..22679c4153a 100644 --- a/tests/components/tedee/snapshots/test_sensor.ambr +++ b/tests/components/tedee/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr index 479d647e1c7..4e34f586280 100644 --- a/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -237,6 +242,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -284,6 +290,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -331,6 +338,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -377,6 +385,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -424,6 +433,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -471,6 +481,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -518,6 +529,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -565,6 +577,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -612,6 +625,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -658,6 +672,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -751,6 +767,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -798,6 +815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -845,6 +863,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -892,6 +911,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -938,6 +958,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -985,6 +1006,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +1054,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1079,6 +1102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1126,6 +1150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1173,6 +1198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1219,6 +1245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_button.ambr b/tests/components/tesla_fleet/snapshots/test_button.ambr index 8b5270d4852..145b10112b3 100644 --- a/tests/components/tesla_fleet/snapshots/test_button.ambr +++ b/tests/components/tesla_fleet/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_climate.ambr b/tests/components/tesla_fleet/snapshots/test_climate.ambr index 696f8c37f08..f3b36730c3f 100644 --- a/tests/components/tesla_fleet/snapshots/test_climate.ambr +++ b/tests/components/tesla_fleet/snapshots/test_climate.ambr @@ -15,6 +15,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -85,6 +86,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +158,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -225,6 +228,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -296,6 +300,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +370,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_cover.ambr b/tests/components/tesla_fleet/snapshots/test_cover.ambr index dbdb003d802..ed6969262f1 100644 --- a/tests/components/tesla_fleet/snapshots/test_cover.ambr +++ b/tests/components/tesla_fleet/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -390,6 +398,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -438,6 +447,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -486,6 +496,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +545,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -582,6 +594,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -630,6 +643,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -678,6 +692,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr index 02ad4b01002..dc142c4ffeb 100644 --- a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr +++ b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_init.ambr b/tests/components/tesla_fleet/snapshots/test_init.ambr index e9828db9f1b..c482d33de86 100644 --- a/tests/components/tesla_fleet/snapshots/test_init.ambr +++ b/tests/components/tesla_fleet/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -99,6 +102,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tesla_fleet/snapshots/test_lock.ambr b/tests/components/tesla_fleet/snapshots/test_lock.ambr index 3384bb0eb97..e98ad09caad 100644 --- a/tests/components/tesla_fleet/snapshots/test_lock.ambr +++ b/tests/components/tesla_fleet/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_media_player.ambr b/tests/components/tesla_fleet/snapshots/test_media_player.ambr index cc3018364a5..77c46faedd7 100644 --- a/tests/components/tesla_fleet/snapshots/test_media_player.ambr +++ b/tests/components/tesla_fleet/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -85,6 +86,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_number.ambr b/tests/components/tesla_fleet/snapshots/test_number.ambr index 00dd67015fe..1981544a024 100644 --- a/tests/components/tesla_fleet/snapshots/test_number.ambr +++ b/tests/components/tesla_fleet/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -184,6 +187,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_select.ambr b/tests/components/tesla_fleet/snapshots/test_select.ambr index f29ce841113..171b52decf1 100644 --- a/tests/components/tesla_fleet/snapshots/test_select.ambr +++ b/tests/components/tesla_fleet/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -186,6 +189,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -245,6 +249,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +309,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +369,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +429,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,6 +489,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -539,6 +548,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index d6b646d7794..f7349c9e2d8 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -81,6 +82,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -154,6 +156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,6 +230,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -300,6 +304,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -373,6 +378,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -446,6 +452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -519,6 +526,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -592,6 +600,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -665,6 +674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -738,6 +748,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -811,6 +822,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -884,6 +896,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -957,6 +970,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1030,6 +1044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1103,6 +1118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1176,6 +1192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1249,6 +1266,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1322,6 +1340,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1395,6 +1414,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1468,6 +1488,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1541,6 +1562,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1614,6 +1636,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1693,6 +1716,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1770,6 +1794,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1843,6 +1868,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1916,6 +1942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1986,6 +2013,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2059,6 +2087,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2132,6 +2161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2205,6 +2235,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2276,6 +2307,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2335,6 +2367,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2400,6 +2433,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2467,6 +2501,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2538,6 +2573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2599,6 +2635,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2669,6 +2706,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2739,6 +2777,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2806,6 +2845,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2873,6 +2913,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2947,6 +2988,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3026,6 +3068,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3096,6 +3139,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3166,6 +3210,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3237,6 +3282,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3298,6 +3344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3371,6 +3418,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3441,6 +3489,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3514,6 +3563,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3584,6 +3634,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3654,6 +3705,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3726,6 +3778,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3801,6 +3854,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3871,6 +3925,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3936,6 +3991,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3997,6 +4053,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4060,6 +4117,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4133,6 +4191,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4206,6 +4265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4279,6 +4339,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4352,6 +4413,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4419,6 +4481,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4484,6 +4547,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4543,6 +4607,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4604,6 +4669,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4677,6 +4743,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4748,6 +4815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4807,6 +4875,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4866,6 +4935,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4925,6 +4995,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tesla_fleet/snapshots/test_switch.ambr b/tests/components/tesla_fleet/snapshots/test_switch.ambr index 43d59a9da85..2ea3bcc5ee5 100644 --- a/tests/components/tesla_fleet/snapshots/test_switch.ambr +++ b/tests/components/tesla_fleet/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index e90cc9ced55..6a6e9826dc2 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -329,6 +336,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -375,6 +383,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -421,6 +430,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -467,6 +477,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -514,6 +525,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -561,6 +573,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -607,6 +620,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -653,6 +667,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -700,6 +715,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -746,6 +762,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -792,6 +809,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -838,6 +856,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -884,6 +903,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -930,6 +950,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -976,6 +997,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1022,6 +1044,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1069,6 +1092,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1116,6 +1140,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1163,6 +1188,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1210,6 +1236,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1257,6 +1284,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1303,6 +1331,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1349,6 +1378,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1395,6 +1425,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1441,6 +1472,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1487,6 +1519,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1533,6 +1566,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1579,6 +1613,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1625,6 +1660,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1672,6 +1708,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1719,6 +1756,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1766,6 +1804,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1813,6 +1852,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1859,6 +1899,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1905,6 +1946,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1951,6 +1993,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1998,6 +2041,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2044,6 +2088,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2091,6 +2136,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2138,6 +2184,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2185,6 +2232,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2232,6 +2280,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2278,6 +2327,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2325,6 +2375,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_button.ambr b/tests/components/teslemetry/snapshots/test_button.ambr index 6d3016186ae..e4e20215020 100644 --- a/tests/components/teslemetry/snapshots/test_button.ambr +++ b/tests/components/teslemetry/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 7064309e98b..4c265c00cb8 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -21,6 +21,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -91,6 +92,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +164,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -231,6 +234,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -300,6 +304,7 @@ 'target_temp_step': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -339,6 +344,7 @@ 'min_temp': 15.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index 8364f2a6a6e..9548a911cf9 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +300,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -342,6 +349,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -390,6 +398,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -438,6 +447,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -486,6 +496,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -534,6 +545,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -582,6 +594,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -630,6 +643,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index 0bc371b2d2d..b9e381ee42d 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_init.ambr b/tests/components/teslemetry/snapshots/test_init.ambr index 7d60ed82859..f1011034d63 100644 --- a/tests/components/teslemetry/snapshots/test_init.ambr +++ b/tests/components/teslemetry/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -67,6 +69,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), @@ -99,6 +102,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://teslemetry.com/console', 'connections': set({ }), diff --git a/tests/components/teslemetry/snapshots/test_lock.ambr b/tests/components/teslemetry/snapshots/test_lock.ambr index bb5693fe3ab..d6b29f0d7d4 100644 --- a/tests/components/teslemetry/snapshots/test_lock.ambr +++ b/tests/components/teslemetry/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr index a9d2569c637..663e91a502c 100644 --- a/tests/components/teslemetry/snapshots/test_media_player.ambr +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -84,6 +85,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 8e8f10397d0..5ca9feb22f2 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -184,6 +187,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_select.ambr b/tests/components/teslemetry/snapshots/test_select.ambr index 90af1259273..755a1a82c41 100644 --- a/tests/components/teslemetry/snapshots/test_select.ambr +++ b/tests/components/teslemetry/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -186,6 +189,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -245,6 +249,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +309,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +369,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -421,6 +428,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 6439e74eecc..c5d98abc95c 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -81,6 +82,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -154,6 +156,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,6 +230,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -300,6 +304,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -373,6 +378,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -446,6 +452,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -519,6 +526,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -592,6 +600,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -665,6 +674,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -738,6 +748,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -811,6 +822,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -884,6 +896,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -957,6 +970,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1030,6 +1044,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1103,6 +1118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1176,6 +1192,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1249,6 +1266,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1322,6 +1340,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1395,6 +1414,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1468,6 +1488,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1541,6 +1562,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1614,6 +1636,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1687,6 +1710,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1766,6 +1790,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1843,6 +1868,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1916,6 +1942,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1986,6 +2013,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2059,6 +2087,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2132,6 +2161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2205,6 +2235,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2276,6 +2307,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2335,6 +2367,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2400,6 +2433,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2470,6 +2504,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2541,6 +2576,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2602,6 +2638,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2672,6 +2709,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2742,6 +2780,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2809,6 +2848,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2876,6 +2916,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2950,6 +2991,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3029,6 +3071,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3099,6 +3142,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3169,6 +3213,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3240,6 +3285,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3301,6 +3347,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3374,6 +3421,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3444,6 +3492,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3517,6 +3566,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3587,6 +3637,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3657,6 +3708,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3729,6 +3781,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3804,6 +3857,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3874,6 +3928,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3939,6 +3994,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4000,6 +4056,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4063,6 +4120,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4136,6 +4194,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4209,6 +4268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4282,6 +4342,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4355,6 +4416,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4422,6 +4484,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4487,6 +4550,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4546,6 +4610,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4607,6 +4672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4680,6 +4746,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4751,6 +4818,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4810,6 +4878,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4869,6 +4938,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -4928,6 +4998,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index b34d9c65393..f9997133044 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 2411d047135..1c7d525af86 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_binary_sensor.ambr b/tests/components/tessie/snapshots/test_binary_sensor.ambr index 6c0da044df2..2fe97b88811 100644 --- a/tests/components/tessie/snapshots/test_binary_sensor.ambr +++ b/tests/components/tessie/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +288,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -328,6 +335,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -375,6 +383,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +431,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -469,6 +479,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -516,6 +527,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -563,6 +575,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -610,6 +623,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -657,6 +671,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -704,6 +719,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -751,6 +767,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -798,6 +815,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -844,6 +862,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -891,6 +910,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -938,6 +958,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -985,6 +1006,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +1054,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1078,6 +1101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1125,6 +1149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1172,6 +1197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1219,6 +1245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1266,6 +1293,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1313,6 +1341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1359,6 +1388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_button.ambr b/tests/components/tessie/snapshots/test_button.ambr index 7757d1f2fea..96ece94a1c9 100644 --- a/tests/components/tessie/snapshots/test_button.ambr +++ b/tests/components/tessie/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_climate.ambr b/tests/components/tessie/snapshots/test_climate.ambr index 959b42cea53..415988e783e 100644 --- a/tests/components/tessie/snapshots/test_climate.ambr +++ b/tests/components/tessie/snapshots/test_climate.ambr @@ -19,6 +19,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_cover.ambr b/tests/components/tessie/snapshots/test_cover.ambr index 6338758afb7..fdf2a967048 100644 --- a/tests/components/tessie/snapshots/test_cover.ambr +++ b/tests/components/tessie/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_device_tracker.ambr b/tests/components/tessie/snapshots/test_device_tracker.ambr index 61f89db8637..92502340aa2 100644 --- a/tests/components/tessie/snapshots/test_device_tracker.ambr +++ b/tests/components/tessie/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_lock.ambr b/tests/components/tessie/snapshots/test_lock.ambr index cea2bebbddb..f819281d79b 100644 --- a/tests/components/tessie/snapshots/test_lock.ambr +++ b/tests/components/tessie/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index 6c355c8ddca..911598004a6 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -7,6 +7,7 @@ 'capabilities': dict({ }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_number.ambr b/tests/components/tessie/snapshots/test_number.ambr index 6e641bdf5b7..0e43695ca78 100644 --- a/tests/components/tessie/snapshots/test_number.ambr +++ b/tests/components/tessie/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -184,6 +187,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +245,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_select.ambr b/tests/components/tessie/snapshots/test_select.ambr index acc1946aab5..f118633aded 100644 --- a/tests/components/tessie/snapshots/test_select.ambr +++ b/tests/components/tessie/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -127,6 +129,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -186,6 +189,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -245,6 +249,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -304,6 +309,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -363,6 +369,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -422,6 +429,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -481,6 +489,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index 0a5ff4603aa..5465f89d808 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -122,6 +124,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +240,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -293,6 +298,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -350,6 +356,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -404,6 +411,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -461,6 +469,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -516,6 +525,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -566,6 +576,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -617,6 +628,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -674,6 +686,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -731,6 +744,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -788,6 +802,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -842,6 +857,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -896,6 +912,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -947,6 +964,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -998,6 +1016,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1056,6 +1075,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1111,6 +1131,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1159,6 +1180,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1213,6 +1235,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1267,6 +1290,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1321,6 +1345,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1378,6 +1403,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1432,6 +1458,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1486,6 +1513,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1542,6 +1570,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1597,6 +1626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1651,6 +1681,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1700,6 +1731,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1747,6 +1779,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1796,6 +1829,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1853,6 +1887,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1910,6 +1945,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1967,6 +2003,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2024,6 +2061,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2075,6 +2113,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2132,6 +2171,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2200,6 +2240,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2272,6 +2313,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2331,6 +2373,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2377,6 +2420,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_switch.ambr b/tests/components/tessie/snapshots/test_switch.ambr index 35e36010830..371ef822122 100644 --- a/tests/components/tessie/snapshots/test_switch.ambr +++ b/tests/components/tessie/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -145,6 +148,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -192,6 +196,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +244,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -286,6 +292,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tessie/snapshots/test_update.ambr b/tests/components/tessie/snapshots/test_update.ambr index 1728c13b0ad..e4c25e2230f 100644 --- a/tests/components/tessie/snapshots/test_update.ambr +++ b/tests/components/tessie/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tile/snapshots/test_binary_sensor.ambr b/tests/components/tile/snapshots/test_binary_sensor.ambr index 5f72f53fa1e..6de356ebf51 100644 --- a/tests/components/tile/snapshots/test_binary_sensor.ambr +++ b/tests/components/tile/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tile/snapshots/test_device_tracker.ambr b/tests/components/tile/snapshots/test_device_tracker.ambr index 15108331e66..f5de1511c99 100644 --- a/tests/components/tile/snapshots/test_device_tracker.ambr +++ b/tests/components/tile/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tile/snapshots/test_init.ambr b/tests/components/tile/snapshots/test_init.ambr index 90f165d1e6e..ffdf6a6251a 100644 --- a/tests/components/tile/snapshots/test_init.ambr +++ b/tests/components/tile/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index ef7cb386b33..a63319a6c76 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr index 1eccff1dfc3..ac79455a0d5 100644 --- a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr +++ b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -156,6 +159,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -206,6 +210,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -256,6 +261,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -306,6 +312,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -356,6 +363,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -406,6 +414,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -456,6 +465,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -506,6 +516,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -556,6 +567,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -606,6 +618,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -656,6 +669,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -706,6 +720,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -756,6 +771,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -806,6 +822,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -854,6 +871,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -902,6 +920,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -949,6 +968,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -997,6 +1017,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1045,6 +1066,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1093,6 +1115,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1143,6 +1166,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1193,6 +1217,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/totalconnect/snapshots/test_button.ambr b/tests/components/totalconnect/snapshots/test_button.ambr index af3318591c6..96d38567236 100644 --- a/tests/components/totalconnect/snapshots/test_button.ambr +++ b/tests/components/totalconnect/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_binary_sensor.ambr b/tests/components/tplink/snapshots/test_binary_sensor.ambr index 125592b053c..17aa2c248e5 100644 --- a/tests/components/tplink/snapshots/test_binary_sensor.ambr +++ b/tests/components/tplink/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -39,6 +40,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -86,6 +88,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -133,6 +136,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -166,6 +170,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -213,6 +218,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -260,6 +266,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -307,6 +314,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -354,6 +362,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -384,6 +393,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tplink/snapshots/test_button.ambr b/tests/components/tplink/snapshots/test_button.ambr index c0c74e11923..bb4e9f85d58 100644 --- a/tests/components/tplink/snapshots/test_button.ambr +++ b/tests/components/tplink/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -177,6 +181,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -210,6 +215,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -243,6 +249,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -276,6 +283,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -309,6 +317,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -342,6 +351,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -388,6 +398,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -434,6 +445,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -480,6 +492,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -526,6 +539,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -556,6 +570,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tplink/snapshots/test_camera.ambr b/tests/components/tplink/snapshots/test_camera.ambr index 4417395078a..e037c2c9e40 100644 --- a/tests/components/tplink/snapshots/test_camera.ambr +++ b/tests/components/tplink/snapshots/test_camera.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tplink/snapshots/test_climate.ambr b/tests/components/tplink/snapshots/test_climate.ambr index e0173e8f59e..02492de92b9 100644 --- a/tests/components/tplink/snapshots/test_climate.ambr +++ b/tests/components/tplink/snapshots/test_climate.ambr @@ -13,6 +13,7 @@ 'min_temp': 5, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/tplink/snapshots/test_fan.ambr b/tests/components/tplink/snapshots/test_fan.ambr index 1a7392dc63a..9c395dc2f21 100644 --- a/tests/components/tplink/snapshots/test_fan.ambr +++ b/tests/components/tplink/snapshots/test_fan.ambr @@ -8,6 +8,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -61,6 +62,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -114,6 +116,7 @@ 'preset_modes': None, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index 4bdb92aeab6..0415039a0ce 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -47,6 +48,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -157,6 +160,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -267,6 +272,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -322,6 +328,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -377,6 +384,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -432,6 +440,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_select.ambr b/tests/components/tplink/snapshots/test_select.ambr index c851979f34c..e5191937ee9 100644 --- a/tests/components/tplink/snapshots/test_select.ambr +++ b/tests/components/tplink/snapshots/test_select.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -64,6 +65,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -137,6 +139,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +197,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_sensor.ambr b/tests/components/tplink/snapshots/test_sensor.ambr index 093b92ef315..72198e579a1 100644 --- a/tests/components/tplink/snapshots/test_sensor.ambr +++ b/tests/components/tplink/snapshots/test_sensor.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -42,6 +43,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -75,6 +77,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +127,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -173,6 +177,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -209,6 +214,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -247,6 +253,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -301,6 +308,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -334,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -387,6 +396,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -441,6 +451,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -493,6 +504,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -541,6 +553,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -602,6 +615,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -638,6 +652,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -676,6 +691,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -725,6 +741,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -758,6 +775,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -796,6 +814,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -832,6 +851,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -879,6 +899,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -915,6 +936,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -951,6 +973,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -984,6 +1007,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1017,6 +1041,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1053,6 +1078,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1089,6 +1115,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1125,6 +1152,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1163,6 +1191,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1212,6 +1241,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1245,6 +1275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1280,6 +1311,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1315,6 +1347,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1369,6 +1402,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1423,6 +1457,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1461,6 +1496,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1496,6 +1532,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': , @@ -1534,6 +1571,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1588,6 +1626,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr index 7141ccfa084..7365e449707 100644 --- a/tests/components/tplink/snapshots/test_siren.ambr +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -47,6 +48,7 @@ ), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index f22f8d0cd36..bd89da8e841 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -42,6 +43,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -88,6 +90,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -134,6 +137,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -180,6 +184,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -226,6 +231,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -272,6 +278,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -318,6 +325,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -364,6 +372,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -410,6 +419,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -456,6 +466,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -502,6 +513,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -548,6 +560,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -594,6 +607,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink/snapshots/test_vacuum.ambr b/tests/components/tplink/snapshots/test_vacuum.ambr index c0a48327e26..e010c9545d1 100644 --- a/tests/components/tplink/snapshots/test_vacuum.ambr +++ b/tests/components/tplink/snapshots/test_vacuum.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ tuple( @@ -47,6 +48,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink_omada/snapshots/test_sensor.ambr b/tests/components/tplink_omada/snapshots/test_sensor.ambr index 6c332eb9696..62167fc9d40 100644 --- a/tests/components/tplink_omada/snapshots/test_sensor.ambr +++ b/tests/components/tplink_omada/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -174,6 +177,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -232,6 +236,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +295,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr index a13d386e721..dde196deaaf 100644 --- a/tests/components/tplink_omada/snapshots/test_switch.ambr +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -71,6 +71,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -117,6 +118,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tractive/snapshots/test_binary_sensor.ambr b/tests/components/tractive/snapshots/test_binary_sensor.ambr index 4b610e927d5..761626347a7 100644 --- a/tests/components/tractive/snapshots/test_binary_sensor.ambr +++ b/tests/components/tractive/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tractive/snapshots/test_device_tracker.ambr b/tests/components/tractive/snapshots/test_device_tracker.ambr index 4e7c5bfe173..ef511299e68 100644 --- a/tests/components/tractive/snapshots/test_device_tracker.ambr +++ b/tests/components/tractive/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tractive/snapshots/test_diagnostics.ambr b/tests/components/tractive/snapshots/test_diagnostics.ambr index 11427a84801..3613f7e5997 100644 --- a/tests/components/tractive/snapshots/test_diagnostics.ambr +++ b/tests/components/tractive/snapshots/test_diagnostics.ambr @@ -17,6 +17,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': 'very_unique_string', 'version': 1, diff --git a/tests/components/tractive/snapshots/test_sensor.ambr b/tests/components/tractive/snapshots/test_sensor.ambr index f10cfb29226..4551492e36e 100644 --- a/tests/components/tractive/snapshots/test_sensor.ambr +++ b/tests/components/tractive/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -116,6 +118,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -164,6 +167,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -213,6 +217,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -313,6 +319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -367,6 +374,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -419,6 +427,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -475,6 +484,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tractive/snapshots/test_switch.ambr b/tests/components/tractive/snapshots/test_switch.ambr index 08e0c984d0c..d443611ef92 100644 --- a/tests/components/tractive/snapshots/test_switch.ambr +++ b/tests/components/tractive/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr index a5a68a12a22..90d83d69814 100644 --- a/tests/components/tuya/snapshots/test_config_flow.ambr +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -24,6 +24,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '12345', 'unique_id': '12345', 'version': 1, @@ -54,6 +56,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Old Tuya configuration entry', 'unique_id': '12345', 'version': 1, @@ -107,10 +111,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'mocked_username', 'unique_id': None, 'version': 1, }), + 'subentries': tuple( + ), 'title': 'mocked_username', 'type': , 'version': 1, diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 1df4beb4232..0576fcd6a70 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -51,6 +51,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -81,6 +82,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 86ffc171082..b40ac0ba9e6 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -129,6 +132,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -178,6 +182,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +213,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -257,6 +263,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +294,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), @@ -336,6 +344,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -366,6 +375,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://www.twentemilieu.nl', 'connections': set({ }), diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 814dc7dfc1f..511bf9addd3 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -69,6 +69,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Twinkly', 'unique_id': '00:2d:13:3b:aa:bb', 'version': 1, diff --git a/tests/components/twinkly/snapshots/test_light.ambr b/tests/components/twinkly/snapshots/test_light.ambr index a97c3f941ff..77a97a0cdd9 100644 --- a/tests/components/twinkly/snapshots/test_light.ambr +++ b/tests/components/twinkly/snapshots/test_light.ambr @@ -14,6 +14,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/twinkly/snapshots/test_select.ambr b/tests/components/twinkly/snapshots/test_select.ambr index 21e09d6b022..26edd4b731d 100644 --- a/tests/components/twinkly/snapshots/test_select.ambr +++ b/tests/components/twinkly/snapshots/test_select.ambr @@ -16,6 +16,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_button.ambr b/tests/components/unifi/snapshots/test_button.ambr index 3729bd31cf0..369b0823063 100644 --- a/tests/components/unifi/snapshots/test_button.ambr +++ b/tests/components/unifi/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_device_tracker.ambr b/tests/components/unifi/snapshots/test_device_tracker.ambr index 3debd512050..5d3407e4e8e 100644 --- a/tests/components/unifi/snapshots/test_device_tracker.ambr +++ b/tests/components/unifi/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -55,6 +56,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -104,6 +106,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index 4ba90a00113..aa7337be0ba 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -42,6 +42,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': '1', 'version': 1, diff --git a/tests/components/unifi/snapshots/test_image.ambr b/tests/components/unifi/snapshots/test_image.ambr index 32e1a5ff622..05cca2c305b 100644 --- a/tests/components/unifi/snapshots/test_image.ambr +++ b/tests/components/unifi/snapshots/test_image.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_sensor.ambr b/tests/components/unifi/snapshots/test_sensor.ambr index e14658b2b96..4d109f630c5 100644 --- a/tests/components/unifi/snapshots/test_sensor.ambr +++ b/tests/components/unifi/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -70,6 +71,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -131,6 +133,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -179,6 +182,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -228,6 +232,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -282,6 +287,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -336,6 +342,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -385,6 +392,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -435,6 +443,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -485,6 +494,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -549,6 +559,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -610,6 +621,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -659,6 +671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -708,6 +721,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -759,6 +773,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -810,6 +825,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -861,6 +877,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -912,6 +929,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -963,6 +981,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1014,6 +1033,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1065,6 +1085,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1119,6 +1140,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1173,6 +1195,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1224,6 +1247,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1278,6 +1302,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1332,6 +1357,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1386,6 +1412,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1440,6 +1467,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1491,6 +1519,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1545,6 +1574,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1612,6 +1642,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1673,6 +1704,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1722,6 +1754,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1771,6 +1804,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1822,6 +1856,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1871,6 +1906,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1920,6 +1956,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1971,6 +2008,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2020,6 +2058,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_switch.ambr b/tests/components/unifi/snapshots/test_switch.ambr index 45e6188a3f4..c07a4799b5a 100644 --- a/tests/components/unifi/snapshots/test_switch.ambr +++ b/tests/components/unifi/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -99,6 +101,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +245,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -287,6 +293,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -334,6 +341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -381,6 +389,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -428,6 +437,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -475,6 +485,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/unifi/snapshots/test_update.ambr b/tests/components/unifi/snapshots/test_update.ambr index 405cb9d52a6..ef3803ac53d 100644 --- a/tests/components/unifi/snapshots/test_update.ambr +++ b/tests/components/unifi/snapshots/test_update.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -124,6 +126,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -183,6 +186,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr index 38312667375..93b1da60998 100644 --- a/tests/components/uptime/snapshots/test_config_flow.ambr +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -27,10 +27,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Uptime', 'unique_id': None, 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Uptime', 'type': , 'version': 1, diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 561e4b83320..d6d896dbcec 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -49,6 +50,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index 6cdf121d7e3..ef235bba99d 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -25,6 +25,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Energy Bill', 'unique_id': None, 'version': 2, diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 96567b80c54..780a00acd64 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': 'ABC123', 'version': 1, diff --git a/tests/components/v2c/snapshots/test_sensor.ambr b/tests/components/v2c/snapshots/test_sensor.ambr index 7b9ae4a9ff3..46054b21324 100644 --- a/tests/components/v2c/snapshots/test_sensor.ambr +++ b/tests/components/v2c/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -312,6 +318,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -396,6 +403,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -482,6 +490,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -533,6 +542,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -580,6 +590,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/velbus/snapshots/test_binary_sensor.ambr b/tests/components/velbus/snapshots/test_binary_sensor.ambr index 58630b9f6c9..70db53257a1 100644 --- a/tests/components/velbus/snapshots/test_binary_sensor.ambr +++ b/tests/components/velbus/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/velbus/snapshots/test_button.ambr b/tests/components/velbus/snapshots/test_button.ambr index 952af21b43c..856ebdb1e21 100644 --- a/tests/components/velbus/snapshots/test_button.ambr +++ b/tests/components/velbus/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/velbus/snapshots/test_climate.ambr b/tests/components/velbus/snapshots/test_climate.ambr index b1933e51868..1d1f49d14d9 100644 --- a/tests/components/velbus/snapshots/test_climate.ambr +++ b/tests/components/velbus/snapshots/test_climate.ambr @@ -19,6 +19,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/velbus/snapshots/test_cover.ambr b/tests/components/velbus/snapshots/test_cover.ambr index a9cbd4aae73..0be18034bc0 100644 --- a/tests/components/velbus/snapshots/test_cover.ambr +++ b/tests/components/velbus/snapshots/test_cover.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/velbus/snapshots/test_diagnostics.ambr b/tests/components/velbus/snapshots/test_diagnostics.ambr index 406a5f2d84e..c8bff1841e8 100644 --- a/tests/components/velbus/snapshots/test_diagnostics.ambr +++ b/tests/components/velbus/snapshots/test_diagnostics.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 2, diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr index a55a00ef0f2..1e17753a02f 100644 --- a/tests/components/velbus/snapshots/test_init.ambr +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -34,6 +35,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -64,6 +66,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -94,6 +97,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -124,6 +128,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -154,6 +159,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -184,6 +190,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -214,6 +221,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/velbus/snapshots/test_light.ambr b/tests/components/velbus/snapshots/test_light.ambr index b7009a0c66a..6dd2ca4939d 100644 --- a/tests/components/velbus/snapshots/test_light.ambr +++ b/tests/components/velbus/snapshots/test_light.ambr @@ -10,6 +10,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/velbus/snapshots/test_select.ambr b/tests/components/velbus/snapshots/test_select.ambr index 288eb10a3c3..94bb109fc71 100644 --- a/tests/components/velbus/snapshots/test_select.ambr +++ b/tests/components/velbus/snapshots/test_select.ambr @@ -13,6 +13,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/velbus/snapshots/test_sensor.ambr b/tests/components/velbus/snapshots/test_sensor.ambr index 6860ad73e2c..6f562f399af 100644 --- a/tests/components/velbus/snapshots/test_sensor.ambr +++ b/tests/components/velbus/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -211,6 +215,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/velbus/snapshots/test_switch.ambr b/tests/components/velbus/snapshots/test_switch.ambr index e9090c396d1..60458b196a8 100644 --- a/tests/components/velbus/snapshots/test_switch.ambr +++ b/tests/components/velbus/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index fddc75630d2..0b56a08eeff 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -46,6 +47,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -96,6 +98,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -137,6 +140,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -193,6 +197,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -235,6 +240,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -292,6 +298,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -334,6 +341,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -391,6 +399,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -429,6 +438,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -467,6 +477,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -505,6 +516,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -543,6 +555,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -581,6 +594,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -625,6 +639,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -684,6 +699,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -722,6 +738,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index b89cf8cdd4d..bed711b1040 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -42,6 +43,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -80,6 +82,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -118,6 +121,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -156,6 +160,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -197,6 +202,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +252,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -287,6 +294,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -338,6 +346,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -376,6 +385,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -414,6 +424,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -452,6 +463,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -490,6 +502,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -535,6 +548,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -595,6 +609,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index ca7a5cf3ea6..c701fa8a324 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -43,6 +44,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -74,6 +76,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -134,6 +137,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -173,6 +177,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -220,6 +225,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -259,6 +265,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +297,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -323,6 +331,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -399,6 +408,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -438,6 +448,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -469,6 +480,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -502,6 +514,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -578,6 +591,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -616,6 +630,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -654,6 +669,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -693,6 +709,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -741,6 +758,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -780,6 +798,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -828,6 +847,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -867,6 +887,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -900,6 +921,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -933,6 +955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -966,6 +989,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -999,6 +1023,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +1057,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1160,6 +1186,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1198,6 +1225,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -1236,6 +1264,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index a736f1cd186..1faed941338 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -4,6 +4,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -42,6 +43,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -80,6 +82,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -118,6 +121,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -156,6 +160,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -194,6 +199,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -232,6 +238,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -270,6 +277,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -308,6 +316,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -345,6 +354,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -391,6 +401,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -429,6 +440,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -467,6 +479,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -504,6 +517,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/snapshots/test_binary_sensor.ambr b/tests/components/vicare/snapshots/test_binary_sensor.ambr index ec2451cd466..93e407ea505 100644 --- a/tests/components/vicare/snapshots/test_binary_sensor.ambr +++ b/tests/components/vicare/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -334,6 +341,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +388,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/snapshots/test_button.ambr b/tests/components/vicare/snapshots/test_button.ambr index 9fadc6a983f..17dfc29e96e 100644 --- a/tests/components/vicare/snapshots/test_button.ambr +++ b/tests/components/vicare/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/snapshots/test_climate.ambr b/tests/components/vicare/snapshots/test_climate.ambr index aea0ea879c2..e1709acea42 100644 --- a/tests/components/vicare/snapshots/test_climate.ambr +++ b/tests/components/vicare/snapshots/test_climate.ambr @@ -18,6 +18,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -101,6 +102,7 @@ 'target_temp_step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index ae9b05389c7..0b1dcef5a29 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4731,6 +4731,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': 'ViCare', 'version': 1, diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index b5b02af39b1..0bac421e2c7 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/snapshots/test_number.ambr b/tests/components/vicare/snapshots/test_number.ambr index 5a030fc0213..b26d2d33590 100644 --- a/tests/components/vicare/snapshots/test_number.ambr +++ b/tests/components/vicare/snapshots/test_number.ambr @@ -11,6 +11,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -68,6 +69,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,64 @@ 'state': 'unavailable', }) # --- +# name: test_all_entities[number.model0_dhw_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.model0_dhw_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': 'DHW temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.model0_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW temperature', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.model0_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_all_entities[number.model0_heating_curve_shift-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -125,6 +185,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +243,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -239,6 +301,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -294,6 +357,7 @@ 'step': 0.1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -349,6 +413,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -406,6 +471,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -463,6 +529,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -520,6 +587,7 @@ 'step': 1.0, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -565,60 +633,3 @@ 'state': 'unavailable', }) # --- -# name: test_all_entities[number.model0_dhw_temperature-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1, - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'number', - 'entity_category': , - 'entity_id': 'number.model0_dhw_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': 'DHW temperature', - 'platform': 'vicare', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'dhw_temperature', - 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[number.model0_dhw_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'model0 DHW temperature', - 'max': 100.0, - 'min': 0.0, - 'mode': , - 'step': 1, - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'number.model0_dhw_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index d842ea0b299..a0d4bf374c8 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -109,6 +111,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -159,6 +162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -208,6 +212,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -257,6 +262,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -306,6 +312,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -355,6 +362,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -404,6 +412,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -455,6 +464,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -506,6 +516,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -557,6 +568,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -608,6 +620,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -659,6 +672,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -710,6 +724,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -759,6 +774,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -808,6 +824,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -857,6 +874,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -906,6 +924,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -957,6 +976,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1008,6 +1028,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1059,6 +1080,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1110,6 +1132,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1158,6 +1181,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1206,6 +1230,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1255,6 +1280,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1306,6 +1332,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1357,6 +1384,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1408,6 +1436,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1459,6 +1488,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1510,6 +1540,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1561,6 +1592,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1612,6 +1644,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1663,6 +1696,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1714,6 +1748,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1765,6 +1800,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1816,6 +1852,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1867,6 +1904,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1917,6 +1955,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1966,6 +2005,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2017,6 +2057,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2068,6 +2109,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2119,6 +2161,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2168,6 +2211,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2217,6 +2261,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2266,6 +2311,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2317,6 +2363,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2367,6 +2414,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2418,6 +2466,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2474,6 +2523,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2537,6 +2587,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2594,6 +2645,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2645,6 +2697,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2696,6 +2749,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2747,6 +2801,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2798,6 +2853,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vicare/snapshots/test_water_heater.ambr b/tests/components/vicare/snapshots/test_water_heater.ambr index bca04b1bbfa..7b7ab91e086 100644 --- a/tests/components/vicare/snapshots/test_water_heater.ambr +++ b/tests/components/vicare/snapshots/test_water_heater.ambr @@ -9,6 +9,7 @@ 'min_temp': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -65,6 +66,7 @@ 'min_temp': 10, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vodafone_station/snapshots/test_button.ambr b/tests/components/vodafone_station/snapshots/test_button.ambr index dc7953ac42a..736f590241a 100644 --- a/tests/components/vodafone_station/snapshots/test_button.ambr +++ b/tests/components/vodafone_station/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr index e019ea73ab9..7f98aad1405 100644 --- a/tests/components/vodafone_station/snapshots/test_device_tracker.ambr +++ b/tests/components/vodafone_station/snapshots/test_device_tracker.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -56,6 +57,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr index 478080700cd..be2956e0aab 100644 --- a/tests/components/vodafone_station/snapshots/test_diagnostics.ambr +++ b/tests/components/vodafone_station/snapshots/test_diagnostics.ambr @@ -41,6 +41,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/vodafone_station/snapshots/test_sensor.ambr b/tests/components/vodafone_station/snapshots/test_sensor.ambr index eb1676938b5..169ee92a24b 100644 --- a/tests/components/vodafone_station/snapshots/test_sensor.ambr +++ b/tests/components/vodafone_station/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -64,6 +65,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -111,6 +113,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -158,6 +161,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -204,6 +208,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/watergate/snapshots/test_sensor.ambr b/tests/components/watergate/snapshots/test_sensor.ambr index a58c7c0eab8..b4b6c4ee0a4 100644 --- a/tests/components/watergate/snapshots/test_sensor.ambr +++ b/tests/components/watergate/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -113,6 +115,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -162,6 +165,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -211,6 +215,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -262,6 +267,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -313,6 +319,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -364,6 +371,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -415,6 +423,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -464,6 +473,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr index 0c137acc36b..3cc5e1d6f66 100644 --- a/tests/components/watttime/snapshots/test_diagnostics.ambr +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -27,6 +27,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr index 95be86664a2..c06229302c5 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -62,6 +63,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -117,6 +119,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -172,6 +175,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -227,6 +231,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -277,6 +282,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -327,6 +333,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -377,6 +384,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -427,6 +435,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -477,6 +486,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -535,6 +545,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -593,6 +604,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -648,6 +660,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -703,6 +716,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -758,6 +772,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr index 569b744529c..0b0d66c34a7 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/webmin/snapshots/test_diagnostics.ambr b/tests/components/webmin/snapshots/test_diagnostics.ambr index 8299b0eafba..c64fa212a98 100644 --- a/tests/components/webmin/snapshots/test_diagnostics.ambr +++ b/tests/components/webmin/snapshots/test_diagnostics.ambr @@ -253,6 +253,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': '**REDACTED**', 'unique_id': None, 'version': 1, diff --git a/tests/components/webmin/snapshots/test_sensor.ambr b/tests/components/webmin/snapshots/test_sensor.ambr index 6af768d63a8..a2068f662ba 100644 --- a/tests/components/webmin/snapshots/test_sensor.ambr +++ b/tests/components/webmin/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -57,6 +58,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -106,6 +108,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -155,6 +158,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -269,6 +274,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -326,6 +332,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -376,6 +383,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -426,6 +434,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -476,6 +485,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -525,6 +535,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -574,6 +585,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -623,6 +635,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -680,6 +693,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -737,6 +751,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -794,6 +809,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -844,6 +860,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -894,6 +911,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -944,6 +962,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -993,6 +1012,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1042,6 +1062,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1091,6 +1112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1148,6 +1170,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1205,6 +1228,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1262,6 +1286,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1319,6 +1344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1376,6 +1402,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1433,6 +1460,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1482,6 +1510,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1531,6 +1560,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1580,6 +1610,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1637,6 +1668,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1694,6 +1726,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1751,6 +1784,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/webostv/snapshots/test_diagnostics.ambr b/tests/components/webostv/snapshots/test_diagnostics.ambr index 07ee50af1f8..030554b963a 100644 --- a/tests/components/webostv/snapshots/test_diagnostics.ambr +++ b/tests/components/webostv/snapshots/test_diagnostics.ambr @@ -59,6 +59,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'LG webOS TV MODEL', 'unique_id': '**REDACTED**', 'version': 1, diff --git a/tests/components/webostv/snapshots/test_media_player.ambr b/tests/components/webostv/snapshots/test_media_player.ambr index 23f45a0f325..9c097b166ec 100644 --- a/tests/components/webostv/snapshots/test_media_player.ambr +++ b/tests/components/webostv/snapshots/test_media_player.ambr @@ -39,6 +39,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/weheat/snapshots/test_binary_sensor.ambr b/tests/components/weheat/snapshots/test_binary_sensor.ambr index 08d609ca610..cd2aa13135a 100644 --- a/tests/components/weheat/snapshots/test_binary_sensor.ambr +++ b/tests/components/weheat/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -146,6 +149,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index 1a54711d6c5..77f85224913 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -18,6 +18,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -78,6 +79,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -132,6 +134,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -182,6 +185,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -232,6 +236,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -284,6 +289,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -338,6 +344,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -392,6 +399,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -446,6 +454,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -497,6 +506,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -551,6 +561,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -605,6 +616,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -659,6 +671,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -713,6 +726,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -764,6 +778,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -818,6 +833,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -872,6 +888,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index c60ce17b952..ee8abe04bf1 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -38,6 +38,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 937502d4d6c..0d99b0596e3 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -30,10 +30,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -70,10 +74,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -110,10 +118,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -150,10 +162,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, @@ -190,10 +206,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Example.com', 'unique_id': 'example.com', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Example.com', 'type': , 'version': 1, diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 4310bc77ebf..b5b1dde1c3d 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -19,6 +19,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -49,6 +50,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -128,6 +131,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -181,6 +185,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -211,6 +216,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -260,6 +266,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -290,6 +297,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -339,6 +347,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -369,6 +378,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -417,6 +427,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -447,6 +458,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -495,6 +507,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -525,6 +538,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -573,6 +587,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -603,6 +618,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -651,6 +667,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -681,6 +698,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -730,6 +748,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/withings/snapshots/test_init.ambr b/tests/components/withings/snapshots/test_init.ambr index be221cad313..ec711def829 100644 --- a/tests/components/withings/snapshots/test_init.ambr +++ b/tests/components/withings/snapshots/test_init.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), @@ -35,6 +36,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ }), diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index cfecfb1e28e..543cba05e21 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -66,6 +67,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -120,6 +122,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -175,6 +178,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -225,6 +229,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -275,6 +280,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -326,6 +332,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +387,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -427,6 +435,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -479,6 +488,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -530,6 +540,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -578,6 +589,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -631,6 +643,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -684,6 +697,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -731,6 +745,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -778,6 +793,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -825,6 +841,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -875,6 +892,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -927,6 +945,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -978,6 +997,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1032,6 +1052,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1086,6 +1107,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1140,6 +1162,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1194,6 +1217,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1248,6 +1272,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1302,6 +1327,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1356,6 +1382,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1410,6 +1437,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1464,6 +1492,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1518,6 +1547,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1572,6 +1602,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1626,6 +1657,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1679,6 +1711,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1729,6 +1762,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1783,6 +1817,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1834,6 +1869,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1889,6 +1925,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1938,6 +1975,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1989,6 +2027,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2087,6 +2126,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2187,6 +2227,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2238,6 +2279,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2288,6 +2330,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2338,6 +2381,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2388,6 +2432,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2438,6 +2483,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2493,6 +2539,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2547,6 +2594,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2601,6 +2649,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2655,6 +2704,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2709,6 +2759,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2763,6 +2814,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2815,6 +2867,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2868,6 +2921,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2919,6 +2973,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -2970,6 +3025,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3021,6 +3077,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3075,6 +3132,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3125,6 +3183,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3174,6 +3233,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3223,6 +3283,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3278,6 +3339,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3328,6 +3390,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3378,6 +3441,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3429,6 +3493,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3479,6 +3544,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3530,6 +3596,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3581,6 +3648,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3632,6 +3700,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3684,6 +3753,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3730,6 +3800,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3778,6 +3849,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3828,6 +3900,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3878,6 +3951,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3929,6 +4003,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -3983,6 +4058,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index 4e6260bc9bd..a22c1a3fb85 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -20,6 +20,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -50,6 +51,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 0fb6cff3d51..a99831d1440 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -28,6 +28,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -58,6 +59,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -119,6 +121,7 @@ 'step': 1, }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -149,6 +152,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 2998583f8b3..ca3b0a5dc6e 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -30,6 +30,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -60,6 +61,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -259,6 +261,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -289,6 +292,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -350,6 +354,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -380,6 +385,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -441,6 +447,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -471,6 +478,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index ee3a72ba872..99358153fe1 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -21,6 +21,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -51,6 +52,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -103,6 +105,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -133,6 +136,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -186,6 +190,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -216,6 +221,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( @@ -269,6 +275,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -299,6 +306,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://127.0.0.1', 'connections': set({ tuple( diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr index 0456f074d49..53b2f6205cb 100644 --- a/tests/components/wmspro/snapshots/test_cover.ambr +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), diff --git a/tests/components/wmspro/snapshots/test_light.ambr b/tests/components/wmspro/snapshots/test_light.ambr index d13e444645d..d6ccebfb5ea 100644 --- a/tests/components/wmspro/snapshots/test_light.ambr +++ b/tests/components/wmspro/snapshots/test_light.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'terrasse', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), diff --git a/tests/components/wmspro/snapshots/test_scene.ambr b/tests/components/wmspro/snapshots/test_scene.ambr index 940d4e31e83..b5dddb368c9 100644 --- a/tests/components/wmspro/snapshots/test_scene.ambr +++ b/tests/components/wmspro/snapshots/test_scene.ambr @@ -17,6 +17,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'raum_0', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://webcontrol/control', 'connections': set({ }), diff --git a/tests/components/workday/snapshots/test_diagnostics.ambr b/tests/components/workday/snapshots/test_diagnostics.ambr index f41b86b7f6d..e7331b911a8 100644 --- a/tests/components/workday/snapshots/test_diagnostics.ambr +++ b/tests/components/workday/snapshots/test_diagnostics.ambr @@ -40,6 +40,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index bdead0f2028..d288c531407 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -36,10 +36,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'hassio', + 'subentries': list([ + ]), 'title': 'Piper', 'unique_id': '1234', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Piper', 'type': , 'version': 1, @@ -82,10 +86,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'hassio', + 'subentries': list([ + ]), 'title': 'Piper', 'unique_id': '1234', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Piper', 'type': , 'version': 1, @@ -127,10 +135,14 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'zeroconf', + 'subentries': list([ + ]), 'title': 'Test Satellite', 'unique_id': 'test_zeroconf_name._wyoming._tcp.local._Test Satellite', 'version': 1, }), + 'subentries': tuple( + ), 'title': 'Test Satellite', 'type': , 'version': 1, diff --git a/tests/components/yale/snapshots/test_binary_sensor.ambr b/tests/components/yale/snapshots/test_binary_sensor.ambr index e294cb7c76c..9db0d760efb 100644 --- a/tests/components/yale/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale/snapshots/test_binary_sensor.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'tmt100_name', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ }), diff --git a/tests/components/yale/snapshots/test_lock.ambr b/tests/components/yale/snapshots/test_lock.ambr index b1a9f6a4d86..00653a9b0c1 100644 --- a/tests/components/yale/snapshots/test_lock.ambr +++ b/tests/components/yale/snapshots/test_lock.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'online_with_doorsense_name', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'https://account.aaecosystem.com', 'connections': set({ tuple( diff --git a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr index fcdb7baca03..daa232ab141 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_alarm_control_panel.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr index e519a880de9..39b3ef09196 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_binary_sensor.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -53,6 +54,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -100,6 +102,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -147,6 +150,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -194,6 +198,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -241,6 +246,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -288,6 +294,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -335,6 +342,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -382,6 +390,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -429,6 +438,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_button.ambr b/tests/components/yale_smart_alarm/snapshots/test_button.ambr index 951caced170..7d52d1d7206 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_button.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_button.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_lock.ambr b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr index 34da7db087a..e7c97b9001b 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_lock.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -54,6 +55,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -102,6 +104,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -150,6 +153,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -198,6 +202,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -246,6 +251,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_select.ambr b/tests/components/yale_smart_alarm/snapshots/test_select.ambr index 52ec7a99c2c..2899e716ea1 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_select.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_select.ambr @@ -12,6 +12,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -69,6 +70,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -126,6 +128,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -183,6 +186,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -240,6 +244,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -297,6 +302,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/yale_smart_alarm/snapshots/test_switch.ambr b/tests/components/yale_smart_alarm/snapshots/test_switch.ambr index f631a6fcbfe..17c44bf6ebf 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_switch.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_switch.ambr @@ -6,6 +6,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -52,6 +53,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -98,6 +100,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -144,6 +147,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -190,6 +194,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -236,6 +241,7 @@ 'area_id': None, 'capabilities': None, 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/youless/snapshots/test_sensor.ambr b/tests/components/youless/snapshots/test_sensor.ambr index 9e79b5b9b5e..8cb28776d74 100644 --- a/tests/components/youless/snapshots/test_sensor.ambr +++ b/tests/components/youless/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -110,6 +112,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -161,6 +164,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -212,6 +216,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -263,6 +268,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -314,6 +320,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -365,6 +372,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -416,6 +424,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -467,6 +476,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -518,6 +528,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -569,6 +580,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -620,6 +632,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -671,6 +684,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -725,6 +739,7 @@ ]), }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -778,6 +793,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -829,6 +845,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -880,6 +897,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -931,6 +949,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -982,6 +1001,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1033,6 +1053,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -1084,6 +1105,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/zeversolar/snapshots/test_sensor.ambr b/tests/components/zeversolar/snapshots/test_sensor.ambr index aaef2c43d79..f948eec79df 100644 --- a/tests/components/zeversolar/snapshots/test_sensor.ambr +++ b/tests/components/zeversolar/snapshots/test_sensor.ambr @@ -8,6 +8,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, @@ -59,6 +60,7 @@ 'state_class': , }), 'config_entry_id': , + 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index c9a5e80b1c9..7a599b00a21 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -113,6 +113,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 4, diff --git a/tests/helpers/snapshots/test_entity_platform.ambr b/tests/helpers/snapshots/test_entity_platform.ambr index 84cbb07bd73..55ff772e08e 100644 --- a/tests/helpers/snapshots/test_entity_platform.ambr +++ b/tests/helpers/snapshots/test_entity_platform.ambr @@ -3,6 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': 'heliport', 'config_entries': , + 'config_entries_subentries': , 'configuration_url': 'http://192.168.0.100/config', 'connections': set({ tuple( @@ -35,3 +36,40 @@ 'via_device_id': , }) # --- +# name: test_device_info_called.1 + DeviceRegistryEntrySnapshot({ + 'area_id': 'heliport', + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'http://192.168.0.100/config', + 'connections': set({ + tuple( + 'mac', + 'efgh', + ), + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': 'test-hw', + 'id': , + 'identifiers': set({ + tuple( + 'hue', + 'efgh', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'test-manuf', + 'model': 'test-model', + 'model_id': None, + 'name': 'test-name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Heliport', + 'sw_version': 'test-sw', + 'via_device_id': , + }) +# --- diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index be4ace87894..29edfb3fea7 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -173,6 +173,109 @@ async def test_multiple_config_entries( assert entry3.primary_config_entry == config_entry_1.entry_id +async def test_multiple_config_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we do not get duplicate entries.""" + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_2.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} + entry_id = entry.id + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id=None, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.id == entry_id + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == {config_entry_1.entry_id: {None}} + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.id == entry_id + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} + } + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.id == entry_id + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"} + } + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert entry.id == entry_id + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + @pytest.mark.parametrize("load_registries", [False]) @pytest.mark.usefixtures("freezer") async def test_loading_from_storage( @@ -191,6 +294,7 @@ async def test_loading_from_storage( { "area_id": "12345A", "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": "https://example.com/config", "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": created_at, @@ -215,6 +319,7 @@ async def test_loading_from_storage( "deleted_devices": [ { "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "connections": [["Zigbee", "23.45.67.89.01"]], "created_at": created_at, "id": "bcdefghijklmn", @@ -233,6 +338,7 @@ async def test_loading_from_storage( assert registry.deleted_devices["bcdefghijklmn"] == dr.DeletedDeviceEntry( config_entries={mock_config_entry.entry_id}, + config_entries_subentries={mock_config_entry.entry_id: {None}}, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), id="bcdefghijklmn", @@ -251,6 +357,7 @@ async def test_loading_from_storage( assert entry == dr.DeviceEntry( area_id="12345A", config_entries={mock_config_entry.entry_id}, + config_entries_subentries={mock_config_entry.entry_id: {None}}, configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, created_at=datetime.fromisoformat(created_at), @@ -285,6 +392,7 @@ async def test_loading_from_storage( ) assert entry == dr.DeviceEntry( config_entries={mock_config_entry.entry_id}, + config_entries_subentries={mock_config_entry.entry_id: {None}}, connections={("Zigbee", "23.45.67.89.01")}, created_at=datetime.fromisoformat(created_at), id="bcdefghijklmn", @@ -384,6 +492,7 @@ async def test_migration_from_1_1( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -407,6 +516,7 @@ async def test_migration_from_1_1( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -431,6 +541,7 @@ async def test_migration_from_1_1( "deleted_devices": [ { "config_entries": ["123456"], + "config_entries_subentries": {"123456": [None]}, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "id": "deletedid", @@ -528,6 +639,7 @@ async def test_migration_from_1_2( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -551,6 +663,7 @@ async def test_migration_from_1_2( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -662,6 +775,7 @@ async def test_migration_fom_1_3( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -685,6 +799,7 @@ async def test_migration_fom_1_3( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -798,6 +913,7 @@ async def test_migration_from_1_4( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -821,6 +937,7 @@ async def test_migration_from_1_4( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -936,6 +1053,7 @@ async def test_migration_from_1_5( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -959,6 +1077,7 @@ async def test_migration_from_1_5( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1076,6 +1195,7 @@ async def test_migration_from_1_6( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1099,6 +1219,7 @@ async def test_migration_from_1_6( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1218,6 +1339,7 @@ async def test_migration_from_1_7( { "area_id": None, "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, "configuration_url": None, "connections": [["Zigbee", "01.23.45.67.89"]], "created_at": "1970-01-01T00:00:00+00:00", @@ -1241,6 +1363,7 @@ async def test_migration_from_1_7( { "area_id": None, "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, "configuration_url": None, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", @@ -1303,6 +1426,10 @@ async def test_removing_config_entries( assert entry.id == entry2.id assert entry.id != entry3.id assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry2.config_entries_subentries == { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + } device_registry.async_clear_config_entry(config_entry_1.entry_id) entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) @@ -1311,6 +1438,7 @@ async def test_removing_config_entries( ) assert entry.config_entries == {config_entry_2.entry_id} + assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} assert entry3_removed is None await hass.async_block_till_done() @@ -1325,6 +1453,7 @@ async def test_removing_config_entries( "device_id": entry.id, "changes": { "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, }, } assert update_events[2].data == { @@ -1336,6 +1465,10 @@ async def test_removing_config_entries( "device_id": entry.id, "changes": { "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + }, "primary_config_entry": config_entry_1.entry_id, }, } @@ -1382,6 +1515,10 @@ async def test_deleted_device_removing_config_entries( assert entry.id == entry2.id assert entry.id != entry3.id assert entry2.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry2.config_entries_subentries == { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + } device_registry.async_remove_device(entry.id) device_registry.async_remove_device(entry3.id) @@ -1400,6 +1537,7 @@ async def test_deleted_device_removing_config_entries( "device_id": entry2.id, "changes": { "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, }, } assert update_events[2].data == { @@ -1418,10 +1556,16 @@ async def test_deleted_device_removing_config_entries( device_registry.async_clear_config_entry(config_entry_1.entry_id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == {config_entry_2.entry_id} + assert entry.config_entries_subentries == {config_entry_2.entry_id: {None}} device_registry.async_clear_config_entry(config_entry_2.entry_id) assert len(device_registry.devices) == 0 assert len(device_registry.deleted_devices) == 2 + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == set() + assert entry.config_entries_subentries == {} # No event when a deleted device is purged await hass.async_block_till_done() @@ -1454,6 +1598,427 @@ async def test_deleted_device_removing_config_entries( assert entry3.id != entry4.id +async def test_removing_config_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we do not get duplicate entries.""" + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_2.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry3 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry4 = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "4567")}, + manufacturer="manufacturer", + model="model", + ) + + assert len(device_registry.devices) == 1 + assert entry.id == entry2.id + assert entry.id == entry3.id + assert entry.id == entry4.id + assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry4.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + device_registry.async_update_device( + entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id=None, + ) + entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") + entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") + entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) + assert entry.config_entries == {config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_2.entry_id: {"mock-subentry-id-2-1"} + } + + hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") + assert device_registry.async_get_device(identifiers={("bridgeid", "0123")}) is None + assert device_registry.async_get_device(identifiers={("bridgeid", "4567")}) is None + + await hass.async_block_till_done() + + assert len(update_events) == 8 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": {config_entry_1.entry_id: {None}}, + }, + } + assert update_events[2].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} + }, + }, + } + assert update_events[3].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + None, + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + } + }, + "identifiers": {("bridgeid", "0123")}, + }, + } + assert update_events[4].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: { + None, + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: { + "mock-subentry-id-2-1", + }, + }, + }, + } + assert update_events[5].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: { + "mock-subentry-id-2-1", + }, + }, + }, + } + assert update_events[6].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: { + "mock-subentry-id-2-1", + }, + }, + "primary_config_entry": config_entry_1.entry_id, + }, + } + assert update_events[7].data == { + "action": "remove", + "device_id": entry.id, + } + + +async def test_deleted_device_removing_config_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we do not get duplicate entries.""" + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_2.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry2 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry3 = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry4 = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "4567")}, + manufacturer="manufacturer", + model="model", + ) + + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + assert entry.id == entry2.id + assert entry.id == entry3.id + assert entry.id == entry4.id + assert entry4.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry4.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + device_registry.async_remove_device(entry.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + + await hass.async_block_till_done() + + assert len(update_events) == 5 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": {config_entry_1.entry_id: {None}}, + }, + } + assert update_events[2].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: {None, "mock-subentry-id-1-1"} + }, + }, + } + assert update_events[3].data == { + "action": "update", + "device_id": entry.id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + None, + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + } + }, + "identifiers": {("bridgeid", "0123")}, + }, + } + assert update_events[4].data == { + "action": "remove", + "device_id": entry.id, + } + + device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + assert entry.orphaned_timestamp is None + + hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-1") + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + assert entry.orphaned_timestamp is None + + # Remove the same subentry again + device_registry.async_clear_config_subentry( + config_entry_1.entry_id, "mock-subentry-id-1-1" + ) + assert ( + device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) is entry + ) + + hass.config_entries.async_remove_subentry(config_entry_1, "mock-subentry-id-1-2") + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == {config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_2.entry_id: {"mock-subentry-id-2-1"} + } + assert entry.orphaned_timestamp is None + + hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == set() + assert entry.config_entries_subentries == {} + assert entry.orphaned_timestamp is not None + + # No event when a deleted device is purged + await hass.async_block_till_done() + assert len(update_events) == 5 + + # Re-add, expect to keep the device id + hass.config_entries.async_add_subentry( + config_entry_2, + config_entries.ConfigSubentry( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + restored_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert restored_entry.id == entry.id + + # Remove again, and trigger purge + device_registry.async_remove_device(entry.id) + hass.config_entries.async_remove_subentry(config_entry_2, "mock-subentry-id-2-1") + entry = device_registry.deleted_devices.get_entry({("bridgeid", "0123")}, None) + assert entry.config_entries == set() + assert entry.config_entries_subentries == {} + assert entry.orphaned_timestamp is not None + + future_time = time.time() + dr.ORPHANED_DEVICE_KEEP_SECONDS + 1 + + with patch("time.time", return_value=future_time): + device_registry.async_purge_expired_orphaned_devices() + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 0 + + # Re-add, expect to get a new device id after the purge + new_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + assert new_entry.id != entry.id + + async def test_removing_area_id( device_registry: dr.DeviceRegistry, mock_config_entry: MockConfigEntry ) -> None: @@ -1834,6 +2399,7 @@ async def test_update( assert updated_entry == dr.DeviceEntry( area_id="12345A", config_entries={mock_config_entry.entry_id}, + config_entries_subentries={mock_config_entry.entry_id: {None}}, configuration_url="https://example.com/config", connections={("mac", "65:43:21:fe:dc:ba")}, created_at=created_at, @@ -2090,6 +2656,7 @@ async def test_update_remove_config_entries( "device_id": entry2.id, "changes": { "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, }, } assert update_events[2].data == { @@ -2100,7 +2667,11 @@ async def test_update_remove_config_entries( "action": "update", "device_id": entry.id, "changes": { - "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id} + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + }, }, } assert update_events[4].data == { @@ -2112,6 +2683,11 @@ async def test_update_remove_config_entries( config_entry_2.entry_id, config_entry_3.entry_id, }, + "config_entries_subentries": { + config_entry_1.entry_id: {None}, + config_entry_2.entry_id: {None}, + config_entry_3.entry_id: {None}, + }, "primary_config_entry": config_entry_1.entry_id, }, } @@ -2119,7 +2695,11 @@ async def test_update_remove_config_entries( "action": "update", "device_id": entry2.id, "changes": { - "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id} + "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, + "config_entries_subentries": { + config_entry_2.entry_id: {None}, + config_entry_3.entry_id: {None}, + }, }, } assert update_events[6].data == { @@ -2128,6 +2708,282 @@ async def test_update_remove_config_entries( } +async def test_update_remove_config_subentries( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Make sure we do not get duplicate entries.""" + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + config_entry_1 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_1.add_to_hass(hass) + config_entry_2 = MockConfigEntry( + subentries_data=( + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ) + ) + config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry() + config_entry_3.add_to_hass(hass) + + entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer", + model="model", + ) + entry_id = entry.id + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1"} + } + + entry = device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_1.entry_id, + add_config_subentry_id="mock-subentry-id-1-2", + ) + assert entry.config_entries == {config_entry_1.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"} + } + + # Try adding the same subentry again + assert ( + device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_1.entry_id, + add_config_subentry_id="mock-subentry-id-1-2", + ) + is entry + ) + + entry = device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_2.entry_id, + add_config_subentry_id="mock-subentry-id-2-1", + ) + assert entry.config_entries == {config_entry_1.entry_id, config_entry_2.entry_id} + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + } + + entry = device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_3.entry_id, + add_config_subentry_id=None, + ) + assert entry.config_entries == { + config_entry_1.entry_id, + config_entry_2.entry_id, + config_entry_3.entry_id, + } + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-1", "mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + } + + # Try to add a subentry without specifying entry + with pytest.raises( + HomeAssistantError, + match="Can't add config subentry without specifying config entry", + ): + device_registry.async_update_device(entry_id, add_config_subentry_id="blabla") + + # Try to add an unknown subentry + with pytest.raises( + HomeAssistantError, + match=f"Config entry {config_entry_3.entry_id} has no subentry blabla", + ): + device_registry.async_update_device( + entry_id, + add_config_entry_id=config_entry_3.entry_id, + add_config_subentry_id="blabla", + ) + + # Try to remove a subentry without specifying entry + with pytest.raises( + HomeAssistantError, + match="Can't remove config subentry without specifying config entry", + ): + device_registry.async_update_device( + entry_id, remove_config_subentry_id="blabla" + ) + + assert len(device_registry.devices) == 1 + + entry = device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1-1", + ) + assert entry.config_entries == { + config_entry_1.entry_id, + config_entry_2.entry_id, + config_entry_3.entry_id, + } + assert entry.config_entries_subentries == { + config_entry_1.entry_id: {"mock-subentry-id-1-2"}, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + } + + # Try removing the same subentry again + assert ( + device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1-1", + ) + is entry + ) + + entry = device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1-2", + ) + assert entry.config_entries == {config_entry_2.entry_id, config_entry_3.entry_id} + assert entry.config_entries_subentries == { + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + } + + entry = device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_2.entry_id, + remove_config_subentry_id="mock-subentry-id-2-1", + ) + assert entry.config_entries == {config_entry_3.entry_id} + assert entry.config_entries_subentries == { + config_entry_3.entry_id: {None}, + } + + entry = device_registry.async_update_device( + entry_id, + remove_config_entry_id=config_entry_3.entry_id, + remove_config_subentry_id=None, + ) + assert entry is None + + await hass.async_block_till_done() + + assert len(update_events) == 8 + assert update_events[0].data == { + "action": "create", + "device_id": entry_id, + } + assert update_events[1].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: {"mock-subentry-id-1-1"} + }, + }, + } + assert update_events[2].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + } + }, + }, + } + assert update_events[3].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries": {config_entry_1.entry_id, config_entry_2.entry_id}, + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + }, + }, + } + assert update_events[4].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-1", + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + }, + }, + } + assert update_events[5].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries": { + config_entry_1.entry_id, + config_entry_2.entry_id, + config_entry_3.entry_id, + }, + "config_entries_subentries": { + config_entry_1.entry_id: { + "mock-subentry-id-1-2", + }, + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + }, + "primary_config_entry": config_entry_1.entry_id, + }, + } + assert update_events[6].data == { + "action": "update", + "device_id": entry_id, + "changes": { + "config_entries": {config_entry_2.entry_id, config_entry_3.entry_id}, + "config_entries_subentries": { + config_entry_2.entry_id: {"mock-subentry-id-2-1"}, + config_entry_3.entry_id: {None}, + }, + }, + } + assert update_events[7].data == { + "action": "remove", + "device_id": entry_id, + } + + async def test_update_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -2542,6 +3398,7 @@ async def test_restore_shared_device( "device_id": entry.id, "changes": { "config_entries": {config_entry_1.entry_id}, + "config_entries_subentries": {config_entry_1.entry_id: {None}}, "identifiers": {("entry_123", "0123")}, }, } @@ -2566,6 +3423,7 @@ async def test_restore_shared_device( "device_id": entry.id, "changes": { "config_entries": {config_entry_2.entry_id}, + "config_entries_subentries": {config_entry_2.entry_id: {None}}, "identifiers": {("entry_234", "2345")}, }, } @@ -2871,6 +3729,7 @@ async def test_loading_invalid_configuration_url_from_storage( { "area_id": None, "config_entries": ["1234"], + "config_entries_subentries": {"1234": [None]}, "configuration_url": "invalid", "connections": [], "created_at": "2024-01-01T00:00:00+00:00", diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index eb076eb9f25..ee9f9f09110 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -11,7 +11,7 @@ import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentryData from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, PERCENTAGE, EntityCategory from homeassistant.core import ( CoreState, @@ -36,7 +36,10 @@ from homeassistant.helpers.entity_component import ( DEFAULT_SCAN_INTERVAL, EntityComponent, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -223,7 +226,7 @@ async def test_set_scan_interval_via_platform(hass: HomeAssistant) -> None: def platform_setup( hass: HomeAssistant, config: ConfigType, - add_entities: entity_platform.AddEntitiesCallback, + add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Test the platform setup.""" @@ -862,13 +865,28 @@ async def test_setup_entry( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" - async_add_entities([MockEntity(name="test1", unique_id="unique")]) + async_add_entities([MockEntity(name="test1", unique_id="unique1")]) + async_add_entities( + [MockEntity(name="test2", unique_id="unique2")], + config_subentry_id="mock-subentry-id-1", + ) platform = MockPlatform(async_setup_entry=async_setup_entry) - config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry = MockConfigEntry( + entry_id="super-mock-id", + subentries_data=( + ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ), + ) config_entry.add_to_hass(hass) entity_platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform @@ -878,11 +896,16 @@ async def test_setup_entry( await hass.async_block_till_done() full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components - assert len(hass.states.async_entity_ids()) == 1 - assert len(entity_registry.entities) == 1 + assert len(hass.states.async_entity_ids()) == 2 + assert len(entity_registry.entities) == 2 entity_registry_entry = entity_registry.entities["test_domain.test1"] assert entity_registry_entry.config_entry_id == "super-mock-id" + assert entity_registry_entry.config_subentry_id is None + + entity_registry_entry = entity_registry.entities["test_domain.test2"] + assert entity_registry_entry.config_entry_id == "super-mock-id" + assert entity_registry_entry.config_subentry_id == "mock-subentry-id-1" async def test_setup_entry_platform_not_ready( @@ -1138,7 +1161,18 @@ async def test_device_info_called( snapshot: SnapshotAssertion, ) -> None: """Test device info is forwarded correctly.""" - config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry = MockConfigEntry( + entry_id="super-mock-id", + subentries_data=( + ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ), + ) config_entry.add_to_hass(hass) via = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -1151,7 +1185,7 @@ async def test_device_info_called( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1177,6 +1211,28 @@ async def test_device_info_called( ), ] ) + async_add_entities( + [ + # Valid device info + MockEntity( + unique_id="efgh", + device_info={ + "identifiers": {("hue", "efgh")}, + "configuration_url": "http://192.168.0.100/config", + "connections": {(dr.CONNECTION_NETWORK_MAC, "efgh")}, + "manufacturer": "test-manuf", + "model": "test-model", + "name": "test-name", + "sw_version": "test-sw", + "hw_version": "test-hw", + "suggested_area": "Heliport", + "entry_type": dr.DeviceEntryType.SERVICE, + "via_device": ("hue", "via-id"), + }, + ), + ], + config_subentry_id="mock-subentry-id-1", + ) platform = MockPlatform(async_setup_entry=async_setup_entry) entity_platform = MockEntityPlatform( @@ -1186,11 +1242,20 @@ async def test_device_info_called( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 2 + assert len(hass.states.async_entity_ids()) == 3 device = device_registry.async_get_device(identifiers={("hue", "1234")}) assert device == snapshot assert device.config_entries == {config_entry.entry_id} + assert device.config_entries_subentries == {config_entry.entry_id: {None}} + assert device.primary_config_entry == config_entry.entry_id + assert device.via_device_id == via.id + device = device_registry.async_get_device(identifiers={("hue", "efgh")}) + assert device == snapshot + assert device.config_entries == {config_entry.entry_id} + assert device.config_entries_subentries == { + config_entry.entry_id: {"mock-subentry-id-1"} + } assert device.primary_config_entry == config_entry.entry_id assert device.via_device_id == via.id @@ -1214,7 +1279,7 @@ async def test_device_info_not_overrides( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1267,7 +1332,7 @@ async def test_device_info_homeassistant_url( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1319,7 +1384,7 @@ async def test_device_info_change_to_no_url( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1391,7 +1456,7 @@ async def test_entity_disabled_by_device( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([entity_disabled]) @@ -1877,7 +1942,7 @@ async def test_setup_entry_with_entities_that_block_forever( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -1926,7 +1991,7 @@ async def test_cancellation_is_not_blocked( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -2024,7 +2089,7 @@ async def test_entity_name_influences_entity_id( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -2112,7 +2177,7 @@ async def test_translated_entity_name_influences_entity_id( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( @@ -2200,7 +2265,7 @@ async def test_translated_device_class_name_influences_entity_id( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([TranslatedDeviceClassEntity(device_class, has_entity_name)]) @@ -2262,7 +2327,7 @@ async def test_device_name_defaulting_config_entry( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([DeviceNameEntity()]) @@ -2318,7 +2383,7 @@ async def test_device_type_error_checking( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([DeviceNameEntity()]) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 19289b09f95..416f2d5121d 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -78,7 +78,19 @@ def test_get_or_create_updates_data( freezer: FrozenDateTimeFactory, ) -> None: """Test that we update data in get_or_create.""" - orig_config_entry = MockConfigEntry(domain="light") + config_subentry_id = "blabla" + orig_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id=config_subentry_id, + subentry_type="test", + title="Mock title", + unique_id="test", + ) + ], + ) orig_config_entry.add_to_hass(hass) orig_device_entry = device_registry.async_get_or_create( config_entry_id=orig_config_entry.entry_id, @@ -93,6 +105,7 @@ def test_get_or_create_updates_data( "5678", capabilities={"max": 100}, config_entry=orig_config_entry, + config_subentry_id=config_subentry_id, device_id=orig_device_entry.id, disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, @@ -114,6 +127,7 @@ def test_get_or_create_updates_data( "hue", capabilities={"max": 100}, config_entry_id=orig_config_entry.entry_id, + config_subentry_id=config_subentry_id, created_at=created, device_class=None, device_id=orig_device_entry.id, @@ -148,6 +162,7 @@ def test_get_or_create_updates_data( "5678", capabilities={"new-max": 150}, config_entry=new_config_entry, + config_subentry_id=None, device_id=new_device_entry.id, disabled_by=er.RegistryEntryDisabler.USER, entity_category=EntityCategory.DIAGNOSTIC, @@ -169,6 +184,7 @@ def test_get_or_create_updates_data( area_id=None, capabilities={"new-max": 150}, config_entry_id=new_config_entry.entry_id, + config_subentry_id=None, created_at=created, device_class=None, device_id=new_device_entry.id, @@ -496,6 +512,7 @@ async def test_load_bad_data( "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "device_id": None, @@ -526,6 +543,7 @@ async def test_load_bad_data( "capabilities": None, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "device_id": None, @@ -554,6 +572,7 @@ async def test_load_bad_data( "deleted_entities": [ { "config_entry_id": None, + "config_subentry_id": None, "created_at": "2024-02-14T12:00:00.900075+00:00", "entity_id": "test.test3", "id": "00003", @@ -564,6 +583,7 @@ async def test_load_bad_data( }, { "config_entry_id": None, + "config_subentry_id": None, "created_at": "2024-02-14T12:00:00.900075+00:00", "entity_id": "test.test4", "id": "00004", @@ -711,6 +731,118 @@ async def test_deleted_entity_removing_config_entry_id( assert entity_registry.deleted_entities[("light", "hue", "1234")] == deleted_entry2 +async def test_removing_config_subentry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that we update config subentry id in registry.""" + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + mock_config = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ) + ], + ) + mock_config.add_to_hass(hass) + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=mock_config, + config_subentry_id="mock-subentry-id-1", + ) + assert entry.config_subentry_id == "mock-subentry-id-1" + hass.config_entries.async_remove_subentry(mock_config, "mock-subentry-id-1") + + assert not entity_registry.entities + + await hass.async_block_till_done() + + assert len(update_events) == 2 + assert update_events[0].data == { + "action": "create", + "entity_id": entry.entity_id, + } + assert update_events[1].data == { + "action": "remove", + "entity_id": entry.entity_id, + } + + +async def test_deleted_entity_removing_config_subentry_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we update config subentry id in registry on deleted entity.""" + mock_config = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + mock_config.add_to_hass(hass) + + entry1 = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=mock_config, + config_subentry_id="mock-subentry-id-1", + ) + assert entry1.config_subentry_id == "mock-subentry-id-1" + entry2 = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + config_entry=mock_config, + config_subentry_id="mock-subentry-id-2", + ) + assert entry2.config_subentry_id == "mock-subentry-id-2" + entity_registry.async_remove(entry1.entity_id) + entity_registry.async_remove(entry2.entity_id) + + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 2 + deleted_entry1 = entity_registry.deleted_entities[("light", "hue", "5678")] + assert deleted_entry1.config_entry_id == "mock-id-1" + assert deleted_entry1.config_subentry_id == "mock-subentry-id-1" + assert deleted_entry1.orphaned_timestamp is None + deleted_entry2 = entity_registry.deleted_entities[("light", "hue", "1234")] + assert deleted_entry2.config_entry_id == "mock-id-1" + assert deleted_entry2.config_subentry_id == "mock-subentry-id-2" + assert deleted_entry2.orphaned_timestamp is None + + hass.config_entries.async_remove_subentry(mock_config, "mock-subentry-id-1") + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 2 + deleted_entry1 = entity_registry.deleted_entities[("light", "hue", "5678")] + assert deleted_entry1.config_entry_id is None + assert deleted_entry1.config_subentry_id is None + assert deleted_entry1.orphaned_timestamp is not None + assert entity_registry.deleted_entities[("light", "hue", "1234")] == deleted_entry2 + + async def test_removing_area_id(entity_registry: er.EntityRegistry) -> None: """Make sure we can clear area id.""" entry = entity_registry.async_get_or_create("light", "hue", "5678") @@ -766,6 +898,7 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) "capabilities": {}, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": "1970-01-01T00:00:00+00:00", "device_id": None, "disabled_by": None, @@ -944,6 +1077,7 @@ async def test_migration_1_11( "capabilities": {}, "categories": {}, "config_entry_id": None, + "config_subentry_id": None, "created_at": "1970-01-01T00:00:00+00:00", "device_id": None, "disabled_by": None, @@ -972,6 +1106,7 @@ async def test_migration_1_11( "deleted_entities": [ { "config_entry_id": None, + "config_subentry_id": None, "created_at": "1970-01-01T00:00:00+00:00", "entity_id": "test.deleted_entity", "id": "23456", @@ -1431,7 +1566,7 @@ async def test_remove_config_entry_from_device_removes_entities_2( config_entry_2.entry_id, } - # Create one entity for each config entry + # Create an entity without config entry entry_1 = entity_registry.async_get_or_create( "light", "hue", @@ -1451,6 +1586,208 @@ async def test_remove_config_entry_from_device_removes_entities_2( assert entity_registry.async_is_registered(entry_1.entity_id) +async def test_remove_config_subentry_from_device_removes_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we remove entities tied to a device when config subentry is removed.""" + config_entry_1 = MockConfigEntry( + domain="hue", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + config_entry_1.add_to_hass(hass) + + # Create device with three config subentries + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert device_entry.config_entries == {config_entry_1.entry_id} + assert device_entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, + } + + # Create one entity entry for each config entry or subentry + entry_1 = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + config_entry=config_entry_1, + config_subentry_id="mock-subentry-id-1", + device_id=device_entry.id, + ) + + entry_2 = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry_1, + config_subentry_id="mock-subentry-id-2", + device_id=device_entry.id, + ) + + entry_3 = entity_registry.async_get_or_create( + "sensor", + "device_tracker", + "6789", + config_entry=config_entry_1, + config_subentry_id=None, + device_id=device_entry.id, + ) + + assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) + + # Remove the first config subentry from the device, the entity associated with it + # should be removed + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1", + ) + await hass.async_block_till_done() + + assert device_registry.async_get(device_entry.id) + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) + + # Remove the second config subentry from the device, the entity associated with it + # should be removed + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id=None, + ) + await hass.async_block_till_done() + + assert device_registry.async_get(device_entry.id) + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert not entity_registry.async_is_registered(entry_3.entity_id) + + # Remove the third config subentry from the device, the entity associated with it + # (and the device itself) should be removed + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-2", + ) + await hass.async_block_till_done() + + assert not device_registry.async_get(device_entry.id) + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not entity_registry.async_is_registered(entry_2.entity_id) + assert not entity_registry.async_is_registered(entry_3.entity_id) + + +async def test_remove_config_subentry_from_device_removes_entities_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we don't remove entities with no config entry when device is modified.""" + config_entry_1 = MockConfigEntry( + domain="hue", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + config_entry_1.add_to_hass(hass) + + # Create device with three config subentries + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-1", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + config_subentry_id="mock-subentry-id-2", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry_1.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert device_entry.config_entries == {config_entry_1.entry_id} + assert device_entry.config_entries_subentries == { + config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, + } + + # Create an entity without config entry or subentry + entry_1 = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + device_id=device_entry.id, + ) + + assert entity_registry.async_is_registered(entry_1.entity_id) + + # Remove the first config subentry from the device + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id=None, + ) + await hass.async_block_till_done() + + assert device_registry.async_get(device_entry.id) + assert entity_registry.async_is_registered(entry_1.entity_id) + + # Remove the second config subentry from the device + device_registry.async_update_device( + device_entry.id, + remove_config_entry_id=config_entry_1.entry_id, + remove_config_subentry_id="mock-subentry-id-1", + ) + await hass.async_block_till_done() + + assert device_registry.async_get(device_entry.id) + assert entity_registry.async_is_registered(entry_1.entity_id) + + async def test_update_device_race( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1881,11 +2218,45 @@ async def test_unique_id_non_string( ) +@pytest.mark.parametrize( + ("create_kwargs", "migrate_kwargs", "new_subentry_id"), + [ + ({}, {}, None), + ({"config_subentry_id": None}, {}, None), + ({}, {"new_config_subentry_id": None}, None), + ({}, {"new_config_subentry_id": "mock-subentry-id-2"}, "mock-subentry-id-2"), + ( + {"config_subentry_id": "mock-subentry-id-1"}, + {"new_config_subentry_id": None}, + None, + ), + ( + {"config_subentry_id": "mock-subentry-id-1"}, + {"new_config_subentry_id": "mock-subentry-id-2"}, + "mock-subentry-id-2", + ), + ], +) def test_migrate_entity_to_new_platform( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + create_kwargs: dict, + migrate_kwargs: dict, + new_subentry_id: str | None, ) -> None: """Test migrate_entity_to_new_platform.""" - orig_config_entry = MockConfigEntry(domain="light") + orig_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) orig_config_entry.add_to_hass(hass) orig_unique_id = "5678" @@ -1900,6 +2271,7 @@ def test_migrate_entity_to_new_platform( original_device_class="mock-device-class", original_icon="initial-original_icon", original_name="initial-original_name", + **create_kwargs, ) assert entity_registry.async_get("light.light") is orig_entry entity_registry.async_update_entity( @@ -1908,7 +2280,18 @@ def test_migrate_entity_to_new_platform( icon="new_icon", ) - new_config_entry = MockConfigEntry(domain="light") + new_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) new_config_entry.add_to_hass(hass) new_unique_id = "1234" @@ -1917,6 +2300,7 @@ def test_migrate_entity_to_new_platform( "hue2", new_unique_id=new_unique_id, new_config_entry_id=new_config_entry.entry_id, + **migrate_kwargs, ) assert not entity_registry.async_get_entity_id("light", "hue", orig_unique_id) @@ -1924,6 +2308,7 @@ def test_migrate_entity_to_new_platform( assert (new_entry := entity_registry.async_get("light.light")) is not orig_entry assert new_entry.config_entry_id == new_config_entry.entry_id + assert new_entry.config_subentry_id == new_subentry_id assert new_entry.unique_id == new_unique_id assert new_entry.name == "new_name" assert new_entry.icon == "new_icon" @@ -1956,6 +2341,99 @@ def test_migrate_entity_to_new_platform( ) +def test_migrate_entity_to_new_platform_error_handling( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrate_entity_to_new_platform.""" + orig_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + orig_config_entry.add_to_hass(hass) + orig_unique_id = "5678" + + orig_entry = entity_registry.async_get_or_create( + "light", + "hue", + orig_unique_id, + suggested_object_id="light", + config_entry=orig_config_entry, + config_subentry_id="mock-subentry-id-1", + disabled_by=er.RegistryEntryDisabler.USER, + entity_category=EntityCategory.CONFIG, + original_device_class="mock-device-class", + original_icon="initial-original_icon", + original_name="initial-original_name", + ) + assert entity_registry.async_get("light.light") is orig_entry + + new_config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + new_config_entry.add_to_hass(hass) + new_unique_id = "1234" + + # Test migrating nonexisting entity + with pytest.raises(KeyError, match="'light.not_a_real_light'"): + entity_registry.async_update_entity_platform( + "light.not_a_real_light", + "hue2", + new_unique_id=new_unique_id, + new_config_entry_id=new_config_entry.entry_id, + ) + + # Test migrate entity without new config entry ID + with pytest.raises( + ValueError, + match="new_config_entry_id required because light.light is already linked to a config entry", + ): + entity_registry.async_update_entity_platform( + "light.light", + "hue3", + ) + + # Test migrate entity without new config subentry ID + with pytest.raises( + ValueError, + match="Can't change config entry without changing subentry", + ): + entity_registry.async_update_entity_platform( + "light.light", + "hue3", + new_config_entry_id=new_config_entry.entry_id, + ) + + # Test entity with a state + hass.states.async_set("light.light", "on") + with pytest.raises( + ValueError, match="Only entities that haven't been loaded can be migrated" + ): + entity_registry.async_update_entity_platform( + "light.light", + "hue2", + new_unique_id=new_unique_id, + new_config_entry_id=new_config_entry.entry_id, + ) + + async def test_restore_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -1963,13 +2441,28 @@ async def test_restore_entity( ) -> None: """Make sure entity registry id is stable and entity_id is reused if possible.""" update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) - config_entry = MockConfigEntry(domain="light") + config_entry = MockConfigEntry( + domain="light", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) config_entry.add_to_hass(hass) entry1 = entity_registry.async_get_or_create( "light", "hue", "1234", config_entry=config_entry ) entry2 = entity_registry.async_get_or_create( - "light", "hue", "5678", config_entry=config_entry + "light", + "hue", + "5678", + config_entry=config_entry, + config_subentry_id="mock-subentry-id-1-1", ) entry1 = entity_registry.async_update_entity( @@ -1993,8 +2486,11 @@ async def test_restore_entity( # entity_id is not restored assert attr.evolve(entry1, entity_id="light.hue_1234") == entry1_restored assert entry2 != entry2_restored - # Config entry is not restored - assert attr.evolve(entry2, config_entry_id=None) == entry2_restored + # Config entry and subentry are not restored + assert ( + attr.evolve(entry2, config_entry_id=None, config_subentry_id=None) + == entry2_restored + ) # Remove two of the entities again, then bump time entity_registry.async_remove(entry1_restored.entity_id) @@ -2305,3 +2801,132 @@ async def test_async_remove_thread_safety( match="Detected code that calls entity_registry.async_remove from a thread.", ): await hass.async_add_executor_job(entity_registry.async_remove, entry.entity_id) + + +async def test_subentry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test subentry error handling.""" + entry1 = MockConfigEntry( + domain="light", + entry_id="mock-id-1", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-1-2", + subentry_type="test", + title="Mock title", + unique_id="test", + ), + ], + ) + entry1.add_to_hass(hass) + entry2 = MockConfigEntry( + domain="light", + entry_id="mock-id-2", + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-2-1", + subentry_type="test", + title="Mock title", + unique_id="test", + ) + ], + ) + entry2.add_to_hass(hass) + + with pytest.raises( + ValueError, match="Config entry mock-id-1 has no subentry bad-subentry-id" + ): + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id="bad-subentry-id", + ) + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id="mock-subentry-id-1-1", + ) + assert entry.config_subentry_id == "mock-subentry-id-1-1" + + # Try updating subentry + with pytest.raises( + ValueError, match="Config entry mock-id-1 has no subentry bad-subentry-id" + ): + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id="bad-subentry-id", + ) + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id="mock-subentry-id-1-2", + ) + assert entry.config_subentry_id == "mock-subentry-id-1-2" + + with pytest.raises( + ValueError, match="Can't change config entry without changing subentry" + ): + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry2, + ) + + with pytest.raises( + ValueError, match="Config entry mock-id-2 has no subentry mock-subentry-id-1-2" + ): + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry2, + config_subentry_id="mock-subentry-id-1-2", + ) + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry2, + config_subentry_id="mock-subentry-id-2-1", + ) + assert entry.config_subentry_id == "mock-subentry-id-2-1" + + entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=entry1, + config_subentry_id=None, + ) + assert entry.config_subentry_id is None + + entry = entity_registry.async_update_entity( + entry.entity_id, + config_entry_id=entry2.entry_id, + config_subentry_id="mock-subentry-id-2-1", + ) + assert entry.config_subentry_id == "mock-subentry-id-2-1" diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr index 51e56f4874e..08b532677f4 100644 --- a/tests/snapshots/test_config_entries.ambr +++ b/tests/snapshots/test_config_entries.ambr @@ -16,6 +16,8 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', + 'subentries': list([ + ]), 'title': 'Mock Title', 'unique_id': None, 'version': 1, diff --git a/tests/syrupy.py b/tests/syrupy.py index 5b1e5faa23d..3c8e398f0f8 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -160,6 +160,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): attrs.asdict(data) | { "config_entries": ANY, + "config_entries_subentries": ANY, "id": ANY, } ) @@ -188,6 +189,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): attrs.asdict(data) | { "config_entry_id": ANY, + "config_subentry_id": ANY, "device_id": ANY, "id": ANY, "options": {k: dict(v) for k, v in data.options.items()}, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3ea1a16e898..420da8cdb59 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Generator +from contextlib import AbstractContextManager, nullcontext as does_not_raise from datetime import timedelta import logging import re @@ -905,7 +906,7 @@ async def test_entries_excludes_ignore_and_disabled( async def test_saving_and_loading( - hass: HomeAssistant, freezer: FrozenDateTimeFactory + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any] ) -> None: """Test that we're saving and loading correctly.""" mock_integration( @@ -922,7 +923,20 @@ async def test_saving_and_loading( async def async_step_user(self, user_input=None): """Test user step.""" await self.async_set_unique_id("unique") - return self.async_create_entry(title="Test Title", data={"token": "abcd"}) + subentries = [ + config_entries.ConfigSubentryData( + data={"foo": "bar"}, subentry_type="test", title="subentry 1" + ), + config_entries.ConfigSubentryData( + data={"sun": "moon"}, + subentry_type="test", + title="subentry 2", + unique_id="very_unique", + ), + ] + return self.async_create_entry( + title="Test Title", data={"token": "abcd"}, subentries=subentries + ) with mock_config_flow("test", TestFlow): await hass.config_entries.flow.async_init( @@ -971,6 +985,100 @@ async def test_saving_and_loading( # To execute the save await hass.async_block_till_done() + stored_data = hass_storage["core.config_entries"] + assert stored_data == { + "data": { + "entries": [ + { + "created_at": ANY, + "data": { + "token": "abcd", + }, + "disabled_by": None, + "discovery_keys": {}, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": True, + "pref_disable_polling": True, + "source": "user", + "subentries": [ + { + "data": {"foo": "bar"}, + "subentry_id": ANY, + "subentry_type": "test", + "title": "subentry 1", + "unique_id": None, + }, + { + "data": {"sun": "moon"}, + "subentry_id": ANY, + "subentry_type": "test", + "title": "subentry 2", + "unique_id": "very_unique", + }, + ], + "title": "Test Title", + "unique_id": "unique", + "version": 5, + }, + { + "created_at": ANY, + "data": { + "username": "bla", + }, + "disabled_by": None, + "discovery_keys": { + "test": [ + {"domain": "test", "key": "blah", "version": 1}, + ], + }, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Test 2 Title", + "unique_id": None, + "version": 3, + }, + { + "created_at": ANY, + "data": { + "username": "bla", + }, + "disabled_by": None, + "discovery_keys": { + "test": [ + {"domain": "test", "key": ["a", "b"], "version": 1}, + ], + }, + "domain": "test", + "entry_id": ANY, + "minor_version": 1, + "modified_at": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "subentries": [], + "title": "Test 2 Title", + "unique_id": None, + "version": 3, + }, + ], + }, + "key": "core.config_entries", + "minor_version": 5, + "version": 1, + } + # Now load written data in new config manager manager = config_entries.ConfigEntries(hass, {}) await manager.async_initialize() @@ -983,6 +1091,25 @@ async def test_saving_and_loading( ): assert orig.as_dict() == loaded.as_dict() + hass.config_entries.async_update_entry( + entry_1, + pref_disable_polling=False, + pref_disable_new_entities=False, + ) + + # To trigger the call_later + freezer.tick(1.0) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + + # Assert no data is lost when storing again + expected_stored_data = stored_data + expected_stored_data["data"]["entries"][0]["modified_at"] = ANY + expected_stored_data["data"]["entries"][0]["pref_disable_new_entities"] = False + expected_stored_data["data"]["entries"][0]["pref_disable_polling"] = False + assert hass_storage["core.config_entries"] == expected_stored_data | {} + @freeze_time("2024-02-14 12:00:00") async def test_as_dict(snapshot: SnapshotAssertion) -> None: @@ -1416,6 +1543,45 @@ async def test_update_entry_options_and_trigger_listener( assert len(update_listener_calls) == 1 +async def test_update_subentry_and_trigger_listener( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can update subentry and trigger listener.""" + entry = MockConfigEntry(domain="test", options={"first": True}) + entry.add_to_manager(manager) + update_listener_calls = [] + + subentry = config_entries.ConfigSubentry( + data={"test": "test"}, + subentry_type="test", + unique_id="test", + title="Mock title", + ) + + async def update_listener( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Test function.""" + assert entry.subentries == expected_subentries + update_listener_calls.append(None) + + entry.add_update_listener(update_listener) + + expected_subentries = {subentry.subentry_id: subentry} + assert manager.async_add_subentry(entry, subentry) is True + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 1 + + expected_subentries = {} + assert manager.async_remove_subentry(entry, subentry.subentry_id) is True + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 2 + + async def test_setup_raise_not_ready( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -1742,20 +1908,413 @@ async def test_entry_options_unknown_config_entry( mock_integration(hass, MockModule("test")) mock_platform(hass, "test.config_flow", None) - class TestFlow: - """Test flow.""" - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Test options flow.""" - with pytest.raises(config_entries.UnknownEntry): await manager.options.async_create_flow( "blah", context={"source": "test"}, data=None ) +async def test_create_entry_subentries( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test a config entry being created with subentries.""" + + subentrydata = config_entries.ConfigSubentryData( + data={"test": "test"}, + title="Mock title", + subentry_type="test", + unique_id="test", + ) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Mock setup.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_IMPORT}, + data={"data": "data", "subentry": subentrydata}, + ) + ) + return True + + async_setup_entry = AsyncMock(return_value=True) + mock_integration( + hass, + MockModule( + "comp", async_setup=mock_async_setup, async_setup_entry=async_setup_entry + ), + ) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_import(self, user_input): + """Test import step creating entry, with subentry.""" + return self.async_create_entry( + title="title", + data={"example": user_input["data"]}, + subentries=[user_input["subentry"]], + ) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + assert await async_setup_component(hass, "comp", {}) + + await hass.async_block_till_done() + + assert len(async_setup_entry.mock_calls) == 1 + + entries = hass.config_entries.async_entries("comp") + assert len(entries) == 1 + assert entries[0].supported_subentry_types == {} + assert entries[0].data == {"example": "data"} + assert len(entries[0].subentries) == 1 + subentry_id = list(entries[0].subentries)[0] + subentry = config_entries.ConfigSubentry( + data=subentrydata["data"], + subentry_id=subentry_id, + subentry_type="test", + title=subentrydata["title"], + unique_id="test", + ) + assert entries[0].subentries == {subentry_id: subentry} + + +async def test_entry_subentry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can add a subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": "test", + }, + ) + + assert entry.data == {"first": True} + assert entry.options == {} + subentry_id = list(entry.subentries)[0] + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="test", + ) + } + assert entry.supported_subentry_types == { + "test": {"supports_reconfigure": False} + } + + +async def test_entry_subentry_non_string( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test adding an invalid subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + with pytest.raises(HomeAssistantError): + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": 123, + }, + ) + + +@pytest.mark.parametrize("context", [None, {}, {"bla": "bleh"}]) +async def test_entry_subentry_no_context( + hass: HomeAssistant, manager: config_entries.ConfigEntries, context: dict | None +) -> None: + """Test starting a subentry flow without "source" in context.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow), pytest.raises(KeyError): + await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context=context, data=None + ) + + +@pytest.mark.parametrize( + ("unique_id", "expected_result"), + [(None, does_not_raise()), ("test", pytest.raises(HomeAssistantError))], +) +async def test_entry_subentry_duplicate( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + unique_id: str | None, + expected_result: AbstractContextManager, +) -> None: + """Test adding a duplicated subentry to an entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry( + domain="test", + data={"first": True}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={}, + subentry_id="blabla", + subentry_type="test", + title="Mock title", + unique_id=unique_id, + ) + ], + ) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + with expected_result: + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": unique_id, + }, + ) + + +async def test_entry_subentry_abort( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can abort subentry flow.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + assert await manager.subentries.async_finish_flow( + flow, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "test"} + ) + + +async def test_entry_subentry_unknown_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for an unknown config entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + + with pytest.raises(config_entries.UnknownEntry): + await manager.subentries.async_create_flow( + ("blah", "blah"), context={"source": "test"}, data=None + ) + + +async def test_entry_subentry_deleted_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to finish a subentry flow for a deleted config entry.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + flow = await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + flow.handler = (entry.entry_id, "test") # Set to keep reference to config entry + + await hass.config_entries.async_remove(entry.entry_id) + + with pytest.raises(config_entries.UnknownEntry): + await manager.subentries.async_finish_flow( + flow, + { + "data": {"second": True}, + "title": "Mock title", + "type": data_entry_flow.FlowResultType.CREATE_ENTRY, + "unique_id": "test", + }, + ) + + +async def test_entry_subentry_unsupported_subentry_type( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for a config entry without support.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(data_entry_flow.FlowHandler): + """Test subentry flow handler.""" + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with ( + mock_config_flow("test", TestFlow), + pytest.raises(data_entry_flow.UnknownHandler), + ): + await manager.subentries.async_create_flow( + ( + entry.entry_id, + "unknown", + ), + context={"source": "test"}, + data=None, + ) + + +async def test_entry_subentry_unsupported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test attempting to start a subentry flow for a config entry without support.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + with ( + mock_config_flow("test", TestFlow), + pytest.raises(data_entry_flow.UnknownHandler), + ): + await manager.subentries.async_create_flow( + (entry.entry_id, "test"), context={"source": "test"}, data=None + ) + + async def test_entry_setup_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -3909,21 +4468,20 @@ async def test_updating_entry_with_and_without_changes( assert manager.async_update_entry(entry) is False - for change in ( - {"data": {"second": True, "third": 456}}, - {"data": {"second": True}}, - {"minor_version": 2}, - {"options": {"hello": True}}, - {"pref_disable_new_entities": True}, - {"pref_disable_polling": True}, - {"title": "sometitle"}, - {"unique_id": "abcd1234"}, - {"version": 2}, + for change, expected_value in ( + ({"data": {"second": True, "third": 456}}, {"second": True, "third": 456}), + ({"data": {"second": True}}, {"second": True}), + ({"minor_version": 2}, 2), + ({"options": {"hello": True}}, {"hello": True}), + ({"pref_disable_new_entities": True}, True), + ({"pref_disable_polling": True}, True), + ({"title": "sometitle"}, "sometitle"), + ({"unique_id": "abcd1234"}, "abcd1234"), + ({"version": 2}, 2), ): assert manager.async_update_entry(entry, **change) is True key = next(iter(change)) - value = next(iter(change.values())) - assert getattr(entry, key) == value + assert getattr(entry, key) == expected_value assert manager.async_update_entry(entry, **change) is False assert manager.async_entry_for_domain_unique_id("test", "abc123") is None @@ -5457,6 +6015,7 @@ async def test_unhashable_unique_id_fails( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=unique_id, version=1, @@ -5492,6 +6051,7 @@ async def test_unhashable_unique_id_fails_on_update( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id="123", version=1, @@ -5522,6 +6082,7 @@ async def test_string_unique_id_no_warning( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id="123", version=1, @@ -5564,6 +6125,7 @@ async def test_hashable_unique_id( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=unique_id, version=1, @@ -5598,6 +6160,7 @@ async def test_no_unique_id_no_warning( minor_version=1, options={}, source="test", + subentries_data=(), title="title", unique_id=None, version=1, @@ -6522,6 +7085,7 @@ async def test_migration_from_1_2( "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "import", + "subentries": {}, "title": "Sun", "unique_id": None, "version": 1, From 53d011f345349cb95a93eef7833d64091e41340f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 10 Feb 2025 16:59:18 +0100 Subject: [PATCH 0356/1941] Improve inexogy logging when failed to update (#138210) --- homeassistant/components/discovergy/coordinator.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index d4ef87049b8..e3f26ad49f8 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -51,9 +51,7 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): ) except InvalidLogin as err: raise ConfigEntryAuthFailed( - f"Auth expired while fetching last reading for meter {self.meter.meter_id}" + "Auth expired while fetching last reading" ) from err except (HTTPError, DiscovergyClientError) as err: - raise UpdateFailed( - f"Error while fetching last reading for meter {self.meter.meter_id}" - ) from err + raise UpdateFailed(f"Error while fetching last reading: {err}") from err From e1817d466e460d83dc779099c0419c10a86d4de4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 10 Feb 2025 17:09:57 +0100 Subject: [PATCH 0357/1941] Keep one backup per backup agent when executing retention policy (#138189) * Keep one backup per backup agent when executing retention policy * Add tests * Use defaultdict instead of dict.setdefault * Update hassio tests --- homeassistant/components/backup/manager.py | 74 ++++- tests/components/backup/test_websocket.py | 313 +++++++++++++++++- tests/components/hassio/test_update.py | 9 + tests/components/hassio/test_websocket_api.py | 9 + 4 files changed, 374 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index afca501d450..e175ff9c03d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,6 +4,7 @@ from __future__ import annotations import abc import asyncio +from collections import defaultdict from collections.abc import AsyncIterator, Callable, Coroutine from dataclasses import dataclass, replace from enum import StrEnum @@ -677,10 +678,13 @@ class BackupManager: return None return with_automatic_settings - async def async_delete_backup(self, backup_id: str) -> dict[str, Exception]: + async def async_delete_backup( + self, backup_id: str, *, agent_ids: list[str] | None = None + ) -> dict[str, Exception]: """Delete a backup.""" agent_errors: dict[str, Exception] = {} - agent_ids = list(self.backup_agents) + if agent_ids is None: + agent_ids = list(self.backup_agents) delete_backup_results = await asyncio.gather( *( @@ -731,35 +735,71 @@ class BackupManager: # Run the include filter first to ensure we only consider backups that # should be included in the deletion process. backups = include_filter(backups) + backups_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(dict) + for backup_id, backup in backups.items(): + for agent_id in backup.agents: + backups_by_agent[agent_id][backup_id] = backup - LOGGER.debug("Total automatic backups: %s", backups) + LOGGER.debug("Backups returned by include filter: %s", backups) + LOGGER.debug( + "Backups returned by include filter by agent: %s", + {agent_id: list(backups) for agent_id, backups in backups_by_agent.items()}, + ) backups_to_delete = delete_filter(backups) + LOGGER.debug("Backups returned by delete filter: %s", backups_to_delete) + 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, - ) + backups_to_delete_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict( + dict + ) + for backup_id, backup in sorted( + backups_to_delete.items(), + key=lambda backup_item: backup_item[1].date, + ): + for agent_id in backup.agents: + backups_to_delete_by_agent[agent_id][backup_id] = backup + LOGGER.debug( + "Backups returned by delete filter by agent: %s", + { + agent_id: list(backups) + for agent_id, backups in backups_to_delete_by_agent.items() + }, + ) + for agent_id, to_delete_from_agent in backups_to_delete_by_agent.items(): + if len(to_delete_from_agent) >= len(backups_by_agent[agent_id]): + # Never delete the last backup. + last_backup = to_delete_from_agent.popitem() + LOGGER.debug( + "Keeping the last backup %s for agent %s", last_backup, agent_id + ) + + LOGGER.debug( + "Backups to delete by agent: %s", + { + agent_id: list(backups) + for agent_id, backups in backups_to_delete_by_agent.items() + }, ) - 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) + backup_ids_to_delete: dict[str, set[str]] = defaultdict(set) + for agent_id, to_delete in backups_to_delete_by_agent.items(): + for backup_id in to_delete: + backup_ids_to_delete[backup_id].add(agent_id) - LOGGER.debug("Backups to delete: %s", backups_to_delete) - - if not backups_to_delete: + if not backup_ids_to_delete: return - backup_ids = list(backups_to_delete) + backup_ids = list(backup_ids_to_delete) delete_results = await asyncio.gather( - *(self.async_delete_backup(backup_id) for backup_id in backups_to_delete) + *( + self.async_delete_backup(backup_id, agent_ids=list(agent_ids)) + for backup_id, agent_ids in backup_ids_to_delete.items() + ) ) agent_errors = { backup_id: error diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 263a36570e6..966cfbbef78 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -6,6 +6,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch from freezegun.api import FrozenDateTimeFactory import pytest +from pytest_unordered import unordered from syrupy import SnapshotAssertion from homeassistant.components.backup import ( @@ -20,6 +21,7 @@ from homeassistant.components.backup import ( from homeassistant.components.backup.agent import BackupAgentUnreachableError from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.components.backup.manager import ( + AgentBackupStatus, CreateBackupEvent, CreateBackupState, ManagerBackup, @@ -1800,21 +1802,25 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1839,21 +1845,25 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1878,11 +1888,13 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, @@ -1907,26 +1919,46 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1940,7 +1972,80 @@ async def test_config_schedule_logic( 1, 1, 1, - [call("backup-1")], + [ + call( + "backup-1", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ) + ], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 1, + [ + call( + "backup-1", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ) + ], ), ( { @@ -1951,26 +2056,31 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1984,7 +2094,10 @@ async def test_config_schedule_logic( 1, 1, 2, - [call("backup-1"), call("backup-2")], + [ + call("backup-1", agent_ids=["test.test-agent"]), + call("backup-2", agent_ids=["test.test-agent"]), + ], ), ( { @@ -1995,21 +2108,25 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2023,7 +2140,7 @@ async def test_config_schedule_logic( 1, 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( { @@ -2034,21 +2151,25 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2062,7 +2183,7 @@ async def test_config_schedule_logic( 1, 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( { @@ -2073,26 +2194,46 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2106,7 +2247,20 @@ async def test_config_schedule_logic( 1, 1, 3, - [call("backup-1"), call("backup-2"), call("backup-3")], + [ + call( + "backup-1", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + call( + "backup-2", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + call( + "backup-3", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + ], ), ( { @@ -2117,11 +2271,86 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 3, + [ + call( + "backup-1", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + call( + "backup-2", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + call("backup-3", agent_ids=["test.test-agent"]), + ], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 0, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2261,21 +2490,25 @@ async def test_config_retention_copies_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2297,21 +2530,25 @@ async def test_config_retention_copies_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2333,26 +2570,31 @@ async def test_config_retention_copies_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2363,7 +2605,7 @@ async def test_config_retention_copies_logic( 1, 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( { @@ -2374,26 +2616,31 @@ async def test_config_retention_copies_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2404,7 +2651,10 @@ async def test_config_retention_copies_logic( 1, 1, 2, - [call("backup-1"), call("backup-2")], + [ + call("backup-1", agent_ids=["test.test-agent"]), + call("backup-2", agent_ids=["test.test-agent"]), + ], ), ], ) @@ -2519,16 +2769,19 @@ async def test_config_retention_copies_logic_manual_backup( [], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2541,7 +2794,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), # No config update - No cleanup ( @@ -2549,16 +2802,19 @@ async def test_config_retention_copies_logic_manual_backup( [], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2586,16 +2842,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2608,7 +2867,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( None, @@ -2622,16 +2881,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2644,7 +2906,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( None, @@ -2658,16 +2920,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2694,21 +2959,25 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2721,7 +2990,10 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 2, - [call("backup-1"), call("backup-2")], + [ + call("backup-1", agent_ids=["test.test-agent"]), + call("backup-2", agent_ids=["test.test-agent"]), + ], ), ( None, @@ -2735,16 +3007,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2757,7 +3032,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( None, @@ -2771,16 +3046,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2793,7 +3071,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( None, @@ -2807,21 +3085,25 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2834,7 +3116,10 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 2, - [call("backup-1"), call("backup-2")], + [ + call("backup-1", agent_ids=["test.test-agent"]), + call("backup-2", agent_ids=["test.test-agent"]), + ], ), ], ) diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 332f2050cf2..83af302e1ce 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -10,6 +10,9 @@ from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest from homeassistant.components.backup import BackupManagerError, ManagerBackup + +# pylint: disable-next=hass-component-root-import +from homeassistant.components.backup.manager import AgentBackupStatus from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION @@ -348,34 +351,40 @@ async def test_update_addon_with_backup( ( { "backup-1": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "other"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "other"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "test"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-6": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "test"}, with_automatic_settings=True, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index bcac19e0fa3..e752b53ae7a 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -9,6 +9,9 @@ from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest from homeassistant.components.backup import BackupManagerError, ManagerBackup + +# pylint: disable-next=hass-component-root-import +from homeassistant.components.backup.manager import AgentBackupStatus from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, @@ -467,34 +470,40 @@ async def test_update_addon_with_backup( ( { "backup-1": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "other"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "other"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "test"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-6": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "test"}, with_automatic_settings=True, From 5c0ea9e845c9b6d6c11246af328b8bc4fd1df04d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 10 Feb 2025 09:11:40 -0700 Subject: [PATCH 0358/1941] Convert coinbase account amounts as floats to properly add them together (#137588) Convert coinbase account amounts as floats to properly add --- homeassistant/components/coinbase/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index a29154d9c1b..317759f820d 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -140,8 +140,10 @@ def get_accounts(client, version): API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID], API_ACCOUNT_NAME: account[API_ACCOUNT_NAME], API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY], - API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE] - + account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE], + API_ACCOUNT_AMOUNT: ( + float(account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]) + + float(account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE]) + ), ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT, } for account in accounts From 663860e9c2ec15285d066d5717173b8b9e727424 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 10 Feb 2025 19:47:41 +0100 Subject: [PATCH 0359/1941] Improve description in Intergas entry setup form (#138225) Improve description in Intergas entrry setup form --- homeassistant/components/incomfort/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 15e28b6e0b9..73ba88078a8 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Set up new Intergas gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.", + "description": "Set up new Intergas gateway. Note that some older systems might not accept 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%]", From e3fa7394460a78d1f571ccf9d0390fc34aaf216b Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 10 Feb 2025 19:52:28 +0100 Subject: [PATCH 0360/1941] Add caching to onedrive (#137950) * Add caching to onedrive * Move cache invalidation --- homeassistant/components/onedrive/backup.py | 101 +++++++++++--------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 9926bd9cbc7..343c332f384 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -3,10 +3,12 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine +from dataclasses import dataclass from functools import wraps from html import unescape from json import dumps, loads import logging +from time import time from typing import Any, Concatenate from aiohttp import ClientTimeout @@ -16,7 +18,7 @@ from onedrive_personal_sdk.exceptions import ( HashMismatchError, OneDriveException, ) -from onedrive_personal_sdk.models.items import File, Folder, ItemUpdate +from onedrive_personal_sdk.models.items import ItemUpdate from onedrive_personal_sdk.models.upload import FileInfo from homeassistant.components.backup import ( @@ -35,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours METADATA_VERSION = 2 +CACHE_TTL = 300 async def async_get_backup_agents( @@ -99,6 +102,15 @@ def handle_backup_errors[_R, **P]( return wrapper +@dataclass(kw_only=True) +class OneDriveBackup: + """Define a OneDrive backup.""" + + backup: AgentBackup + backup_file_id: str + metadata_file_id: str + + class OneDriveBackupAgent(BackupAgent): """OneDrive backup agent.""" @@ -115,24 +127,20 @@ class OneDriveBackupAgent(BackupAgent): self.name = entry.title assert entry.unique_id self.unique_id = entry.unique_id + self._backup_cache: dict[str, OneDriveBackup] = {} + self._cache_expiration = time() @handle_backup_errors async def async_download_backup( self, backup_id: str, **kwargs: Any ) -> AsyncIterator[bytes]: """Download a backup file.""" - metadata_item = await self._find_item_by_backup_id(backup_id) - if ( - metadata_item is None - or metadata_item.description is None - or "backup_file_id" not in metadata_item.description - ): + backups = await self._list_cached_backups() + if backup_id not in backups: raise BackupAgentError("Backup not found") - metadata_info = loads(unescape(metadata_item.description)) - stream = await self._client.download_drive_item( - metadata_info["backup_file_id"], timeout=TIMEOUT + backups[backup_id].backup_file_id, timeout=TIMEOUT ) return stream.iter_chunked(1024) @@ -181,6 +189,7 @@ class OneDriveBackupAgent(BackupAgent): path_or_id=metadata_file.id, data=ItemUpdate(description=dumps(metadata_description)), ) + self._cache_expiration = time() @handle_backup_errors async def async_delete_backup( @@ -189,28 +198,21 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - metadata_item = await self._find_item_by_backup_id(backup_id) - if ( - metadata_item is None - or metadata_item.description is None - or "backup_file_id" not in metadata_item.description - ): + backups = await self._list_cached_backups() + if backup_id not in backups: return - metadata_info = loads(unescape(metadata_item.description)) - await self._client.delete_drive_item(metadata_info["backup_file_id"]) - await self._client.delete_drive_item(metadata_item.id) + backup = backups[backup_id] + + await self._client.delete_drive_item(backup.backup_file_id) + await self._client.delete_drive_item(backup.metadata_file_id) + self._cache_expiration = time() @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" - items = await self._client.list_drive_items(self._folder_id) return [ - await self._download_backup_metadata(item.id) - for item in items - if item.description - and "backup_id" in item.description - and f'"metadata_version": {METADATA_VERSION}' in unescape(item.description) + backup.backup for backup in (await self._list_cached_backups()).values() ] @handle_backup_errors @@ -218,27 +220,34 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AgentBackup | None: """Return a backup.""" - metadata_file = await self._find_item_by_backup_id(backup_id) - if metadata_file is None or metadata_file.description is None: - return None + backups = await self._list_cached_backups() + return backups[backup_id].backup if backup_id in backups else None - return await self._download_backup_metadata(metadata_file.id) + async def _list_cached_backups(self) -> dict[str, OneDriveBackup]: + """List backups with a cache.""" + if time() <= self._cache_expiration: + return self._backup_cache - async def _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None: - """Find an item by backup ID.""" - return next( - ( - item - for item in await self._client.list_drive_items(self._folder_id) - if item.description - and backup_id in item.description - and f'"metadata_version": {METADATA_VERSION}' - in unescape(item.description) - ), - None, - ) + items = await self._client.list_drive_items(self._folder_id) - async def _download_backup_metadata(self, item_id: str) -> AgentBackup: - metadata_stream = await self._client.download_drive_item(item_id) - metadata_json = loads(await metadata_stream.read()) - return AgentBackup.from_dict(metadata_json) + async def download_backup_metadata(item_id: str) -> AgentBackup: + metadata_stream = await self._client.download_drive_item(item_id) + metadata_json = loads(await metadata_stream.read()) + return AgentBackup.from_dict(metadata_json) + + backups: dict[str, OneDriveBackup] = {} + for item in items: + if item.description and f'"metadata_version": {METADATA_VERSION}' in ( + metadata_description_json := unescape(item.description) + ): + backup = await download_backup_metadata(item.id) + metadata_description = loads(metadata_description_json) + backups[backup.backup_id] = OneDriveBackup( + backup=backup, + backup_file_id=metadata_description["backup_file_id"], + metadata_file_id=item.id, + ) + + self._cache_expiration = time() + CACHE_TTL + self._backup_cache = backups + return backups From 9ca93ebf12a2198f6db1b7212ee1ad6caa4621af Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Mon, 10 Feb 2025 20:04:57 +0100 Subject: [PATCH 0361/1941] bump pyHomee to 1.2.7 (#138212) --- homeassistant/components/homee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index d85ba25b6e7..e4622222be1 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.5"] + "requirements": ["pyHomee==1.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 924bf5cd31b..985496da9f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1773,7 +1773,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.5 +pyHomee==1.2.7 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2699793935..75a06a8e6ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1462,7 +1462,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.5 +pyHomee==1.2.7 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 From 21d5c5199c0a1b0a242c9d7391ef708045c0dcca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 20:08:16 +0100 Subject: [PATCH 0362/1941] Bump github/codeql-action from 3.28.8 to 3.28.9 (#138184) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.8 to 3.28.9. - [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.8...v3.28.9) --- 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 c1272759acc..a4469cde0d8 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.8 + uses: github/codeql-action/init@v3.28.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.8 + uses: github/codeql-action/analyze@v3.28.9 with: category: "/language:python" From c06ad5d7993f56a6b5ff10e30687c3399b452e58 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 10 Feb 2025 20:11:39 +0100 Subject: [PATCH 0363/1941] Update frontend to 20250210.0 (#138227) --- 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 d27785dcea5..912ce508e00 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==20250205.0"] + "requirements": ["home-assistant-frontend==20250210.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0f53b732c13..cdafeb97040 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.1 hass-nabucasa==0.89.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250205.0 +home-assistant-frontend==20250210.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 985496da9f8..e4755e99699 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250205.0 +home-assistant-frontend==20250210.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75a06a8e6ba..e8208b0d39c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250205.0 +home-assistant-frontend==20250210.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From 7aab1de72d27c89bed3ff402122447e1b8f5abf5 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 10 Feb 2025 13:18:12 -0600 Subject: [PATCH 0364/1941] Bump pyheos to v1.0.2 (#138224) Bump pyheos --- homeassistant/components/heos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/heos/conftest.py | 3 +++ tests/components/heos/snapshots/test_diagnostics.ambr | 5 +++++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 22dbbf4da28..72472760951 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "silver", - "requirements": ["pyheos==1.0.1"], + "requirements": ["pyheos==1.0.2"], "single_config_entry": true, "ssdp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index e4755e99699..2bb0e3de2e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.1 +pyheos==1.0.2 # homeassistant.components.hive pyhive-integration==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8208b0d39c..328483c710e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1619,7 +1619,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.1 +pyheos==1.0.2 # homeassistant.components.hive pyhive-integration==1.0.1 diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 5ec809b10e9..39937a8355f 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -110,6 +110,7 @@ def system_info_fixture() -> HeosSystem: "1.0.0", "127.0.0.1", NetworkType.WIRED, + True, ) return HeosSystem( "user@user.com", @@ -123,6 +124,7 @@ def system_info_fixture() -> HeosSystem: "1.0.0", "127.0.0.2", NetworkType.WIFI, + True, ), ], ) @@ -140,6 +142,7 @@ def players_fixture() -> dict[int, HeosPlayer]: model="HEOS Drive HS2" if i == 1 else "Speaker", serial="123456", version="1.0.0", + supported_version=True, line_out=LineOutLevelType.VARIABLE, is_muted=False, available=True, diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 9526e21ee94..98ce8a7bcbf 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -107,6 +107,7 @@ 'name': 'Test Player', 'network': 'wired', 'serial': '**REDACTED**', + 'supported_version': True, 'version': '1.0.0', }), 'hosts': list([ @@ -116,6 +117,7 @@ 'name': 'Test Player', 'network': 'wired', 'serial': '**REDACTED**', + 'supported_version': True, 'version': '1.0.0', }), dict({ @@ -124,6 +126,7 @@ 'name': 'Test Player 2', 'network': 'wifi', 'serial': '**REDACTED**', + 'supported_version': True, 'version': '1.0.0', }), ]), @@ -135,6 +138,7 @@ 'name': 'Test Player', 'network': 'wired', 'serial': '**REDACTED**', + 'supported_version': True, 'version': '1.0.0', }), ]), @@ -376,6 +380,7 @@ 'serial': '**REDACTED**', 'shuffle': False, 'state': 'stop', + 'supported_version': True, 'version': '1.0.0', 'volume': 25, }), From 5529fbdc94891ec8feecb052c04ed705cf5696d9 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 10 Feb 2025 20:18:31 +0100 Subject: [PATCH 0365/1941] Allow ignored IronOS devices to be set up from the user flow (#138223) --- .../components/iron_os/config_flow.py | 2 +- tests/components/iron_os/conftest.py | 14 +++++++++++ tests/components/iron_os/test_config_flow.py | 25 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/iron_os/config_flow.py b/homeassistant/components/iron_os/config_flow.py index 444db79c926..8509577114f 100644 --- a/homeassistant/components/iron_os/config_flow.py +++ b/homeassistant/components/iron_os/config_flow.py @@ -61,7 +61,7 @@ class IronOSConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry(title=title, data={}) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, True): address = discovery_info.address if ( diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index f14043c096e..63c7d129987 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -24,6 +24,7 @@ from pynecil import ( import pytest from homeassistant.components.iron_os import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS from tests.common import MockConfigEntry @@ -110,6 +111,19 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture(name="config_entry_ignored") +def mock_config_entry_ignored() -> MockConfigEntry: + """Mock Pinecil configuration entry for ignored device.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={}, + unique_id="c0:ff:ee:c0:ff:ee", + entry_id="1234567890", + source=SOURCE_IGNORE, + ) + + @pytest.fixture(name="ble_device") def mock_ble_device() -> Generator[MagicMock]: """Mock BLEDevice.""" diff --git a/tests/components/iron_os/test_config_flow.py b/tests/components/iron_os/test_config_flow.py index e1ac8fb9f00..88bef117c26 100644 --- a/tests/components/iron_os/test_config_flow.py +++ b/tests/components/iron_os/test_config_flow.py @@ -106,3 +106,28 @@ async def test_async_step_bluetooth_devices_already_setup( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("discovery") +async def test_async_step_user_setup_replaces_igonored_device( + hass: HomeAssistant, config_entry_ignored: AsyncMock +) -> None: + """Test the user initiated form can replace an ignored device.""" + + config_entry_ignored.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == {} + assert result["result"].unique_id == "c0:ff:ee:c0:ff:ee" From 68eb0d81c8910b3252c57fe84508e2dce49f61fc Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 10 Feb 2025 21:32:41 +0200 Subject: [PATCH 0366/1941] Fix LG webOS TV fails to setup when device is off (#137870) --- homeassistant/components/webostv/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index e505611db52..118ea7b32db 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -31,6 +31,7 @@ WEBOSTV_EXCEPTIONS = ( WebOsTvCommandError, aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError, + aiohttp.WSMessageTypeError, asyncio.CancelledError, asyncio.TimeoutError, ) From 12173a9d6252c28036b1367b5ee97a448eb35434 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 10 Feb 2025 20:34:12 +0100 Subject: [PATCH 0367/1941] Replace (wrong) xiaomi vacuum action key names with friendly names (#138214) Replace (wrong) xiaomi action key names with friendly names --- homeassistant/components/xiaomi_miio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index dd49ba502f0..75563b07559 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -509,7 +509,7 @@ }, "vacuum_remote_control_start": { "name": "Vacuum remote control start", - "description": "Starts remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`." + "description": "Starts remote control of the vacuum cleaner. You can then move it with the 'Vacuum remote control move' action, when done use 'Vacuum remote control stop'." }, "vacuum_remote_control_stop": { "name": "Vacuum remote control stop", @@ -517,7 +517,7 @@ }, "vacuum_remote_control_move": { "name": "Vacuum remote control move", - "description": "Remote controls the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`.", + "description": "Remote controls the vacuum cleaner, make sure you first set it in remote control mode with the 'Vacuum remote control start' action.", "fields": { "velocity": { "name": "Velocity", From 20f6bd309eb8c200ec86d60e04edd117de95d26a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 10 Feb 2025 20:34:38 +0100 Subject: [PATCH 0368/1941] Change light.turn_on and light.turn_off descriptions to match HA style (#138213) Change light.turn_on and turn_off descriptions to match HA style Also remove one excessive comma from the light.toggle action description. --- homeassistant/components/light/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index b874e48406e..c0f658c3a44 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -285,7 +285,7 @@ "services": { "turn_on": { "name": "[%key:common::action::turn_on%]", - "description": "Turn on one or more lights and adjust properties of the light, even when they are turned on already.", + "description": "Turns on one or more lights and adjusts their properties, even when they are turned on already.", "fields": { "transition": { "name": "[%key:component::light::common::field_transition_name%]", @@ -364,7 +364,7 @@ }, "turn_off": { "name": "[%key:common::action::turn_off%]", - "description": "Turn off one or more lights.", + "description": "Turns off one or more lights.", "fields": { "transition": { "name": "[%key:component::light::common::field_transition_name%]", @@ -383,7 +383,7 @@ }, "toggle": { "name": "[%key:common::action::toggle%]", - "description": "Toggles one or more lights, from on to off, or, off to on, based on their current state.", + "description": "Toggles one or more lights, from on to off, or off to on, based on their current state.", "fields": { "transition": { "name": "[%key:component::light::common::field_transition_name%]", From a62619894a5fa8db10461f435590613fe717a443 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 10 Feb 2025 20:36:10 +0100 Subject: [PATCH 0369/1941] Rework ondilo ico coordinator (#138204) Rework ondilo ico coordinators --- .../components/ondilo_ico/__init__.py | 4 +- .../components/ondilo_ico/coordinator.py | 158 ++++++++-- homeassistant/components/ondilo_ico/sensor.py | 75 +++-- .../components/ondilo_ico/fixtures/pool2.json | 2 +- tests/components/ondilo_ico/test_init.py | 276 +++++++++++++++++- 5 files changed, 454 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index 9a1fac6aba4..ddcd7ab8831 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -8,7 +8,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from .api import OndiloClient from .config_flow import OndiloIcoOAuth2FlowHandler from .const import DOMAIN -from .coordinator import OndiloIcoCoordinator +from .coordinator import OndiloIcoPoolsCoordinator from .oauth_impl import OndiloOauth2Implementation PLATFORMS = [Platform.SENSOR] @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - coordinator = OndiloIcoCoordinator( + coordinator = OndiloIcoPoolsCoordinator( hass, entry, OndiloClient(hass, entry, implementation) ) diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index 349dac7de72..7545f6d61e0 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -1,7 +1,10 @@ """Define an object to coordinate fetching Ondilo ICO data.""" -from dataclasses import dataclass -from datetime import timedelta +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from datetime import datetime, timedelta import logging from typing import Any @@ -9,25 +12,37 @@ from ondilo import OndiloError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from . import DOMAIN from .api import OndiloClient _LOGGER = logging.getLogger(__name__) +TIME_TO_NEXT_UPDATE = timedelta(hours=1, minutes=5) +UPDATE_LOCK = asyncio.Lock() + @dataclass -class OndiloIcoData: - """Class for storing the data.""" +class OndiloIcoPoolData: + """Store the pools the data.""" ico: dict[str, Any] pool: dict[str, Any] + measures_coordinator: OndiloIcoMeasuresCoordinator = field(init=False) + + +@dataclass +class OndiloIcoMeasurementData: + """Store the measurement data for one pool.""" + sensors: dict[str, Any] -class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]): - """Class to manage fetching Ondilo ICO data from API.""" +class OndiloIcoPoolsCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoPoolData]]): + """Fetch Ondilo ICO pools data from API.""" config_entry: ConfigEntry @@ -39,45 +54,138 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]): hass, logger=_LOGGER, config_entry=config_entry, - name=DOMAIN, - update_interval=timedelta(hours=1), + name=f"{DOMAIN}_pools", + update_interval=timedelta(minutes=20), ) self.api = api + self.config_entry = config_entry + self._device_registry = dr.async_get(self.hass) - async def _async_update_data(self) -> dict[str, OndiloIcoData]: - """Fetch data from API endpoint.""" + async def _async_update_data(self) -> dict[str, OndiloIcoPoolData]: + """Fetch pools data from API endpoint and update devices.""" + known_pools: set[str] = set(self.data) if self.data else set() try: - return await self.hass.async_add_executor_job(self._update_data) + async with UPDATE_LOCK: + data = await self.hass.async_add_executor_job(self._update_data) except OndiloError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - def _update_data(self) -> dict[str, OndiloIcoData]: - """Fetch data from API endpoint.""" + current_pools = set(data) + + new_pools = current_pools - known_pools + for pool_id in new_pools: + pool_data = data[pool_id] + pool_data.measures_coordinator = OndiloIcoMeasuresCoordinator( + self.hass, self.config_entry, self.api, pool_id + ) + self._device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, pool_data.ico["serial_number"])}, + manufacturer="Ondilo", + model="ICO", + name=pool_data.pool["name"], + sw_version=pool_data.ico["sw_version"], + ) + + removed_pools = known_pools - current_pools + for pool_id in removed_pools: + pool_data = self.data.pop(pool_id) + await pool_data.measures_coordinator.async_shutdown() + device_entry = self._device_registry.async_get_device( + identifiers={(DOMAIN, pool_data.ico["serial_number"])} + ) + if device_entry: + self._device_registry.async_update_device( + device_id=device_entry.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + for pool_id in current_pools: + pool_data = data[pool_id] + measures_coordinator = pool_data.measures_coordinator + measures_coordinator.set_next_refresh(pool_data) + if not measures_coordinator.data: + await measures_coordinator.async_refresh() + + return data + + def _update_data(self) -> dict[str, OndiloIcoPoolData]: + """Fetch pools data from API endpoint.""" res = {} pools = self.api.get_pools() _LOGGER.debug("Pools: %s", pools) error: OndiloError | None = None for pool in pools: pool_id = pool["id"] + if (data := self.data) and pool_id in data: + pool_data = res[pool_id] = data[pool_id] + pool_data.pool = pool + # Skip requesting new ICO data for known pools + # to avoid unnecessary API calls. + continue try: ico = self.api.get_ICO_details(pool_id) - if not ico: - _LOGGER.debug( - "The pool id %s does not have any ICO attached", pool_id - ) - continue - sensors = self.api.get_last_pool_measures(pool_id) except OndiloError as err: error = err _LOGGER.debug("Error communicating with API for %s: %s", pool_id, err) continue - res[pool_id] = OndiloIcoData( - ico=ico, - pool=pool, - sensors={sensor["data_type"]: sensor["value"] for sensor in sensors}, - ) + + if not ico: + _LOGGER.debug("The pool id %s does not have any ICO attached", pool_id) + continue + + res[pool_id] = OndiloIcoPoolData(ico=ico, pool=pool) if not res: if error: raise UpdateFailed(f"Error communicating with API: {error}") from error - raise UpdateFailed("No data available") return res + + +class OndiloIcoMeasuresCoordinator(DataUpdateCoordinator[OndiloIcoMeasurementData]): + """Fetch Ondilo ICO measurement data for one pool from API.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api: OndiloClient, + pool_id: str, + ) -> None: + """Initialize.""" + super().__init__( + hass, + config_entry=config_entry, + logger=_LOGGER, + name=f"{DOMAIN}_measures_{pool_id}", + ) + self.api = api + self._next_refresh: datetime | None = None + self._pool_id = pool_id + + async def _async_update_data(self) -> OndiloIcoMeasurementData: + """Fetch measurement data from API endpoint.""" + async with UPDATE_LOCK: + data = await self.hass.async_add_executor_job(self._update_data) + if next_refresh := self._next_refresh: + now = dt_util.utcnow() + # If we've missed the next refresh, schedule a refresh in one hour. + if next_refresh <= now: + next_refresh = now + timedelta(hours=1) + self.update_interval = next_refresh - now + + return data + + def _update_data(self) -> OndiloIcoMeasurementData: + """Fetch measurement data from API endpoint.""" + try: + sensors = self.api.get_last_pool_measures(self._pool_id) + except OndiloError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + return OndiloIcoMeasurementData( + sensors={sensor["data_type"]: sensor["value"] for sensor in sensors}, + ) + + def set_next_refresh(self, pool_data: OndiloIcoPoolData) -> None: + """Set next refresh of this coordinator.""" + last_update = datetime.fromisoformat(pool_data.pool["updated_at"]) + self._next_refresh = last_update + TIME_TO_NEXT_UPDATE diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 66b07335663..de755c5e8d0 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -15,14 +15,18 @@ from homeassistant.const import ( UnitOfElectricPotential, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import OndiloIcoCoordinator, OndiloIcoData +from .coordinator import ( + OndiloIcoMeasuresCoordinator, + OndiloIcoPoolData, + OndiloIcoPoolsCoordinator, +) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -73,50 +77,67 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Ondilo ICO sensors.""" + pools_coordinator: OndiloIcoPoolsCoordinator = hass.data[DOMAIN][entry.entry_id] + known_entities: set[str] = set() - coordinator: OndiloIcoCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities(get_new_entities(pools_coordinator, known_entities)) - async_add_entities( - OndiloICO(coordinator, pool_id, description) - for pool_id, pool in coordinator.data.items() - for description in SENSOR_TYPES - if description.key in pool.sensors - ) + @callback + def add_new_entities(): + """Add any new entities after update of the pools coordinator.""" + async_add_entities(get_new_entities(pools_coordinator, known_entities)) + + entry.async_on_unload(pools_coordinator.async_add_listener(add_new_entities)) -class OndiloICO(CoordinatorEntity[OndiloIcoCoordinator], SensorEntity): +@callback +def get_new_entities( + pools_coordinator: OndiloIcoPoolsCoordinator, + known_entities: set[str], +) -> list[OndiloICO]: + """Return new Ondilo ICO sensor entities.""" + entities = [] + for pool_id, pool_data in pools_coordinator.data.items(): + for description in SENSOR_TYPES: + measurement_id = f"{pool_id}-{description.key}" + if ( + measurement_id in known_entities + or (data := pool_data.measures_coordinator.data) is None + or description.key not in data.sensors + ): + continue + known_entities.add(measurement_id) + entities.append( + OndiloICO( + pool_data.measures_coordinator, description, pool_id, pool_data + ) + ) + + return entities + + +class OndiloICO(CoordinatorEntity[OndiloIcoMeasuresCoordinator], SensorEntity): """Representation of a Sensor.""" _attr_has_entity_name = True def __init__( self, - coordinator: OndiloIcoCoordinator, - pool_id: str, + coordinator: OndiloIcoMeasuresCoordinator, description: SensorEntityDescription, + pool_id: str, + pool_data: OndiloIcoPoolData, ) -> None: """Initialize sensor entity with data from coordinator.""" super().__init__(coordinator) self.entity_description = description - self._pool_id = pool_id - - data = self.pool_data - self._attr_unique_id = f"{data.ico['serial_number']}-{description.key}" + self._attr_unique_id = f"{pool_data.ico['serial_number']}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, data.ico["serial_number"])}, - manufacturer="Ondilo", - model="ICO", - name=data.pool["name"], - sw_version=data.ico["sw_version"], + identifiers={(DOMAIN, pool_data.ico["serial_number"])}, ) - @property - def pool_data(self) -> OndiloIcoData: - """Get pool data.""" - return self.coordinator.data[self._pool_id] - @property def native_value(self) -> StateType: """Last value of the sensor.""" - return self.pool_data.sensors[self.entity_description.key] + return self.coordinator.data.sensors[self.entity_description.key] diff --git a/tests/components/ondilo_ico/fixtures/pool2.json b/tests/components/ondilo_ico/fixtures/pool2.json index da0cb62d484..24e72b469f0 100644 --- a/tests/components/ondilo_ico/fixtures/pool2.json +++ b/tests/components/ondilo_ico/fixtures/pool2.json @@ -15,5 +15,5 @@ "latitude": 48.861783, "longitude": 2.337421 }, - "updated_at": "2024-01-01T01:00:00+0000" + "updated_at": "2024-01-01T01:05:00+0000" } diff --git a/tests/components/ondilo_ico/test_init.py b/tests/components/ondilo_ico/test_init.py index 67f68f27b3e..58b1e27987d 100644 --- a/tests/components/ondilo_ico/test_init.py +++ b/tests/components/ondilo_ico/test_init.py @@ -1,8 +1,10 @@ """Test Ondilo ICO initialization.""" +from datetime import datetime, timedelta from typing import Any from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from ondilo import OndiloError import pytest from syrupy import SnapshotAssertion @@ -13,7 +15,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_devices( @@ -63,6 +65,7 @@ async def test_get_pools_error( async def test_init_with_no_ico_attached( hass: HomeAssistant, mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, pool1: dict[str, Any], ) -> None: @@ -73,14 +76,104 @@ async def test_init_with_no_ico_attached( mock_ondilo_client.get_ICO_details.return_value = None await setup_integration(hass, config_entry, mock_ondilo_client) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + # No devices should be created + assert len(device_entries) == 0 # No sensor should be created assert len(hass.states.async_all()) == 0 # We should not have tried to retrieve pool measures mock_ondilo_client.get_last_pool_measures.assert_not_called() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("api", ["get_ICO_details", "get_last_pool_measures"]) +async def test_adding_pool_after_setup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + pool1: dict[str, Any], + two_pools: list[dict[str, Any]], + ico_details1: dict[str, Any], + ico_details2: dict[str, Any], +) -> None: + """Test adding one pool after integration setup.""" + mock_ondilo_client.get_pools.return_value = pool1 + mock_ondilo_client.get_ICO_details.return_value = ico_details1 + + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # One pool is created with 7 entities. + assert len(device_entries) == 1 + assert len(hass.states.async_all()) == 7 + + mock_ondilo_client.get_pools.return_value = two_pools + mock_ondilo_client.get_ICO_details.return_value = ico_details2 + + # Trigger a refresh of the pools coordinator. + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # Two pool have been created with 7 entities each. + assert len(device_entries) == 2 + assert len(hass.states.async_all()) == 14 + + +async def test_removing_pool_after_setup( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + pool1: dict[str, Any], + ico_details1: dict[str, Any], +) -> None: + """Test removing one pool after integration setup.""" + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # Two pools are created with 7 entities each. + assert len(device_entries) == 2 + assert len(hass.states.async_all()) == 14 + + mock_ondilo_client.get_pools.return_value = pool1 + mock_ondilo_client.get_ICO_details.return_value = ico_details1 + + # Trigger a refresh of the pools coordinator. + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # One pool is left with 7 entities. + assert len(device_entries) == 1 + assert len(hass.states.async_all()) == 7 + + +@pytest.mark.parametrize( + ("api", "devices", "config_entry_state"), + [ + ("get_ICO_details", 0, ConfigEntryState.SETUP_RETRY), + ("get_last_pool_measures", 1, ConfigEntryState.LOADED), + ], +) async def test_details_error_all_pools( hass: HomeAssistant, mock_ondilo_client: MagicMock, @@ -88,6 +181,8 @@ async def test_details_error_all_pools( config_entry: MockConfigEntry, pool1: dict[str, Any], api: str, + devices: int, + config_entry_state: ConfigEntryState, ) -> None: """Test details and measures error for all pools.""" mock_ondilo_client.get_pools.return_value = pool1 @@ -100,8 +195,8 @@ async def test_details_error_all_pools( device_registry, config_entry.entry_id ) - assert not device_entries - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert len(device_entries) == devices + assert config_entry.state is config_entry_state async def test_details_error_one_pool( @@ -131,12 +226,15 @@ async def test_details_error_one_pool( async def test_measures_error_one_pool( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_ondilo_client: MagicMock, device_registry: dr.DeviceRegistry, config_entry: MockConfigEntry, last_measures: list[dict[str, Any]], ) -> None: """Test measures error for one pool and success for the other.""" + entity_id_1 = "sensor.pool_1_temperature" + entity_id_2 = "sensor.pool_2_temperature" mock_ondilo_client.get_last_pool_measures.side_effect = [ OndiloError( 404, @@ -151,4 +249,170 @@ async def test_measures_error_one_pool( device_registry, config_entry.entry_id ) - assert len(device_entries) == 1 + assert len(device_entries) == 2 + # One pool returned an error, the other is ok. + # 7 entities are created for the second pool. + assert len(hass.states.async_all()) == 7 + assert hass.states.get(entity_id_1) is None + assert hass.states.get(entity_id_2) is not None + + # All pools now return measures. + mock_ondilo_client.get_last_pool_measures.side_effect = None + + # Move time to next pools coordinator refresh. + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert len(device_entries) == 2 + # 14 entities in total, 7 entities per pool. + assert len(hass.states.async_all()) == 14 + assert hass.states.get(entity_id_1) is not None + assert hass.states.get(entity_id_2) is not None + + +async def test_measures_scheduling( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test refresh scheduling of measures coordinator.""" + # Move time to 10 min after pool 1 was updated and 5 min after pool 2 was updated. + freezer.move_to("2024-01-01T01:10:00+00:00") + entity_id_1 = "sensor.pool_1_temperature" + entity_id_2 = "sensor.pool_2_temperature" + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + # Two pools are created with 7 entities each. + assert len(device_entries) == 2 + assert len(hass.states.async_all()) == 14 + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + + # Tick time by 20 min. + # The measures coordinators for both pools should not have been refreshed again. + freezer.tick(timedelta(minutes=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + + # Move time to 65 min after pool 1 was last updated. + # This is 5 min after we expect pool 1 to be updated again. + # The measures coordinator for pool 1 should refresh at this time. + # The measures coordinator for pool 2 should not have been refreshed again. + # The pools coordinator has updated the last update time + # of the pools to a stale time that is already passed. + freezer.move_to("2024-01-01T02:05:00+00:00") + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T02:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T01:10:00+00:00") + + # Tick time by 5 min. + # The measures coordinator for pool 1 should not have been refreshed again. + # The measures coordinator for pool 2 should refresh at this time. + # The pools coordinator has updated the last update time + # of the pools to a stale time that is already passed. + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T02:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T02:10:00+00:00") + + # Tick time by 55 min. + # The measures coordinator for pool 1 should refresh at this time. + # This is 1 hour after the last refresh of the measures coordinator for pool 1. + freezer.tick(timedelta(minutes=55)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T03:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T02:10:00+00:00") + + # Tick time by 5 min. + # The measures coordinator for pool 2 should refresh at this time. + # This is 1 hour after the last refresh of the measures coordinator for pool 2. + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T03:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T03:10:00+00:00") + + # Set an error on the pools coordinator endpoint. + # This will cause the pools coordinator to not update the next refresh. + # This should cause the measures coordinators to keep the 1 hour cadence. + mock_ondilo_client.get_pools.side_effect = OndiloError( + 502, + ( + " 502 Bad Gateway " + "

502 Bad Gateway

" + ), + ) + + # Tick time by 55 min. + # The measures coordinator for pool 1 should refresh at this time. + # This is 1 hour after the last refresh of the measures coordinator for pool 1. + freezer.tick(timedelta(minutes=55)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T04:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T03:10:00+00:00") + + # Tick time by 5 min. + # The measures coordinator for pool 2 should refresh at this time. + # This is 1 hour after the last refresh of the measures coordinator for pool 2. + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get(entity_id_1) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T04:05:00+00:00") + state = hass.states.get(entity_id_2) + assert state is not None + assert state.last_reported == datetime.fromisoformat("2024-01-01T04:10:00+00:00") From dc07f72fc2010a32fa24894c2182c643ebba592c Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 10 Feb 2025 20:37:54 +0100 Subject: [PATCH 0370/1941] Bump habiticalib to v0.3.7 (#137993) * bump habiticalib to 0.3.6 * bump to v0.3.7 --- .../components/habitica/coordinator.py | 12 +- .../components/habitica/diagnostics.py | 2 +- homeassistant/components/habitica/image.py | 7 +- .../components/habitica/manifest.json | 2 +- homeassistant/components/habitica/services.py | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/habitica/fixtures/user.json | 21 +- .../habitica/snapshots/test_diagnostics.ambr | 262 +----------------- 9 files changed, 48 insertions(+), 266 deletions(-) diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 19d31f18fd7..3c3a16f591a 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -11,6 +11,7 @@ from typing import Any from aiohttp import ClientError from habiticalib import ( + Avatar, ContentData, Habitica, HabiticaException, @@ -19,7 +20,6 @@ from habiticalib import ( TaskFilter, TooManyRequestsError, UserData, - UserStyles, ) from homeassistant.config_entries import ConfigEntry @@ -165,12 +165,10 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): else: await self.async_request_refresh() - async def generate_avatar(self, user_styles: UserStyles) -> bytes: + async def generate_avatar(self, avatar: Avatar) -> bytes: """Generate Avatar.""" - avatar = BytesIO() - await self.habitica.generate_avatar( - fp=avatar, user_styles=user_styles, fmt="PNG" - ) + png = BytesIO() + await self.habitica.generate_avatar(fp=png, avatar=avatar, fmt="PNG") - return avatar.getvalue() + return png.getvalue() diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py index 09b8b9ba0bb..40a6d75b366 100644 --- a/homeassistant/components/habitica/diagnostics.py +++ b/homeassistant/components/habitica/diagnostics.py @@ -23,5 +23,5 @@ async def async_get_config_entry_diagnostics( CONF_URL: config_entry.data[CONF_URL], CONF_API_USER: config_entry.data[CONF_API_USER], }, - "habitica_data": habitica_data.to_dict()["data"], + "habitica_data": habitica_data.to_dict(omit_none=False)["data"], } diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index b3b2fbb85a8..1e21cd73fdc 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -2,10 +2,9 @@ from __future__ import annotations -from dataclasses import asdict from enum import StrEnum -from habiticalib import UserStyles +from habiticalib import Avatar, extract_avatar from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant @@ -44,7 +43,7 @@ class HabiticaImage(HabiticaBase, ImageEntity): translation_key=HabiticaImageEntity.AVATAR, ) _attr_content_type = "image/png" - _current_appearance: UserStyles | None = None + _current_appearance: Avatar | None = None _cache: bytes | None = None def __init__( @@ -59,7 +58,7 @@ class HabiticaImage(HabiticaBase, ImageEntity): def _handle_coordinator_update(self) -> None: """Check if equipped gear and other things have changed since last avatar image generation.""" - new_appearance = UserStyles.from_dict(asdict(self.coordinator.data.user)) + new_appearance = extract_avatar(self.coordinator.data.user) if self._current_appearance != new_appearance: self._current_appearance = new_appearance diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 9ea346a0dcb..a58bd1296e0 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], - "requirements": ["habiticalib==0.3.5"] + "requirements": ["habiticalib==0.3.7"] } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 12d5b3e6ef8..59bcc8cc7cc 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -510,7 +510,9 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 or (task.notes and keyword in task.notes.lower()) or any(keyword in item.text.lower() for item in task.checklist) ] - result: dict[str, Any] = {"tasks": [task.to_dict() for task in response]} + result: dict[str, Any] = { + "tasks": [task.to_dict(omit_none=False) for task in response] + } return result diff --git a/requirements_all.txt b/requirements_all.txt index 2bb0e3de2e1..af1d0853ce8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1100,7 +1100,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.5 +habiticalib==0.3.7 # homeassistant.components.bluetooth habluetooth==3.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 328483c710e..9f57f38a8be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -941,7 +941,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.5 +habiticalib==0.3.7 # homeassistant.components.bluetooth habluetooth==3.21.1 diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 255d9c7c3b5..991f2db0ba8 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -143,6 +143,25 @@ "trinkets": 0 } } - } + }, + "webhooks": [ + { + "id": "43a67e37-1bae-4b11-8d3d-6c4b1b480231", + "type": "taskActivity", + "label": "My Webhook", + "url": "https://some-webhook-url.com", + "enabled": true, + "failures": 0, + "options": { + "created": false, + "updated": false, + "deleted": false, + "checklistScored": false, + "scored": true + }, + "createdAt": "2025-02-08T22:06:08.894Z", + "updatedAt": "2025-02-08T22:06:17.195Z" + } + ] } } diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index 2fe3513a646..718aea99ebc 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -8,48 +8,31 @@ 'habitica_data': dict({ 'tasks': list([ dict({ - 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, - 'completed': None, 'counterDown': 0, 'counterUp': 0, 'createdAt': '2024-10-10T15:57:14.287000+00:00', - 'date': None, 'daysOfMonth': list([ ]), 'down': False, - 'everyX': None, 'frequency': 'daily', 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ ]), 'id': '30923acd-3b4c-486d-9ef3-c8f57cf56049', - 'isDue': None, 'nextDue': list([ ]), 'notes': 'task notes', @@ -65,8 +48,6 @@ 'th': False, 'w': True, }), - 'startDate': None, - 'streak': None, 'tags': list([ ]), 'text': 'task text', @@ -77,51 +58,30 @@ 'value': 0.0, 'weeksOfMonth': list([ ]), - 'yesterDaily': None, }), dict({ - 'alias': None, 'attribute': 'str', 'byHabitica': True, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, 'completed': False, - 'counterDown': None, - 'counterUp': None, 'createdAt': '2024-10-10T15:57:14.290000+00:00', - 'date': None, 'daysOfMonth': list([ ]), - 'down': None, - 'everyX': None, - 'frequency': None, 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ ]), 'id': 'e6e06dc6-c887-4b86-b175-b99cc2e20fdf', - 'isDue': None, 'nextDue': list([ ]), 'notes': 'task notes', @@ -137,63 +97,38 @@ 'th': False, 'w': True, }), - 'startDate': None, - 'streak': None, 'tags': list([ ]), 'text': 'task text', 'type': 'todo', - 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': -6.418582324043852, 'weeksOfMonth': list([ ]), - 'yesterDaily': None, }), dict({ - 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, - 'completed': None, - 'counterDown': None, - 'counterUp': None, 'createdAt': '2024-10-10T15:57:14.290000+00:00', - 'date': None, 'daysOfMonth': list([ ]), - 'down': None, - 'everyX': None, - 'frequency': None, 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ ]), 'id': '2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9', - 'isDue': None, 'nextDue': list([ ]), 'notes': 'task notes', @@ -209,106 +144,73 @@ 'th': False, 'w': True, }), - 'startDate': None, - 'streak': None, 'tags': list([ ]), 'text': 'task text', 'type': 'reward', - 'up': None, 'updatedAt': '2024-10-10T15:57:14.290000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': 10.0, 'weeksOfMonth': list([ ]), - 'yesterDaily': None, }), dict({ - 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, 'completed': False, - 'counterDown': None, - 'counterUp': None, 'createdAt': '2024-10-10T15:57:14.304000+00:00', - 'date': None, 'daysOfMonth': list([ ]), - 'down': None, 'everyX': 1, 'frequency': 'weekly', 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ dict({ 'completed': True, 'date': '2024-10-30T19:37:01.817000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 1.0, }), dict({ 'completed': True, 'date': '2024-10-31T23:33:14.890000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 1.9747, }), dict({ 'completed': False, 'date': '2024-11-05T18:25:04.730000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 1.024043774264157, }), dict({ 'completed': False, 'date': '2024-11-21T15:09:07.573000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 0.049944135963563174, }), dict({ 'completed': False, 'date': '2024-11-22T00:41:21.228000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': -0.9487768368544092, }), dict({ 'completed': False, 'date': '2024-11-27T19:34:28.973000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': -1.973387732005249, }), ]), @@ -341,7 +243,6 @@ ]), 'text': 'task text', 'type': 'daily', - 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': -1.973387732005249, @@ -352,60 +253,23 @@ ]), 'user': dict({ 'achievements': dict({ - 'backToBasics': None, - 'boneCollector': None, 'challenges': list([ ]), 'completedTask': True, 'createdTask': True, - 'dustDevil': None, - 'fedPet': None, - 'goodAsGold': None, - 'hatchedPet': None, - 'joinedChallenge': None, - 'joinedGuild': None, - 'partyUp': None, 'perfect': 2, - 'primedForPainting': None, - 'purchasedEquipment': None, 'quests': dict({ - 'atom1': None, - 'atom2': None, - 'atom3': None, - 'bewilder': None, - 'burnout': None, - 'dilatory': None, - 'dilatory_derby': None, - 'dysheartener': None, - 'evilsanta': None, - 'evilsanta2': None, - 'gryphon': None, - 'harpy': None, - 'stressbeast': None, - 'vice1': None, - 'vice3': None, }), - 'seeingRed': None, - 'shadyCustomer': None, 'streak': 0, - 'tickledPink': None, 'ultimateGearSets': dict({ 'healer': False, 'rogue': False, 'warrior': False, 'wizard': False, }), - 'violetsAreBlue': None, }), 'auth': dict({ - 'apple': None, - 'facebook': None, - 'google': None, 'local': dict({ - 'email': None, - 'has_password': None, - 'lowerCaseUsername': None, - 'username': None, }), 'timestamps': dict({ 'created': '2024-10-10T15:57:01.106000+00:00', @@ -414,17 +278,11 @@ }), }), 'backer': dict({ - 'npc': None, - 'tier': None, - 'tokensApplied': None, }), 'balance': 0.0, 'challenges': list([ ]), 'contributor': dict({ - 'contributions': None, - 'level': None, - 'text': None, }), 'extra': dict({ }), @@ -433,23 +291,17 @@ 'armoireEnabled': True, 'armoireOpened': False, 'cardReceived': False, - 'chatRevoked': None, - 'chatShadowMuted': None, 'classSelected': False, 'communityGuidelinesAccepted': True, 'cronCount': 6, 'customizationsNotification': True, 'dropsEnabled': False, 'itemsEnabled': True, - 'lastFreeRebirth': None, 'lastNewStuffRead': '', 'lastWeeklyRecap': '2024-10-10T15:57:01.106000+00:00', - 'lastWeeklyRecapDiscriminator': None, 'levelDrops': dict({ }), - 'mathUpdates': None, 'newStuff': False, - 'onboardingEmailsPhase': None, 'rebirthEnabled': False, 'recaptureEmailsPhase': 0, 'rewrite': True, @@ -508,101 +360,53 @@ 'history': dict({ 'exp': list([ dict({ - 'completed': None, 'date': '2024-10-30T19:37:01.970000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 24.0, }), dict({ - 'completed': None, 'date': '2024-10-31T23:33:14.972000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 48.0, }), dict({ - 'completed': None, 'date': '2024-11-05T18:25:04.681000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), dict({ - 'completed': None, 'date': '2024-11-21T15:09:07.501000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), dict({ - 'completed': None, 'date': '2024-11-22T00:41:21.137000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), dict({ - 'completed': None, 'date': '2024-11-27T19:34:28.887000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), ]), 'todos': list([ dict({ - 'completed': None, 'date': '2024-10-30T19:37:01.970000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -5.0, }), dict({ - 'completed': None, 'date': '2024-10-31T23:33:14.972000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -10.129783523135325, }), dict({ - 'completed': None, 'date': '2024-11-05T18:25:04.681000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -16.396221153338182, }), dict({ - 'completed': None, 'date': '2024-11-21T15:09:07.501000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -22.8326979965846, }), dict({ - 'completed': None, 'date': '2024-11-22T00:41:21.137000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -29.448636229365235, }), dict({ - 'completed': None, 'date': '2024-11-27T19:34:28.887000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -36.25425987861077, }), ]), @@ -643,23 +447,13 @@ 'gear': dict({ 'costume': dict({ 'armor': 'armor_base_0', - 'back': None, - 'body': None, - 'eyewear': None, 'head': 'head_base_0', - 'headAccessory': None, 'shield': 'shield_base_0', - 'weapon': None, }), 'equipped': dict({ 'armor': 'armor_base_0', - 'back': None, - 'body': None, - 'eyewear': None, 'head': 'head_base_0', - 'headAccessory': None, 'shield': 'shield_base_0', - 'weapon': None, }), 'owned': dict({ 'armor_special_bardRobes': True, @@ -736,7 +530,6 @@ }), 'lastCron': '2024-11-27T19:34:28.887000+00:00', 'loginIncentives': 6, - 'needsCron': None, 'newMessages': dict({ }), 'notifications': list([ @@ -747,7 +540,6 @@ 'orderAscending': 'ascending', 'quest': dict({ 'RSVPNeeded': True, - 'completed': None, 'key': 'dustbunnies', 'progress': dict({ 'collect': dict({ @@ -759,37 +551,31 @@ }), }), 'permissions': dict({ - 'challengeAdmin': None, - 'coupons': None, - 'fullAccess': None, - 'moderator': None, - 'news': None, - 'userSupport': None, }), 'pinnedItems': list([ dict({ - 'Type': 'marketGear', 'path': 'gear.flat.weapon_warrior_0', + 'type': 'marketGear', }), dict({ - 'Type': 'marketGear', 'path': 'gear.flat.armor_warrior_1', + 'type': 'marketGear', }), dict({ - 'Type': 'marketGear', 'path': 'gear.flat.shield_warrior_1', + 'type': 'marketGear', }), dict({ - 'Type': 'marketGear', 'path': 'gear.flat.head_warrior_1', + 'type': 'marketGear', }), dict({ - 'Type': 'potion', 'path': 'potion', + 'type': 'potion', }), dict({ - 'Type': 'armoire', 'path': 'armoire', + 'type': 'armoire', }), ]), 'pinnedItemsOrder': list([ @@ -798,7 +584,6 @@ 'advancedCollapsed': False, 'allocationMode': 'flat', 'autoEquip': True, - 'automaticAllocation': None, 'background': 'violet', 'chair': 'none', 'costume': False, @@ -888,9 +673,6 @@ }), }), 'profile': dict({ - 'blurb': None, - 'imageUrl': None, - 'name': None, }), 'purchased': dict({ 'ads': False, @@ -904,21 +686,11 @@ }), 'hair': dict({ }), - 'mobileChat': None, 'plan': dict({ 'consecutive': dict({ - 'count': None, - 'gemCapExtra': None, - 'offset': None, - 'trinkets': None, }), - 'dateUpdated': None, - 'extraMonths': None, - 'gemsBought': None, 'mysteryItems': list([ ]), - 'perkMonthCount': None, - 'quantity': None, }), 'shirt': dict({ }), @@ -928,81 +700,73 @@ }), 'pushDevices': list([ ]), - 'secret': None, 'stats': dict({ - 'Class': 'warrior', - 'Int': 0, - 'Str': 0, 'buffs': dict({ - 'Int': 0, - 'Str': 0, 'con': 0, + 'int': 0, 'per': 0, 'seafoam': False, 'shinySeed': False, 'snowball': False, 'spookySparkles': False, 'stealth': 0, + 'str': 0, 'streaks': False, }), + 'class': 'warrior', 'con': 0, 'exp': 41, 'gp': 11.100978952781748, 'hp': 25.40000000000002, + 'int': 0, 'lvl': 2, 'maxHealth': 50, 'maxMP': 32, 'mp': 32.0, 'per': 0, 'points': 2, + 'str': 0, 'toNextLevel': 50, 'training': dict({ - 'Int': 0, - 'Str': 0.0, 'con': 0, + 'int': 0, 'per': 0, + 'str': 0.0, }), }), 'tags': list([ dict({ 'challenge': True, - 'group': None, 'id': 'c1a35186-9895-4ac0-9cd7-49e7bb875695', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '53d1deb8-ed2b-4f94-bbfc-955e9e92aa98', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '29bf6a99-536f-446b-838f-a81d41e1ed4d', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '1b1297e7-4fd8-460a-b148-e92d7bcfa9a5', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '05e6cf40-48ea-415a-9b8b-e2ecad258ef6', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': 'fe53f179-59d8-4c28-9bf7-b9068ab552a4', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': 'c44e9e8c-4bff-42df-98d5-1a1a7b69eada', 'name': 'tag', }), From efe7050030cabef9a4c8f178ade7b4d2e67abb55 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 8 Feb 2025 03:14:00 -0500 Subject: [PATCH 0371/1941] LaCrosse View new endpoint (#137284) * Switch to new endpoint in LaCrosse View * Coverage * Avoid merge conflict * Switch to UpdateFailed --- .../components/lacrosse_view/coordinator.py | 37 ++++++---- .../components/lacrosse_view/sensor.py | 2 +- tests/components/lacrosse_view/__init__.py | 67 ++++++++++++++++--- .../snapshots/test_diagnostics.ambr | 2 +- .../lacrosse_view/test_diagnostics.py | 7 +- tests/components/lacrosse_view/test_init.py | 31 ++++++--- tests/components/lacrosse_view/test_sensor.py | 50 +++++++++++--- 7 files changed, 151 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 5ec02a86709..8d7e44ecd99 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -10,8 +10,8 @@ from lacrosse_view import HTTPError, LaCrosse, Location, LoginError, Sensor from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import SCAN_INTERVAL @@ -26,6 +26,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): name: str id: str hass: HomeAssistant + devices: list[Sensor] | None = None def __init__( self, @@ -60,24 +61,34 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): except LoginError as error: raise ConfigEntryAuthFailed from error + if self.devices is None: + _LOGGER.debug("Getting devices") + try: + self.devices = await self.api.get_devices( + location=Location(id=self.id, name=self.name), + ) + except HTTPError as error: + raise UpdateFailed from error + try: # Fetch last hour of data - sensors = await self.api.get_sensors( - location=Location(id=self.id, name=self.name), - tz=self.hass.config.time_zone, - start=str(now - 3600), - end=str(now), - ) - except HTTPError as error: - raise ConfigEntryNotReady from error + for sensor in self.devices: + sensor.data = ( + await self.api.get_sensor_status( + sensor=sensor, + tz=self.hass.config.time_zone, + ) + )["data"]["current"] + _LOGGER.debug("Got data: %s", sensor.data) - _LOGGER.debug("Got data: %s", sensors) + except HTTPError as error: + raise UpdateFailed from error # Verify that we have permission to read the sensors - for sensor in sensors: + for sensor in self.devices: if not sensor.permissions.get("read", False): raise ConfigEntryAuthFailed( f"This account does not have permission to read {sensor.name}" ) - return sensors + return self.devices diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index b2ad9672504..64fd8259966 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -48,7 +48,7 @@ def get_value(sensor: Sensor, field: str) -> float | int | str | None: field_data = sensor.data.get(field) if field_data is None: return None - value = field_data["values"][-1]["s"] + value = field_data["spot"]["value"] try: value = float(value) except ValueError: diff --git a/tests/components/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 913f6c72f24..860156beb6c 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -15,7 +15,13 @@ TEST_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -26,7 +32,13 @@ TEST_NO_PERMISSION_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": False}, model="Test", ) @@ -37,7 +49,16 @@ TEST_UNSUPPORTED_SENSOR = Sensor( sensor_id="2", sensor_field_names=["SomeUnsupportedField"], location=Location(id="1", name="Test"), - data={"SomeUnsupportedField": {"values": [{"s": "2"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "SomeUnsupportedField": { + "spot": {"value": "2"}, + "unit": "degrees_celsius", + } + } + } + }, permissions={"read": True}, model="Test", ) @@ -48,7 +69,13 @@ TEST_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2.3"}], "unit": "degrees_celsius"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2.3"}, "unit": "degrees_celsius"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -59,7 +86,9 @@ TEST_STRING_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WetDry"], location=Location(id="1", name="Test"), - data={"WetDry": {"values": [{"s": "dry"}], "unit": "wet_dry"}}, + data={ + "data": {"current": {"WetDry": {"spot": {"value": "dry"}, "unit": "wet_dry"}}} + }, permissions={"read": True}, model="Test", ) @@ -70,7 +99,13 @@ TEST_ALREADY_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["HeatIndex"], location=Location(id="1", name="Test"), - data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_fahrenheit"}}, + data={ + "data": { + "current": { + "HeatIndex": {"spot": {"value": 2.3}, "unit": "degrees_fahrenheit"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -81,7 +116,13 @@ TEST_ALREADY_INT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WindSpeed"], location=Location(id="1", name="Test"), - data={"WindSpeed": {"values": [{"s": 2}], "unit": "kilometers_per_hour"}}, + data={ + "data": { + "current": { + "WindSpeed": {"spot": {"value": 2}, "unit": "kilometers_per_hour"} + } + } + }, permissions={"read": True}, model="Test", ) @@ -92,7 +133,7 @@ TEST_NO_FIELD_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={}, + data={"data": {"current": {}}}, permissions={"read": True}, model="Test", ) @@ -103,7 +144,7 @@ TEST_MISSING_FIELD_DATA_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": None}, + data={"data": {"current": {"Temperature": None}}}, permissions={"read": True}, model="Test", ) @@ -114,7 +155,13 @@ TEST_UNITS_OVERRIDE_SENSOR = Sensor( sensor_id="2", sensor_field_names=["Temperature"], location=Location(id="1", name="Test"), - data={"Temperature": {"values": [{"s": "2.1"}], "unit": "degrees_fahrenheit"}}, + data={ + "data": { + "current": { + "Temperature": {"spot": {"value": "2.1"}, "unit": "degrees_fahrenheit"} + } + } + }, permissions={"read": True}, model="Test", ) diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 201bbbc971e..bfbfa2901a6 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -4,7 +4,7 @@ 'coordinator_data': list([ dict({ '__type': "", - 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'values': [{'s': '2'}], 'unit': 'degrees_celsius'}})", + 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'spot': {'value': '2'}, 'unit': 'degrees_celsius'}})", }), ]), 'entry': dict({ diff --git a/tests/components/lacrosse_view/test_diagnostics.py b/tests/components/lacrosse_view/test_diagnostics.py index dc48f160113..4306173c6b3 100644 --- a/tests/components/lacrosse_view/test_diagnostics.py +++ b/tests/components/lacrosse_view/test_diagnostics.py @@ -26,9 +26,14 @@ async def test_entry_diagnostics( ) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index 51fa7e5abf4..af92d0e64f1 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -20,12 +20,17 @@ async def test_unload_entry(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -68,7 +73,7 @@ async def test_http_error(hass: HomeAssistant) -> None: with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", side_effect=HTTPError), + patch("lacrosse_view.LaCrosse.get_devices", side_effect=HTTPError), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -84,12 +89,17 @@ async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -103,7 +113,7 @@ async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", + "lacrosse_view.LaCrosse.get_devices", return_value=[TEST_SENSOR], ), ): @@ -121,12 +131,17 @@ async def test_failed_token( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True) as login, patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 11faaf8877e..74e9f001792 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -32,9 +32,14 @@ async def test_entities_added(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch("lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -54,12 +59,17 @@ async def test_sensor_permission( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_NO_PERMISSION_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_NO_PERMISSION_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -79,11 +89,14 @@ async def test_field_not_supported( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_UNSUPPORTED_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), - patch( - "lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_UNSUPPORTED_SENSOR] - ), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[sensor]), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -114,12 +127,17 @@ async def test_field_types( config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = test_input.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", + "lacrosse_view.LaCrosse.get_devices", return_value=[test_input], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -137,12 +155,17 @@ async def test_no_field(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_NO_FIELD_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_NO_FIELD_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -160,12 +183,17 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) config_entry.add_to_hass(hass) + sensor = TEST_MISSING_FIELD_DATA_SENSOR.model_copy() + status = sensor.data + sensor.data = None + with ( patch("lacrosse_view.LaCrosse.login", return_value=True), patch( - "lacrosse_view.LaCrosse.get_sensors", - return_value=[TEST_MISSING_FIELD_DATA_SENSOR], + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 4bd1d0199b78e7a2504f89fc3aea6e60cd319a11 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 10 Feb 2025 09:11:40 -0700 Subject: [PATCH 0372/1941] Convert coinbase account amounts as floats to properly add them together (#137588) Convert coinbase account amounts as floats to properly add --- homeassistant/components/coinbase/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index a29154d9c1b..317759f820d 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -140,8 +140,10 @@ def get_accounts(client, version): API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID], API_ACCOUNT_NAME: account[API_ACCOUNT_NAME], API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY], - API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE] - + account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE], + API_ACCOUNT_AMOUNT: ( + float(account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]) + + float(account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE]) + ), ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT, } for account in accounts From da23eb22dbbcc85cdb583da78f6839deccc3aa4a Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Sat, 8 Feb 2025 12:52:59 +0000 Subject: [PATCH 0373/1941] Bump ohmepy to 1.2.9 (#137695) --- homeassistant/components/ohme/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 602c53ced7b..100967f819f 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.2.8"] + "requirements": ["ohme==1.2.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7bbd141347b..b7dcd60df5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1544,7 +1544,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.8 +ohme==1.2.9 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51be41c684c..f4e06d4b6a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1292,7 +1292,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.8 +ohme==1.2.9 # homeassistant.components.ollama ollama==0.4.7 From 16298b419514d686f36e4c91665f8685097ae3bb Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 8 Feb 2025 07:38:49 +0100 Subject: [PATCH 0374/1941] Bump onedrive_personal_sdk to 0.0.9 (#137729) --- homeassistant/components/onedrive/__init__.py | 2 +- homeassistant/components/onedrive/backup.py | 2 +- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 9716f692ec8..8355cddb0b5 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -133,7 +133,7 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) - metadata_file = await client.upload_file( backup_folder_id, metadata_filename, - dumps(metadata), # type: ignore[arg-type] + dumps(metadata), ) metadata_description = { "metadata_version": 2, diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 182e29aa63f..9926bd9cbc7 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -168,7 +168,7 @@ class OneDriveBackupAgent(BackupAgent): metadata_file = await self._client.upload_file( self._folder_id, metadata_filename, - description, # type: ignore[arg-type] + description, ) # add metadata to the metadata file diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 88d51e6d73a..fcc922b3e46 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.8"] + "requirements": ["onedrive-personal-sdk==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7dcd60df5e..73d350b7f32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1556,7 +1556,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.8 +onedrive-personal-sdk==0.0.9 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4e06d4b6a2..82d20de6970 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1304,7 +1304,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.8 +onedrive-personal-sdk==0.0.9 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From c63e688ba8ff8dce1c72860d3c3a1a8a3dc9bbad Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 8 Feb 2025 13:20:30 +0100 Subject: [PATCH 0375/1941] Limit habitica ConfigEntrySelect to integration domain (#137767) --- homeassistant/components/habitica/services.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index ed4a6444ea2..2537655dbfb 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -77,7 +77,7 @@ SERVICE_API_CALL_SCHEMA = vol.Schema( SERVICE_CAST_SKILL_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_SKILL): cv.string, vol.Optional(ATTR_TASK): cv.string, } @@ -85,12 +85,12 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema( SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), } ) SERVICE_SCORE_TASK_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_TASK): cv.string, vol.Optional(ATTR_DIRECTION): cv.string, } @@ -98,7 +98,7 @@ SERVICE_SCORE_TASK_SCHEMA = vol.Schema( SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_ITEM): cv.string, vol.Required(ATTR_TARGET): cv.string, } @@ -106,7 +106,7 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( SERVICE_GET_TASKS_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Optional(ATTR_TYPE): vol.All( cv.ensure_list, [vol.All(vol.Upper, vol.In({x.name for x in TaskType}))] ), From a4c0304e1f99a30deed2fbbb79f9a302fc7da5a5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 9 Feb 2025 18:57:25 +0100 Subject: [PATCH 0376/1941] Limit nordpool ConfigEntrySelect to integration domain (#137768) --- homeassistant/components/nordpool/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index 872bd5b1e6b..6607edfdbcb 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -41,7 +41,7 @@ ATTR_CURRENCY = "currency" SERVICE_GET_PRICES_FOR_DATE = "get_prices_for_date" SERVICE_GET_PRICES_SCHEMA = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), vol.Required(ATTR_DATE): cv.date, vol.Optional(ATTR_AREAS): vol.All(vol.In(list(AREAS)), cv.ensure_list, [str]), vol.Optional(ATTR_CURRENCY): vol.All( From 42d8889778a36e19c69818ce92193ad825d13067 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 8 Feb 2025 14:20:30 +0100 Subject: [PATCH 0377/1941] Limit transmission ConfigEntrySelect to integration domain (#137769) --- homeassistant/components/transmission/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 1a8ffdea0c2..578488dad1a 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -78,7 +78,9 @@ MIGRATION_NAME_TO_KEY = { SERVICE_BASE_SCHEMA = vol.Schema( { - vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(), + vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector( + {"integration": DOMAIN} + ), } ) From 7c6afd50dc02efecfb2cd8ed74dfb7f52f2e8355 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Feb 2025 08:47:01 -0600 Subject: [PATCH 0378/1941] Fix tplink child updates taking up to 60s (#137782) * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Fix tplink child updates taking up to 60s fixes #137562 * Revert "Fix tplink child updates taking up to 60s" This reverts commit 5cd20a120f772b8df96ec32890b071b22135895e. --- homeassistant/components/tplink/coordinator.py | 14 +++++++++++++- homeassistant/components/tplink/entity.py | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index d1b4694779d..fcd1335a77a 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -46,9 +46,11 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): device: Device, update_interval: timedelta, config_entry: TPLinkConfigEntry, + parent_coordinator: TPLinkDataUpdateCoordinator | None = None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device + self.parent_coordinator = parent_coordinator # The iot HS300 allows a limited number of concurrent requests and # fetching the emeter information requires separate ones, so child @@ -95,6 +97,12 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) from ex await self._process_child_devices() + if not self._update_children: + # If the children are not being updated, it means this is an + # IotStrip, and we need to tell the children to write state + # since the power state is provided by the parent. + for child_coordinator in self._child_coordinators.values(): + child_coordinator.async_set_updated_data(None) async def _process_child_devices(self) -> None: """Process child devices and remove stale devices.""" @@ -132,7 +140,11 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): # The child coordinators only update energy data so we can # set a longer update interval to avoid flooding the device child_coordinator = TPLinkDataUpdateCoordinator( - self.hass, child, timedelta(seconds=60), self.config_entry + self.hass, + child, + timedelta(seconds=60), + self.config_entry, + parent_coordinator=self, ) self._child_coordinators[child.device_id] = child_coordinator return child_coordinator diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 15c07655e69..7a0d811b30d 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -151,7 +151,13 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( "exc": str(ex), }, ) from ex - await self.coordinator.async_request_refresh() + coordinator = self.coordinator + if coordinator.parent_coordinator: + # If there is a parent coordinator we need to refresh + # the parent as its what provides the power state data + # for the child entities. + coordinator = coordinator.parent_coordinator + await coordinator.async_request_refresh() return _async_wrap From 8049699efbfab3344fd091692d082c03ef9bdd6b Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 8 Feb 2025 05:53:15 -0800 Subject: [PATCH 0379/1941] Call backup listener during setup in Google Drive (#137789) --- homeassistant/components/google_drive/__init__.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index af93956931a..b30bc2ae1f6 100644 --- a/homeassistant/components/google_drive/__init__.py +++ b/homeassistant/components/google_drive/__init__.py @@ -7,7 +7,7 @@ from collections.abc import Callable from google_drive_api.exceptions import GoogleDriveApiError from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,6 +49,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) except GoogleDriveApiError as err: raise ConfigEntryNotReady from err + _async_notify_backup_listeners_soon(hass) + return True @@ -56,10 +58,15 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleDriveConfigEntry ) -> bool: """Unload a config entry.""" - hass.loop.call_soon(_notify_backup_listeners, hass) + _async_notify_backup_listeners_soon(hass) return True -def _notify_backup_listeners(hass: HomeAssistant) -> None: +def _async_notify_backup_listeners(hass: HomeAssistant) -> None: for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): listener() + + +@callback +def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: + hass.loop.call_soon(_async_notify_backup_listeners, hass) From eca714a45ab3e296395e1048a451e1f8301704a3 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 8 Feb 2025 01:16:10 -0800 Subject: [PATCH 0380/1941] Use the external URL set in Settings > System > Network if my is disabled as redirect URL for Google Drive instructions (#137791) * Use the Assistant URL set in Settings > System > Network if my is disabled * fix * Remove async_get_redirect_uri --- .../google_drive/application_credentials.py | 12 +++++-- .../test_application_credentials.py | 36 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/components/google_drive/test_application_credentials.py diff --git a/homeassistant/components/google_drive/application_credentials.py b/homeassistant/components/google_drive/application_credentials.py index 1c4421623d4..8bcab2b039c 100644 --- a/homeassistant/components/google_drive/application_credentials.py +++ b/homeassistant/components/google_drive/application_credentials.py @@ -2,7 +2,10 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import ( + AUTH_CALLBACK_PATH, + MY_AUTH_CALLBACK_PATH, +) async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -15,9 +18,14 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: """Return description placeholders for the credentials dialog.""" + if "my" in hass.config.components: + redirect_url = MY_AUTH_CALLBACK_PATH + else: + ha_host = hass.config.external_url or "https://YOUR_DOMAIN:PORT" + redirect_url = f"{ha_host}{AUTH_CALLBACK_PATH}" return { "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", - "redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass), + "redirect_url": redirect_url, } diff --git a/tests/components/google_drive/test_application_credentials.py b/tests/components/google_drive/test_application_credentials.py new file mode 100644 index 00000000000..ec46db510a5 --- /dev/null +++ b/tests/components/google_drive/test_application_credentials.py @@ -0,0 +1,36 @@ +"""Test the Google Drive application_credentials.""" + +import pytest + +from homeassistant import setup +from homeassistant.components.google_drive.application_credentials import ( + async_get_description_placeholders, +) +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + ("additional_components", "external_url", "expected_redirect_uri"), + [ + ([], "https://example.com", "https://example.com/auth/external/callback"), + ([], None, "https://YOUR_DOMAIN:PORT/auth/external/callback"), + (["my"], "https://example.com", "https://my.home-assistant.io/redirect/oauth"), + ], +) +async def test_description_placeholders( + hass: HomeAssistant, + additional_components: list[str], + external_url: str | None, + expected_redirect_uri: str, +) -> None: + """Test description placeholders.""" + for component in additional_components: + assert await setup.async_setup_component(hass, component, {}) + hass.config.external_url = external_url + placeholders = await async_get_description_placeholders(hass) + assert placeholders == { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_drive/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": expected_redirect_uri, + } From b5a9c3d1f6b42ad9d41253668d4b75b6c8f601e8 Mon Sep 17 00:00:00 2001 From: Patrick <14628713+patman15@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:40:59 +0100 Subject: [PATCH 0381/1941] Fix manufacturer_id matching for 0 (#137802) fix manufacturer_id matching for 0 --- homeassistant/components/bluetooth/match.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 6307d3ca93b..c37fa4615f6 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -411,7 +411,7 @@ def ble_device_matches( ) and service_data_uuid not in service_info.service_data: return False - if manufacturer_id := matcher.get(MANUFACTURER_ID): + if (manufacturer_id := matcher.get(MANUFACTURER_ID)) is not None: if manufacturer_id not in service_info.manufacturer_data: return False From 3dd241a398936a13a2f86a3b1a54dd2ac6ccbdd1 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:51:05 +0100 Subject: [PATCH 0382/1941] Fix DAB radio in Onkyo (#137852) --- homeassistant/components/onkyo/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 97a82fc8a1a..acb57e594b8 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -92,7 +92,7 @@ SUPPORT_ONKYO = ( DEFAULT_PLAYABLE_SOURCES = ( InputSource.from_meaning("FM"), InputSource.from_meaning("AM"), - InputSource.from_meaning("TUNER"), + InputSource.from_meaning("DAB"), ) ATTR_PRESET = "preset" From 36b722960ae4f440fe765614f6dafa28885a5047 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 10 Feb 2025 21:32:41 +0200 Subject: [PATCH 0383/1941] Fix LG webOS TV fails to setup when device is off (#137870) --- homeassistant/components/webostv/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index e505611db52..118ea7b32db 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -31,6 +31,7 @@ WEBOSTV_EXCEPTIONS = ( WebOsTvCommandError, aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError, + aiohttp.WSMessageTypeError, asyncio.CancelledError, asyncio.TimeoutError, ) From 23e7638687b7523beb05a4a9f5428d125b74afc5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 8 Feb 2025 11:56:23 -0500 Subject: [PATCH 0384/1941] Fix heos migration (#137887) * Fix heos migration * Fix for loop --- homeassistant/components/heos/__init__.py | 36 +++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 0c268b612ea..7bbd3765602 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -37,24 +37,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool for device in device_registry.devices.get_devices_for_config_entry_id( entry.entry_id ): - for domain, player_id in device.identifiers: - if domain == DOMAIN and not isinstance(player_id, str): - # Create set of identifiers excluding this integration - identifiers = { # type: ignore[unreachable] - (domain, identifier) - for domain, identifier in device.identifiers - if domain != DOMAIN - } - migrated_identifiers = {(DOMAIN, str(player_id))} - # Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded - if not device_registry.async_get_device(migrated_identifiers): - identifiers.update(migrated_identifiers) - if len(identifiers) > 0: - device_registry.async_update_device( - device.id, new_identifiers=identifiers - ) - else: - device_registry.async_remove_device(device.id) + for ident in device.identifiers: + if ident[0] != DOMAIN or isinstance(ident[1], str): + continue + + player_id = int(ident[1]) # type: ignore[unreachable] + + # Create set of identifiers excluding this integration + identifiers = {ident for ident in device.identifiers if ident[0] != DOMAIN} + migrated_identifiers = {(DOMAIN, str(player_id))} + # Add migrated if not already present in another device, which occurs if the user downgraded and then upgraded + if not device_registry.async_get_device(migrated_identifiers): + identifiers.update(migrated_identifiers) + if len(identifiers) > 0: + device_registry.async_update_device( + device.id, new_identifiers=identifiers + ) + else: + device_registry.async_remove_device(device.id) break coordinator = HeosCoordinator(hass, entry) From af77e69eb0fa01a837fde3e68ffd5055cc77cf83 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 8 Feb 2025 16:29:18 -0500 Subject: [PATCH 0385/1941] Bump pydrawise to 2025.2.0 (#137961) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index de45eb061d5..73423882e4a 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.1.0"] + "requirements": ["pydrawise==2025.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 73d350b7f32..386ae70077c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1897,7 +1897,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.1.0 +pydrawise==2025.2.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82d20de6970..9a011bece24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1547,7 +1547,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.1.0 +pydrawise==2025.2.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 090dbba06efef1aab39a5f84fd5c62bcdc2bff28 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 9 Feb 2025 13:51:02 +0100 Subject: [PATCH 0386/1941] Bump aioshelly to version 12.4.2 (#137986) --- 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 4cfb49b680f..4c9927f515a 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.1"], + "requirements": ["aioshelly==12.4.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 386ae70077c..d6634ad9eb8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.4.1 +aioshelly==12.4.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a011bece24..489b16c1bf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==12.4.1 +aioshelly==12.4.2 # homeassistant.components.skybell aioskybell==22.7.0 From 7903348d791571a29e5b3d621537892b313b6afa Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Mon, 10 Feb 2025 20:17:02 +1030 Subject: [PATCH 0387/1941] Prevent crash if telegram message failed and did not generate an ID (#137989) Fix #137901 - Regression introduced in 6fdccda2256f92c824a98712ef102b4a77140126 --- homeassistant/components/telegram_bot/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index fa3ec1dc4f7..b3c09049ae5 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -756,7 +756,8 @@ class TelegramNotificationService: message_thread_id=params[ATTR_MESSAGE_THREAD_ID], context=context, ) - msg_ids[chat_id] = msg.id + if msg is not None: + msg_ids[chat_id] = msg.id return msg_ids async def delete_message(self, chat_id=None, context=None, **kwargs): From fd8d4e937cb7724087acef9e9f08b01bcb047a57 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 10 Feb 2025 20:37:54 +0100 Subject: [PATCH 0388/1941] Bump habiticalib to v0.3.7 (#137993) * bump habiticalib to 0.3.6 * bump to v0.3.7 --- .../components/habitica/coordinator.py | 12 +- .../components/habitica/diagnostics.py | 2 +- homeassistant/components/habitica/image.py | 7 +- .../components/habitica/manifest.json | 2 +- homeassistant/components/habitica/services.py | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/habitica/fixtures/user.json | 21 +- .../habitica/snapshots/test_diagnostics.ambr | 262 +----------------- 9 files changed, 48 insertions(+), 266 deletions(-) diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index f97b98410bb..ed88a7fe8d3 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -11,6 +11,7 @@ from typing import Any from aiohttp import ClientError from habiticalib import ( + Avatar, ContentData, Habitica, HabiticaException, @@ -19,7 +20,6 @@ from habiticalib import ( TaskFilter, TooManyRequestsError, UserData, - UserStyles, ) from homeassistant.config_entries import ConfigEntry @@ -159,12 +159,10 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): else: await self.async_request_refresh() - async def generate_avatar(self, user_styles: UserStyles) -> bytes: + async def generate_avatar(self, avatar: Avatar) -> bytes: """Generate Avatar.""" - avatar = BytesIO() - await self.habitica.generate_avatar( - fp=avatar, user_styles=user_styles, fmt="PNG" - ) + png = BytesIO() + await self.habitica.generate_avatar(fp=png, avatar=avatar, fmt="PNG") - return avatar.getvalue() + return png.getvalue() diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py index abfa0f35c4b..0997977dc46 100644 --- a/homeassistant/components/habitica/diagnostics.py +++ b/homeassistant/components/habitica/diagnostics.py @@ -23,5 +23,5 @@ async def async_get_config_entry_diagnostics( CONF_URL: config_entry.data[CONF_URL], CONF_API_USER: config_entry.data[CONF_API_USER], }, - "habitica_data": habitica_data.to_dict()["data"], + "habitica_data": habitica_data.to_dict(omit_none=False)["data"], } diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index f1dbbc64d41..4fa6d1e0693 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -2,10 +2,9 @@ from __future__ import annotations -from dataclasses import asdict from enum import StrEnum -from habiticalib import UserStyles +from habiticalib import Avatar, extract_avatar from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant @@ -45,7 +44,7 @@ class HabiticaImage(HabiticaBase, ImageEntity): translation_key=HabiticaImageEntity.AVATAR, ) _attr_content_type = "image/png" - _current_appearance: UserStyles | None = None + _current_appearance: Avatar | None = None _cache: bytes | None = None def __init__( @@ -60,7 +59,7 @@ class HabiticaImage(HabiticaBase, ImageEntity): def _handle_coordinator_update(self) -> None: """Check if equipped gear and other things have changed since last avatar image generation.""" - new_appearance = UserStyles.from_dict(asdict(self.coordinator.data.user)) + new_appearance = extract_avatar(self.coordinator.data.user) if self._current_appearance != new_appearance: self._current_appearance = new_appearance diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 9ea346a0dcb..a58bd1296e0 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], - "requirements": ["habiticalib==0.3.5"] + "requirements": ["habiticalib==0.3.7"] } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 2537655dbfb..aad5945548e 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -510,7 +510,9 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 or (task.notes and keyword in task.notes.lower()) or any(keyword in item.text.lower() for item in task.checklist) ] - result: dict[str, Any] = {"tasks": [task.to_dict() for task in response]} + result: dict[str, Any] = { + "tasks": [task.to_dict(omit_none=False) for task in response] + } return result diff --git a/requirements_all.txt b/requirements_all.txt index d6634ad9eb8..0a40975f2fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1097,7 +1097,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.5 +habiticalib==0.3.7 # homeassistant.components.bluetooth habluetooth==3.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 489b16c1bf2..309d07b773b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ ha-iotawattpy==0.1.2 ha-philipsjs==3.2.2 # homeassistant.components.habitica -habiticalib==0.3.5 +habiticalib==0.3.7 # homeassistant.components.bluetooth habluetooth==3.21.1 diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 255d9c7c3b5..991f2db0ba8 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -143,6 +143,25 @@ "trinkets": 0 } } - } + }, + "webhooks": [ + { + "id": "43a67e37-1bae-4b11-8d3d-6c4b1b480231", + "type": "taskActivity", + "label": "My Webhook", + "url": "https://some-webhook-url.com", + "enabled": true, + "failures": 0, + "options": { + "created": false, + "updated": false, + "deleted": false, + "checklistScored": false, + "scored": true + }, + "createdAt": "2025-02-08T22:06:08.894Z", + "updatedAt": "2025-02-08T22:06:17.195Z" + } + ] } } diff --git a/tests/components/habitica/snapshots/test_diagnostics.ambr b/tests/components/habitica/snapshots/test_diagnostics.ambr index 2fe3513a646..718aea99ebc 100644 --- a/tests/components/habitica/snapshots/test_diagnostics.ambr +++ b/tests/components/habitica/snapshots/test_diagnostics.ambr @@ -8,48 +8,31 @@ 'habitica_data': dict({ 'tasks': list([ dict({ - 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, - 'completed': None, 'counterDown': 0, 'counterUp': 0, 'createdAt': '2024-10-10T15:57:14.287000+00:00', - 'date': None, 'daysOfMonth': list([ ]), 'down': False, - 'everyX': None, 'frequency': 'daily', 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ ]), 'id': '30923acd-3b4c-486d-9ef3-c8f57cf56049', - 'isDue': None, 'nextDue': list([ ]), 'notes': 'task notes', @@ -65,8 +48,6 @@ 'th': False, 'w': True, }), - 'startDate': None, - 'streak': None, 'tags': list([ ]), 'text': 'task text', @@ -77,51 +58,30 @@ 'value': 0.0, 'weeksOfMonth': list([ ]), - 'yesterDaily': None, }), dict({ - 'alias': None, 'attribute': 'str', 'byHabitica': True, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, 'completed': False, - 'counterDown': None, - 'counterUp': None, 'createdAt': '2024-10-10T15:57:14.290000+00:00', - 'date': None, 'daysOfMonth': list([ ]), - 'down': None, - 'everyX': None, - 'frequency': None, 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ ]), 'id': 'e6e06dc6-c887-4b86-b175-b99cc2e20fdf', - 'isDue': None, 'nextDue': list([ ]), 'notes': 'task notes', @@ -137,63 +97,38 @@ 'th': False, 'w': True, }), - 'startDate': None, - 'streak': None, 'tags': list([ ]), 'text': 'task text', 'type': 'todo', - 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': -6.418582324043852, 'weeksOfMonth': list([ ]), - 'yesterDaily': None, }), dict({ - 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, - 'completed': None, - 'counterDown': None, - 'counterUp': None, 'createdAt': '2024-10-10T15:57:14.290000+00:00', - 'date': None, 'daysOfMonth': list([ ]), - 'down': None, - 'everyX': None, - 'frequency': None, 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ ]), 'id': '2fbf11a5-ab1e-4fb7-97f0-dfb5c45c96a9', - 'isDue': None, 'nextDue': list([ ]), 'notes': 'task notes', @@ -209,106 +144,73 @@ 'th': False, 'w': True, }), - 'startDate': None, - 'streak': None, 'tags': list([ ]), 'text': 'task text', 'type': 'reward', - 'up': None, 'updatedAt': '2024-10-10T15:57:14.290000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': 10.0, 'weeksOfMonth': list([ ]), - 'yesterDaily': None, }), dict({ - 'alias': None, 'attribute': 'str', 'byHabitica': False, 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, }), 'checklist': list([ ]), 'collapseChecklist': False, 'completed': False, - 'counterDown': None, - 'counterUp': None, 'createdAt': '2024-10-10T15:57:14.304000+00:00', - 'date': None, 'daysOfMonth': list([ ]), - 'down': None, 'everyX': 1, 'frequency': 'weekly', 'group': dict({ - 'assignedDate': None, 'assignedUsers': list([ ]), 'assignedUsersDetail': dict({ }), - 'assigningUsername': None, 'completedBy': dict({ - 'date': None, - 'userId': None, }), - 'id': None, - 'managerNotes': None, - 'taskId': None, }), 'history': list([ dict({ 'completed': True, 'date': '2024-10-30T19:37:01.817000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 1.0, }), dict({ 'completed': True, 'date': '2024-10-31T23:33:14.890000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 1.9747, }), dict({ 'completed': False, 'date': '2024-11-05T18:25:04.730000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 1.024043774264157, }), dict({ 'completed': False, 'date': '2024-11-21T15:09:07.573000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': 0.049944135963563174, }), dict({ 'completed': False, 'date': '2024-11-22T00:41:21.228000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': -0.9487768368544092, }), dict({ 'completed': False, 'date': '2024-11-27T19:34:28.973000+00:00', 'isDue': True, - 'scoredDown': None, - 'scoredUp': None, 'value': -1.973387732005249, }), ]), @@ -341,7 +243,6 @@ ]), 'text': 'task text', 'type': 'daily', - 'up': None, 'updatedAt': '2024-11-27T19:34:29.001000+00:00', 'userId': 'ffce870c-3ff3-4fa4-bad1-87612e52b8e7', 'value': -1.973387732005249, @@ -352,60 +253,23 @@ ]), 'user': dict({ 'achievements': dict({ - 'backToBasics': None, - 'boneCollector': None, 'challenges': list([ ]), 'completedTask': True, 'createdTask': True, - 'dustDevil': None, - 'fedPet': None, - 'goodAsGold': None, - 'hatchedPet': None, - 'joinedChallenge': None, - 'joinedGuild': None, - 'partyUp': None, 'perfect': 2, - 'primedForPainting': None, - 'purchasedEquipment': None, 'quests': dict({ - 'atom1': None, - 'atom2': None, - 'atom3': None, - 'bewilder': None, - 'burnout': None, - 'dilatory': None, - 'dilatory_derby': None, - 'dysheartener': None, - 'evilsanta': None, - 'evilsanta2': None, - 'gryphon': None, - 'harpy': None, - 'stressbeast': None, - 'vice1': None, - 'vice3': None, }), - 'seeingRed': None, - 'shadyCustomer': None, 'streak': 0, - 'tickledPink': None, 'ultimateGearSets': dict({ 'healer': False, 'rogue': False, 'warrior': False, 'wizard': False, }), - 'violetsAreBlue': None, }), 'auth': dict({ - 'apple': None, - 'facebook': None, - 'google': None, 'local': dict({ - 'email': None, - 'has_password': None, - 'lowerCaseUsername': None, - 'username': None, }), 'timestamps': dict({ 'created': '2024-10-10T15:57:01.106000+00:00', @@ -414,17 +278,11 @@ }), }), 'backer': dict({ - 'npc': None, - 'tier': None, - 'tokensApplied': None, }), 'balance': 0.0, 'challenges': list([ ]), 'contributor': dict({ - 'contributions': None, - 'level': None, - 'text': None, }), 'extra': dict({ }), @@ -433,23 +291,17 @@ 'armoireEnabled': True, 'armoireOpened': False, 'cardReceived': False, - 'chatRevoked': None, - 'chatShadowMuted': None, 'classSelected': False, 'communityGuidelinesAccepted': True, 'cronCount': 6, 'customizationsNotification': True, 'dropsEnabled': False, 'itemsEnabled': True, - 'lastFreeRebirth': None, 'lastNewStuffRead': '', 'lastWeeklyRecap': '2024-10-10T15:57:01.106000+00:00', - 'lastWeeklyRecapDiscriminator': None, 'levelDrops': dict({ }), - 'mathUpdates': None, 'newStuff': False, - 'onboardingEmailsPhase': None, 'rebirthEnabled': False, 'recaptureEmailsPhase': 0, 'rewrite': True, @@ -508,101 +360,53 @@ 'history': dict({ 'exp': list([ dict({ - 'completed': None, 'date': '2024-10-30T19:37:01.970000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 24.0, }), dict({ - 'completed': None, 'date': '2024-10-31T23:33:14.972000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 48.0, }), dict({ - 'completed': None, 'date': '2024-11-05T18:25:04.681000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), dict({ - 'completed': None, 'date': '2024-11-21T15:09:07.501000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), dict({ - 'completed': None, 'date': '2024-11-22T00:41:21.137000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), dict({ - 'completed': None, 'date': '2024-11-27T19:34:28.887000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': 66.0, }), ]), 'todos': list([ dict({ - 'completed': None, 'date': '2024-10-30T19:37:01.970000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -5.0, }), dict({ - 'completed': None, 'date': '2024-10-31T23:33:14.972000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -10.129783523135325, }), dict({ - 'completed': None, 'date': '2024-11-05T18:25:04.681000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -16.396221153338182, }), dict({ - 'completed': None, 'date': '2024-11-21T15:09:07.501000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -22.8326979965846, }), dict({ - 'completed': None, 'date': '2024-11-22T00:41:21.137000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -29.448636229365235, }), dict({ - 'completed': None, 'date': '2024-11-27T19:34:28.887000+00:00', - 'isDue': None, - 'scoredDown': None, - 'scoredUp': None, 'value': -36.25425987861077, }), ]), @@ -643,23 +447,13 @@ 'gear': dict({ 'costume': dict({ 'armor': 'armor_base_0', - 'back': None, - 'body': None, - 'eyewear': None, 'head': 'head_base_0', - 'headAccessory': None, 'shield': 'shield_base_0', - 'weapon': None, }), 'equipped': dict({ 'armor': 'armor_base_0', - 'back': None, - 'body': None, - 'eyewear': None, 'head': 'head_base_0', - 'headAccessory': None, 'shield': 'shield_base_0', - 'weapon': None, }), 'owned': dict({ 'armor_special_bardRobes': True, @@ -736,7 +530,6 @@ }), 'lastCron': '2024-11-27T19:34:28.887000+00:00', 'loginIncentives': 6, - 'needsCron': None, 'newMessages': dict({ }), 'notifications': list([ @@ -747,7 +540,6 @@ 'orderAscending': 'ascending', 'quest': dict({ 'RSVPNeeded': True, - 'completed': None, 'key': 'dustbunnies', 'progress': dict({ 'collect': dict({ @@ -759,37 +551,31 @@ }), }), 'permissions': dict({ - 'challengeAdmin': None, - 'coupons': None, - 'fullAccess': None, - 'moderator': None, - 'news': None, - 'userSupport': None, }), 'pinnedItems': list([ dict({ - 'Type': 'marketGear', 'path': 'gear.flat.weapon_warrior_0', + 'type': 'marketGear', }), dict({ - 'Type': 'marketGear', 'path': 'gear.flat.armor_warrior_1', + 'type': 'marketGear', }), dict({ - 'Type': 'marketGear', 'path': 'gear.flat.shield_warrior_1', + 'type': 'marketGear', }), dict({ - 'Type': 'marketGear', 'path': 'gear.flat.head_warrior_1', + 'type': 'marketGear', }), dict({ - 'Type': 'potion', 'path': 'potion', + 'type': 'potion', }), dict({ - 'Type': 'armoire', 'path': 'armoire', + 'type': 'armoire', }), ]), 'pinnedItemsOrder': list([ @@ -798,7 +584,6 @@ 'advancedCollapsed': False, 'allocationMode': 'flat', 'autoEquip': True, - 'automaticAllocation': None, 'background': 'violet', 'chair': 'none', 'costume': False, @@ -888,9 +673,6 @@ }), }), 'profile': dict({ - 'blurb': None, - 'imageUrl': None, - 'name': None, }), 'purchased': dict({ 'ads': False, @@ -904,21 +686,11 @@ }), 'hair': dict({ }), - 'mobileChat': None, 'plan': dict({ 'consecutive': dict({ - 'count': None, - 'gemCapExtra': None, - 'offset': None, - 'trinkets': None, }), - 'dateUpdated': None, - 'extraMonths': None, - 'gemsBought': None, 'mysteryItems': list([ ]), - 'perkMonthCount': None, - 'quantity': None, }), 'shirt': dict({ }), @@ -928,81 +700,73 @@ }), 'pushDevices': list([ ]), - 'secret': None, 'stats': dict({ - 'Class': 'warrior', - 'Int': 0, - 'Str': 0, 'buffs': dict({ - 'Int': 0, - 'Str': 0, 'con': 0, + 'int': 0, 'per': 0, 'seafoam': False, 'shinySeed': False, 'snowball': False, 'spookySparkles': False, 'stealth': 0, + 'str': 0, 'streaks': False, }), + 'class': 'warrior', 'con': 0, 'exp': 41, 'gp': 11.100978952781748, 'hp': 25.40000000000002, + 'int': 0, 'lvl': 2, 'maxHealth': 50, 'maxMP': 32, 'mp': 32.0, 'per': 0, 'points': 2, + 'str': 0, 'toNextLevel': 50, 'training': dict({ - 'Int': 0, - 'Str': 0.0, 'con': 0, + 'int': 0, 'per': 0, + 'str': 0.0, }), }), 'tags': list([ dict({ 'challenge': True, - 'group': None, 'id': 'c1a35186-9895-4ac0-9cd7-49e7bb875695', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '53d1deb8-ed2b-4f94-bbfc-955e9e92aa98', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '29bf6a99-536f-446b-838f-a81d41e1ed4d', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '1b1297e7-4fd8-460a-b148-e92d7bcfa9a5', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': '05e6cf40-48ea-415a-9b8b-e2ecad258ef6', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': 'fe53f179-59d8-4c28-9bf7-b9068ab552a4', 'name': 'tag', }), dict({ 'challenge': True, - 'group': None, 'id': 'c44e9e8c-4bff-42df-98d5-1a1a7b69eada', 'name': 'tag', }), From ff22bbd0e4612afa3a68c7d9d5e2da83b30e8535 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 9 Feb 2025 09:31:18 -0800 Subject: [PATCH 0389/1941] Refresh the nest authentication token on integration start before invoking the pub/sub subsciber (#138003) * Refresh the nest authentication token on integration start before invoking the pub/sub subscriber * Apply suggestions from code review --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/nest/__init__.py | 11 +++- homeassistant/components/nest/api.py | 18 ++++-- tests/components/nest/test_api.py | 77 ----------------------- 3 files changed, 22 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 8adc0e4f714..67c14bbf544 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -198,7 +198,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool entry, unique_id=entry.data[CONF_PROJECT_ID] ) - subscriber = await api.new_subscriber(hass, entry) + auth = await api.new_auth(hass, entry) + try: + await auth.async_get_access_token() + except AuthException as err: + raise ConfigEntryAuthFailed(f"Authentication error: {err!s}") from err + except ConfigurationException as err: + _LOGGER.error("Configuration error: %s", err) + return False + + subscriber = await api.new_subscriber(hass, entry, auth) if not subscriber: return False # Keep media for last N events in memory diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index e86e326b1c2..727b126dda4 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -101,9 +101,7 @@ class AccessTokenAuthImpl(AbstractAuth): ) -async def new_subscriber( - hass: HomeAssistant, entry: NestConfigEntry -) -> GoogleNestSubscriber | None: +async def new_auth(hass: HomeAssistant, entry: NestConfigEntry) -> AbstractAuth: """Create a GoogleNestSubscriber.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -114,14 +112,22 @@ async def new_subscriber( implementation, config_entry_oauth2_flow.LocalOAuth2Implementation ): raise TypeError(f"Unexpected auth implementation {implementation}") - if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: - subscription_name = entry.data[CONF_SUBSCRIBER_ID] - auth = AsyncConfigEntryAuth( + return AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), implementation.client_id, implementation.client_secret, ) + + +async def new_subscriber( + hass: HomeAssistant, + entry: NestConfigEntry, + auth: AbstractAuth, +) -> GoogleNestSubscriber: + """Create a GoogleNestSubscriber.""" + if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: + subscription_name = entry.data[CONF_SUBSCRIBER_ID] return GoogleNestSubscriber(auth, entry.data[CONF_PROJECT_ID], subscription_name) diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 98c3e06cfb8..1a5c4d63dba 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -89,80 +89,3 @@ async def test_auth( assert creds.client_id == CLIENT_ID assert creds.client_secret == CLIENT_SECRET assert creds.scopes == SDM_SCOPES - - -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize( - "token_expiration_time", - [time.time() - 7 * 86400], - ids=["expires-in-past"], -) -async def test_auth_expired_token( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - setup_platform: PlatformSetup, - token_expiration_time: float, -) -> None: - """Verify behavior of an expired token.""" - # Prepare a token refresh response - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "access_token": FAKE_UPDATED_TOKEN, - "expires_at": time.time() + 86400, - "expires_in": 86400, - }, - ) - # Prepare to capture credentials in API request. Empty payloads just mean - # no devices or structures are loaded. - aioclient_mock.get(f"{API_URL}/enterprises/{PROJECT_ID}/structures", json={}) - aioclient_mock.get(f"{API_URL}/enterprises/{PROJECT_ID}/devices", json={}) - - # Prepare to capture credentials for Subscriber - captured_creds = None - - def async_new_subscriber( - credentials: Credentials, - ) -> Mock: - """Capture credentials for tests.""" - nonlocal captured_creds - captured_creds = credentials - return AsyncMock() - - with patch( - "google_nest_sdm.subscriber_client.pubsub_v1.SubscriberAsyncClient", - side_effect=async_new_subscriber, - ) as new_subscriber_mock: - await setup_platform() - - calls = aioclient_mock.mock_calls - assert len(calls) == 3 - # Verify refresh token call to get an updated token - (method, url, data, headers) = calls[0] - assert data == { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "grant_type": "refresh_token", - "refresh_token": FAKE_REFRESH_TOKEN, - } - # Verify API requests are made with the new token - (method, url, data, headers) = calls[1] - assert headers == {"Authorization": f"Bearer {FAKE_UPDATED_TOKEN}"} - (method, url, data, headers) = calls[2] - assert headers == {"Authorization": f"Bearer {FAKE_UPDATED_TOKEN}"} - - # The subscriber is created with a token that is expired. Verify that the - # credential is expired so the subscriber knows it needs to refresh it. - assert len(new_subscriber_mock.mock_calls) == 1 - assert captured_creds - creds = captured_creds - assert creds.token == FAKE_TOKEN - assert creds.refresh_token == FAKE_REFRESH_TOKEN - assert int(dt_util.as_timestamp(creds.expiry)) == int(token_expiration_time) - assert not creds.valid - assert creds.expired - assert creds.token_uri == OAUTH2_TOKEN - assert creds.client_id == CLIENT_ID - assert creds.client_secret == CLIENT_SECRET - assert creds.scopes == SDM_SCOPES From 201bf95ab87b8bb9ec292f9e6a21f474e8715d20 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 9 Feb 2025 09:30:52 -0800 Subject: [PATCH 0390/1941] Use resumable uploads in Google Drive (#138010) * Use resumable uploads in Google Drive * tests --- homeassistant/components/google_drive/api.py | 3 ++- homeassistant/components/google_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../google_drive/snapshots/test_backup.ambr | 6 ++++-- tests/components/google_drive/test_backup.py | 12 +++++++----- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py index 475eddb6231..c21d42e0f3a 100644 --- a/homeassistant/components/google_drive/api.py +++ b/homeassistant/components/google_drive/api.py @@ -146,9 +146,10 @@ class DriveClient: backup.backup_id, backup_metadata, ) - await self._api.upload_file( + await self._api.resumable_upload_file( backup_metadata, open_stream, + backup.size, timeout=ClientTimeout(total=_UPLOAD_AND_DOWNLOAD_TIMEOUT), ) _LOGGER.debug( diff --git a/homeassistant/components/google_drive/manifest.json b/homeassistant/components/google_drive/manifest.json index a1abb9b260a..6b199a5d3eb 100644 --- a/homeassistant/components/google_drive/manifest.json +++ b/homeassistant/components/google_drive/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_polling", "loggers": ["google_drive_api"], "quality_scale": "platinum", - "requirements": ["python-google-drive-api==0.0.2"] + "requirements": ["python-google-drive-api==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a40975f2fa..817013324a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2385,7 +2385,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.google_drive -python-google-drive-api==0.0.2 +python-google-drive-api==0.1.0 # homeassistant.components.analytics_insights python-homeassistant-analytics==0.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 309d07b773b..1fbff2f2ba2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1930,7 +1930,7 @@ python-fullykiosk==0.0.14 # python-gammu==3.2.4 # homeassistant.components.google_drive -python-google-drive-api==0.0.2 +python-google-drive-api==0.1.0 # homeassistant.components.analytics_insights python-homeassistant-analytics==0.8.1 diff --git a/tests/components/google_drive/snapshots/test_backup.ambr b/tests/components/google_drive/snapshots/test_backup.ambr index 2f3df3eed7f..891eb0e1cbe 100644 --- a/tests/components/google_drive/snapshots/test_backup.ambr +++ b/tests/components/google_drive/snapshots/test_backup.ambr @@ -136,7 +136,7 @@ }), ), tuple( - 'upload_file', + 'resumable_upload_file', tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', @@ -151,6 +151,7 @@ }), }), "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + 987, ), dict({ 'timeout': dict({ @@ -207,7 +208,7 @@ }), ), tuple( - 'upload_file', + 'resumable_upload_file', tuple( dict({ 'description': '{"addons": [{"name": "Test", "slug": "test", "version": "1.0.0"}], "backup_id": "test-backup", "date": "2025-01-01T01:23:45.678Z", "database_included": true, "extra_metadata": {"with_automatic_settings": false}, "folders": [], "homeassistant_included": true, "homeassistant_version": "2024.12.0", "name": "Test", "protected": false, "size": 987}', @@ -222,6 +223,7 @@ }), }), "CoreBackupReaderWriter.async_receive_backup..open_backup() -> 'AsyncIterator[bytes]'", + 987, ), dict({ 'timeout': dict({ diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 115a30a3eb6..70431e2049f 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -281,7 +281,7 @@ async def test_agents_upload( snapshot: SnapshotAssertion, ) -> None: """Test agent upload backup.""" - mock_api.upload_file = AsyncMock(return_value=None) + mock_api.resumable_upload_file = AsyncMock(return_value=None) client = await hass_client() @@ -306,7 +306,7 @@ async def test_agents_upload( assert f"Uploading backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text - mock_api.upload_file.assert_called_once() + mock_api.resumable_upload_file.assert_called_once() assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot @@ -322,7 +322,7 @@ async def test_agents_upload_create_folder_if_missing( mock_api.create_file = AsyncMock( return_value={"id": "new folder id", "name": "Home Assistant"} ) - mock_api.upload_file = AsyncMock(return_value=None) + mock_api.resumable_upload_file = AsyncMock(return_value=None) client = await hass_client() @@ -348,7 +348,7 @@ async def test_agents_upload_create_folder_if_missing( assert f"Uploaded backup: {TEST_AGENT_BACKUP.backup_id}" in caplog.text mock_api.create_file.assert_called_once() - mock_api.upload_file.assert_called_once() + mock_api.resumable_upload_file.assert_called_once() assert [tuple(mock_call) for mock_call in mock_api.mock_calls] == snapshot @@ -359,7 +359,9 @@ async def test_agents_upload_fail( mock_api: MagicMock, ) -> None: """Test agent upload backup fails.""" - mock_api.upload_file = AsyncMock(side_effect=GoogleDriveApiError("some error")) + mock_api.resumable_upload_file = AsyncMock( + side_effect=GoogleDriveApiError("some error") + ) client = await hass_client() From 00e6866664eb20e7a0f9683a6a70da99140daf97 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 9 Feb 2025 15:30:48 +0100 Subject: [PATCH 0391/1941] Bump py-synologydsm-api to 2.6.2 (#138060) bump py-synologydsm-api to 2.6.2 --- 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 ab6fc20b5cb..a083fa5a15f 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.0"], + "requirements": ["py-synologydsm-api==2.6.2"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index 817013324a3..0e07a799cdd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1746,7 +1746,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.0 +py-synologydsm-api==2.6.2 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fbff2f2ba2..7fd00154f67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1444,7 +1444,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.0 +py-synologydsm-api==2.6.2 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From 476ea35bdba3eadb5cc01f093d9b165c6e6071a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 10 Feb 2025 00:31:55 +0000 Subject: [PATCH 0392/1941] Handle generic agent exceptions when getting and deleting backups (#138145) * Handle generic agent exceptions when getting backups * Update hassio test * Update delete_backup --- homeassistant/components/backup/manager.py | 31 ++- .../backup/snapshots/test_websocket.ambr | 212 ++++++++++++++++-- tests/components/backup/test_websocket.py | 6 +- tests/components/hassio/test_backup.py | 8 +- 4 files changed, 226 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 25393a872cc..afca501d450 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -560,8 +560,15 @@ class BackupManager: return_exceptions=True, ) for idx, result in enumerate(list_backups_results): + agent_id = agent_ids[idx] if isinstance(result, BackupAgentError): - agent_errors[agent_ids[idx]] = result + agent_errors[agent_id] = result + continue + if isinstance(result, Exception): + agent_errors[agent_id] = result + LOGGER.error( + "Unexpected error for %s: %s", agent_id, result, exc_info=result + ) continue if isinstance(result, BaseException): raise result # unexpected error @@ -588,7 +595,7 @@ class BackupManager: name=agent_backup.name, with_automatic_settings=with_automatic_settings, ) - backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus( + backups[backup_id].agents[agent_id] = AgentBackupStatus( protected=agent_backup.protected, size=agent_backup.size, ) @@ -611,8 +618,15 @@ class BackupManager: return_exceptions=True, ) for idx, result in enumerate(get_backup_results): + agent_id = agent_ids[idx] if isinstance(result, BackupAgentError): - agent_errors[agent_ids[idx]] = result + agent_errors[agent_id] = result + continue + if isinstance(result, Exception): + agent_errors[agent_id] = result + LOGGER.error( + "Unexpected error for %s: %s", agent_id, result, exc_info=result + ) continue if isinstance(result, BaseException): raise result # unexpected error @@ -640,7 +654,7 @@ class BackupManager: name=result.name, with_automatic_settings=with_automatic_settings, ) - backup.agents[agent_ids[idx]] = AgentBackupStatus( + backup.agents[agent_id] = AgentBackupStatus( protected=result.protected, size=result.size, ) @@ -676,8 +690,15 @@ class BackupManager: return_exceptions=True, ) for idx, result in enumerate(delete_backup_results): + agent_id = agent_ids[idx] if isinstance(result, BackupAgentError): - agent_errors[agent_ids[idx]] = result + agent_errors[agent_id] = result + continue + if isinstance(result, Exception): + agent_errors[agent_id] = result + LOGGER.error( + "Unexpected error for %s: %s", agent_id, result, exc_info=result + ) continue if isinstance(result, BaseException): raise result # unexpected error diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 421432fb66e..2f063262f34 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -3697,12 +3697,13 @@ # --- # name: test_delete_with_errors[side_effect1-storage_data0] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Boom!', + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -3757,12 +3758,13 @@ # --- # name: test_delete_with_errors[side_effect1-storage_data1] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Boom!', + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -4019,12 +4021,89 @@ # --- # name: test_details_with_errors[side_effect0] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Oops', + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_details_with_errors[side_effect1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Boom!', + }), + 'backup': dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + }), + 'success': True, 'type': 'result', }) # --- @@ -4542,12 +4621,105 @@ # --- # name: test_info_with_errors[side_effect0] dict({ - 'error': dict({ - 'code': 'home_assistant_error', - 'message': 'Boom!', - }), 'id': 1, - 'success': False, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Oops', + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + ]), + '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', + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_info_with_errors[side_effect1] + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + 'domain.test': 'Boom!', + }), + 'backups': list([ + dict({ + 'addons': list([ + dict({ + 'name': 'Test', + 'slug': 'test', + 'version': '1.0.0', + }), + ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': False, + 'size': 0, + }), + }), + 'backup_id': 'abc123', + 'database_included': True, + 'date': '1970-01-01T00:00:00.000Z', + 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, + }), + 'failed_agent_ids': list([ + ]), + 'folders': list([ + 'media', + 'share', + ]), + 'homeassistant_included': True, + 'homeassistant_version': '2024.12.0', + 'name': 'Test', + 'with_automatic_settings': True, + }), + ]), + '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', + }), + 'success': True, 'type': 'result', }) # --- diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 5af6d595938..263a36570e6 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -148,7 +148,8 @@ async def test_info( @pytest.mark.parametrize( - "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] + "side_effect", + [Exception("Oops"), HomeAssistantError("Boom!"), BackupAgentUnreachableError], ) async def test_info_with_errors( hass: HomeAssistant, @@ -209,7 +210,8 @@ async def test_details( @pytest.mark.parametrize( - "side_effect", [HomeAssistantError("Boom!"), BackupAgentUnreachableError] + "side_effect", + [Exception("Oops"), HomeAssistantError("Boom!"), BackupAgentUnreachableError], ) async def test_details_with_errors( hass: HomeAssistant, diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 0dd2adc99ed..7547e3e3586 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -661,8 +661,8 @@ async def test_agent_get_backup( ( SupervisorBadRequestError("blah"), { - "success": False, - "error": {"code": "unknown_error", "message": "Unknown error"}, + "success": True, + "result": {"agent_errors": {"hassio.local": "blah"}, "backup": None}, }, ), ( @@ -733,8 +733,8 @@ async def test_agent_delete_backup( ( SupervisorBadRequestError("blah"), { - "success": False, - "error": {"code": "unknown_error", "message": "Unknown error"}, + "success": True, + "result": {"agent_errors": {"hassio.local": "blah"}}, }, ), ( From 171061a778d528fd820e7c9eaffec55551a284ba Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 10 Feb 2025 12:41:28 +0100 Subject: [PATCH 0393/1941] Bump onedrive-personal-sdk to 0.0.10 (#138186) --- homeassistant/components/onedrive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/onedrive/const.py | 12 ++++++------ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index fcc922b3e46..899a5e77b47 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.9"] + "requirements": ["onedrive-personal-sdk==0.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0e07a799cdd..f034e5c8316 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1556,7 +1556,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.9 +onedrive-personal-sdk==0.0.10 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7fd00154f67..f70a63e13bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1304,7 +1304,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.9 +onedrive-personal-sdk==0.0.10 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 3739369887d..3ba54dc40d7 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -5,10 +5,10 @@ from json import dumps from onedrive_personal_sdk.models.items import ( AppRoot, - Contributor, File, Folder, Hashes, + IdentitySet, ItemParentReference, User, ) @@ -31,7 +31,7 @@ BACKUP_METADATA = { "size": 34519040, } -CONTRIBUTOR = Contributor( +IDENTITY_SET = IdentitySet( user=User( display_name="John Doe", id="id", @@ -47,7 +47,7 @@ MOCK_APPROOT = AppRoot( parent_reference=ItemParentReference( drive_id="mock_drive_id", id="id", path="path" ), - created_by=CONTRIBUTOR, + created_by=IDENTITY_SET, ) MOCK_BACKUP_FOLDER = Folder( @@ -58,7 +58,7 @@ MOCK_BACKUP_FOLDER = Folder( parent_reference=ItemParentReference( drive_id="mock_drive_id", id="id", path="path" ), - created_by=CONTRIBUTOR, + created_by=IDENTITY_SET, ) MOCK_BACKUP_FILE = File( @@ -73,7 +73,7 @@ MOCK_BACKUP_FILE = File( ), mime_type="application/x-tar", description="", - created_by=CONTRIBUTOR, + created_by=IDENTITY_SET, ) MOCK_METADATA_FILE = File( @@ -96,5 +96,5 @@ MOCK_METADATA_FILE = File( } ) ), - created_by=CONTRIBUTOR, + created_by=IDENTITY_SET, ) From c32f57f85a4884ca37bddc434ea2896e8389a0d7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 10 Feb 2025 17:09:57 +0100 Subject: [PATCH 0394/1941] Keep one backup per backup agent when executing retention policy (#138189) * Keep one backup per backup agent when executing retention policy * Add tests * Use defaultdict instead of dict.setdefault * Update hassio tests --- homeassistant/components/backup/manager.py | 74 ++++- tests/components/backup/test_websocket.py | 313 +++++++++++++++++- tests/components/hassio/test_update.py | 9 + tests/components/hassio/test_websocket_api.py | 9 + 4 files changed, 374 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index afca501d450..e175ff9c03d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,6 +4,7 @@ from __future__ import annotations import abc import asyncio +from collections import defaultdict from collections.abc import AsyncIterator, Callable, Coroutine from dataclasses import dataclass, replace from enum import StrEnum @@ -677,10 +678,13 @@ class BackupManager: return None return with_automatic_settings - async def async_delete_backup(self, backup_id: str) -> dict[str, Exception]: + async def async_delete_backup( + self, backup_id: str, *, agent_ids: list[str] | None = None + ) -> dict[str, Exception]: """Delete a backup.""" agent_errors: dict[str, Exception] = {} - agent_ids = list(self.backup_agents) + if agent_ids is None: + agent_ids = list(self.backup_agents) delete_backup_results = await asyncio.gather( *( @@ -731,35 +735,71 @@ class BackupManager: # Run the include filter first to ensure we only consider backups that # should be included in the deletion process. backups = include_filter(backups) + backups_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(dict) + for backup_id, backup in backups.items(): + for agent_id in backup.agents: + backups_by_agent[agent_id][backup_id] = backup - LOGGER.debug("Total automatic backups: %s", backups) + LOGGER.debug("Backups returned by include filter: %s", backups) + LOGGER.debug( + "Backups returned by include filter by agent: %s", + {agent_id: list(backups) for agent_id, backups in backups_by_agent.items()}, + ) backups_to_delete = delete_filter(backups) + LOGGER.debug("Backups returned by delete filter: %s", backups_to_delete) + 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, - ) + backups_to_delete_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict( + dict + ) + for backup_id, backup in sorted( + backups_to_delete.items(), + key=lambda backup_item: backup_item[1].date, + ): + for agent_id in backup.agents: + backups_to_delete_by_agent[agent_id][backup_id] = backup + LOGGER.debug( + "Backups returned by delete filter by agent: %s", + { + agent_id: list(backups) + for agent_id, backups in backups_to_delete_by_agent.items() + }, + ) + for agent_id, to_delete_from_agent in backups_to_delete_by_agent.items(): + if len(to_delete_from_agent) >= len(backups_by_agent[agent_id]): + # Never delete the last backup. + last_backup = to_delete_from_agent.popitem() + LOGGER.debug( + "Keeping the last backup %s for agent %s", last_backup, agent_id + ) + + LOGGER.debug( + "Backups to delete by agent: %s", + { + agent_id: list(backups) + for agent_id, backups in backups_to_delete_by_agent.items() + }, ) - 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) + backup_ids_to_delete: dict[str, set[str]] = defaultdict(set) + for agent_id, to_delete in backups_to_delete_by_agent.items(): + for backup_id in to_delete: + backup_ids_to_delete[backup_id].add(agent_id) - LOGGER.debug("Backups to delete: %s", backups_to_delete) - - if not backups_to_delete: + if not backup_ids_to_delete: return - backup_ids = list(backups_to_delete) + backup_ids = list(backup_ids_to_delete) delete_results = await asyncio.gather( - *(self.async_delete_backup(backup_id) for backup_id in backups_to_delete) + *( + self.async_delete_backup(backup_id, agent_ids=list(agent_ids)) + for backup_id, agent_ids in backup_ids_to_delete.items() + ) ) agent_errors = { backup_id: error diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 263a36570e6..966cfbbef78 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -6,6 +6,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch from freezegun.api import FrozenDateTimeFactory import pytest +from pytest_unordered import unordered from syrupy import SnapshotAssertion from homeassistant.components.backup import ( @@ -20,6 +21,7 @@ from homeassistant.components.backup import ( from homeassistant.components.backup.agent import BackupAgentUnreachableError from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN from homeassistant.components.backup.manager import ( + AgentBackupStatus, CreateBackupEvent, CreateBackupState, ManagerBackup, @@ -1800,21 +1802,25 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1839,21 +1845,25 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1878,11 +1888,13 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, @@ -1907,26 +1919,46 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1940,7 +1972,80 @@ async def test_config_schedule_logic( 1, 1, 1, - [call("backup-1")], + [ + call( + "backup-1", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ) + ], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 3, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 1, + [ + call( + "backup-1", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ) + ], ), ( { @@ -1951,26 +2056,31 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -1984,7 +2094,10 @@ async def test_config_schedule_logic( 1, 1, 2, - [call("backup-1"), call("backup-2")], + [ + call("backup-1", agent_ids=["test.test-agent"]), + call("backup-2", agent_ids=["test.test-agent"]), + ], ), ( { @@ -1995,21 +2108,25 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2023,7 +2140,7 @@ async def test_config_schedule_logic( 1, 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( { @@ -2034,21 +2151,25 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2062,7 +2183,7 @@ async def test_config_schedule_logic( 1, 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( { @@ -2073,26 +2194,46 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2106,7 +2247,20 @@ async def test_config_schedule_logic( 1, 1, 3, - [call("backup-1"), call("backup-2"), call("backup-3")], + [ + call( + "backup-1", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + call( + "backup-2", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + call( + "backup-3", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + ], ), ( { @@ -2117,11 +2271,86 @@ async def test_config_schedule_logic( }, { "backup-1": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-09T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-2": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-10T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-3": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-11T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-4": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=True, + spec=ManagerBackup, + ), + "backup-5": MagicMock( + agents={ + "test.test-agent": MagicMock(spec=AgentBackupStatus), + "test.test-agent2": MagicMock(spec=AgentBackupStatus), + }, + date="2024-11-12T04:45:00+01:00", + with_automatic_settings=False, + spec=ManagerBackup, + ), + }, + {}, + {}, + "2024-11-11T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + "2024-11-12T04:45:00+01:00", + 1, + 1, + 3, + [ + call( + "backup-1", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + call( + "backup-2", + agent_ids=unordered(["test.test-agent", "test.test-agent2"]), + ), + call("backup-3", agent_ids=["test.test-agent"]), + ], + ), + ( + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "retention": {"copies": 0, "days": None}, + "schedule": {"recurrence": "daily"}, + }, + { + "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2261,21 +2490,25 @@ async def test_config_retention_copies_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2297,21 +2530,25 @@ async def test_config_retention_copies_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2333,26 +2570,31 @@ async def test_config_retention_copies_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2363,7 +2605,7 @@ async def test_config_retention_copies_logic( 1, 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( { @@ -2374,26 +2616,31 @@ async def test_config_retention_copies_logic( }, { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2404,7 +2651,10 @@ async def test_config_retention_copies_logic( 1, 1, 2, - [call("backup-1"), call("backup-2")], + [ + call("backup-1", agent_ids=["test.test-agent"]), + call("backup-2", agent_ids=["test.test-agent"]), + ], ), ], ) @@ -2519,16 +2769,19 @@ async def test_config_retention_copies_logic_manual_backup( [], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2541,7 +2794,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), # No config update - No cleanup ( @@ -2549,16 +2802,19 @@ async def test_config_retention_copies_logic_manual_backup( [], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2586,16 +2842,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2608,7 +2867,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( None, @@ -2622,16 +2881,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2644,7 +2906,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( None, @@ -2658,16 +2920,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2694,21 +2959,25 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2721,7 +2990,10 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 2, - [call("backup-1"), call("backup-2")], + [ + call("backup-1", agent_ids=["test.test-agent"]), + call("backup-2", agent_ids=["test.test-agent"]), + ], ), ( None, @@ -2735,16 +3007,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2757,7 +3032,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( None, @@ -2771,16 +3046,19 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2793,7 +3071,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 1, - [call("backup-1")], + [call("backup-1", agent_ids=["test.test-agent"])], ), ( None, @@ -2807,21 +3085,25 @@ async def test_config_retention_copies_logic_manual_backup( ], { "backup-1": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-09T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"test.test-agent": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, @@ -2834,7 +3116,10 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-12T12:00:00+01:00", 1, 2, - [call("backup-1"), call("backup-2")], + [ + call("backup-1", agent_ids=["test.test-agent"]), + call("backup-2", agent_ids=["test.test-agent"]), + ], ), ], ) diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 332f2050cf2..83af302e1ce 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -10,6 +10,9 @@ from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest from homeassistant.components.backup import BackupManagerError, ManagerBackup + +# pylint: disable-next=hass-component-root-import +from homeassistant.components.backup.manager import AgentBackupStatus from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION @@ -348,34 +351,40 @@ async def test_update_addon_with_backup( ( { "backup-1": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "other"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "other"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "test"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-6": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "test"}, with_automatic_settings=True, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index bcac19e0fa3..e752b53ae7a 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -9,6 +9,9 @@ from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate import pytest from homeassistant.components.backup import BackupManagerError, ManagerBackup + +# pylint: disable-next=hass-component-root-import +from homeassistant.components.backup.manager import AgentBackupStatus from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( ATTR_DATA, @@ -467,34 +470,40 @@ async def test_update_addon_with_backup( ( { "backup-1": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-10T04:45:00+01:00", with_automatic_settings=True, spec=ManagerBackup, ), "backup-2": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", with_automatic_settings=False, spec=ManagerBackup, ), "backup-3": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "other"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-4": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "other"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-5": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-11T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "test"}, with_automatic_settings=True, spec=ManagerBackup, ), "backup-6": MagicMock( + agents={"hassio.local": MagicMock(spec=AgentBackupStatus)}, date="2024-11-12T04:45:00+01:00", extra_metadata={"supervisor.addon_update": "test"}, with_automatic_settings=True, From af06521f66dddb95ad5eb988ac156cc4eaf316d8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 10 Feb 2025 16:59:18 +0100 Subject: [PATCH 0395/1941] Improve inexogy logging when failed to update (#138210) --- homeassistant/components/discovergy/coordinator.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index 3be4c71c987..2c85bc40775 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -44,9 +44,7 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): ) except InvalidLogin as err: raise ConfigEntryAuthFailed( - f"Auth expired while fetching last reading for meter {self.meter.meter_id}" + "Auth expired while fetching last reading" ) from err except (HTTPError, DiscovergyClientError) as err: - raise UpdateFailed( - f"Error while fetching last reading for meter {self.meter.meter_id}" - ) from err + raise UpdateFailed(f"Error while fetching last reading: {err}") from err From 713931661ea6e15b5ad501d72fd4a14028689c6e Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 10 Feb 2025 13:18:12 -0600 Subject: [PATCH 0396/1941] Bump pyheos to v1.0.2 (#138224) Bump pyheos --- homeassistant/components/heos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/heos/conftest.py | 3 +++ tests/components/heos/snapshots/test_diagnostics.ambr | 5 +++++ 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 22dbbf4da28..72472760951 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "silver", - "requirements": ["pyheos==1.0.1"], + "requirements": ["pyheos==1.0.2"], "single_config_entry": true, "ssdp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index f034e5c8316..c745a8d9ff0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1987,7 +1987,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.1 +pyheos==1.0.2 # homeassistant.components.hive pyhive-integration==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f70a63e13bc..cea175268a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1616,7 +1616,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.1 +pyheos==1.0.2 # homeassistant.components.hive pyhive-integration==1.0.1 diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 5ec809b10e9..39937a8355f 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -110,6 +110,7 @@ def system_info_fixture() -> HeosSystem: "1.0.0", "127.0.0.1", NetworkType.WIRED, + True, ) return HeosSystem( "user@user.com", @@ -123,6 +124,7 @@ def system_info_fixture() -> HeosSystem: "1.0.0", "127.0.0.2", NetworkType.WIFI, + True, ), ], ) @@ -140,6 +142,7 @@ def players_fixture() -> dict[int, HeosPlayer]: model="HEOS Drive HS2" if i == 1 else "Speaker", serial="123456", version="1.0.0", + supported_version=True, line_out=LineOutLevelType.VARIABLE, is_muted=False, available=True, diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 1df2d172142..36a0bfa4172 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -105,6 +105,7 @@ 'name': 'Test Player', 'network': 'wired', 'serial': '**REDACTED**', + 'supported_version': True, 'version': '1.0.0', }), 'hosts': list([ @@ -114,6 +115,7 @@ 'name': 'Test Player', 'network': 'wired', 'serial': '**REDACTED**', + 'supported_version': True, 'version': '1.0.0', }), dict({ @@ -122,6 +124,7 @@ 'name': 'Test Player 2', 'network': 'wifi', 'serial': '**REDACTED**', + 'supported_version': True, 'version': '1.0.0', }), ]), @@ -133,6 +136,7 @@ 'name': 'Test Player', 'network': 'wired', 'serial': '**REDACTED**', + 'supported_version': True, 'version': '1.0.0', }), ]), @@ -371,6 +375,7 @@ 'serial': '**REDACTED**', 'shuffle': False, 'state': 'stop', + 'supported_version': True, 'version': '1.0.0', 'volume': 25, }), From 010993fc5f6a7b1e34b8a3bbba13de22706639ae Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 10 Feb 2025 20:11:39 +0100 Subject: [PATCH 0397/1941] Update frontend to 20250210.0 (#138227) --- 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 d27785dcea5..912ce508e00 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==20250205.0"] + "requirements": ["home-assistant-frontend==20250210.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a53534f4f6d..91d73428f80 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.1 hass-nabucasa==0.88.1 hassil==2.2.3 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250205.0 +home-assistant-frontend==20250210.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 c745a8d9ff0..1b77d0d896c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250205.0 +home-assistant-frontend==20250210.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cea175268a9..9758594549e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250205.0 +home-assistant-frontend==20250210.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From b9280edbfa1cc9146c88df7df4289b726e59d222 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 10 Feb 2025 19:52:33 +0000 Subject: [PATCH 0398/1941] Bump version to 2025.2.2 --- 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 6c49cab3d41..bf9e76df60d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __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 14fc8fda870..8fb18fa7f07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.1" +version = "2025.2.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f83c8de8d3df745bc855be989ac307667c265ad0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 10 Feb 2025 21:08:03 +0100 Subject: [PATCH 0399/1941] Update signature of platforms' async_setup_entry (#138201) --- homeassistant/components/abode/alarm_control_panel.py | 6 ++++-- homeassistant/components/abode/binary_sensor.py | 6 ++++-- homeassistant/components/abode/camera.py | 6 ++++-- homeassistant/components/abode/cover.py | 6 ++++-- homeassistant/components/abode/light.py | 6 ++++-- homeassistant/components/abode/lock.py | 6 ++++-- homeassistant/components/abode/sensor.py | 6 ++++-- homeassistant/components/abode/switch.py | 6 ++++-- homeassistant/components/acaia/binary_sensor.py | 4 ++-- homeassistant/components/acaia/button.py | 4 ++-- homeassistant/components/acaia/sensor.py | 4 ++-- homeassistant/components/accuweather/sensor.py | 4 ++-- homeassistant/components/accuweather/weather.py | 4 ++-- homeassistant/components/acmeda/cover.py | 4 ++-- homeassistant/components/acmeda/helpers.py | 4 ++-- homeassistant/components/acmeda/sensor.py | 4 ++-- homeassistant/components/adax/climate.py | 4 ++-- homeassistant/components/adguard/sensor.py | 4 ++-- homeassistant/components/adguard/switch.py | 4 ++-- .../components/advantage_air/binary_sensor.py | 4 ++-- homeassistant/components/advantage_air/climate.py | 4 ++-- homeassistant/components/advantage_air/cover.py | 4 ++-- homeassistant/components/advantage_air/light.py | 4 ++-- homeassistant/components/advantage_air/select.py | 4 ++-- homeassistant/components/advantage_air/sensor.py | 4 ++-- homeassistant/components/advantage_air/switch.py | 4 ++-- homeassistant/components/advantage_air/update.py | 4 ++-- homeassistant/components/aemet/image.py | 4 ++-- homeassistant/components/aemet/sensor.py | 4 ++-- homeassistant/components/aemet/weather.py | 4 ++-- homeassistant/components/aftership/sensor.py | 4 ++-- .../components/agent_dvr/alarm_control_panel.py | 4 ++-- homeassistant/components/agent_dvr/camera.py | 4 ++-- homeassistant/components/airgradient/button.py | 4 ++-- homeassistant/components/airgradient/number.py | 4 ++-- homeassistant/components/airgradient/select.py | 4 ++-- homeassistant/components/airgradient/sensor.py | 4 ++-- homeassistant/components/airgradient/switch.py | 4 ++-- homeassistant/components/airgradient/update.py | 4 ++-- homeassistant/components/airly/sensor.py | 4 ++-- homeassistant/components/airnow/sensor.py | 4 ++-- homeassistant/components/airq/sensor.py | 4 ++-- homeassistant/components/airthings/sensor.py | 4 ++-- homeassistant/components/airthings_ble/sensor.py | 4 ++-- homeassistant/components/airtouch4/climate.py | 4 ++-- homeassistant/components/airtouch5/climate.py | 4 ++-- homeassistant/components/airtouch5/cover.py | 4 ++-- homeassistant/components/airvisual/sensor.py | 4 ++-- homeassistant/components/airvisual_pro/sensor.py | 4 ++-- homeassistant/components/airzone/binary_sensor.py | 4 ++-- homeassistant/components/airzone/climate.py | 4 ++-- homeassistant/components/airzone/select.py | 4 ++-- homeassistant/components/airzone/sensor.py | 4 ++-- homeassistant/components/airzone/switch.py | 4 ++-- homeassistant/components/airzone/water_heater.py | 4 ++-- .../components/airzone_cloud/binary_sensor.py | 4 ++-- homeassistant/components/airzone_cloud/climate.py | 4 ++-- homeassistant/components/airzone_cloud/select.py | 4 ++-- homeassistant/components/airzone_cloud/sensor.py | 4 ++-- homeassistant/components/airzone_cloud/switch.py | 4 ++-- .../components/airzone_cloud/water_heater.py | 4 ++-- .../components/alarmdecoder/alarm_control_panel.py | 4 ++-- .../components/alarmdecoder/binary_sensor.py | 4 ++-- homeassistant/components/alarmdecoder/sensor.py | 4 ++-- .../components/amberelectric/binary_sensor.py | 4 ++-- homeassistant/components/amberelectric/sensor.py | 4 ++-- homeassistant/components/ambient_network/sensor.py | 4 ++-- .../components/ambient_station/binary_sensor.py | 4 ++-- homeassistant/components/ambient_station/sensor.py | 4 ++-- homeassistant/components/analytics_insights/sensor.py | 4 ++-- .../components/android_ip_webcam/binary_sensor.py | 4 ++-- homeassistant/components/android_ip_webcam/camera.py | 4 ++-- homeassistant/components/android_ip_webcam/sensor.py | 4 ++-- homeassistant/components/android_ip_webcam/switch.py | 4 ++-- homeassistant/components/androidtv/media_player.py | 4 ++-- homeassistant/components/androidtv/remote.py | 6 ++++-- .../components/androidtv_remote/media_player.py | 4 ++-- homeassistant/components/androidtv_remote/remote.py | 4 ++-- homeassistant/components/anova/sensor.py | 6 +++--- homeassistant/components/anthemav/media_player.py | 4 ++-- homeassistant/components/anthropic/conversation.py | 4 ++-- homeassistant/components/aosmith/sensor.py | 4 ++-- homeassistant/components/aosmith/water_heater.py | 4 ++-- homeassistant/components/apcupsd/binary_sensor.py | 4 ++-- homeassistant/components/apcupsd/sensor.py | 4 ++-- homeassistant/components/apple_tv/media_player.py | 4 ++-- homeassistant/components/apple_tv/remote.py | 4 ++-- homeassistant/components/aprilaire/climate.py | 4 ++-- homeassistant/components/aprilaire/humidifier.py | 4 ++-- homeassistant/components/aprilaire/select.py | 4 ++-- homeassistant/components/aprilaire/sensor.py | 4 ++-- homeassistant/components/apsystems/binary_sensor.py | 4 ++-- homeassistant/components/apsystems/number.py | 4 ++-- homeassistant/components/apsystems/sensor.py | 4 ++-- homeassistant/components/apsystems/switch.py | 4 ++-- homeassistant/components/aquacell/sensor.py | 4 ++-- homeassistant/components/aranet/sensor.py | 4 ++-- homeassistant/components/arcam_fmj/media_player.py | 4 ++-- homeassistant/components/arve/sensor.py | 6 ++++-- .../components/aseko_pool_live/binary_sensor.py | 4 ++-- homeassistant/components/aseko_pool_live/sensor.py | 4 ++-- homeassistant/components/asuswrt/device_tracker.py | 8 +++++--- homeassistant/components/asuswrt/sensor.py | 4 ++-- homeassistant/components/atag/climate.py | 6 ++++-- homeassistant/components/atag/sensor.py | 4 ++-- homeassistant/components/atag/water_heater.py | 4 ++-- homeassistant/components/august/binary_sensor.py | 4 ++-- homeassistant/components/august/button.py | 4 ++-- homeassistant/components/august/camera.py | 4 ++-- homeassistant/components/august/event.py | 4 ++-- homeassistant/components/august/lock.py | 4 ++-- homeassistant/components/august/sensor.py | 4 ++-- homeassistant/components/aurora/binary_sensor.py | 4 ++-- homeassistant/components/aurora/sensor.py | 4 ++-- .../components/aurora_abb_powerone/sensor.py | 4 ++-- homeassistant/components/aussie_broadband/sensor.py | 4 ++-- homeassistant/components/autarco/sensor.py | 4 ++-- homeassistant/components/awair/sensor.py | 4 ++-- homeassistant/components/axis/binary_sensor.py | 4 ++-- homeassistant/components/axis/camera.py | 4 ++-- homeassistant/components/axis/light.py | 4 ++-- homeassistant/components/axis/switch.py | 4 ++-- homeassistant/components/azure_devops/sensor.py | 4 ++-- homeassistant/components/baf/binary_sensor.py | 4 ++-- homeassistant/components/baf/climate.py | 4 ++-- homeassistant/components/baf/fan.py | 4 ++-- homeassistant/components/baf/light.py | 4 ++-- homeassistant/components/baf/number.py | 4 ++-- homeassistant/components/baf/sensor.py | 4 ++-- homeassistant/components/baf/switch.py | 4 ++-- homeassistant/components/balboa/binary_sensor.py | 4 ++-- homeassistant/components/balboa/climate.py | 4 ++-- homeassistant/components/balboa/fan.py | 4 ++-- homeassistant/components/balboa/light.py | 4 ++-- homeassistant/components/balboa/select.py | 4 ++-- homeassistant/components/bang_olufsen/event.py | 4 ++-- homeassistant/components/bang_olufsen/media_player.py | 4 ++-- homeassistant/components/blebox/binary_sensor.py | 4 ++-- homeassistant/components/blebox/button.py | 4 ++-- homeassistant/components/blebox/climate.py | 4 ++-- homeassistant/components/blebox/cover.py | 4 ++-- homeassistant/components/blebox/light.py | 4 ++-- homeassistant/components/blebox/sensor.py | 4 ++-- homeassistant/components/blebox/switch.py | 4 ++-- homeassistant/components/blink/alarm_control_panel.py | 4 ++-- homeassistant/components/blink/binary_sensor.py | 4 ++-- homeassistant/components/blink/camera.py | 4 ++-- homeassistant/components/blink/sensor.py | 4 ++-- homeassistant/components/blink/switch.py | 4 ++-- homeassistant/components/blue_current/sensor.py | 4 ++-- homeassistant/components/bluemaestro/sensor.py | 4 ++-- homeassistant/components/bluesound/media_player.py | 4 ++-- .../components/bmw_connected_drive/binary_sensor.py | 4 ++-- .../components/bmw_connected_drive/button.py | 4 ++-- .../components/bmw_connected_drive/device_tracker.py | 4 ++-- homeassistant/components/bmw_connected_drive/lock.py | 4 ++-- .../components/bmw_connected_drive/number.py | 4 ++-- .../components/bmw_connected_drive/select.py | 4 ++-- .../components/bmw_connected_drive/sensor.py | 4 ++-- .../components/bmw_connected_drive/switch.py | 4 ++-- homeassistant/components/bond/button.py | 4 ++-- homeassistant/components/bond/cover.py | 4 ++-- homeassistant/components/bond/fan.py | 4 ++-- homeassistant/components/bond/light.py | 4 ++-- homeassistant/components/bond/switch.py | 4 ++-- homeassistant/components/bosch_shc/binary_sensor.py | 4 ++-- homeassistant/components/bosch_shc/cover.py | 4 ++-- homeassistant/components/bosch_shc/sensor.py | 4 ++-- homeassistant/components/bosch_shc/switch.py | 4 ++-- homeassistant/components/braviatv/button.py | 4 ++-- homeassistant/components/braviatv/media_player.py | 4 ++-- homeassistant/components/braviatv/remote.py | 4 ++-- homeassistant/components/bring/event.py | 4 ++-- homeassistant/components/bring/sensor.py | 4 ++-- homeassistant/components/bring/todo.py | 4 ++-- homeassistant/components/broadlink/climate.py | 4 ++-- homeassistant/components/broadlink/light.py | 4 ++-- homeassistant/components/broadlink/remote.py | 4 ++-- homeassistant/components/broadlink/select.py | 4 ++-- homeassistant/components/broadlink/sensor.py | 4 ++-- homeassistant/components/broadlink/switch.py | 9 ++++++--- homeassistant/components/broadlink/time.py | 4 ++-- homeassistant/components/brother/sensor.py | 4 ++-- homeassistant/components/brottsplatskartan/sensor.py | 6 ++++-- homeassistant/components/brunt/cover.py | 4 ++-- homeassistant/components/bryant_evolution/climate.py | 4 ++-- homeassistant/components/bsblan/climate.py | 4 ++-- homeassistant/components/bsblan/sensor.py | 4 ++-- homeassistant/components/bsblan/water_heater.py | 4 ++-- homeassistant/components/bthome/binary_sensor.py | 4 ++-- homeassistant/components/bthome/event.py | 4 ++-- homeassistant/components/bthome/sensor.py | 4 ++-- homeassistant/components/buienradar/camera.py | 4 ++-- homeassistant/components/buienradar/sensor.py | 4 ++-- homeassistant/components/buienradar/weather.py | 4 ++-- homeassistant/components/caldav/calendar.py | 7 +++++-- homeassistant/components/caldav/todo.py | 4 ++-- .../components/cambridge_audio/media_player.py | 4 ++-- homeassistant/components/cambridge_audio/select.py | 4 ++-- homeassistant/components/cambridge_audio/switch.py | 4 ++-- .../components/canary/alarm_control_panel.py | 4 ++-- homeassistant/components/canary/camera.py | 4 ++-- homeassistant/components/canary/sensor.py | 4 ++-- homeassistant/components/cast/media_player.py | 4 ++-- homeassistant/components/ccm15/climate.py | 4 ++-- homeassistant/components/cert_expiry/sensor.py | 4 ++-- homeassistant/components/chacon_dio/cover.py | 4 ++-- homeassistant/components/chacon_dio/switch.py | 4 ++-- homeassistant/components/cloud/binary_sensor.py | 4 ++-- homeassistant/components/cloud/stt.py | 4 ++-- homeassistant/components/cloud/tts.py | 4 ++-- homeassistant/components/co2signal/sensor.py | 4 ++-- homeassistant/components/coinbase/sensor.py | 4 ++-- .../components/comelit/alarm_control_panel.py | 4 ++-- homeassistant/components/comelit/binary_sensor.py | 4 ++-- homeassistant/components/comelit/climate.py | 4 ++-- homeassistant/components/comelit/cover.py | 4 ++-- homeassistant/components/comelit/humidifier.py | 4 ++-- homeassistant/components/comelit/light.py | 4 ++-- homeassistant/components/comelit/sensor.py | 8 ++++---- homeassistant/components/comelit/switch.py | 4 ++-- homeassistant/components/control4/light.py | 4 ++-- homeassistant/components/control4/media_player.py | 4 ++-- homeassistant/components/cookidoo/button.py | 4 ++-- homeassistant/components/cookidoo/sensor.py | 4 ++-- homeassistant/components/cookidoo/todo.py | 4 ++-- homeassistant/components/coolmaster/binary_sensor.py | 4 ++-- homeassistant/components/coolmaster/button.py | 4 ++-- homeassistant/components/coolmaster/climate.py | 4 ++-- homeassistant/components/coolmaster/sensor.py | 4 ++-- homeassistant/components/cpuspeed/sensor.py | 4 ++-- homeassistant/components/crownstone/light.py | 4 ++-- homeassistant/components/daikin/climate.py | 4 ++-- homeassistant/components/daikin/sensor.py | 4 ++-- homeassistant/components/daikin/switch.py | 4 ++-- homeassistant/components/deako/light.py | 4 ++-- .../components/deconz/alarm_control_panel.py | 4 ++-- homeassistant/components/deconz/binary_sensor.py | 4 ++-- homeassistant/components/deconz/button.py | 4 ++-- homeassistant/components/deconz/climate.py | 4 ++-- homeassistant/components/deconz/cover.py | 4 ++-- homeassistant/components/deconz/fan.py | 4 ++-- homeassistant/components/deconz/light.py | 4 ++-- homeassistant/components/deconz/lock.py | 4 ++-- homeassistant/components/deconz/number.py | 4 ++-- homeassistant/components/deconz/scene.py | 4 ++-- homeassistant/components/deconz/select.py | 4 ++-- homeassistant/components/deconz/sensor.py | 6 +++--- homeassistant/components/deconz/siren.py | 4 ++-- homeassistant/components/deconz/switch.py | 4 ++-- homeassistant/components/deluge/sensor.py | 4 ++-- homeassistant/components/deluge/switch.py | 4 ++-- homeassistant/components/demo/air_quality.py | 4 ++-- homeassistant/components/demo/alarm_control_panel.py | 4 ++-- homeassistant/components/demo/binary_sensor.py | 4 ++-- homeassistant/components/demo/button.py | 4 ++-- homeassistant/components/demo/calendar.py | 4 ++-- homeassistant/components/demo/camera.py | 4 ++-- homeassistant/components/demo/climate.py | 4 ++-- homeassistant/components/demo/cover.py | 4 ++-- homeassistant/components/demo/date.py | 4 ++-- homeassistant/components/demo/datetime.py | 4 ++-- homeassistant/components/demo/event.py | 4 ++-- homeassistant/components/demo/fan.py | 4 ++-- homeassistant/components/demo/humidifier.py | 4 ++-- homeassistant/components/demo/light.py | 4 ++-- homeassistant/components/demo/lock.py | 4 ++-- homeassistant/components/demo/media_player.py | 4 ++-- homeassistant/components/demo/notify.py | 4 ++-- homeassistant/components/demo/number.py | 4 ++-- homeassistant/components/demo/remote.py | 4 ++-- homeassistant/components/demo/select.py | 4 ++-- homeassistant/components/demo/sensor.py | 4 ++-- homeassistant/components/demo/siren.py | 4 ++-- homeassistant/components/demo/stt.py | 4 ++-- homeassistant/components/demo/switch.py | 4 ++-- homeassistant/components/demo/text.py | 4 ++-- homeassistant/components/demo/time.py | 4 ++-- homeassistant/components/demo/update.py | 4 ++-- homeassistant/components/demo/vacuum.py | 4 ++-- homeassistant/components/demo/water_heater.py | 4 ++-- homeassistant/components/demo/weather.py | 4 ++-- homeassistant/components/denonavr/media_player.py | 4 ++-- homeassistant/components/derivative/sensor.py | 7 +++++-- homeassistant/components/devialet/media_player.py | 4 ++-- .../components/devolo_home_control/binary_sensor.py | 4 ++-- .../components/devolo_home_control/climate.py | 4 ++-- homeassistant/components/devolo_home_control/cover.py | 4 ++-- homeassistant/components/devolo_home_control/light.py | 4 ++-- .../components/devolo_home_control/sensor.py | 4 ++-- homeassistant/components/devolo_home_control/siren.py | 4 ++-- .../components/devolo_home_control/switch.py | 4 ++-- .../components/devolo_home_network/binary_sensor.py | 4 ++-- .../components/devolo_home_network/button.py | 4 ++-- .../components/devolo_home_network/device_tracker.py | 4 ++-- homeassistant/components/devolo_home_network/image.py | 4 ++-- .../components/devolo_home_network/sensor.py | 4 ++-- .../components/devolo_home_network/switch.py | 4 ++-- .../components/devolo_home_network/update.py | 4 ++-- homeassistant/components/dexcom/sensor.py | 4 ++-- homeassistant/components/directv/media_player.py | 4 ++-- homeassistant/components/directv/remote.py | 4 ++-- homeassistant/components/discovergy/sensor.py | 4 ++-- homeassistant/components/dlink/switch.py | 4 ++-- homeassistant/components/dlna_dmr/media_player.py | 4 ++-- homeassistant/components/dnsip/sensor.py | 6 ++++-- homeassistant/components/doorbird/button.py | 4 ++-- homeassistant/components/doorbird/camera.py | 4 ++-- homeassistant/components/doorbird/event.py | 4 ++-- .../components/dormakaba_dkey/binary_sensor.py | 4 ++-- homeassistant/components/dormakaba_dkey/lock.py | 4 ++-- homeassistant/components/dormakaba_dkey/sensor.py | 4 ++-- .../components/dremel_3d_printer/binary_sensor.py | 4 ++-- homeassistant/components/dremel_3d_printer/button.py | 4 ++-- homeassistant/components/dremel_3d_printer/camera.py | 4 ++-- homeassistant/components/dremel_3d_printer/sensor.py | 4 ++-- .../components/drop_connect/binary_sensor.py | 4 ++-- homeassistant/components/drop_connect/select.py | 4 ++-- homeassistant/components/drop_connect/sensor.py | 4 ++-- homeassistant/components/drop_connect/switch.py | 4 ++-- homeassistant/components/dsmr/sensor.py | 6 ++++-- homeassistant/components/dsmr_reader/sensor.py | 4 ++-- homeassistant/components/dunehd/media_player.py | 4 ++-- homeassistant/components/duotecno/binary_sensor.py | 4 ++-- homeassistant/components/duotecno/climate.py | 4 ++-- homeassistant/components/duotecno/cover.py | 4 ++-- homeassistant/components/duotecno/light.py | 4 ++-- homeassistant/components/duotecno/switch.py | 4 ++-- .../components/dwd_weather_warnings/sensor.py | 4 ++-- homeassistant/components/dynalite/cover.py | 4 ++-- homeassistant/components/dynalite/light.py | 4 ++-- homeassistant/components/dynalite/switch.py | 4 ++-- homeassistant/components/eafm/sensor.py | 4 ++-- homeassistant/components/easyenergy/sensor.py | 4 ++-- homeassistant/components/ecobee/binary_sensor.py | 4 ++-- homeassistant/components/ecobee/climate.py | 4 ++-- homeassistant/components/ecobee/humidifier.py | 4 ++-- homeassistant/components/ecobee/notify.py | 4 ++-- homeassistant/components/ecobee/number.py | 4 ++-- homeassistant/components/ecobee/sensor.py | 4 ++-- homeassistant/components/ecobee/switch.py | 4 ++-- homeassistant/components/ecobee/weather.py | 4 ++-- homeassistant/components/ecoforest/number.py | 4 ++-- homeassistant/components/ecoforest/sensor.py | 4 ++-- homeassistant/components/ecoforest/switch.py | 4 ++-- homeassistant/components/econet/binary_sensor.py | 4 ++-- homeassistant/components/econet/climate.py | 4 ++-- homeassistant/components/econet/sensor.py | 4 ++-- homeassistant/components/econet/switch.py | 4 ++-- homeassistant/components/econet/water_heater.py | 4 ++-- homeassistant/components/ecovacs/binary_sensor.py | 4 ++-- homeassistant/components/ecovacs/button.py | 4 ++-- homeassistant/components/ecovacs/event.py | 4 ++-- homeassistant/components/ecovacs/image.py | 4 ++-- homeassistant/components/ecovacs/lawn_mower.py | 4 ++-- homeassistant/components/ecovacs/number.py | 4 ++-- homeassistant/components/ecovacs/select.py | 4 ++-- homeassistant/components/ecovacs/sensor.py | 4 ++-- homeassistant/components/ecovacs/switch.py | 4 ++-- homeassistant/components/ecovacs/vacuum.py | 4 ++-- homeassistant/components/ecowitt/binary_sensor.py | 4 ++-- homeassistant/components/ecowitt/sensor.py | 4 ++-- homeassistant/components/edl21/sensor.py | 6 +++--- homeassistant/components/efergy/sensor.py | 4 ++-- homeassistant/components/eheimdigital/climate.py | 4 ++-- homeassistant/components/eheimdigital/light.py | 4 ++-- homeassistant/components/electrasmart/climate.py | 4 ++-- homeassistant/components/electric_kiwi/select.py | 4 ++-- homeassistant/components/electric_kiwi/sensor.py | 4 ++-- homeassistant/components/elevenlabs/tts.py | 4 ++-- homeassistant/components/elgato/button.py | 4 ++-- homeassistant/components/elgato/light.py | 4 ++-- homeassistant/components/elgato/sensor.py | 4 ++-- homeassistant/components/elgato/switch.py | 4 ++-- homeassistant/components/elkm1/alarm_control_panel.py | 4 ++-- homeassistant/components/elkm1/binary_sensor.py | 4 ++-- homeassistant/components/elkm1/climate.py | 4 ++-- homeassistant/components/elkm1/light.py | 4 ++-- homeassistant/components/elkm1/scene.py | 4 ++-- homeassistant/components/elkm1/sensor.py | 4 ++-- homeassistant/components/elkm1/switch.py | 4 ++-- homeassistant/components/elmax/alarm_control_panel.py | 4 ++-- homeassistant/components/elmax/binary_sensor.py | 4 ++-- homeassistant/components/elmax/cover.py | 4 ++-- homeassistant/components/elmax/switch.py | 4 ++-- homeassistant/components/emoncms/sensor.py | 7 +++++-- homeassistant/components/emonitor/sensor.py | 4 ++-- .../components/energenie_power_sockets/switch.py | 4 ++-- homeassistant/components/energyzero/sensor.py | 4 ++-- homeassistant/components/enigma2/media_player.py | 4 ++-- .../components/enphase_envoy/binary_sensor.py | 4 ++-- homeassistant/components/enphase_envoy/number.py | 4 ++-- homeassistant/components/enphase_envoy/select.py | 4 ++-- homeassistant/components/enphase_envoy/sensor.py | 4 ++-- homeassistant/components/enphase_envoy/switch.py | 4 ++-- homeassistant/components/environment_canada/camera.py | 4 ++-- homeassistant/components/environment_canada/sensor.py | 4 ++-- .../components/environment_canada/weather.py | 4 ++-- homeassistant/components/epic_games_store/calendar.py | 4 ++-- homeassistant/components/epion/sensor.py | 4 ++-- homeassistant/components/epson/media_player.py | 4 ++-- homeassistant/components/eq3btsmart/binary_sensor.py | 4 ++-- homeassistant/components/eq3btsmart/climate.py | 4 ++-- homeassistant/components/eq3btsmart/number.py | 4 ++-- homeassistant/components/eq3btsmart/sensor.py | 4 ++-- homeassistant/components/eq3btsmart/switch.py | 4 ++-- homeassistant/components/escea/climate.py | 4 ++-- homeassistant/components/esphome/assist_satellite.py | 4 ++-- homeassistant/components/esphome/binary_sensor.py | 4 ++-- homeassistant/components/esphome/select.py | 4 ++-- homeassistant/components/esphome/sensor.py | 6 ++++-- homeassistant/components/esphome/update.py | 4 ++-- homeassistant/components/eufylife_ble/sensor.py | 4 ++-- homeassistant/components/evil_genius_labs/light.py | 4 ++-- homeassistant/components/ezviz/alarm_control_panel.py | 4 ++-- homeassistant/components/ezviz/binary_sensor.py | 4 ++-- homeassistant/components/ezviz/button.py | 4 ++-- homeassistant/components/ezviz/camera.py | 4 ++-- homeassistant/components/ezviz/image.py | 4 ++-- homeassistant/components/ezviz/light.py | 4 ++-- homeassistant/components/ezviz/number.py | 4 ++-- homeassistant/components/ezviz/select.py | 4 ++-- homeassistant/components/ezviz/sensor.py | 4 ++-- homeassistant/components/ezviz/siren.py | 4 ++-- homeassistant/components/ezviz/switch.py | 4 ++-- homeassistant/components/ezviz/update.py | 4 ++-- homeassistant/components/faa_delays/binary_sensor.py | 6 ++++-- homeassistant/components/fastdotcom/sensor.py | 4 ++-- homeassistant/components/feedreader/event.py | 4 ++-- homeassistant/components/fibaro/binary_sensor.py | 4 ++-- homeassistant/components/fibaro/climate.py | 4 ++-- homeassistant/components/fibaro/cover.py | 4 ++-- homeassistant/components/fibaro/event.py | 4 ++-- homeassistant/components/fibaro/light.py | 4 ++-- homeassistant/components/fibaro/lock.py | 4 ++-- homeassistant/components/fibaro/scene.py | 4 ++-- homeassistant/components/fibaro/sensor.py | 4 ++-- homeassistant/components/fibaro/switch.py | 4 ++-- homeassistant/components/file/notify.py | 4 ++-- homeassistant/components/file/sensor.py | 4 ++-- homeassistant/components/filesize/sensor.py | 4 ++-- homeassistant/components/filter/sensor.py | 7 +++++-- .../components/fireservicerota/binary_sensor.py | 6 ++++-- homeassistant/components/fireservicerota/sensor.py | 6 ++++-- homeassistant/components/fireservicerota/switch.py | 6 ++++-- homeassistant/components/firmata/binary_sensor.py | 4 ++-- homeassistant/components/firmata/light.py | 4 ++-- homeassistant/components/firmata/sensor.py | 4 ++-- homeassistant/components/firmata/switch.py | 4 ++-- homeassistant/components/fitbit/sensor.py | 4 ++-- homeassistant/components/fivem/binary_sensor.py | 4 ++-- homeassistant/components/fivem/sensor.py | 4 ++-- homeassistant/components/fjaraskupan/binary_sensor.py | 4 ++-- homeassistant/components/fjaraskupan/fan.py | 4 ++-- homeassistant/components/fjaraskupan/light.py | 4 ++-- homeassistant/components/fjaraskupan/number.py | 4 ++-- homeassistant/components/fjaraskupan/sensor.py | 4 ++-- .../components/flexit_bacnet/binary_sensor.py | 4 ++-- homeassistant/components/flexit_bacnet/climate.py | 4 ++-- homeassistant/components/flexit_bacnet/number.py | 4 ++-- homeassistant/components/flexit_bacnet/sensor.py | 4 ++-- homeassistant/components/flexit_bacnet/switch.py | 4 ++-- homeassistant/components/flick_electric/sensor.py | 4 ++-- homeassistant/components/flipr/binary_sensor.py | 4 ++-- homeassistant/components/flipr/select.py | 4 ++-- homeassistant/components/flipr/sensor.py | 4 ++-- homeassistant/components/flipr/switch.py | 4 ++-- homeassistant/components/flo/binary_sensor.py | 4 ++-- homeassistant/components/flo/sensor.py | 4 ++-- homeassistant/components/flo/switch.py | 4 ++-- homeassistant/components/flume/binary_sensor.py | 4 ++-- homeassistant/components/flume/sensor.py | 4 ++-- homeassistant/components/flux_led/button.py | 4 ++-- homeassistant/components/flux_led/light.py | 4 ++-- homeassistant/components/flux_led/number.py | 4 ++-- homeassistant/components/flux_led/select.py | 4 ++-- homeassistant/components/flux_led/sensor.py | 4 ++-- homeassistant/components/flux_led/switch.py | 4 ++-- homeassistant/components/folder_watcher/event.py | 4 ++-- homeassistant/components/forecast_solar/sensor.py | 4 ++-- homeassistant/components/forked_daapd/media_player.py | 4 ++-- homeassistant/components/foscam/camera.py | 4 ++-- homeassistant/components/foscam/switch.py | 4 ++-- .../components/freebox/alarm_control_panel.py | 6 ++++-- homeassistant/components/freebox/binary_sensor.py | 6 ++++-- homeassistant/components/freebox/button.py | 6 ++++-- homeassistant/components/freebox/camera.py | 8 +++++--- homeassistant/components/freebox/device_tracker.py | 10 +++++++--- homeassistant/components/freebox/sensor.py | 6 ++++-- homeassistant/components/freebox/switch.py | 6 ++++-- homeassistant/components/freedompro/binary_sensor.py | 4 ++-- homeassistant/components/freedompro/climate.py | 4 ++-- homeassistant/components/freedompro/cover.py | 4 ++-- homeassistant/components/freedompro/fan.py | 4 ++-- homeassistant/components/freedompro/light.py | 4 ++-- homeassistant/components/freedompro/lock.py | 4 ++-- homeassistant/components/freedompro/sensor.py | 4 ++-- homeassistant/components/freedompro/switch.py | 4 ++-- homeassistant/components/fritz/binary_sensor.py | 4 ++-- homeassistant/components/fritz/button.py | 4 ++-- homeassistant/components/fritz/device_tracker.py | 6 +++--- homeassistant/components/fritz/image.py | 4 ++-- homeassistant/components/fritz/sensor.py | 4 ++-- homeassistant/components/fritz/switch.py | 4 ++-- homeassistant/components/fritz/update.py | 4 ++-- homeassistant/components/fritzbox/binary_sensor.py | 4 ++-- homeassistant/components/fritzbox/button.py | 4 ++-- homeassistant/components/fritzbox/climate.py | 4 ++-- homeassistant/components/fritzbox/cover.py | 4 ++-- homeassistant/components/fritzbox/light.py | 4 ++-- homeassistant/components/fritzbox/sensor.py | 4 ++-- homeassistant/components/fritzbox/switch.py | 4 ++-- .../components/fritzbox_callmonitor/sensor.py | 4 ++-- homeassistant/components/fronius/sensor.py | 4 ++-- .../components/frontier_silicon/media_player.py | 4 ++-- homeassistant/components/fujitsu_fglair/climate.py | 4 ++-- homeassistant/components/fujitsu_fglair/sensor.py | 4 ++-- homeassistant/components/fully_kiosk/binary_sensor.py | 4 ++-- homeassistant/components/fully_kiosk/button.py | 4 ++-- homeassistant/components/fully_kiosk/camera.py | 4 ++-- homeassistant/components/fully_kiosk/image.py | 4 ++-- homeassistant/components/fully_kiosk/media_player.py | 4 ++-- homeassistant/components/fully_kiosk/notify.py | 4 ++-- homeassistant/components/fully_kiosk/number.py | 4 ++-- homeassistant/components/fully_kiosk/sensor.py | 4 ++-- homeassistant/components/fully_kiosk/switch.py | 4 ++-- homeassistant/components/fyta/binary_sensor.py | 6 ++++-- homeassistant/components/fyta/image.py | 6 ++++-- homeassistant/components/fyta/sensor.py | 6 ++++-- .../components/garages_amsterdam/binary_sensor.py | 4 ++-- homeassistant/components/garages_amsterdam/sensor.py | 4 ++-- .../components/gardena_bluetooth/binary_sensor.py | 4 ++-- homeassistant/components/gardena_bluetooth/button.py | 4 ++-- homeassistant/components/gardena_bluetooth/number.py | 4 ++-- homeassistant/components/gardena_bluetooth/sensor.py | 4 ++-- homeassistant/components/gardena_bluetooth/switch.py | 4 ++-- homeassistant/components/gardena_bluetooth/valve.py | 4 ++-- homeassistant/components/gdacs/geo_location.py | 6 ++++-- homeassistant/components/gdacs/sensor.py | 6 ++++-- homeassistant/components/generic/camera.py | 6 ++++-- .../components/generic_hygrostat/humidifier.py | 9 ++++++--- .../components/generic_thermostat/climate.py | 9 ++++++--- homeassistant/components/geniushub/binary_sensor.py | 4 ++-- homeassistant/components/geniushub/climate.py | 4 ++-- homeassistant/components/geniushub/sensor.py | 4 ++-- homeassistant/components/geniushub/switch.py | 4 ++-- homeassistant/components/geniushub/water_heater.py | 4 ++-- .../components/geo_json_events/geo_location.py | 6 ++++-- homeassistant/components/geocaching/sensor.py | 6 ++++-- homeassistant/components/geofency/device_tracker.py | 4 ++-- .../components/geonetnz_quakes/geo_location.py | 6 ++++-- homeassistant/components/geonetnz_quakes/sensor.py | 6 ++++-- homeassistant/components/geonetnz_volcano/sensor.py | 6 ++++-- homeassistant/components/gios/sensor.py | 6 ++++-- homeassistant/components/github/sensor.py | 4 ++-- homeassistant/components/glances/sensor.py | 4 ++-- homeassistant/components/goalzero/binary_sensor.py | 4 ++-- homeassistant/components/goalzero/sensor.py | 4 ++-- homeassistant/components/goalzero/switch.py | 4 ++-- homeassistant/components/gogogate2/cover.py | 4 ++-- homeassistant/components/gogogate2/sensor.py | 4 ++-- homeassistant/components/goodwe/button.py | 4 ++-- homeassistant/components/goodwe/number.py | 4 ++-- homeassistant/components/goodwe/select.py | 4 ++-- homeassistant/components/goodwe/sensor.py | 4 ++-- homeassistant/components/google/calendar.py | 4 ++-- homeassistant/components/google_assistant/button.py | 4 ++-- homeassistant/components/google_cloud/stt.py | 4 ++-- homeassistant/components/google_cloud/tts.py | 4 ++-- .../google_generative_ai_conversation/conversation.py | 4 ++-- homeassistant/components/google_mail/sensor.py | 4 ++-- homeassistant/components/google_tasks/todo.py | 4 ++-- homeassistant/components/google_translate/tts.py | 4 ++-- homeassistant/components/google_travel_time/sensor.py | 4 ++-- homeassistant/components/govee_ble/binary_sensor.py | 4 ++-- homeassistant/components/govee_ble/event.py | 4 ++-- homeassistant/components/govee_ble/sensor.py | 4 ++-- homeassistant/components/govee_light_local/light.py | 4 ++-- homeassistant/components/gpsd/sensor.py | 4 ++-- homeassistant/components/gpslogger/device_tracker.py | 6 ++++-- homeassistant/components/gree/climate.py | 4 ++-- homeassistant/components/gree/switch.py | 4 ++-- homeassistant/components/group/binary_sensor.py | 7 +++++-- homeassistant/components/group/button.py | 7 +++++-- homeassistant/components/group/cover.py | 7 +++++-- homeassistant/components/group/event.py | 7 +++++-- homeassistant/components/group/fan.py | 7 +++++-- homeassistant/components/group/light.py | 7 +++++-- homeassistant/components/group/lock.py | 7 +++++-- homeassistant/components/group/media_player.py | 7 +++++-- homeassistant/components/group/notify.py | 4 ++-- homeassistant/components/group/sensor.py | 7 +++++-- homeassistant/components/group/switch.py | 7 +++++-- .../components/growatt_server/sensor/__init__.py | 4 ++-- homeassistant/components/guardian/binary_sensor.py | 6 ++++-- homeassistant/components/guardian/button.py | 6 ++++-- homeassistant/components/guardian/sensor.py | 6 ++++-- homeassistant/components/guardian/switch.py | 6 ++++-- homeassistant/components/guardian/valve.py | 6 ++++-- homeassistant/components/habitica/binary_sensor.py | 4 ++-- homeassistant/components/habitica/button.py | 4 ++-- homeassistant/components/habitica/calendar.py | 4 ++-- homeassistant/components/habitica/image.py | 4 ++-- homeassistant/components/habitica/sensor.py | 4 ++-- homeassistant/components/habitica/switch.py | 4 ++-- homeassistant/components/habitica/todo.py | 4 ++-- homeassistant/components/harmony/remote.py | 4 ++-- homeassistant/components/harmony/select.py | 4 ++-- homeassistant/components/hassio/binary_sensor.py | 4 ++-- homeassistant/components/hassio/sensor.py | 4 ++-- homeassistant/components/hassio/update.py | 4 ++-- homeassistant/components/heos/media_player.py | 6 ++++-- homeassistant/components/here_travel_time/sensor.py | 4 ++-- homeassistant/components/hisense_aehw4a1/climate.py | 4 ++-- homeassistant/components/history_stats/sensor.py | 7 +++++-- homeassistant/components/hive/alarm_control_panel.py | 6 ++++-- homeassistant/components/hive/binary_sensor.py | 6 ++++-- homeassistant/components/hive/climate.py | 6 ++++-- homeassistant/components/hive/light.py | 6 ++++-- homeassistant/components/hive/sensor.py | 6 ++++-- homeassistant/components/hive/switch.py | 6 ++++-- homeassistant/components/hive/water_heater.py | 6 ++++-- homeassistant/components/hko/weather.py | 4 ++-- homeassistant/components/hlk_sw16/switch.py | 6 ++++-- homeassistant/components/holiday/calendar.py | 4 ++-- .../components/home_connect/binary_sensor.py | 4 ++-- homeassistant/components/home_connect/common.py | 6 +++--- homeassistant/components/home_connect/light.py | 4 ++-- homeassistant/components/home_connect/number.py | 4 ++-- homeassistant/components/home_connect/select.py | 4 ++-- homeassistant/components/home_connect/sensor.py | 4 ++-- homeassistant/components/home_connect/switch.py | 4 ++-- homeassistant/components/home_connect/time.py | 4 ++-- homeassistant/components/homee/cover.py | 4 ++-- homeassistant/components/homee/sensor.py | 4 ++-- .../homekit_controller/alarm_control_panel.py | 4 ++-- .../components/homekit_controller/binary_sensor.py | 4 ++-- homeassistant/components/homekit_controller/button.py | 4 ++-- homeassistant/components/homekit_controller/camera.py | 4 ++-- .../components/homekit_controller/climate.py | 4 ++-- homeassistant/components/homekit_controller/cover.py | 4 ++-- homeassistant/components/homekit_controller/event.py | 4 ++-- homeassistant/components/homekit_controller/fan.py | 4 ++-- .../components/homekit_controller/humidifier.py | 4 ++-- homeassistant/components/homekit_controller/light.py | 4 ++-- homeassistant/components/homekit_controller/lock.py | 4 ++-- .../components/homekit_controller/media_player.py | 4 ++-- homeassistant/components/homekit_controller/number.py | 4 ++-- homeassistant/components/homekit_controller/select.py | 4 ++-- homeassistant/components/homekit_controller/sensor.py | 4 ++-- homeassistant/components/homekit_controller/switch.py | 4 ++-- .../homematicip_cloud/alarm_control_panel.py | 4 ++-- .../components/homematicip_cloud/binary_sensor.py | 4 ++-- homeassistant/components/homematicip_cloud/button.py | 4 ++-- homeassistant/components/homematicip_cloud/climate.py | 4 ++-- homeassistant/components/homematicip_cloud/cover.py | 4 ++-- homeassistant/components/homematicip_cloud/event.py | 4 ++-- homeassistant/components/homematicip_cloud/light.py | 4 ++-- homeassistant/components/homematicip_cloud/lock.py | 4 ++-- homeassistant/components/homematicip_cloud/sensor.py | 4 ++-- homeassistant/components/homematicip_cloud/switch.py | 4 ++-- homeassistant/components/homematicip_cloud/weather.py | 4 ++-- homeassistant/components/homewizard/button.py | 4 ++-- homeassistant/components/homewizard/number.py | 4 ++-- homeassistant/components/homewizard/sensor.py | 4 ++-- homeassistant/components/homewizard/switch.py | 4 ++-- homeassistant/components/homeworks/binary_sensor.py | 6 ++++-- homeassistant/components/homeworks/button.py | 6 ++++-- homeassistant/components/homeworks/light.py | 6 ++++-- homeassistant/components/honeywell/climate.py | 4 ++-- homeassistant/components/honeywell/humidifier.py | 4 ++-- homeassistant/components/honeywell/sensor.py | 4 ++-- homeassistant/components/honeywell/switch.py | 4 ++-- homeassistant/components/huawei_lte/binary_sensor.py | 4 ++-- homeassistant/components/huawei_lte/button.py | 2 +- homeassistant/components/huawei_lte/device_tracker.py | 6 +++--- homeassistant/components/huawei_lte/select.py | 4 ++-- homeassistant/components/huawei_lte/sensor.py | 4 ++-- homeassistant/components/huawei_lte/switch.py | 4 ++-- homeassistant/components/hue/binary_sensor.py | 4 ++-- homeassistant/components/hue/event.py | 4 ++-- homeassistant/components/hue/light.py | 4 ++-- homeassistant/components/hue/scene.py | 4 ++-- homeassistant/components/hue/sensor.py | 4 ++-- homeassistant/components/hue/switch.py | 4 ++-- homeassistant/components/hue/v2/binary_sensor.py | 4 ++-- homeassistant/components/hue/v2/group.py | 4 ++-- homeassistant/components/hue/v2/light.py | 4 ++-- homeassistant/components/hue/v2/sensor.py | 4 ++-- homeassistant/components/huisbaasje/sensor.py | 4 ++-- .../components/hunterdouglas_powerview/button.py | 4 ++-- .../components/hunterdouglas_powerview/cover.py | 4 ++-- .../components/hunterdouglas_powerview/number.py | 4 ++-- .../components/hunterdouglas_powerview/scene.py | 4 ++-- .../components/hunterdouglas_powerview/select.py | 4 ++-- .../components/hunterdouglas_powerview/sensor.py | 4 ++-- .../components/husqvarna_automower/binary_sensor.py | 4 ++-- .../components/husqvarna_automower/button.py | 4 ++-- .../components/husqvarna_automower/calendar.py | 4 ++-- .../components/husqvarna_automower/device_tracker.py | 4 ++-- .../components/husqvarna_automower/lawn_mower.py | 4 ++-- .../components/husqvarna_automower/number.py | 4 ++-- .../components/husqvarna_automower/select.py | 4 ++-- .../components/husqvarna_automower/sensor.py | 4 ++-- .../components/husqvarna_automower/switch.py | 4 ++-- .../components/husqvarna_automower_ble/lawn_mower.py | 4 ++-- homeassistant/components/huum/climate.py | 4 ++-- .../components/hvv_departures/binary_sensor.py | 6 ++++-- homeassistant/components/hvv_departures/sensor.py | 4 ++-- homeassistant/components/hydrawise/binary_sensor.py | 4 ++-- homeassistant/components/hydrawise/sensor.py | 4 ++-- homeassistant/components/hydrawise/switch.py | 4 ++-- homeassistant/components/hydrawise/valve.py | 4 ++-- homeassistant/components/hyperion/camera.py | 4 ++-- homeassistant/components/hyperion/light.py | 4 ++-- homeassistant/components/hyperion/sensor.py | 4 ++-- homeassistant/components/hyperion/switch.py | 4 ++-- .../components/ialarm/alarm_control_panel.py | 6 ++++-- homeassistant/components/iaqualink/binary_sensor.py | 4 ++-- homeassistant/components/iaqualink/climate.py | 4 ++-- homeassistant/components/iaqualink/light.py | 4 ++-- homeassistant/components/iaqualink/sensor.py | 4 ++-- homeassistant/components/iaqualink/switch.py | 4 ++-- homeassistant/components/ibeacon/device_tracker.py | 4 ++-- homeassistant/components/ibeacon/sensor.py | 4 ++-- homeassistant/components/icloud/device_tracker.py | 6 ++++-- homeassistant/components/icloud/sensor.py | 6 ++++-- homeassistant/components/idasen_desk/button.py | 4 ++-- homeassistant/components/idasen_desk/cover.py | 4 ++-- homeassistant/components/idasen_desk/sensor.py | 4 ++-- homeassistant/components/igloohome/sensor.py | 4 ++-- homeassistant/components/imap/sensor.py | 6 ++++-- homeassistant/components/imgw_pib/sensor.py | 4 ++-- homeassistant/components/incomfort/binary_sensor.py | 4 ++-- homeassistant/components/incomfort/climate.py | 4 ++-- homeassistant/components/incomfort/sensor.py | 4 ++-- homeassistant/components/incomfort/water_heater.py | 4 ++-- homeassistant/components/inkbird/sensor.py | 4 ++-- homeassistant/components/insteon/binary_sensor.py | 4 ++-- homeassistant/components/insteon/climate.py | 4 ++-- homeassistant/components/insteon/cover.py | 4 ++-- homeassistant/components/insteon/fan.py | 4 ++-- homeassistant/components/insteon/light.py | 4 ++-- homeassistant/components/insteon/lock.py | 4 ++-- homeassistant/components/insteon/switch.py | 4 ++-- homeassistant/components/insteon/utils.py | 6 +++--- homeassistant/components/integration/sensor.py | 7 +++++-- homeassistant/components/intellifire/binary_sensor.py | 4 ++-- homeassistant/components/intellifire/climate.py | 4 ++-- homeassistant/components/intellifire/fan.py | 4 ++-- homeassistant/components/intellifire/light.py | 4 ++-- homeassistant/components/intellifire/number.py | 4 ++-- homeassistant/components/intellifire/sensor.py | 6 ++++-- homeassistant/components/intellifire/switch.py | 4 ++-- homeassistant/components/iometer/sensor.py | 4 ++-- homeassistant/components/ios/sensor.py | 7 +++++-- homeassistant/components/iotawatt/sensor.py | 4 ++-- homeassistant/components/iotty/cover.py | 4 ++-- homeassistant/components/iotty/switch.py | 4 ++-- homeassistant/components/ipma/sensor.py | 6 ++++-- homeassistant/components/ipma/weather.py | 4 ++-- homeassistant/components/ipp/sensor.py | 4 ++-- homeassistant/components/iqvia/sensor.py | 6 ++++-- homeassistant/components/iron_os/binary_sensor.py | 4 ++-- homeassistant/components/iron_os/button.py | 4 ++-- homeassistant/components/iron_os/number.py | 4 ++-- homeassistant/components/iron_os/select.py | 4 ++-- homeassistant/components/iron_os/sensor.py | 4 ++-- homeassistant/components/iron_os/switch.py | 4 ++-- homeassistant/components/iron_os/update.py | 4 ++-- homeassistant/components/iskra/sensor.py | 4 ++-- .../components/islamic_prayer_times/sensor.py | 4 ++-- homeassistant/components/israel_rail/sensor.py | 4 ++-- homeassistant/components/iss/sensor.py | 4 ++-- homeassistant/components/ista_ecotrend/sensor.py | 4 ++-- homeassistant/components/isy994/binary_sensor.py | 6 ++++-- homeassistant/components/isy994/button.py | 4 ++-- homeassistant/components/isy994/climate.py | 6 ++++-- homeassistant/components/isy994/cover.py | 6 ++++-- homeassistant/components/isy994/fan.py | 6 ++++-- homeassistant/components/isy994/light.py | 6 ++++-- homeassistant/components/isy994/lock.py | 6 ++++-- homeassistant/components/isy994/number.py | 4 ++-- homeassistant/components/isy994/select.py | 4 ++-- homeassistant/components/isy994/sensor.py | 6 ++++-- homeassistant/components/isy994/switch.py | 6 ++++-- homeassistant/components/ituran/device_tracker.py | 4 ++-- homeassistant/components/ituran/sensor.py | 4 ++-- homeassistant/components/izone/climate.py | 6 ++++-- homeassistant/components/jellyfin/media_player.py | 4 ++-- homeassistant/components/jellyfin/remote.py | 4 ++-- homeassistant/components/jellyfin/sensor.py | 4 ++-- .../components/jewish_calendar/binary_sensor.py | 4 ++-- homeassistant/components/jewish_calendar/sensor.py | 4 ++-- homeassistant/components/juicenet/number.py | 4 ++-- homeassistant/components/juicenet/sensor.py | 4 ++-- homeassistant/components/juicenet/switch.py | 4 ++-- homeassistant/components/justnimbus/sensor.py | 6 ++++-- .../components/jvc_projector/binary_sensor.py | 6 ++++-- homeassistant/components/jvc_projector/remote.py | 6 ++++-- homeassistant/components/jvc_projector/select.py | 4 ++-- homeassistant/components/jvc_projector/sensor.py | 6 ++++-- homeassistant/components/kaleidescape/media_player.py | 6 ++++-- homeassistant/components/kaleidescape/remote.py | 6 ++++-- homeassistant/components/kaleidescape/sensor.py | 6 ++++-- .../components/keenetic_ndms2/binary_sensor.py | 4 ++-- .../components/keenetic_ndms2/device_tracker.py | 4 ++-- homeassistant/components/kegtron/sensor.py | 4 ++-- homeassistant/components/keymitt_ble/switch.py | 6 ++++-- homeassistant/components/kitchen_sink/button.py | 4 ++-- homeassistant/components/kitchen_sink/image.py | 7 +++++-- homeassistant/components/kitchen_sink/lawn_mower.py | 7 +++++-- homeassistant/components/kitchen_sink/lock.py | 7 +++++-- homeassistant/components/kitchen_sink/notify.py | 4 ++-- homeassistant/components/kitchen_sink/sensor.py | 1 - homeassistant/components/kitchen_sink/switch.py | 4 ++-- homeassistant/components/kitchen_sink/weather.py | 4 ++-- homeassistant/components/kmtronic/switch.py | 6 ++++-- homeassistant/components/knocki/event.py | 4 ++-- homeassistant/components/knx/binary_sensor.py | 4 ++-- homeassistant/components/knx/button.py | 4 ++-- homeassistant/components/knx/climate.py | 4 ++-- homeassistant/components/knx/cover.py | 4 ++-- homeassistant/components/knx/date.py | 4 ++-- homeassistant/components/knx/datetime.py | 4 ++-- homeassistant/components/knx/fan.py | 4 ++-- homeassistant/components/knx/light.py | 4 ++-- homeassistant/components/knx/notify.py | 4 ++-- homeassistant/components/knx/number.py | 4 ++-- homeassistant/components/knx/scene.py | 4 ++-- homeassistant/components/knx/select.py | 4 ++-- homeassistant/components/knx/sensor.py | 4 ++-- homeassistant/components/knx/switch.py | 4 ++-- homeassistant/components/knx/text.py | 4 ++-- homeassistant/components/knx/time.py | 4 ++-- homeassistant/components/knx/weather.py | 4 ++-- homeassistant/components/kodi/media_player.py | 7 +++++-- homeassistant/components/konnected/binary_sensor.py | 4 ++-- homeassistant/components/konnected/sensor.py | 4 ++-- homeassistant/components/konnected/switch.py | 4 ++-- homeassistant/components/kostal_plenticore/number.py | 6 ++++-- homeassistant/components/kostal_plenticore/select.py | 6 ++++-- homeassistant/components/kostal_plenticore/sensor.py | 6 ++++-- homeassistant/components/kostal_plenticore/switch.py | 6 ++++-- homeassistant/components/kraken/sensor.py | 4 ++-- homeassistant/components/kulersky/light.py | 4 ++-- homeassistant/components/lacrosse_view/sensor.py | 4 ++-- homeassistant/components/lamarzocco/binary_sensor.py | 4 ++-- homeassistant/components/lamarzocco/button.py | 4 ++-- homeassistant/components/lamarzocco/calendar.py | 4 ++-- homeassistant/components/lamarzocco/number.py | 4 ++-- homeassistant/components/lamarzocco/select.py | 4 ++-- homeassistant/components/lamarzocco/sensor.py | 4 ++-- homeassistant/components/lamarzocco/switch.py | 4 ++-- homeassistant/components/lamarzocco/update.py | 4 ++-- homeassistant/components/lametric/button.py | 4 ++-- homeassistant/components/lametric/number.py | 4 ++-- homeassistant/components/lametric/select.py | 4 ++-- homeassistant/components/lametric/sensor.py | 4 ++-- homeassistant/components/lametric/switch.py | 4 ++-- .../components/landisgyr_heat_meter/sensor.py | 6 ++++-- homeassistant/components/lastfm/sensor.py | 4 ++-- homeassistant/components/launch_library/sensor.py | 4 ++-- homeassistant/components/laundrify/binary_sensor.py | 6 ++++-- homeassistant/components/laundrify/sensor.py | 6 ++++-- homeassistant/components/lcn/binary_sensor.py | 6 +++--- homeassistant/components/lcn/climate.py | 6 +++--- homeassistant/components/lcn/cover.py | 6 +++--- homeassistant/components/lcn/light.py | 6 +++--- homeassistant/components/lcn/scene.py | 6 +++--- homeassistant/components/lcn/sensor.py | 6 +++--- homeassistant/components/lcn/switch.py | 6 +++--- homeassistant/components/ld2410_ble/binary_sensor.py | 4 ++-- homeassistant/components/ld2410_ble/sensor.py | 4 ++-- homeassistant/components/leaone/sensor.py | 4 ++-- homeassistant/components/led_ble/light.py | 4 ++-- homeassistant/components/lektrico/binary_sensor.py | 4 ++-- homeassistant/components/lektrico/button.py | 4 ++-- homeassistant/components/lektrico/number.py | 4 ++-- homeassistant/components/lektrico/select.py | 4 ++-- homeassistant/components/lektrico/sensor.py | 4 ++-- homeassistant/components/lektrico/switch.py | 4 ++-- homeassistant/components/letpot/switch.py | 4 ++-- homeassistant/components/letpot/time.py | 4 ++-- homeassistant/components/lg_netcast/media_player.py | 4 ++-- homeassistant/components/lg_soundbar/media_player.py | 4 ++-- homeassistant/components/lg_thinq/binary_sensor.py | 4 ++-- homeassistant/components/lg_thinq/climate.py | 4 ++-- homeassistant/components/lg_thinq/event.py | 4 ++-- homeassistant/components/lg_thinq/fan.py | 4 ++-- homeassistant/components/lg_thinq/number.py | 4 ++-- homeassistant/components/lg_thinq/select.py | 4 ++-- homeassistant/components/lg_thinq/sensor.py | 4 ++-- homeassistant/components/lg_thinq/switch.py | 4 ++-- homeassistant/components/lg_thinq/vacuum.py | 4 ++-- homeassistant/components/lidarr/sensor.py | 4 ++-- homeassistant/components/lifx/binary_sensor.py | 6 ++++-- homeassistant/components/lifx/button.py | 4 ++-- homeassistant/components/lifx/light.py | 4 ++-- homeassistant/components/lifx/select.py | 6 ++++-- homeassistant/components/lifx/sensor.py | 6 ++++-- homeassistant/components/linear_garage_door/cover.py | 4 ++-- homeassistant/components/linear_garage_door/light.py | 4 ++-- homeassistant/components/linkplay/button.py | 4 ++-- homeassistant/components/linkplay/media_player.py | 4 ++-- homeassistant/components/litejet/light.py | 4 ++-- homeassistant/components/litejet/scene.py | 4 ++-- homeassistant/components/litejet/switch.py | 4 ++-- homeassistant/components/litterrobot/binary_sensor.py | 4 ++-- homeassistant/components/litterrobot/button.py | 4 ++-- homeassistant/components/litterrobot/select.py | 4 ++-- homeassistant/components/litterrobot/sensor.py | 4 ++-- homeassistant/components/litterrobot/switch.py | 4 ++-- homeassistant/components/litterrobot/time.py | 4 ++-- homeassistant/components/litterrobot/update.py | 4 ++-- homeassistant/components/litterrobot/vacuum.py | 4 ++-- homeassistant/components/livisi/binary_sensor.py | 4 ++-- homeassistant/components/livisi/climate.py | 4 ++-- homeassistant/components/livisi/switch.py | 4 ++-- homeassistant/components/local_calendar/calendar.py | 4 ++-- homeassistant/components/local_file/camera.py | 7 +++++-- homeassistant/components/local_ip/sensor.py | 4 ++-- homeassistant/components/local_todo/todo.py | 4 ++-- homeassistant/components/locative/device_tracker.py | 6 ++++-- homeassistant/components/lookin/climate.py | 4 ++-- homeassistant/components/lookin/light.py | 4 ++-- homeassistant/components/lookin/media_player.py | 4 ++-- homeassistant/components/lookin/sensor.py | 4 ++-- homeassistant/components/loqed/lock.py | 6 ++++-- homeassistant/components/loqed/sensor.py | 6 ++++-- homeassistant/components/luftdaten/sensor.py | 6 ++++-- .../components/lupusec/alarm_control_panel.py | 4 ++-- homeassistant/components/lupusec/binary_sensor.py | 4 ++-- homeassistant/components/lupusec/switch.py | 4 ++-- homeassistant/components/lutron/binary_sensor.py | 4 ++-- homeassistant/components/lutron/cover.py | 4 ++-- homeassistant/components/lutron/event.py | 4 ++-- homeassistant/components/lutron/fan.py | 4 ++-- homeassistant/components/lutron/light.py | 4 ++-- homeassistant/components/lutron/scene.py | 4 ++-- homeassistant/components/lutron/switch.py | 4 ++-- .../components/lutron_caseta/binary_sensor.py | 4 ++-- homeassistant/components/lutron_caseta/button.py | 4 ++-- homeassistant/components/lutron_caseta/cover.py | 4 ++-- homeassistant/components/lutron_caseta/fan.py | 4 ++-- homeassistant/components/lutron_caseta/light.py | 4 ++-- homeassistant/components/lutron_caseta/scene.py | 4 ++-- homeassistant/components/lutron_caseta/switch.py | 4 ++-- homeassistant/components/lyric/climate.py | 6 ++++-- homeassistant/components/lyric/sensor.py | 6 ++++-- homeassistant/components/madvr/binary_sensor.py | 4 ++-- homeassistant/components/madvr/remote.py | 4 ++-- homeassistant/components/madvr/sensor.py | 4 ++-- homeassistant/components/mastodon/sensor.py | 4 ++-- homeassistant/components/matter/binary_sensor.py | 4 ++-- homeassistant/components/matter/button.py | 4 ++-- homeassistant/components/matter/climate.py | 4 ++-- homeassistant/components/matter/cover.py | 4 ++-- homeassistant/components/matter/event.py | 4 ++-- homeassistant/components/matter/fan.py | 4 ++-- homeassistant/components/matter/light.py | 4 ++-- homeassistant/components/matter/lock.py | 4 ++-- homeassistant/components/matter/number.py | 4 ++-- homeassistant/components/matter/select.py | 4 ++-- homeassistant/components/matter/sensor.py | 4 ++-- homeassistant/components/matter/switch.py | 4 ++-- homeassistant/components/matter/update.py | 4 ++-- homeassistant/components/matter/vacuum.py | 4 ++-- homeassistant/components/matter/valve.py | 4 ++-- homeassistant/components/mealie/calendar.py | 4 ++-- homeassistant/components/mealie/sensor.py | 4 ++-- homeassistant/components/mealie/todo.py | 4 ++-- homeassistant/components/meater/sensor.py | 6 ++++-- homeassistant/components/medcom_ble/sensor.py | 4 ++-- homeassistant/components/melcloud/climate.py | 6 ++++-- homeassistant/components/melcloud/sensor.py | 6 ++++-- homeassistant/components/melcloud/water_heater.py | 6 ++++-- homeassistant/components/melnor/number.py | 4 ++-- homeassistant/components/melnor/sensor.py | 4 ++-- homeassistant/components/melnor/switch.py | 4 ++-- homeassistant/components/melnor/time.py | 4 ++-- homeassistant/components/met/weather.py | 4 ++-- homeassistant/components/met_eireann/weather.py | 4 ++-- homeassistant/components/meteo_france/sensor.py | 6 ++++-- homeassistant/components/meteo_france/weather.py | 6 ++++-- homeassistant/components/meteoclimatic/sensor.py | 6 ++++-- homeassistant/components/meteoclimatic/weather.py | 6 ++++-- homeassistant/components/metoffice/sensor.py | 6 ++++-- homeassistant/components/metoffice/weather.py | 6 ++++-- homeassistant/components/microbees/binary_sensor.py | 6 ++++-- homeassistant/components/microbees/button.py | 6 ++++-- homeassistant/components/microbees/climate.py | 6 ++++-- homeassistant/components/microbees/cover.py | 6 ++++-- homeassistant/components/microbees/light.py | 6 ++++-- homeassistant/components/microbees/sensor.py | 6 ++++-- homeassistant/components/microbees/switch.py | 6 ++++-- homeassistant/components/mikrotik/device_tracker.py | 6 +++--- homeassistant/components/mill/climate.py | 6 ++++-- homeassistant/components/mill/number.py | 6 ++++-- homeassistant/components/mill/sensor.py | 6 ++++-- homeassistant/components/min_max/sensor.py | 7 +++++-- .../components/minecraft_server/binary_sensor.py | 4 ++-- homeassistant/components/minecraft_server/sensor.py | 4 ++-- homeassistant/components/mjpeg/camera.py | 4 ++-- homeassistant/components/moat/sensor.py | 4 ++-- homeassistant/components/mobile_app/binary_sensor.py | 4 ++-- homeassistant/components/mobile_app/device_tracker.py | 6 ++++-- homeassistant/components/mobile_app/sensor.py | 4 ++-- homeassistant/components/modem_callerid/button.py | 6 ++++-- homeassistant/components/modem_callerid/sensor.py | 6 ++++-- .../components/modern_forms/binary_sensor.py | 4 ++-- homeassistant/components/modern_forms/fan.py | 4 ++-- homeassistant/components/modern_forms/light.py | 4 ++-- homeassistant/components/modern_forms/sensor.py | 4 ++-- homeassistant/components/modern_forms/switch.py | 4 ++-- .../components/moehlenhoff_alpha2/binary_sensor.py | 4 ++-- homeassistant/components/moehlenhoff_alpha2/button.py | 4 ++-- .../components/moehlenhoff_alpha2/climate.py | 4 ++-- homeassistant/components/moehlenhoff_alpha2/sensor.py | 4 ++-- homeassistant/components/mold_indicator/sensor.py | 7 +++++-- homeassistant/components/monarch_money/sensor.py | 4 ++-- homeassistant/components/monoprice/media_player.py | 4 ++-- homeassistant/components/monzo/sensor.py | 4 ++-- homeassistant/components/moon/sensor.py | 4 ++-- homeassistant/components/mopeka/sensor.py | 4 ++-- homeassistant/components/motion_blinds/button.py | 4 ++-- homeassistant/components/motion_blinds/cover.py | 4 ++-- homeassistant/components/motion_blinds/sensor.py | 4 ++-- homeassistant/components/motionblinds_ble/button.py | 6 ++++-- homeassistant/components/motionblinds_ble/cover.py | 6 ++++-- homeassistant/components/motionblinds_ble/select.py | 6 ++++-- homeassistant/components/motionblinds_ble/sensor.py | 6 ++++-- homeassistant/components/motioneye/camera.py | 6 ++++-- homeassistant/components/motioneye/sensor.py | 6 ++++-- homeassistant/components/motioneye/switch.py | 6 ++++-- homeassistant/components/motionmount/binary_sensor.py | 4 ++-- homeassistant/components/motionmount/number.py | 4 ++-- homeassistant/components/motionmount/select.py | 4 ++-- homeassistant/components/motionmount/sensor.py | 4 ++-- homeassistant/components/mpd/media_player.py | 6 ++++-- homeassistant/components/mqtt/alarm_control_panel.py | 4 ++-- homeassistant/components/mqtt/binary_sensor.py | 4 ++-- homeassistant/components/mqtt/button.py | 4 ++-- homeassistant/components/mqtt/camera.py | 4 ++-- homeassistant/components/mqtt/climate.py | 4 ++-- homeassistant/components/mqtt/cover.py | 4 ++-- homeassistant/components/mqtt/device_tracker.py | 4 ++-- homeassistant/components/mqtt/event.py | 4 ++-- homeassistant/components/mqtt/fan.py | 4 ++-- homeassistant/components/mqtt/humidifier.py | 4 ++-- homeassistant/components/mqtt/image.py | 4 ++-- homeassistant/components/mqtt/lawn_mower.py | 4 ++-- homeassistant/components/mqtt/light/__init__.py | 4 ++-- homeassistant/components/mqtt/lock.py | 4 ++-- homeassistant/components/mqtt/notify.py | 4 ++-- homeassistant/components/mqtt/number.py | 4 ++-- homeassistant/components/mqtt/scene.py | 4 ++-- homeassistant/components/mqtt/select.py | 4 ++-- homeassistant/components/mqtt/sensor.py | 4 ++-- homeassistant/components/mqtt/siren.py | 4 ++-- homeassistant/components/mqtt/switch.py | 4 ++-- homeassistant/components/mqtt/text.py | 4 ++-- homeassistant/components/mqtt/update.py | 4 ++-- homeassistant/components/mqtt/vacuum.py | 4 ++-- homeassistant/components/mqtt/valve.py | 4 ++-- homeassistant/components/mqtt/water_heater.py | 4 ++-- homeassistant/components/mullvad/binary_sensor.py | 4 ++-- .../components/music_assistant/media_player.py | 4 ++-- homeassistant/components/mutesync/binary_sensor.py | 4 ++-- homeassistant/components/mysensors/binary_sensor.py | 4 ++-- homeassistant/components/mysensors/climate.py | 4 ++-- homeassistant/components/mysensors/cover.py | 4 ++-- homeassistant/components/mysensors/device_tracker.py | 4 ++-- homeassistant/components/mysensors/light.py | 4 ++-- homeassistant/components/mysensors/remote.py | 4 ++-- homeassistant/components/mysensors/sensor.py | 4 ++-- homeassistant/components/mysensors/switch.py | 4 ++-- homeassistant/components/mysensors/text.py | 4 ++-- homeassistant/components/mystrom/light.py | 6 ++++-- homeassistant/components/mystrom/sensor.py | 6 ++++-- homeassistant/components/mystrom/switch.py | 6 ++++-- homeassistant/components/myuplink/binary_sensor.py | 4 ++-- homeassistant/components/myuplink/number.py | 4 ++-- homeassistant/components/myuplink/select.py | 4 ++-- homeassistant/components/myuplink/sensor.py | 4 ++-- homeassistant/components/myuplink/switch.py | 4 ++-- homeassistant/components/myuplink/update.py | 4 ++-- homeassistant/components/nam/button.py | 6 ++++-- homeassistant/components/nam/sensor.py | 6 ++++-- homeassistant/components/nanoleaf/button.py | 4 ++-- homeassistant/components/nanoleaf/event.py | 4 ++-- homeassistant/components/nanoleaf/light.py | 4 ++-- homeassistant/components/nasweb/switch.py | 4 ++-- homeassistant/components/neato/button.py | 6 ++++-- homeassistant/components/neato/camera.py | 6 ++++-- homeassistant/components/neato/sensor.py | 6 ++++-- homeassistant/components/neato/switch.py | 6 ++++-- homeassistant/components/neato/vacuum.py | 6 ++++-- homeassistant/components/nest/camera.py | 6 ++++-- homeassistant/components/nest/climate.py | 6 ++++-- homeassistant/components/nest/event.py | 6 ++++-- homeassistant/components/nest/sensor.py | 6 ++++-- homeassistant/components/netatmo/binary_sensor.py | 6 ++++-- homeassistant/components/netatmo/button.py | 4 ++-- homeassistant/components/netatmo/camera.py | 6 ++++-- homeassistant/components/netatmo/climate.py | 6 ++++-- homeassistant/components/netatmo/cover.py | 4 ++-- homeassistant/components/netatmo/fan.py | 4 ++-- homeassistant/components/netatmo/light.py | 6 ++++-- homeassistant/components/netatmo/select.py | 6 ++++-- homeassistant/components/netatmo/sensor.py | 6 ++++-- homeassistant/components/netatmo/switch.py | 4 ++-- homeassistant/components/netgear/button.py | 6 ++++-- homeassistant/components/netgear/device_tracker.py | 6 ++++-- homeassistant/components/netgear/sensor.py | 6 ++++-- homeassistant/components/netgear/switch.py | 6 ++++-- homeassistant/components/netgear/update.py | 6 ++++-- homeassistant/components/netgear_lte/binary_sensor.py | 4 ++-- homeassistant/components/netgear_lte/sensor.py | 4 ++-- homeassistant/components/nexia/binary_sensor.py | 4 ++-- homeassistant/components/nexia/climate.py | 4 ++-- homeassistant/components/nexia/number.py | 4 ++-- homeassistant/components/nexia/scene.py | 4 ++-- homeassistant/components/nexia/sensor.py | 4 ++-- homeassistant/components/nexia/switch.py | 4 ++-- homeassistant/components/nextbus/sensor.py | 4 ++-- homeassistant/components/nextcloud/binary_sensor.py | 4 ++-- homeassistant/components/nextcloud/sensor.py | 4 ++-- homeassistant/components/nextcloud/update.py | 4 ++-- homeassistant/components/nextdns/binary_sensor.py | 4 ++-- homeassistant/components/nextdns/button.py | 4 ++-- homeassistant/components/nextdns/sensor.py | 4 ++-- homeassistant/components/nextdns/switch.py | 4 ++-- .../components/nibe_heatpump/binary_sensor.py | 4 ++-- homeassistant/components/nibe_heatpump/button.py | 4 ++-- homeassistant/components/nibe_heatpump/climate.py | 4 ++-- homeassistant/components/nibe_heatpump/number.py | 4 ++-- homeassistant/components/nibe_heatpump/select.py | 4 ++-- homeassistant/components/nibe_heatpump/sensor.py | 4 ++-- homeassistant/components/nibe_heatpump/switch.py | 4 ++-- .../components/nibe_heatpump/water_heater.py | 4 ++-- homeassistant/components/nice_go/cover.py | 4 ++-- homeassistant/components/nice_go/event.py | 4 ++-- homeassistant/components/nice_go/light.py | 4 ++-- homeassistant/components/nice_go/switch.py | 4 ++-- homeassistant/components/nightscout/sensor.py | 4 ++-- homeassistant/components/niko_home_control/cover.py | 4 ++-- homeassistant/components/niko_home_control/light.py | 7 +++++-- homeassistant/components/nina/binary_sensor.py | 4 ++-- .../components/nmap_tracker/device_tracker.py | 6 ++++-- homeassistant/components/nmbs/sensor.py | 7 +++++-- homeassistant/components/nobo_hub/climate.py | 4 ++-- homeassistant/components/nobo_hub/select.py | 4 ++-- homeassistant/components/nobo_hub/sensor.py | 4 ++-- homeassistant/components/nordpool/sensor.py | 4 ++-- homeassistant/components/notion/binary_sensor.py | 6 ++++-- homeassistant/components/notion/sensor.py | 6 ++++-- homeassistant/components/nuheat/climate.py | 4 ++-- homeassistant/components/nuki/binary_sensor.py | 6 ++++-- homeassistant/components/nuki/lock.py | 6 ++++-- homeassistant/components/nuki/sensor.py | 6 ++++-- homeassistant/components/nut/sensor.py | 4 ++-- homeassistant/components/nws/sensor.py | 6 ++++-- homeassistant/components/nws/weather.py | 6 ++++-- homeassistant/components/nyt_games/sensor.py | 4 ++-- homeassistant/components/nzbget/sensor.py | 4 ++-- homeassistant/components/nzbget/switch.py | 4 ++-- homeassistant/components/obihai/button.py | 2 +- homeassistant/components/obihai/sensor.py | 6 ++++-- homeassistant/components/octoprint/binary_sensor.py | 4 ++-- homeassistant/components/octoprint/button.py | 4 ++-- homeassistant/components/octoprint/camera.py | 4 ++-- homeassistant/components/octoprint/sensor.py | 4 ++-- homeassistant/components/ohme/button.py | 4 ++-- homeassistant/components/ohme/number.py | 4 ++-- homeassistant/components/ohme/select.py | 4 ++-- homeassistant/components/ohme/sensor.py | 4 ++-- homeassistant/components/ohme/switch.py | 4 ++-- homeassistant/components/ohme/time.py | 4 ++-- homeassistant/components/ollama/conversation.py | 4 ++-- homeassistant/components/omnilogic/sensor.py | 6 ++++-- homeassistant/components/omnilogic/switch.py | 6 ++++-- homeassistant/components/oncue/binary_sensor.py | 4 ++-- homeassistant/components/oncue/sensor.py | 4 ++-- homeassistant/components/ondilo_ico/sensor.py | 6 ++++-- homeassistant/components/onewire/binary_sensor.py | 4 ++-- homeassistant/components/onewire/select.py | 4 ++-- homeassistant/components/onewire/sensor.py | 4 ++-- homeassistant/components/onewire/switch.py | 4 ++-- homeassistant/components/onkyo/media_player.py | 7 +++++-- homeassistant/components/onvif/binary_sensor.py | 4 ++-- homeassistant/components/onvif/button.py | 4 ++-- homeassistant/components/onvif/camera.py | 4 ++-- homeassistant/components/onvif/sensor.py | 4 ++-- homeassistant/components/onvif/switch.py | 4 ++-- homeassistant/components/open_meteo/weather.py | 4 ++-- .../components/openai_conversation/conversation.py | 4 ++-- homeassistant/components/openexchangerates/sensor.py | 4 ++-- homeassistant/components/opengarage/binary_sensor.py | 6 ++++-- homeassistant/components/opengarage/button.py | 4 ++-- homeassistant/components/opengarage/cover.py | 6 ++++-- homeassistant/components/opengarage/sensor.py | 6 ++++-- homeassistant/components/openhome/media_player.py | 4 ++-- homeassistant/components/openhome/update.py | 4 ++-- homeassistant/components/opensky/sensor.py | 4 ++-- .../components/opentherm_gw/binary_sensor.py | 4 ++-- homeassistant/components/opentherm_gw/button.py | 4 ++-- homeassistant/components/opentherm_gw/climate.py | 4 ++-- homeassistant/components/opentherm_gw/select.py | 4 ++-- homeassistant/components/opentherm_gw/sensor.py | 4 ++-- homeassistant/components/opentherm_gw/switch.py | 4 ++-- homeassistant/components/openuv/binary_sensor.py | 6 ++++-- homeassistant/components/openuv/sensor.py | 6 ++++-- homeassistant/components/openweathermap/sensor.py | 4 ++-- homeassistant/components/openweathermap/weather.py | 4 ++-- homeassistant/components/opower/sensor.py | 4 ++-- homeassistant/components/oralb/sensor.py | 4 ++-- homeassistant/components/osoenergy/binary_sensor.py | 6 ++++-- homeassistant/components/osoenergy/sensor.py | 6 ++++-- homeassistant/components/osoenergy/water_heater.py | 6 ++++-- homeassistant/components/otp/sensor.py | 6 ++++-- homeassistant/components/ourgroceries/todo.py | 6 ++++-- .../components/overkiz/alarm_control_panel.py | 4 ++-- homeassistant/components/overkiz/binary_sensor.py | 4 ++-- homeassistant/components/overkiz/button.py | 4 ++-- homeassistant/components/overkiz/climate/__init__.py | 4 ++-- homeassistant/components/overkiz/cover/__init__.py | 4 ++-- homeassistant/components/overkiz/light.py | 4 ++-- homeassistant/components/overkiz/lock.py | 4 ++-- homeassistant/components/overkiz/number.py | 4 ++-- homeassistant/components/overkiz/scene.py | 4 ++-- homeassistant/components/overkiz/select.py | 4 ++-- homeassistant/components/overkiz/sensor.py | 4 ++-- homeassistant/components/overkiz/siren.py | 4 ++-- homeassistant/components/overkiz/switch.py | 4 ++-- .../components/overkiz/water_heater/__init__.py | 4 ++-- homeassistant/components/overseerr/event.py | 4 ++-- homeassistant/components/overseerr/sensor.py | 4 ++-- homeassistant/components/ovo_energy/sensor.py | 6 ++++-- homeassistant/components/owntracks/device_tracker.py | 6 ++++-- homeassistant/components/p1_monitor/sensor.py | 4 ++-- homeassistant/components/palazzetti/button.py | 4 ++-- homeassistant/components/palazzetti/climate.py | 4 ++-- homeassistant/components/palazzetti/number.py | 4 ++-- homeassistant/components/palazzetti/sensor.py | 4 ++-- .../components/panasonic_viera/media_player.py | 4 ++-- homeassistant/components/panasonic_viera/remote.py | 4 ++-- homeassistant/components/peblar/binary_sensor.py | 4 ++-- homeassistant/components/peblar/button.py | 4 ++-- homeassistant/components/peblar/number.py | 4 ++-- homeassistant/components/peblar/select.py | 4 ++-- homeassistant/components/peblar/sensor.py | 4 ++-- homeassistant/components/peblar/switch.py | 4 ++-- homeassistant/components/peblar/update.py | 4 ++-- homeassistant/components/peco/binary_sensor.py | 4 ++-- homeassistant/components/peco/sensor.py | 4 ++-- homeassistant/components/pegel_online/sensor.py | 4 ++-- homeassistant/components/permobil/binary_sensor.py | 4 ++-- homeassistant/components/permobil/sensor.py | 4 ++-- homeassistant/components/pglab/switch.py | 4 ++-- homeassistant/components/philips_js/binary_sensor.py | 4 ++-- homeassistant/components/philips_js/light.py | 4 ++-- homeassistant/components/philips_js/media_player.py | 4 ++-- homeassistant/components/philips_js/remote.py | 4 ++-- homeassistant/components/philips_js/switch.py | 4 ++-- homeassistant/components/pi_hole/binary_sensor.py | 4 ++-- homeassistant/components/pi_hole/sensor.py | 4 ++-- homeassistant/components/pi_hole/switch.py | 4 ++-- homeassistant/components/pi_hole/update.py | 4 ++-- homeassistant/components/picnic/sensor.py | 4 ++-- homeassistant/components/picnic/todo.py | 4 ++-- homeassistant/components/ping/binary_sensor.py | 6 ++++-- homeassistant/components/ping/device_tracker.py | 6 ++++-- homeassistant/components/ping/sensor.py | 6 ++++-- homeassistant/components/plaato/binary_sensor.py | 4 ++-- homeassistant/components/plaato/sensor.py | 9 +++++++-- homeassistant/components/plex/button.py | 4 ++-- homeassistant/components/plex/media_player.py | 4 ++-- homeassistant/components/plex/sensor.py | 4 ++-- homeassistant/components/plex/update.py | 4 ++-- homeassistant/components/plugwise/binary_sensor.py | 4 ++-- homeassistant/components/plugwise/button.py | 4 ++-- homeassistant/components/plugwise/climate.py | 4 ++-- homeassistant/components/plugwise/number.py | 4 ++-- homeassistant/components/plugwise/select.py | 4 ++-- homeassistant/components/plugwise/sensor.py | 4 ++-- homeassistant/components/plugwise/switch.py | 4 ++-- homeassistant/components/plum_lightpad/light.py | 4 ++-- homeassistant/components/point/alarm_control_panel.py | 4 ++-- homeassistant/components/point/binary_sensor.py | 4 ++-- homeassistant/components/point/sensor.py | 4 ++-- homeassistant/components/poolsense/binary_sensor.py | 4 ++-- homeassistant/components/poolsense/sensor.py | 4 ++-- homeassistant/components/powerfox/sensor.py | 4 ++-- homeassistant/components/powerwall/binary_sensor.py | 4 ++-- homeassistant/components/powerwall/sensor.py | 4 ++-- homeassistant/components/powerwall/switch.py | 4 ++-- .../components/private_ble_device/device_tracker.py | 4 ++-- homeassistant/components/private_ble_device/sensor.py | 6 ++++-- .../components/progettihwsw/binary_sensor.py | 4 ++-- homeassistant/components/progettihwsw/switch.py | 4 ++-- .../components/prosegur/alarm_control_panel.py | 6 ++++-- homeassistant/components/prosegur/camera.py | 6 ++++-- homeassistant/components/proximity/sensor.py | 4 ++-- homeassistant/components/prusalink/binary_sensor.py | 4 ++-- homeassistant/components/prusalink/button.py | 4 ++-- homeassistant/components/prusalink/camera.py | 4 ++-- homeassistant/components/prusalink/sensor.py | 4 ++-- homeassistant/components/ps4/media_player.py | 4 ++-- homeassistant/components/pure_energie/sensor.py | 4 ++-- homeassistant/components/purpleair/sensor.py | 4 ++-- homeassistant/components/pushbullet/sensor.py | 6 ++++-- homeassistant/components/pvoutput/sensor.py | 4 ++-- .../components/pvpc_hourly_pricing/sensor.py | 6 ++++-- homeassistant/components/pyload/button.py | 4 ++-- homeassistant/components/pyload/sensor.py | 4 ++-- homeassistant/components/pyload/switch.py | 4 ++-- homeassistant/components/qbittorrent/sensor.py | 4 ++-- homeassistant/components/qbittorrent/switch.py | 4 ++-- homeassistant/components/qbus/switch.py | 6 ++++-- homeassistant/components/qingping/binary_sensor.py | 4 ++-- homeassistant/components/qingping/sensor.py | 4 ++-- homeassistant/components/qnap/sensor.py | 4 ++-- homeassistant/components/qnap_qsw/binary_sensor.py | 6 ++++-- homeassistant/components/qnap_qsw/button.py | 6 ++++-- homeassistant/components/qnap_qsw/sensor.py | 6 ++++-- homeassistant/components/qnap_qsw/update.py | 6 ++++-- homeassistant/components/rabbitair/fan.py | 6 ++++-- homeassistant/components/rachio/binary_sensor.py | 4 ++-- homeassistant/components/rachio/calendar.py | 4 ++-- homeassistant/components/rachio/switch.py | 4 ++-- homeassistant/components/radarr/binary_sensor.py | 4 ++-- homeassistant/components/radarr/calendar.py | 4 ++-- homeassistant/components/radarr/sensor.py | 4 ++-- homeassistant/components/radiotherm/climate.py | 4 ++-- homeassistant/components/radiotherm/switch.py | 4 ++-- homeassistant/components/rainbird/binary_sensor.py | 4 ++-- homeassistant/components/rainbird/calendar.py | 4 ++-- homeassistant/components/rainbird/number.py | 4 ++-- homeassistant/components/rainbird/sensor.py | 4 ++-- homeassistant/components/rainbird/switch.py | 4 ++-- homeassistant/components/rainforest_eagle/sensor.py | 6 ++++-- homeassistant/components/rainforest_raven/sensor.py | 4 ++-- homeassistant/components/rainmachine/binary_sensor.py | 4 ++-- homeassistant/components/rainmachine/button.py | 4 ++-- homeassistant/components/rainmachine/select.py | 4 ++-- homeassistant/components/rainmachine/sensor.py | 4 ++-- homeassistant/components/rainmachine/switch.py | 4 ++-- homeassistant/components/rainmachine/update.py | 4 ++-- homeassistant/components/random/binary_sensor.py | 7 +++++-- homeassistant/components/random/sensor.py | 7 +++++-- homeassistant/components/rapt_ble/sensor.py | 4 ++-- homeassistant/components/rdw/binary_sensor.py | 4 ++-- homeassistant/components/rdw/sensor.py | 4 ++-- homeassistant/components/recollect_waste/calendar.py | 6 ++++-- homeassistant/components/recollect_waste/sensor.py | 6 ++++-- homeassistant/components/refoss/sensor.py | 4 ++-- homeassistant/components/refoss/switch.py | 4 ++-- homeassistant/components/renault/binary_sensor.py | 4 ++-- homeassistant/components/renault/button.py | 4 ++-- homeassistant/components/renault/device_tracker.py | 4 ++-- homeassistant/components/renault/select.py | 4 ++-- homeassistant/components/renault/sensor.py | 4 ++-- homeassistant/components/renson/binary_sensor.py | 4 ++-- homeassistant/components/renson/button.py | 4 ++-- homeassistant/components/renson/fan.py | 4 ++-- homeassistant/components/renson/number.py | 4 ++-- homeassistant/components/renson/sensor.py | 4 ++-- homeassistant/components/renson/switch.py | 4 ++-- homeassistant/components/renson/time.py | 4 ++-- homeassistant/components/reolink/binary_sensor.py | 4 ++-- homeassistant/components/reolink/button.py | 4 ++-- homeassistant/components/reolink/camera.py | 4 ++-- homeassistant/components/reolink/light.py | 4 ++-- homeassistant/components/reolink/number.py | 4 ++-- homeassistant/components/reolink/select.py | 4 ++-- homeassistant/components/reolink/sensor.py | 4 ++-- homeassistant/components/reolink/siren.py | 4 ++-- homeassistant/components/reolink/switch.py | 4 ++-- homeassistant/components/reolink/update.py | 4 ++-- homeassistant/components/rfxtrx/binary_sensor.py | 4 ++-- homeassistant/components/rfxtrx/cover.py | 4 ++-- homeassistant/components/rfxtrx/event.py | 4 ++-- homeassistant/components/rfxtrx/light.py | 4 ++-- homeassistant/components/rfxtrx/sensor.py | 4 ++-- homeassistant/components/rfxtrx/siren.py | 4 ++-- homeassistant/components/rfxtrx/switch.py | 4 ++-- homeassistant/components/ridwell/calendar.py | 6 ++++-- homeassistant/components/ridwell/sensor.py | 6 ++++-- homeassistant/components/ridwell/switch.py | 6 ++++-- homeassistant/components/ring/binary_sensor.py | 4 ++-- homeassistant/components/ring/button.py | 4 ++-- homeassistant/components/ring/camera.py | 4 ++-- homeassistant/components/ring/event.py | 4 ++-- homeassistant/components/ring/light.py | 4 ++-- homeassistant/components/ring/number.py | 4 ++-- homeassistant/components/ring/sensor.py | 4 ++-- homeassistant/components/ring/siren.py | 4 ++-- homeassistant/components/ring/switch.py | 4 ++-- homeassistant/components/risco/alarm_control_panel.py | 4 ++-- homeassistant/components/risco/binary_sensor.py | 4 ++-- homeassistant/components/risco/sensor.py | 4 ++-- homeassistant/components/risco/switch.py | 4 ++-- .../components/rituals_perfume_genie/binary_sensor.py | 4 ++-- .../components/rituals_perfume_genie/number.py | 4 ++-- .../components/rituals_perfume_genie/select.py | 4 ++-- .../components/rituals_perfume_genie/sensor.py | 4 ++-- .../components/rituals_perfume_genie/switch.py | 4 ++-- homeassistant/components/roborock/binary_sensor.py | 4 ++-- homeassistant/components/roborock/button.py | 4 ++-- homeassistant/components/roborock/image.py | 4 ++-- homeassistant/components/roborock/number.py | 4 ++-- homeassistant/components/roborock/scene.py | 4 ++-- homeassistant/components/roborock/select.py | 4 ++-- homeassistant/components/roborock/sensor.py | 4 ++-- homeassistant/components/roborock/switch.py | 4 ++-- homeassistant/components/roborock/time.py | 4 ++-- homeassistant/components/roborock/vacuum.py | 4 ++-- homeassistant/components/roku/binary_sensor.py | 4 ++-- homeassistant/components/roku/media_player.py | 6 ++++-- homeassistant/components/roku/remote.py | 4 ++-- homeassistant/components/roku/select.py | 4 ++-- homeassistant/components/roku/sensor.py | 4 ++-- homeassistant/components/romy/binary_sensor.py | 4 ++-- homeassistant/components/romy/sensor.py | 4 ++-- homeassistant/components/romy/vacuum.py | 4 ++-- homeassistant/components/roomba/binary_sensor.py | 4 ++-- homeassistant/components/roomba/sensor.py | 4 ++-- homeassistant/components/roomba/vacuum.py | 4 ++-- homeassistant/components/roon/event.py | 4 ++-- homeassistant/components/roon/media_player.py | 4 ++-- homeassistant/components/rova/sensor.py | 4 ++-- homeassistant/components/rpi_power/binary_sensor.py | 4 ++-- .../components/ruckus_unleashed/device_tracker.py | 8 +++++--- homeassistant/components/russound_rio/media_player.py | 4 ++-- homeassistant/components/ruuvitag_ble/sensor.py | 4 ++-- homeassistant/components/rympro/sensor.py | 4 ++-- homeassistant/components/sabnzbd/binary_sensor.py | 4 ++-- homeassistant/components/sabnzbd/button.py | 4 ++-- homeassistant/components/sabnzbd/number.py | 4 ++-- homeassistant/components/sabnzbd/sensor.py | 4 ++-- homeassistant/components/samsungtv/media_player.py | 4 ++-- homeassistant/components/samsungtv/remote.py | 4 ++-- homeassistant/components/sanix/sensor.py | 6 ++++-- homeassistant/components/schlage/binary_sensor.py | 4 ++-- homeassistant/components/schlage/lock.py | 4 ++-- homeassistant/components/schlage/select.py | 4 ++-- homeassistant/components/schlage/sensor.py | 4 ++-- homeassistant/components/schlage/switch.py | 4 ++-- homeassistant/components/scrape/sensor.py | 7 +++++-- homeassistant/components/screenlogic/binary_sensor.py | 4 ++-- homeassistant/components/screenlogic/climate.py | 4 ++-- homeassistant/components/screenlogic/light.py | 4 ++-- homeassistant/components/screenlogic/number.py | 4 ++-- homeassistant/components/screenlogic/sensor.py | 4 ++-- homeassistant/components/screenlogic/switch.py | 4 ++-- homeassistant/components/season/sensor.py | 4 ++-- homeassistant/components/sense/binary_sensor.py | 4 ++-- homeassistant/components/sense/sensor.py | 4 ++-- homeassistant/components/sensibo/binary_sensor.py | 4 ++-- homeassistant/components/sensibo/button.py | 4 ++-- homeassistant/components/sensibo/climate.py | 4 ++-- homeassistant/components/sensibo/number.py | 4 ++-- homeassistant/components/sensibo/select.py | 4 ++-- homeassistant/components/sensibo/sensor.py | 4 ++-- homeassistant/components/sensibo/switch.py | 4 ++-- homeassistant/components/sensibo/update.py | 4 ++-- homeassistant/components/sensirion_ble/sensor.py | 4 ++-- homeassistant/components/sensorpro/sensor.py | 4 ++-- homeassistant/components/sensorpush/sensor.py | 4 ++-- homeassistant/components/sensoterra/sensor.py | 4 ++-- homeassistant/components/senz/climate.py | 4 ++-- homeassistant/components/seventeentrack/sensor.py | 4 ++-- homeassistant/components/sfr_box/binary_sensor.py | 6 ++++-- homeassistant/components/sfr_box/button.py | 6 ++++-- homeassistant/components/sfr_box/sensor.py | 6 ++++-- homeassistant/components/sharkiq/vacuum.py | 4 ++-- homeassistant/components/shelly/binary_sensor.py | 4 ++-- homeassistant/components/shelly/button.py | 4 ++-- homeassistant/components/shelly/climate.py | 10 +++++----- homeassistant/components/shelly/cover.py | 8 ++++---- homeassistant/components/shelly/event.py | 4 ++-- homeassistant/components/shelly/light.py | 8 ++++---- homeassistant/components/shelly/number.py | 4 ++-- homeassistant/components/shelly/select.py | 4 ++-- homeassistant/components/shelly/sensor.py | 4 ++-- homeassistant/components/shelly/switch.py | 8 ++++---- homeassistant/components/shelly/text.py | 4 ++-- homeassistant/components/shelly/update.py | 4 ++-- homeassistant/components/shelly/valve.py | 6 +++--- homeassistant/components/shopping_list/todo.py | 4 ++-- homeassistant/components/sia/alarm_control_panel.py | 4 ++-- homeassistant/components/sia/binary_sensor.py | 4 ++-- homeassistant/components/simplefin/binary_sensor.py | 4 ++-- homeassistant/components/simplefin/sensor.py | 4 ++-- .../components/simplisafe/alarm_control_panel.py | 6 ++++-- homeassistant/components/simplisafe/binary_sensor.py | 6 ++++-- homeassistant/components/simplisafe/button.py | 6 ++++-- homeassistant/components/simplisafe/lock.py | 6 ++++-- homeassistant/components/simplisafe/sensor.py | 6 ++++-- homeassistant/components/sky_remote/remote.py | 4 ++-- homeassistant/components/skybell/binary_sensor.py | 6 ++++-- homeassistant/components/skybell/camera.py | 6 ++++-- homeassistant/components/skybell/light.py | 6 ++++-- homeassistant/components/skybell/sensor.py | 6 ++++-- homeassistant/components/skybell/switch.py | 6 ++++-- homeassistant/components/slack/sensor.py | 4 ++-- homeassistant/components/sleepiq/binary_sensor.py | 4 ++-- homeassistant/components/sleepiq/button.py | 4 ++-- homeassistant/components/sleepiq/light.py | 4 ++-- homeassistant/components/sleepiq/number.py | 4 ++-- homeassistant/components/sleepiq/select.py | 4 ++-- homeassistant/components/sleepiq/sensor.py | 4 ++-- homeassistant/components/sleepiq/switch.py | 4 ++-- homeassistant/components/slide_local/button.py | 4 ++-- homeassistant/components/slide_local/cover.py | 4 ++-- homeassistant/components/slide_local/switch.py | 4 ++-- homeassistant/components/slimproto/media_player.py | 4 ++-- homeassistant/components/sma/sensor.py | 4 ++-- homeassistant/components/smappee/binary_sensor.py | 4 ++-- homeassistant/components/smappee/sensor.py | 4 ++-- homeassistant/components/smappee/switch.py | 4 ++-- homeassistant/components/smart_meter_texas/sensor.py | 4 ++-- homeassistant/components/smartthings/binary_sensor.py | 4 ++-- homeassistant/components/smartthings/climate.py | 4 ++-- homeassistant/components/smartthings/cover.py | 4 ++-- homeassistant/components/smartthings/fan.py | 4 ++-- homeassistant/components/smartthings/light.py | 4 ++-- homeassistant/components/smartthings/lock.py | 4 ++-- homeassistant/components/smartthings/scene.py | 4 ++-- homeassistant/components/smartthings/sensor.py | 4 ++-- homeassistant/components/smartthings/switch.py | 4 ++-- homeassistant/components/smarttub/binary_sensor.py | 6 ++++-- homeassistant/components/smarttub/climate.py | 6 ++++-- homeassistant/components/smarttub/light.py | 6 ++++-- homeassistant/components/smarttub/sensor.py | 6 ++++-- homeassistant/components/smarttub/switch.py | 6 ++++-- homeassistant/components/smarty/binary_sensor.py | 4 ++-- homeassistant/components/smarty/button.py | 4 ++-- homeassistant/components/smarty/fan.py | 4 ++-- homeassistant/components/smarty/sensor.py | 4 ++-- homeassistant/components/smarty/switch.py | 4 ++-- homeassistant/components/smhi/weather.py | 4 ++-- homeassistant/components/smlight/binary_sensor.py | 4 ++-- homeassistant/components/smlight/button.py | 4 ++-- homeassistant/components/smlight/sensor.py | 4 ++-- homeassistant/components/smlight/switch.py | 4 ++-- homeassistant/components/smlight/update.py | 6 ++++-- homeassistant/components/sms/sensor.py | 4 ++-- homeassistant/components/snapcast/media_player.py | 4 ++-- homeassistant/components/snooz/fan.py | 6 ++++-- homeassistant/components/solaredge/sensor.py | 4 ++-- homeassistant/components/solarlog/sensor.py | 4 ++-- homeassistant/components/solax/sensor.py | 4 ++-- homeassistant/components/soma/cover.py | 4 ++-- homeassistant/components/soma/sensor.py | 4 ++-- homeassistant/components/somfy_mylink/cover.py | 4 ++-- homeassistant/components/sonarr/sensor.py | 4 ++-- homeassistant/components/songpal/media_player.py | 7 +++++-- homeassistant/components/sonos/binary_sensor.py | 4 ++-- homeassistant/components/sonos/media_player.py | 4 ++-- homeassistant/components/sonos/number.py | 4 ++-- homeassistant/components/sonos/sensor.py | 4 ++-- homeassistant/components/sonos/switch.py | 4 ++-- homeassistant/components/soundtouch/media_player.py | 4 ++-- homeassistant/components/speedtestdotnet/sensor.py | 4 ++-- homeassistant/components/spotify/media_player.py | 4 ++-- homeassistant/components/sql/sensor.py | 11 ++++++++--- homeassistant/components/squeezebox/binary_sensor.py | 4 ++-- homeassistant/components/squeezebox/media_player.py | 4 ++-- homeassistant/components/squeezebox/sensor.py | 4 ++-- homeassistant/components/srp_energy/sensor.py | 6 ++++-- homeassistant/components/starline/binary_sensor.py | 6 ++++-- homeassistant/components/starline/button.py | 6 ++++-- homeassistant/components/starline/device_tracker.py | 6 ++++-- homeassistant/components/starline/lock.py | 6 ++++-- homeassistant/components/starline/sensor.py | 6 ++++-- homeassistant/components/starline/switch.py | 6 ++++-- homeassistant/components/starlink/binary_sensor.py | 6 ++++-- homeassistant/components/starlink/button.py | 6 ++++-- homeassistant/components/starlink/device_tracker.py | 6 ++++-- homeassistant/components/starlink/sensor.py | 6 ++++-- homeassistant/components/starlink/switch.py | 6 ++++-- homeassistant/components/starlink/time.py | 6 ++++-- homeassistant/components/statistics/sensor.py | 7 +++++-- homeassistant/components/steam_online/sensor.py | 4 ++-- homeassistant/components/steamist/sensor.py | 4 ++-- homeassistant/components/steamist/switch.py | 4 ++-- homeassistant/components/stookwijzer/sensor.py | 4 ++-- .../components/streamlabswater/binary_sensor.py | 4 ++-- homeassistant/components/streamlabswater/sensor.py | 4 ++-- homeassistant/components/subaru/device_tracker.py | 4 ++-- homeassistant/components/subaru/lock.py | 4 ++-- homeassistant/components/subaru/sensor.py | 4 ++-- homeassistant/components/suez_water/sensor.py | 4 ++-- homeassistant/components/sun/sensor.py | 6 ++++-- homeassistant/components/sunweg/sensor/__init__.py | 4 ++-- homeassistant/components/surepetcare/binary_sensor.py | 6 ++++-- homeassistant/components/surepetcare/lock.py | 6 ++++-- homeassistant/components/surepetcare/sensor.py | 6 ++++-- .../components/swiss_public_transport/sensor.py | 4 ++-- homeassistant/components/switch_as_x/cover.py | 4 ++-- homeassistant/components/switch_as_x/fan.py | 4 ++-- homeassistant/components/switch_as_x/light.py | 4 ++-- homeassistant/components/switch_as_x/lock.py | 4 ++-- homeassistant/components/switch_as_x/siren.py | 4 ++-- homeassistant/components/switch_as_x/valve.py | 4 ++-- homeassistant/components/switchbee/button.py | 6 ++++-- homeassistant/components/switchbee/climate.py | 6 ++++-- homeassistant/components/switchbee/cover.py | 6 ++++-- homeassistant/components/switchbee/light.py | 6 ++++-- homeassistant/components/switchbee/switch.py | 6 ++++-- homeassistant/components/switchbot/binary_sensor.py | 4 ++-- homeassistant/components/switchbot/cover.py | 4 ++-- homeassistant/components/switchbot/humidifier.py | 4 ++-- homeassistant/components/switchbot/light.py | 4 ++-- homeassistant/components/switchbot/lock.py | 4 ++-- homeassistant/components/switchbot/sensor.py | 4 ++-- homeassistant/components/switchbot/switch.py | 4 ++-- homeassistant/components/switchbot_cloud/button.py | 4 ++-- homeassistant/components/switchbot_cloud/climate.py | 4 ++-- homeassistant/components/switchbot_cloud/lock.py | 4 ++-- homeassistant/components/switchbot_cloud/sensor.py | 4 ++-- homeassistant/components/switchbot_cloud/switch.py | 4 ++-- homeassistant/components/switchbot_cloud/vacuum.py | 4 ++-- homeassistant/components/switcher_kis/button.py | 4 ++-- homeassistant/components/switcher_kis/climate.py | 4 ++-- homeassistant/components/switcher_kis/cover.py | 4 ++-- homeassistant/components/switcher_kis/light.py | 4 ++-- homeassistant/components/switcher_kis/sensor.py | 4 ++-- homeassistant/components/switcher_kis/switch.py | 4 ++-- homeassistant/components/syncthing/sensor.py | 4 ++-- homeassistant/components/syncthru/binary_sensor.py | 4 ++-- homeassistant/components/syncthru/sensor.py | 4 ++-- .../components/synology_dsm/binary_sensor.py | 6 ++++-- homeassistant/components/synology_dsm/button.py | 4 ++-- homeassistant/components/synology_dsm/camera.py | 6 ++++-- homeassistant/components/synology_dsm/sensor.py | 6 ++++-- homeassistant/components/synology_dsm/switch.py | 6 ++++-- homeassistant/components/synology_dsm/update.py | 6 ++++-- .../components/system_bridge/binary_sensor.py | 6 ++++-- .../components/system_bridge/media_player.py | 4 ++-- homeassistant/components/system_bridge/sensor.py | 4 ++-- homeassistant/components/system_bridge/update.py | 4 ++-- .../components/systemmonitor/binary_sensor.py | 4 ++-- homeassistant/components/systemmonitor/sensor.py | 4 ++-- homeassistant/components/tado/binary_sensor.py | 6 ++++-- homeassistant/components/tado/climate.py | 6 ++++-- homeassistant/components/tado/device_tracker.py | 6 +++--- homeassistant/components/tado/sensor.py | 6 ++++-- homeassistant/components/tado/water_heater.py | 6 ++++-- homeassistant/components/tailscale/binary_sensor.py | 4 ++-- homeassistant/components/tailscale/sensor.py | 4 ++-- homeassistant/components/tailwind/binary_sensor.py | 4 ++-- homeassistant/components/tailwind/button.py | 4 ++-- homeassistant/components/tailwind/cover.py | 4 ++-- homeassistant/components/tailwind/number.py | 4 ++-- homeassistant/components/tami4/button.py | 6 ++++-- homeassistant/components/tami4/sensor.py | 6 ++++-- .../components/tankerkoenig/binary_sensor.py | 4 ++-- homeassistant/components/tankerkoenig/sensor.py | 4 ++-- homeassistant/components/tasmota/binary_sensor.py | 4 ++-- homeassistant/components/tasmota/cover.py | 4 ++-- homeassistant/components/tasmota/fan.py | 4 ++-- homeassistant/components/tasmota/light.py | 4 ++-- homeassistant/components/tasmota/sensor.py | 4 ++-- homeassistant/components/tasmota/switch.py | 4 ++-- homeassistant/components/tautulli/sensor.py | 7 +++++-- homeassistant/components/technove/binary_sensor.py | 4 ++-- homeassistant/components/technove/number.py | 4 ++-- homeassistant/components/technove/sensor.py | 4 ++-- homeassistant/components/technove/switch.py | 4 ++-- homeassistant/components/tedee/binary_sensor.py | 4 ++-- homeassistant/components/tedee/lock.py | 4 ++-- homeassistant/components/tedee/sensor.py | 4 ++-- homeassistant/components/tellduslive/binary_sensor.py | 4 ++-- homeassistant/components/tellduslive/cover.py | 4 ++-- homeassistant/components/tellduslive/light.py | 4 ++-- homeassistant/components/tellduslive/sensor.py | 4 ++-- homeassistant/components/tellduslive/switch.py | 4 ++-- .../components/template/alarm_control_panel.py | 7 +++++-- homeassistant/components/template/binary_sensor.py | 9 ++++++--- homeassistant/components/template/button.py | 7 +++++-- homeassistant/components/template/image.py | 7 +++++-- homeassistant/components/template/number.py | 7 +++++-- homeassistant/components/template/select.py | 7 +++++-- homeassistant/components/template/sensor.py | 9 ++++++--- homeassistant/components/template/switch.py | 7 +++++-- homeassistant/components/tesla_fleet/binary_sensor.py | 4 ++-- homeassistant/components/tesla_fleet/button.py | 4 ++-- homeassistant/components/tesla_fleet/climate.py | 4 ++-- homeassistant/components/tesla_fleet/cover.py | 4 ++-- .../components/tesla_fleet/device_tracker.py | 6 ++++-- homeassistant/components/tesla_fleet/lock.py | 4 ++-- homeassistant/components/tesla_fleet/media_player.py | 4 ++-- homeassistant/components/tesla_fleet/number.py | 4 ++-- homeassistant/components/tesla_fleet/select.py | 4 ++-- homeassistant/components/tesla_fleet/sensor.py | 4 ++-- homeassistant/components/tesla_fleet/switch.py | 4 ++-- .../components/tesla_wall_connector/binary_sensor.py | 4 ++-- .../components/tesla_wall_connector/sensor.py | 4 ++-- homeassistant/components/teslemetry/binary_sensor.py | 4 ++-- homeassistant/components/teslemetry/button.py | 4 ++-- homeassistant/components/teslemetry/climate.py | 4 ++-- homeassistant/components/teslemetry/cover.py | 4 ++-- homeassistant/components/teslemetry/device_tracker.py | 4 ++-- homeassistant/components/teslemetry/lock.py | 4 ++-- homeassistant/components/teslemetry/media_player.py | 4 ++-- homeassistant/components/teslemetry/number.py | 4 ++-- homeassistant/components/teslemetry/select.py | 4 ++-- homeassistant/components/teslemetry/sensor.py | 4 ++-- homeassistant/components/teslemetry/switch.py | 4 ++-- homeassistant/components/teslemetry/update.py | 4 ++-- homeassistant/components/tessie/binary_sensor.py | 4 ++-- homeassistant/components/tessie/button.py | 4 ++-- homeassistant/components/tessie/climate.py | 4 ++-- homeassistant/components/tessie/cover.py | 4 ++-- homeassistant/components/tessie/device_tracker.py | 4 ++-- homeassistant/components/tessie/lock.py | 4 ++-- homeassistant/components/tessie/media_player.py | 4 ++-- homeassistant/components/tessie/number.py | 4 ++-- homeassistant/components/tessie/select.py | 4 ++-- homeassistant/components/tessie/sensor.py | 4 ++-- homeassistant/components/tessie/switch.py | 4 ++-- homeassistant/components/tessie/update.py | 4 ++-- homeassistant/components/thermobeacon/sensor.py | 4 ++-- homeassistant/components/thermopro/sensor.py | 4 ++-- homeassistant/components/thethingsnetwork/sensor.py | 6 ++++-- homeassistant/components/threshold/binary_sensor.py | 7 +++++-- homeassistant/components/tibber/notify.py | 6 ++++-- homeassistant/components/tibber/sensor.py | 8 +++++--- homeassistant/components/tile/binary_sensor.py | 6 ++++-- homeassistant/components/tile/device_tracker.py | 6 ++++-- homeassistant/components/tilt_ble/sensor.py | 4 ++-- homeassistant/components/time_date/sensor.py | 9 +++++++-- homeassistant/components/tod/binary_sensor.py | 7 +++++-- homeassistant/components/todoist/calendar.py | 9 +++++++-- homeassistant/components/todoist/todo.py | 6 ++++-- homeassistant/components/tolo/binary_sensor.py | 4 ++-- homeassistant/components/tolo/button.py | 4 ++-- homeassistant/components/tolo/climate.py | 4 ++-- homeassistant/components/tolo/fan.py | 4 ++-- homeassistant/components/tolo/light.py | 4 ++-- homeassistant/components/tolo/number.py | 4 ++-- homeassistant/components/tolo/select.py | 4 ++-- homeassistant/components/tolo/sensor.py | 4 ++-- homeassistant/components/tolo/switch.py | 4 ++-- homeassistant/components/tomorrowio/sensor.py | 4 ++-- homeassistant/components/tomorrowio/weather.py | 4 ++-- homeassistant/components/toon/binary_sensor.py | 6 ++++-- homeassistant/components/toon/climate.py | 6 ++++-- homeassistant/components/toon/sensor.py | 6 ++++-- homeassistant/components/toon/switch.py | 6 ++++-- .../components/totalconnect/alarm_control_panel.py | 4 ++-- .../components/totalconnect/binary_sensor.py | 4 ++-- homeassistant/components/totalconnect/button.py | 4 ++-- homeassistant/components/touchline_sl/climate.py | 4 ++-- homeassistant/components/tplink/binary_sensor.py | 4 ++-- homeassistant/components/tplink/button.py | 4 ++-- homeassistant/components/tplink/camera.py | 4 ++-- homeassistant/components/tplink/climate.py | 4 ++-- homeassistant/components/tplink/fan.py | 4 ++-- homeassistant/components/tplink/light.py | 4 ++-- homeassistant/components/tplink/number.py | 4 ++-- homeassistant/components/tplink/select.py | 4 ++-- homeassistant/components/tplink/sensor.py | 4 ++-- homeassistant/components/tplink/siren.py | 4 ++-- homeassistant/components/tplink/switch.py | 4 ++-- homeassistant/components/tplink/vacuum.py | 4 ++-- .../components/tplink_omada/binary_sensor.py | 4 ++-- .../components/tplink_omada/device_tracker.py | 4 ++-- homeassistant/components/tplink_omada/sensor.py | 4 ++-- homeassistant/components/tplink_omada/switch.py | 4 ++-- homeassistant/components/tplink_omada/update.py | 4 ++-- homeassistant/components/traccar/device_tracker.py | 6 ++++-- .../components/traccar_server/binary_sensor.py | 4 ++-- .../components/traccar_server/device_tracker.py | 4 ++-- homeassistant/components/traccar_server/sensor.py | 4 ++-- homeassistant/components/tractive/binary_sensor.py | 4 ++-- homeassistant/components/tractive/device_tracker.py | 4 ++-- homeassistant/components/tractive/sensor.py | 4 ++-- homeassistant/components/tractive/switch.py | 4 ++-- homeassistant/components/tradfri/cover.py | 4 ++-- homeassistant/components/tradfri/fan.py | 4 ++-- homeassistant/components/tradfri/light.py | 4 ++-- homeassistant/components/tradfri/sensor.py | 4 ++-- homeassistant/components/tradfri/switch.py | 4 ++-- .../components/trafikverket_camera/binary_sensor.py | 4 ++-- .../components/trafikverket_camera/camera.py | 4 ++-- .../components/trafikverket_camera/sensor.py | 4 ++-- homeassistant/components/trafikverket_ferry/sensor.py | 4 ++-- homeassistant/components/trafikverket_train/sensor.py | 4 ++-- .../components/trafikverket_weatherstation/sensor.py | 4 ++-- homeassistant/components/transmission/sensor.py | 4 ++-- homeassistant/components/transmission/switch.py | 4 ++-- homeassistant/components/trend/binary_sensor.py | 7 +++++-- homeassistant/components/triggercmd/switch.py | 4 ++-- homeassistant/components/tuya/alarm_control_panel.py | 6 ++++-- homeassistant/components/tuya/binary_sensor.py | 6 ++++-- homeassistant/components/tuya/button.py | 6 ++++-- homeassistant/components/tuya/camera.py | 6 ++++-- homeassistant/components/tuya/climate.py | 6 ++++-- homeassistant/components/tuya/cover.py | 6 ++++-- homeassistant/components/tuya/fan.py | 6 ++++-- homeassistant/components/tuya/humidifier.py | 6 ++++-- homeassistant/components/tuya/light.py | 6 ++++-- homeassistant/components/tuya/number.py | 6 ++++-- homeassistant/components/tuya/scene.py | 6 ++++-- homeassistant/components/tuya/select.py | 6 ++++-- homeassistant/components/tuya/sensor.py | 6 ++++-- homeassistant/components/tuya/siren.py | 6 ++++-- homeassistant/components/tuya/switch.py | 6 ++++-- homeassistant/components/tuya/vacuum.py | 6 ++++-- homeassistant/components/twentemilieu/calendar.py | 4 ++-- homeassistant/components/twentemilieu/sensor.py | 4 ++-- homeassistant/components/twinkly/light.py | 4 ++-- homeassistant/components/twinkly/select.py | 4 ++-- homeassistant/components/twitch/sensor.py | 4 ++-- .../components/ukraine_alarm/binary_sensor.py | 4 ++-- homeassistant/components/unifi/button.py | 4 ++-- homeassistant/components/unifi/device_tracker.py | 4 ++-- homeassistant/components/unifi/image.py | 4 ++-- homeassistant/components/unifi/sensor.py | 4 ++-- homeassistant/components/unifi/switch.py | 4 ++-- homeassistant/components/unifi/update.py | 4 ++-- .../components/unifiprotect/binary_sensor.py | 4 ++-- homeassistant/components/unifiprotect/button.py | 4 ++-- homeassistant/components/unifiprotect/camera.py | 4 ++-- homeassistant/components/unifiprotect/event.py | 4 ++-- homeassistant/components/unifiprotect/light.py | 4 ++-- homeassistant/components/unifiprotect/lock.py | 4 ++-- homeassistant/components/unifiprotect/media_player.py | 4 ++-- homeassistant/components/unifiprotect/number.py | 4 ++-- homeassistant/components/unifiprotect/select.py | 6 ++++-- homeassistant/components/unifiprotect/sensor.py | 4 ++-- homeassistant/components/unifiprotect/switch.py | 4 ++-- homeassistant/components/unifiprotect/text.py | 4 ++-- homeassistant/components/upb/light.py | 4 ++-- homeassistant/components/upb/scene.py | 4 ++-- homeassistant/components/upcloud/binary_sensor.py | 4 ++-- homeassistant/components/upcloud/switch.py | 4 ++-- homeassistant/components/upnp/binary_sensor.py | 4 ++-- homeassistant/components/upnp/sensor.py | 4 ++-- homeassistant/components/uptime/sensor.py | 4 ++-- homeassistant/components/uptimerobot/binary_sensor.py | 4 ++-- homeassistant/components/uptimerobot/sensor.py | 4 ++-- homeassistant/components/uptimerobot/switch.py | 6 ++++-- homeassistant/components/utility_meter/select.py | 7 +++++-- homeassistant/components/utility_meter/sensor.py | 7 +++++-- homeassistant/components/v2c/binary_sensor.py | 4 ++-- homeassistant/components/v2c/number.py | 4 ++-- homeassistant/components/v2c/sensor.py | 4 ++-- homeassistant/components/v2c/switch.py | 4 ++-- homeassistant/components/vallox/binary_sensor.py | 4 ++-- homeassistant/components/vallox/date.py | 4 ++-- homeassistant/components/vallox/fan.py | 6 ++++-- homeassistant/components/vallox/number.py | 6 ++++-- homeassistant/components/vallox/sensor.py | 6 ++++-- homeassistant/components/vallox/switch.py | 4 ++-- homeassistant/components/velbus/binary_sensor.py | 4 ++-- homeassistant/components/velbus/button.py | 4 ++-- homeassistant/components/velbus/climate.py | 4 ++-- homeassistant/components/velbus/cover.py | 4 ++-- homeassistant/components/velbus/light.py | 4 ++-- homeassistant/components/velbus/select.py | 4 ++-- homeassistant/components/velbus/sensor.py | 4 ++-- homeassistant/components/velbus/switch.py | 4 ++-- homeassistant/components/velux/cover.py | 6 ++++-- homeassistant/components/velux/light.py | 6 ++++-- homeassistant/components/velux/scene.py | 6 ++++-- homeassistant/components/venstar/binary_sensor.py | 4 ++-- homeassistant/components/venstar/climate.py | 7 +++++-- homeassistant/components/venstar/sensor.py | 4 ++-- homeassistant/components/vera/binary_sensor.py | 4 ++-- homeassistant/components/vera/climate.py | 4 ++-- homeassistant/components/vera/cover.py | 4 ++-- homeassistant/components/vera/light.py | 4 ++-- homeassistant/components/vera/lock.py | 4 ++-- homeassistant/components/vera/scene.py | 4 ++-- homeassistant/components/vera/sensor.py | 4 ++-- homeassistant/components/vera/switch.py | 4 ++-- .../components/verisure/alarm_control_panel.py | 4 ++-- homeassistant/components/verisure/binary_sensor.py | 4 ++-- homeassistant/components/verisure/camera.py | 4 ++-- homeassistant/components/verisure/lock.py | 4 ++-- homeassistant/components/verisure/sensor.py | 4 ++-- homeassistant/components/verisure/switch.py | 4 ++-- homeassistant/components/version/binary_sensor.py | 4 ++-- homeassistant/components/version/sensor.py | 4 ++-- homeassistant/components/vesync/binary_sensor.py | 4 ++-- homeassistant/components/vesync/fan.py | 4 ++-- homeassistant/components/vesync/humidifier.py | 6 +++--- homeassistant/components/vesync/light.py | 4 ++-- homeassistant/components/vesync/number.py | 6 +++--- homeassistant/components/vesync/sensor.py | 6 +++--- homeassistant/components/vesync/switch.py | 4 ++-- homeassistant/components/vicare/binary_sensor.py | 4 ++-- homeassistant/components/vicare/button.py | 4 ++-- homeassistant/components/vicare/climate.py | 4 ++-- homeassistant/components/vicare/fan.py | 4 ++-- homeassistant/components/vicare/number.py | 4 ++-- homeassistant/components/vicare/sensor.py | 4 ++-- homeassistant/components/vicare/water_heater.py | 4 ++-- homeassistant/components/vilfo/sensor.py | 4 ++-- homeassistant/components/vizio/media_player.py | 4 ++-- homeassistant/components/vlc_telnet/media_player.py | 6 ++++-- homeassistant/components/vodafone_station/button.py | 6 ++++-- .../components/vodafone_station/device_tracker.py | 8 +++++--- homeassistant/components/vodafone_station/sensor.py | 6 ++++-- homeassistant/components/voip/assist_satellite.py | 4 ++-- homeassistant/components/voip/binary_sensor.py | 4 ++-- homeassistant/components/voip/select.py | 4 ++-- homeassistant/components/voip/switch.py | 4 ++-- homeassistant/components/volumio/media_player.py | 4 ++-- homeassistant/components/volvooncall/binary_sensor.py | 4 ++-- .../components/volvooncall/device_tracker.py | 4 ++-- homeassistant/components/volvooncall/lock.py | 4 ++-- homeassistant/components/volvooncall/sensor.py | 4 ++-- homeassistant/components/volvooncall/switch.py | 4 ++-- homeassistant/components/vulcan/calendar.py | 4 ++-- homeassistant/components/wake_on_lan/button.py | 4 ++-- homeassistant/components/wallbox/lock.py | 6 ++++-- homeassistant/components/wallbox/number.py | 6 ++++-- homeassistant/components/wallbox/sensor.py | 6 ++++-- homeassistant/components/wallbox/switch.py | 6 ++++-- homeassistant/components/waqi/sensor.py | 6 ++++-- homeassistant/components/watergate/sensor.py | 4 ++-- homeassistant/components/watergate/valve.py | 4 ++-- homeassistant/components/watttime/sensor.py | 6 ++++-- homeassistant/components/waze_travel_time/sensor.py | 4 ++-- homeassistant/components/weatherflow/sensor.py | 4 ++-- homeassistant/components/weatherflow_cloud/sensor.py | 4 ++-- homeassistant/components/weatherflow_cloud/weather.py | 4 ++-- homeassistant/components/weatherkit/sensor.py | 4 ++-- homeassistant/components/weatherkit/weather.py | 4 ++-- homeassistant/components/webmin/sensor.py | 4 ++-- homeassistant/components/webostv/media_player.py | 4 ++-- homeassistant/components/weheat/binary_sensor.py | 4 ++-- homeassistant/components/weheat/sensor.py | 4 ++-- homeassistant/components/wemo/binary_sensor.py | 4 ++-- homeassistant/components/wemo/fan.py | 4 ++-- homeassistant/components/wemo/light.py | 6 +++--- homeassistant/components/wemo/sensor.py | 4 ++-- homeassistant/components/wemo/switch.py | 4 ++-- homeassistant/components/whirlpool/climate.py | 4 ++-- homeassistant/components/whirlpool/sensor.py | 4 ++-- homeassistant/components/whois/sensor.py | 4 ++-- homeassistant/components/wiffi/binary_sensor.py | 4 ++-- homeassistant/components/wiffi/sensor.py | 4 ++-- homeassistant/components/wilight/cover.py | 6 ++++-- homeassistant/components/wilight/fan.py | 6 ++++-- homeassistant/components/wilight/light.py | 6 ++++-- homeassistant/components/wilight/switch.py | 6 ++++-- homeassistant/components/withings/binary_sensor.py | 4 ++-- homeassistant/components/withings/calendar.py | 4 ++-- homeassistant/components/withings/sensor.py | 4 ++-- homeassistant/components/wiz/binary_sensor.py | 4 ++-- homeassistant/components/wiz/light.py | 4 ++-- homeassistant/components/wiz/number.py | 4 ++-- homeassistant/components/wiz/sensor.py | 4 ++-- homeassistant/components/wiz/switch.py | 4 ++-- homeassistant/components/wled/button.py | 4 ++-- homeassistant/components/wled/light.py | 6 +++--- homeassistant/components/wled/number.py | 6 +++--- homeassistant/components/wled/select.py | 6 +++--- homeassistant/components/wled/sensor.py | 4 ++-- homeassistant/components/wled/switch.py | 6 +++--- homeassistant/components/wled/update.py | 4 ++-- homeassistant/components/wmspro/cover.py | 4 ++-- homeassistant/components/wmspro/light.py | 4 ++-- homeassistant/components/wmspro/scene.py | 4 ++-- homeassistant/components/wolflink/sensor.py | 4 ++-- homeassistant/components/workday/binary_sensor.py | 6 ++++-- homeassistant/components/worldclock/sensor.py | 4 ++-- homeassistant/components/ws66i/media_player.py | 4 ++-- homeassistant/components/wyoming/assist_satellite.py | 4 ++-- homeassistant/components/wyoming/binary_sensor.py | 4 ++-- homeassistant/components/wyoming/conversation.py | 4 ++-- homeassistant/components/wyoming/number.py | 4 ++-- homeassistant/components/wyoming/select.py | 4 ++-- homeassistant/components/wyoming/stt.py | 4 ++-- homeassistant/components/wyoming/switch.py | 4 ++-- homeassistant/components/wyoming/tts.py | 4 ++-- homeassistant/components/wyoming/wake_word.py | 4 ++-- homeassistant/components/xbox/binary_sensor.py | 6 ++++-- homeassistant/components/xbox/media_player.py | 6 ++++-- homeassistant/components/xbox/remote.py | 6 ++++-- homeassistant/components/xbox/sensor.py | 4 ++-- .../components/xiaomi_aqara/binary_sensor.py | 4 ++-- homeassistant/components/xiaomi_aqara/cover.py | 4 ++-- homeassistant/components/xiaomi_aqara/light.py | 4 ++-- homeassistant/components/xiaomi_aqara/lock.py | 4 ++-- homeassistant/components/xiaomi_aqara/sensor.py | 4 ++-- homeassistant/components/xiaomi_aqara/switch.py | 4 ++-- homeassistant/components/xiaomi_ble/binary_sensor.py | 4 ++-- homeassistant/components/xiaomi_ble/event.py | 4 ++-- homeassistant/components/xiaomi_ble/sensor.py | 4 ++-- homeassistant/components/xiaomi_miio/air_quality.py | 4 ++-- .../components/xiaomi_miio/alarm_control_panel.py | 4 ++-- homeassistant/components/xiaomi_miio/binary_sensor.py | 4 ++-- homeassistant/components/xiaomi_miio/button.py | 4 ++-- homeassistant/components/xiaomi_miio/fan.py | 4 ++-- homeassistant/components/xiaomi_miio/humidifier.py | 4 ++-- homeassistant/components/xiaomi_miio/light.py | 4 ++-- homeassistant/components/xiaomi_miio/number.py | 4 ++-- homeassistant/components/xiaomi_miio/select.py | 4 ++-- homeassistant/components/xiaomi_miio/sensor.py | 4 ++-- homeassistant/components/xiaomi_miio/switch.py | 4 ++-- homeassistant/components/xiaomi_miio/vacuum.py | 4 ++-- homeassistant/components/yale/binary_sensor.py | 4 ++-- homeassistant/components/yale/button.py | 4 ++-- homeassistant/components/yale/camera.py | 4 ++-- homeassistant/components/yale/event.py | 4 ++-- homeassistant/components/yale/lock.py | 4 ++-- homeassistant/components/yale/sensor.py | 4 ++-- .../yale_smart_alarm/alarm_control_panel.py | 6 ++++-- .../components/yale_smart_alarm/binary_sensor.py | 6 ++++-- homeassistant/components/yale_smart_alarm/button.py | 4 ++-- homeassistant/components/yale_smart_alarm/lock.py | 6 ++++-- homeassistant/components/yale_smart_alarm/select.py | 6 ++++-- homeassistant/components/yale_smart_alarm/sensor.py | 6 ++++-- homeassistant/components/yale_smart_alarm/switch.py | 6 ++++-- homeassistant/components/yalexs_ble/binary_sensor.py | 4 ++-- homeassistant/components/yalexs_ble/lock.py | 4 ++-- homeassistant/components/yalexs_ble/sensor.py | 4 ++-- .../components/yamaha_musiccast/media_player.py | 4 ++-- homeassistant/components/yamaha_musiccast/number.py | 4 ++-- homeassistant/components/yamaha_musiccast/select.py | 4 ++-- homeassistant/components/yamaha_musiccast/switch.py | 4 ++-- homeassistant/components/yardian/switch.py | 4 ++-- homeassistant/components/yeelight/binary_sensor.py | 4 ++-- homeassistant/components/yeelight/light.py | 4 ++-- homeassistant/components/yolink/binary_sensor.py | 4 ++-- homeassistant/components/yolink/climate.py | 4 ++-- homeassistant/components/yolink/cover.py | 4 ++-- homeassistant/components/yolink/light.py | 4 ++-- homeassistant/components/yolink/lock.py | 4 ++-- homeassistant/components/yolink/number.py | 4 ++-- homeassistant/components/yolink/sensor.py | 4 ++-- homeassistant/components/yolink/siren.py | 4 ++-- homeassistant/components/yolink/switch.py | 4 ++-- homeassistant/components/yolink/valve.py | 4 ++-- homeassistant/components/youless/sensor.py | 6 ++++-- homeassistant/components/youtube/sensor.py | 6 ++++-- homeassistant/components/zamg/sensor.py | 6 ++++-- homeassistant/components/zamg/weather.py | 6 ++++-- homeassistant/components/zerproc/light.py | 4 ++-- homeassistant/components/zeversolar/sensor.py | 6 ++++-- homeassistant/components/zha/alarm_control_panel.py | 4 ++-- homeassistant/components/zha/binary_sensor.py | 4 ++-- homeassistant/components/zha/button.py | 4 ++-- homeassistant/components/zha/climate.py | 4 ++-- homeassistant/components/zha/cover.py | 4 ++-- homeassistant/components/zha/device_tracker.py | 4 ++-- homeassistant/components/zha/fan.py | 4 ++-- homeassistant/components/zha/light.py | 4 ++-- homeassistant/components/zha/lock.py | 4 ++-- homeassistant/components/zha/number.py | 4 ++-- homeassistant/components/zha/select.py | 4 ++-- homeassistant/components/zha/sensor.py | 4 ++-- homeassistant/components/zha/siren.py | 4 ++-- homeassistant/components/zha/switch.py | 4 ++-- homeassistant/components/zha/update.py | 4 ++-- homeassistant/components/zodiac/sensor.py | 4 ++-- homeassistant/components/zwave_js/binary_sensor.py | 4 ++-- homeassistant/components/zwave_js/button.py | 4 ++-- homeassistant/components/zwave_js/climate.py | 4 ++-- homeassistant/components/zwave_js/cover.py | 4 ++-- homeassistant/components/zwave_js/event.py | 4 ++-- homeassistant/components/zwave_js/fan.py | 4 ++-- homeassistant/components/zwave_js/humidifier.py | 4 ++-- homeassistant/components/zwave_js/light.py | 4 ++-- homeassistant/components/zwave_js/lock.py | 4 ++-- homeassistant/components/zwave_js/number.py | 4 ++-- homeassistant/components/zwave_js/select.py | 4 ++-- homeassistant/components/zwave_js/sensor.py | 4 ++-- homeassistant/components/zwave_js/siren.py | 4 ++-- homeassistant/components/zwave_js/switch.py | 4 ++-- homeassistant/components/zwave_js/update.py | 4 ++-- homeassistant/components/zwave_me/binary_sensor.py | 4 ++-- homeassistant/components/zwave_me/button.py | 4 ++-- homeassistant/components/zwave_me/climate.py | 4 ++-- homeassistant/components/zwave_me/cover.py | 4 ++-- homeassistant/components/zwave_me/fan.py | 4 ++-- homeassistant/components/zwave_me/light.py | 4 ++-- homeassistant/components/zwave_me/lock.py | 4 ++-- homeassistant/components/zwave_me/number.py | 4 ++-- homeassistant/components/zwave_me/sensor.py | 4 ++-- homeassistant/components/zwave_me/siren.py | 4 ++-- homeassistant/components/zwave_me/switch.py | 4 ++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- .../config_flow_helper/integration/sensor.py | 4 ++-- tests/pylint/test_enforce_type_hints.py | 4 ++-- 2093 files changed, 5043 insertions(+), 4230 deletions(-) diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index 4ec59ca4c39..554cf932fca 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -11,7 +11,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AbodeSystem from .const import DOMAIN @@ -19,7 +19,9 @@ from .entity import AbodeDevice async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode alarm control panel device.""" data: AbodeSystem = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index ca9679a5aaa..6f64fa46c0a 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.enum import try_parse_enum from . import AbodeSystem @@ -21,7 +21,9 @@ from .entity import AbodeDevice async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode binary sensor devices.""" data: AbodeSystem = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 58107f16462..3587c8c1799 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -15,7 +15,7 @@ from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle from . import AbodeSystem @@ -26,7 +26,9 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode camera devices.""" data: AbodeSystem = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py index b5b1e878b96..bc9df9d4a25 100644 --- a/homeassistant/components/abode/cover.py +++ b/homeassistant/components/abode/cover.py @@ -7,7 +7,7 @@ from jaraco.abode.devices.cover import Cover from homeassistant.components.cover import CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AbodeSystem from .const import DOMAIN @@ -15,7 +15,9 @@ from .entity import AbodeDevice async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode cover devices.""" data: AbodeSystem = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index e2d0a331f0a..9614e84ebad 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AbodeSystem from .const import DOMAIN @@ -26,7 +26,9 @@ from .entity import AbodeDevice async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode light devices.""" data: AbodeSystem = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py index ceff263e6b5..94832ed41f9 100644 --- a/homeassistant/components/abode/lock.py +++ b/homeassistant/components/abode/lock.py @@ -7,7 +7,7 @@ from jaraco.abode.devices.lock import Lock from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AbodeSystem from .const import DOMAIN @@ -15,7 +15,9 @@ from .entity import AbodeDevice async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode lock devices.""" data: AbodeSystem = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index d6a5389029b..ee168c16509 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AbodeSystem from .const import DOMAIN @@ -61,7 +61,9 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode sensor devices.""" data: AbodeSystem = hass.data[DOMAIN] diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index 7dad750c8d5..f6018d99fc8 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AbodeSystem from .const import DOMAIN @@ -20,7 +20,9 @@ DEVICE_TYPES = ["switch", "valve"] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode switch devices.""" data: AbodeSystem = hass.data[DOMAIN] diff --git a/homeassistant/components/acaia/binary_sensor.py b/homeassistant/components/acaia/binary_sensor.py index ecb7ac06eb5..d720488faa0 100644 --- a/homeassistant/components/acaia/binary_sensor.py +++ b/homeassistant/components/acaia/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AcaiaConfigEntry from .entity import AcaiaEntity @@ -40,7 +40,7 @@ BINARY_SENSORS: tuple[AcaiaBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AcaiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors.""" diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py index a41233bfc17..446f6134789 100644 --- a/homeassistant/components/acaia/button.py +++ b/homeassistant/components/acaia/button.py @@ -8,7 +8,7 @@ from aioacaia.acaiascale import AcaiaScale from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AcaiaConfigEntry from .entity import AcaiaEntity @@ -45,7 +45,7 @@ BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AcaiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button entities and services.""" diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py index 7ba44958eca..f62b93ddf1d 100644 --- a/homeassistant/components/acaia/sensor.py +++ b/homeassistant/components/acaia/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfMass, UnitOfVolumeFlowRate from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AcaiaConfigEntry from .entity import AcaiaEntity @@ -77,7 +77,7 @@ RESTORE_SENSORS: tuple[AcaiaSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AcaiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 001edc5f197..f14584cf08c 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -375,7 +375,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AccuWeatherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add AccuWeather entities from a config_entry.""" observation_coordinator: AccuWeatherObservationDataUpdateCoordinator = ( diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 7d754278d91..770f2b64f20 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utc_from_timestamp from .const import ( @@ -54,7 +54,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: AccuWeatherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a AccuWeather weather entity from a config_entry.""" async_add_entities([AccuWeatherEntity(entry.runtime_data)]) diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 77099e86adc..d09ba4bac08 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AcmedaConfigEntry from .const import ACMEDA_HUB_UPDATE @@ -22,7 +22,7 @@ from .helpers import async_add_acmeda_entities async def async_setup_entry( hass: HomeAssistant, config_entry: AcmedaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Acmeda Rollers from a config entry.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/acmeda/helpers.py b/homeassistant/components/acmeda/helpers.py index 52af7d586de..4c0f9b32cff 100644 --- a/homeassistant/components/acmeda/helpers.py +++ b/homeassistant/components/acmeda/helpers.py @@ -9,7 +9,7 @@ from aiopulse import Roller from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, LOGGER @@ -23,7 +23,7 @@ def async_add_acmeda_entities( entity_class: type, config_entry: AcmedaConfigEntry, current: set[int], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add any new entities.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index f5df1bf013d..515146f3d1a 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AcmedaConfigEntry from .const import ACMEDA_HUB_UPDATE @@ -17,7 +17,7 @@ from .helpers import async_add_acmeda_entities async def async_setup_entry( hass: HomeAssistant, config_entry: AcmedaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Acmeda Rollers from a config entry.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 15022ba3c9f..078640cd367 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL @@ -33,7 +33,7 @@ from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Adax thermostat with config flow.""" if entry.data.get(CONNECTION_TYPE) == LOCAL: diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index b2404a88278..f1af8ac32a4 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -12,7 +12,7 @@ from adguardhome import AdGuardHome from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdGuardConfigEntry, AdGuardData from .const import DOMAIN @@ -85,7 +85,7 @@ SENSORS: tuple[AdGuardHomeEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AdGuardConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdGuard Home sensor based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index 3ea4f9d1d93..5128102a955 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -11,7 +11,7 @@ from adguardhome import AdGuardHome, AdGuardHomeError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdGuardConfigEntry, AdGuardData from .const import DOMAIN, LOGGER @@ -79,7 +79,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AdGuardConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdGuard Home switch based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index 601b10aeb4a..dd306b82c8a 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity @@ -20,7 +20,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdvantageAir Binary Sensor platform.""" diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index d07a3182ed7..c023d4cf8f3 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .const import ( @@ -76,7 +76,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdvantageAir climate platform.""" diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index b091f0077a1..b5b982597f0 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -9,7 +9,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OPEN @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdvantageAir cover platform.""" diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index 7dd0a0a183b..ffd502663b0 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN @@ -16,7 +16,7 @@ from .models import AdvantageAirData async def async_setup_entry( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdvantageAir light platform.""" diff --git a/homeassistant/components/advantage_air/select.py b/homeassistant/components/advantage_air/select.py index 84c37f38d7f..320bfd35aba 100644 --- a/homeassistant/components/advantage_air/select.py +++ b/homeassistant/components/advantage_air/select.py @@ -2,7 +2,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .entity import AdvantageAirAcEntity @@ -14,7 +14,7 @@ ADVANTAGE_AIR_INACTIVE = "Inactive" async def async_setup_entry( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdvantageAir select platform.""" diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index ab1a1c4f9a0..2abcc3b5a68 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_OPEN @@ -32,7 +32,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdvantageAir sensor platform.""" diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 876875a2510..5c4528b44c6 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -4,7 +4,7 @@ from typing import Any from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .const import ( @@ -19,7 +19,7 @@ from .models import AdvantageAirData async def async_setup_entry( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdvantageAir switch platform.""" diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py index b639e4df867..92a162303dd 100644 --- a/homeassistant/components/advantage_air/update.py +++ b/homeassistant/components/advantage_air/update.py @@ -3,7 +3,7 @@ from homeassistant.components.update import UpdateEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN @@ -14,7 +14,7 @@ from .models import AdvantageAirData async def async_setup_entry( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AdvantageAir update platform.""" diff --git a/homeassistant/components/aemet/image.py b/homeassistant/components/aemet/image.py index ffc53022e4c..ba9986a5ccc 100644 --- a/homeassistant/components/aemet/image.py +++ b/homeassistant/components/aemet/image.py @@ -9,7 +9,7 @@ from aemet_opendata.helpers import dict_nested_value from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator from .entity import AemetEntity @@ -25,7 +25,7 @@ AEMET_IMAGES: Final[tuple[ImageEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AemetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AEMET OpenData image entities based on a config entry.""" domain_data = config_entry.runtime_data diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 88eb34b6f84..9077b2bc44d 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -52,7 +52,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import ( @@ -358,7 +358,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AemetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AEMET OpenData sensor entities based on a config entry.""" domain_data = config_entry.runtime_data diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index a156652eadd..3a17430300d 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONDITIONS_MAP from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator @@ -35,7 +35,7 @@ from .entity import AemetEntity async def async_setup_entry( hass: HomeAssistant, config_entry: AemetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AEMET OpenData weather entity based on a config entry.""" domain_data = config_entry.runtime_data diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index 085be2499d4..7e0c6f524ab 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle from . import AfterShipConfigEntry @@ -42,7 +42,7 @@ PLATFORM_SCHEMA: Final = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry( hass: HomeAssistant, config_entry: AfterShipConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AfterShip sensor entities based on a config entry.""" aftership = config_entry.runtime_data diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 23328315e42..1ac808c87ad 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -9,7 +9,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AgentDVRConfigEntry from .const import DOMAIN as AGENT_DOMAIN @@ -24,7 +24,7 @@ CONST_ALARM_CONTROL_PANEL_NAME = "Alarm Panel" async def async_setup_entry( hass: HomeAssistant, config_entry: AgentDVRConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Agent DVR Alarm Control Panels.""" async_add_entities([AgentBaseStation(config_entry.runtime_data)]) diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py index 933d0c6b40b..3de7f095b13 100644 --- a/homeassistant/components/agent_dvr/camera.py +++ b/homeassistant/components/agent_dvr/camera.py @@ -10,7 +10,7 @@ from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -39,7 +39,7 @@ CAMERA_SERVICES = { async def async_setup_entry( hass: HomeAssistant, config_entry: AgentDVRConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Agent cameras.""" filter_urllib3_logging() diff --git a/homeassistant/components/airgradient/button.py b/homeassistant/components/airgradient/button.py index ea7b12062e8..5e6f857f686 100644 --- a/homeassistant/components/airgradient/button.py +++ b/homeassistant/components/airgradient/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN @@ -47,7 +47,7 @@ LED_BAR_TEST = AirGradientButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: AirGradientConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AirGradient button entities based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py index 4265215fa25..eeb24867845 100644 --- a/homeassistant/components/airgradient/number.py +++ b/homeassistant/components/airgradient/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN @@ -60,7 +60,7 @@ LED_BAR_BRIGHTNESS = AirGradientNumberEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: AirGradientConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AirGradient number entities based on a config entry.""" diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py index 8c15102ad3a..f288055ebf4 100644 --- a/homeassistant/components/airgradient/select.py +++ b/homeassistant/components/airgradient/select.py @@ -14,7 +14,7 @@ from homeassistant.components.select import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE @@ -142,7 +142,7 @@ CONTROL_ENTITIES: tuple[AirGradientSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AirGradientConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AirGradient select entities based on a config entry.""" diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py index 3b20b31f923..a4944a6196e 100644 --- a/homeassistant/components/airgradient/sensor.py +++ b/homeassistant/components/airgradient/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import AirGradientConfigEntry @@ -225,7 +225,7 @@ CONFIG_DISPLAY_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ... async def async_setup_entry( hass: HomeAssistant, entry: AirGradientConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AirGradient sensor entities based on a config entry.""" diff --git a/homeassistant/components/airgradient/switch.py b/homeassistant/components/airgradient/switch.py index 55835fa30a6..0d404ed0f15 100644 --- a/homeassistant/components/airgradient/switch.py +++ b/homeassistant/components/airgradient/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN @@ -45,7 +45,7 @@ POST_DATA_TO_AIRGRADIENT = AirGradientSwitchEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: AirGradientConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AirGradient switch entities based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py index 12cec65f791..97cb8576e79 100644 --- a/homeassistant/components/airgradient/update.py +++ b/homeassistant/components/airgradient/update.py @@ -6,7 +6,7 @@ from propcache.api import cached_property from homeassistant.components.update import UpdateDeviceClass, UpdateEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AirGradientConfigEntry, AirGradientCoordinator from .entity import AirGradientEntity @@ -18,7 +18,7 @@ SCAN_INTERVAL = timedelta(hours=1) async def async_setup_entry( hass: HomeAssistant, config_entry: AirGradientConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Airgradient update platform.""" diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index fbf73ed753e..2aa99d9c792 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -175,7 +175,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AirlyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Airly sensor entities based on a config entry.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index c8b1c985c8b..db579de4976 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -130,7 +130,7 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AirNowConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AirNow sensor entities based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index c465d710406..08a344ae9f4 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AirQConfigEntry, AirQCoordinator @@ -399,7 +399,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: AirQConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities based on a config entry.""" diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 1b604d72032..a0d9c97c8c8 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -114,7 +114,7 @@ SENSORS: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: AirthingsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airthings sensor.""" diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 248561706a3..9c1a2af7a9f 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import ( RegistryEntry, async_entries_for_device, @@ -153,7 +153,7 @@ def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: async def async_setup_entry( hass: HomeAssistant, entry: AirthingsBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airthings BLE sensors.""" is_metric = hass.config.units is METRIC_SYSTEM diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 0af920bd7a9..6d393ed0c99 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AirTouch4ConfigEntry @@ -64,7 +64,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: AirTouch4ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airtouch 4.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py index 16566f5d664..f3b914bf341 100644 --- a/homeassistant/components/airtouch5/climate.py +++ b/homeassistant/components/airtouch5/climate.py @@ -37,7 +37,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Airtouch5ConfigEntry from .const import DOMAIN, FAN_INTELLIGENT_AUTO, FAN_TURBO @@ -93,7 +93,7 @@ FAN_MODE_TO_SET_AC_FAN_SPEED = { async def async_setup_entry( hass: HomeAssistant, config_entry: Airtouch5ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airtouch 5 Climate entities.""" client = config_entry.runtime_data diff --git a/homeassistant/components/airtouch5/cover.py b/homeassistant/components/airtouch5/cover.py index 62cf7938fc2..b811ed5c451 100644 --- a/homeassistant/components/airtouch5/cover.py +++ b/homeassistant/components/airtouch5/cover.py @@ -20,7 +20,7 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Airtouch5ConfigEntry from .const import DOMAIN @@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: Airtouch5ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Airtouch 5 Cover entities.""" client = config_entry.runtime_data diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 88a670edb82..1f406bd8f36 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import AirVisualConfigEntry @@ -108,7 +108,7 @@ POLLUTANT_UNITS = { async def async_setup_entry( hass: HomeAssistant, entry: AirVisualConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AirVisual sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 58ad730bc31..215370736fe 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AirVisualProConfigEntry from .entity import AirVisualProEntity @@ -130,7 +130,7 @@ def async_get_aqi_locale(settings: dict[str, Any]) -> str: async def async_setup_entry( hass: HomeAssistant, entry: AirVisualProConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AirVisual sensors based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index 48f6ce8fd94..7274df44261 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity @@ -76,7 +76,7 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, entry: AirzoneConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone binary sensors from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 23355a070ab..39e70e58e6d 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -48,7 +48,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API_TEMPERATURE_STEP, TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator @@ -100,7 +100,7 @@ HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = { async def async_setup_entry( hass: HomeAssistant, entry: AirzoneConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone climate from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 56a9b06ea21..c00e83f2c5b 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -25,7 +25,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity @@ -117,7 +117,7 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, entry: AirzoneConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone select from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index 0b5c5666c89..f76eb1466a3 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator @@ -79,7 +79,7 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, entry: AirzoneConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone sensors from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone/switch.py b/homeassistant/components/airzone/switch.py index 69bf33666a5..07278970e03 100644 --- a/homeassistant/components/airzone/switch.py +++ b/homeassistant/components/airzone/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity @@ -39,7 +39,7 @@ ZONE_SWITCH_TYPES: Final[tuple[AirzoneSwitchDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, entry: AirzoneConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone switch from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py index 2a1ca72db21..eb1537dc222 100644 --- a/homeassistant/components/airzone/water_heater.py +++ b/homeassistant/components/airzone/water_heater.py @@ -28,7 +28,7 @@ from homeassistant.components.water_heater import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator @@ -58,7 +58,7 @@ OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = { async def async_setup_entry( hass: HomeAssistant, entry: AirzoneConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone Water Heater from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 4a7b5441b68..64fa8cb5151 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -26,7 +26,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import ( @@ -111,7 +111,7 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, entry: AirzoneCloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone Cloud binary sensors from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 69b10d2a69e..115f6e32dbf 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -56,7 +56,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import ( @@ -119,7 +119,7 @@ HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = { async def async_setup_entry( hass: HomeAssistant, entry: AirzoneCloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone climate from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone_cloud/select.py b/homeassistant/components/airzone_cloud/select.py index e0c595a80e8..816544efdf8 100644 --- a/homeassistant/components/airzone_cloud/select.py +++ b/homeassistant/components/airzone_cloud/select.py @@ -21,7 +21,7 @@ from aioairzone_cloud.const import ( from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity @@ -89,7 +89,7 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, entry: AirzoneCloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone Cloud select from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index 4b13e09d126..43526c3aa52 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -47,7 +47,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import ( @@ -221,7 +221,7 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, entry: AirzoneCloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone Cloud sensors from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone_cloud/switch.py b/homeassistant/components/airzone_cloud/switch.py index 8de0685e15e..ab703cd537a 100644 --- a/homeassistant/components/airzone_cloud/switch.py +++ b/homeassistant/components/airzone_cloud/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity @@ -38,7 +38,7 @@ ZONE_SWITCH_TYPES: Final[tuple[AirzoneSwitchDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, entry: AirzoneCloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone Cloud switch from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/airzone_cloud/water_heater.py b/homeassistant/components/airzone_cloud/water_heater.py index 381dce913fe..41d43002569 100644 --- a/homeassistant/components/airzone_cloud/water_heater.py +++ b/homeassistant/components/airzone_cloud/water_heater.py @@ -29,7 +29,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator from .entity import AirzoneHotWaterEntity @@ -68,7 +68,7 @@ OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = { async def async_setup_entry( hass: HomeAssistant, entry: AirzoneCloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Airzone Cloud Water Heater from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index d7092bbe1c4..52687f04bf9 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AlarmDecoderConfigEntry from .const import ( @@ -36,7 +36,7 @@ ATTR_KEYPRESS = "keypress" async def async_setup_entry( hass: HomeAssistant, entry: AlarmDecoderConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up for AlarmDecoder alarm panels.""" options = entry.options diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py index 1234c9f349b..b025da70d59 100644 --- a/homeassistant/components/alarmdecoder/binary_sensor.py +++ b/homeassistant/components/alarmdecoder/binary_sensor.py @@ -5,7 +5,7 @@ import logging from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AlarmDecoderConfigEntry from .const import ( @@ -40,7 +40,7 @@ ATTR_RF_LOOP1 = "rf_loop1" async def async_setup_entry( hass: HomeAssistant, entry: AlarmDecoderConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up for AlarmDecoder sensor.""" diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py index f5e744457fd..8fdc6d57c67 100644 --- a/homeassistant/components/alarmdecoder/sensor.py +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -3,7 +3,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AlarmDecoderConfigEntry from .const import SIGNAL_PANEL_MESSAGE @@ -13,7 +13,7 @@ from .entity import AlarmDecoderEntity async def async_setup_entry( hass: HomeAssistant, entry: AlarmDecoderConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up for AlarmDecoder sensor.""" diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py index 66292ea5524..3ee27f19849 100644 --- a/homeassistant/components/amberelectric/binary_sensor.py +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION @@ -85,7 +85,7 @@ class AmberDemandWindowBinarySensor(AmberPriceGridSensor): async def async_setup_entry( hass: HomeAssistant, entry: AmberConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 49d6e5f4eac..7276ddb26a5 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, UnitOfEnergy from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION @@ -196,7 +196,7 @@ class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity): async def async_setup_entry( hass: HomeAssistant, entry: AmberConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py index 9d262e5a987..eff99503cc8 100644 --- a/homeassistant/components/ambient_network/sensor.py +++ b/homeassistant/components/ambient_network/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import AmbientNetworkConfigEntry, AmbientNetworkDataUpdateCoordinator @@ -270,7 +270,7 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: AmbientNetworkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ambient Network sensor entities.""" diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index a79788a4c38..9a7c89db95e 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_NAME, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AmbientStationConfigEntry from .const import ATTR_LAST_DATA @@ -381,7 +381,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: AmbientStationConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ambient PWS binary sensors based on a config entry.""" ambient = entry.runtime_data diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index dfbd2d1b4a0..d1ac39ba01a 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AmbientStation, AmbientStationConfigEntry from .const import ATTR_LAST_DATA, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX @@ -662,7 +662,7 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: AmbientStationConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ambient PWS sensors based on a config entry.""" ambient = entry.runtime_data diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index 324ca6991d2..830d914b311 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -94,7 +94,7 @@ GENERAL_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the entries.""" diff --git a/homeassistant/components/android_ip_webcam/binary_sensor.py b/homeassistant/components/android_ip_webcam/binary_sensor.py index 1846889bfda..67816664752 100644 --- a/homeassistant/components/android_ip_webcam/binary_sensor.py +++ b/homeassistant/components/android_ip_webcam/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import MOTION_ACTIVE from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordinator @@ -24,7 +24,7 @@ BINARY_SENSOR_DESCRIPTION = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: AndroidIPCamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the IP Webcam sensors from config entry.""" diff --git a/homeassistant/components/android_ip_webcam/camera.py b/homeassistant/components/android_ip_webcam/camera.py index 95d4fb9f67a..833b9a0d296 100644 --- a/homeassistant/components/android_ip_webcam/camera.py +++ b/homeassistant/components/android_ip_webcam/camera.py @@ -11,7 +11,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordinator @@ -20,7 +20,7 @@ from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordina async def async_setup_entry( hass: HomeAssistant, config_entry: AndroidIPCamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the IP Webcam camera from config entry.""" filter_urllib3_logging() diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index 9b2454d6c09..e9d5f8514e8 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordinator @@ -119,7 +119,7 @@ SENSOR_TYPES: tuple[AndroidIPWebcamSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AndroidIPCamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the IP Webcam sensors from config entry.""" diff --git a/homeassistant/components/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py index f813415df0b..3ceaf6e59b9 100644 --- a/homeassistant/components/android_ip_webcam/switch.py +++ b/homeassistant/components/android_ip_webcam/switch.py @@ -11,7 +11,7 @@ from pydroid_ipcam import PyDroidIPCam from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordinator from .entity import AndroidIPCamBaseEntity @@ -112,7 +112,7 @@ SWITCH_TYPES: tuple[AndroidIPWebcamSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AndroidIPCamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the IP Webcam switches from config entry.""" diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 728411ddf42..c9e62908cac 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_COMMAND from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from . import AndroidTVConfigEntry @@ -65,7 +65,7 @@ ANDROIDTV_STATES = { async def async_setup_entry( hass: HomeAssistant, entry: AndroidTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Android Debug Bridge entity.""" device_class = entry.runtime_data.aftv.DEVICE_CLASS diff --git a/homeassistant/components/androidtv/remote.py b/homeassistant/components/androidtv/remote.py index db48b0cf1b6..026d1485e07 100644 --- a/homeassistant/components/androidtv/remote.py +++ b/homeassistant/components/androidtv/remote.py @@ -12,7 +12,7 @@ from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_TURN_OFF_COMMAND, CONF_TURN_ON_COMMAND, DOMAIN from .entity import AndroidTVEntity, adb_decorator @@ -21,7 +21,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AndroidTV remote from a config entry.""" async_add_entities([AndroidTVRemote(entry)]) diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index cdc307a0472..3d3a97092bc 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -18,7 +18,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AndroidTVRemoteConfigEntry from .const import CONF_APP_ICON, CONF_APP_NAME @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: AndroidTVRemoteConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Android TV media player entity based on a config entry.""" api = config_entry.runtime_data diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index c9a261c8735..212b0491d2d 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -18,7 +18,7 @@ from homeassistant.components.remote import ( RemoteEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AndroidTVRemoteConfigEntry from .const import CONF_APP_NAME @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: AndroidTVRemoteConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Android TV remote entity based on a config entry.""" api = config_entry.runtime_data diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index 7365e4597ba..c3a3d3861f2 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import AnovaConfigEntry, AnovaCoordinator @@ -97,7 +97,7 @@ SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: AnovaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Anova device.""" anova_data = entry.runtime_data @@ -108,7 +108,7 @@ async def async_setup_entry( def setup_coordinator( coordinator: AnovaCoordinator, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an individual Anova Coordinator.""" diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index be5a6ad2258..cfbd3c29547 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_MAC, CONF_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthemavConfigEntry from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: AnthemavConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" name = config_entry.title diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index b479ee4409c..9f513509ce7 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -24,7 +24,7 @@ from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, device_registry as dr, intent, llm -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry from .const import ( @@ -46,7 +46,7 @@ MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( hass: HomeAssistant, config_entry: AnthropicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" agent = AnthropicConversationEntity(config_entry) diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py index 8a7a98115fa..071407e7b17 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfEnergy from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ( AOSmithConfigEntry, @@ -43,7 +43,7 @@ STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AOSmithConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up A. O. Smith sensor platform.""" data = entry.runtime_data diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index 110f997065b..d29b00955b6 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -15,7 +15,7 @@ from homeassistant.components.water_heater import ( from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AOSmithConfigEntry, AOSmithStatusCoordinator from .entity import AOSmithStatusEntity @@ -45,7 +45,7 @@ DEFAULT_OPERATION_MODE_PRIORITY = [ async def async_setup_entry( hass: HomeAssistant, entry: AOSmithConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up A. O. Smith water heater platform.""" data = entry.runtime_data diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index 2a44845618e..f3829b41f61 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator @@ -27,7 +27,7 @@ _VALUE_ONLINE_MASK: Final = 0b1000 async def async_setup_entry( hass: HomeAssistant, config_entry: APCUPSdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an APCUPSd Online Status binary sensor.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index b3c396daf5e..02016efa4ca 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import LAST_S_TEST @@ -406,7 +406,7 @@ INFERRED_UNITS = { async def async_setup_entry( hass: HomeAssistant, config_entry: APCUPSdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the APCUPSd sensors from config entries.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 8a2336eea3b..b68d74e6115 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -39,7 +39,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import AppleTvConfigEntry, AppleTVManager @@ -100,7 +100,7 @@ SUPPORT_FEATURE_MAPPING = { async def async_setup_entry( hass: HomeAssistant, config_entry: AppleTvConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Apple TV media player based on a config entry.""" name: str = config_entry.data[CONF_NAME] diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 7f2c9f1b591..97e31bd4bb0 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -17,7 +17,7 @@ from homeassistant.components.remote import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AppleTvConfigEntry from .entity import AppleTVEntity @@ -38,7 +38,7 @@ COMMAND_TO_ATTRIBUTE = { async def async_setup_entry( hass: HomeAssistant, config_entry: AppleTvConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Apple TV remote based on a config entry.""" name: str = config_entry.data[CONF_NAME] diff --git a/homeassistant/components/aprilaire/climate.py b/homeassistant/components/aprilaire/climate.py index 194453046e6..5116e06b58f 100644 --- a/homeassistant/components/aprilaire/climate.py +++ b/homeassistant/components/aprilaire/climate.py @@ -18,7 +18,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( FAN_CIRCULATE, @@ -63,7 +63,7 @@ FAN_MODE_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: AprilaireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add climates for passed config_entry in HA.""" diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py index 8a173e5e95e..fdb9233a0e3 100644 --- a/homeassistant/components/aprilaire/humidifier.py +++ b/homeassistant/components/aprilaire/humidifier.py @@ -15,7 +15,7 @@ from homeassistant.components.humidifier import ( HumidifierEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import AprilaireConfigEntry, AprilaireCoordinator @@ -40,7 +40,7 @@ DEHUMIDIFIER_ACTION_MAP: dict[StateType, HumidifierAction] = { async def async_setup_entry( hass: HomeAssistant, config_entry: AprilaireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Aprilaire humidifier devices.""" diff --git a/homeassistant/components/aprilaire/select.py b/homeassistant/components/aprilaire/select.py index d8f6137f53d..c38c9e94501 100644 --- a/homeassistant/components/aprilaire/select.py +++ b/homeassistant/components/aprilaire/select.py @@ -10,7 +10,7 @@ from pyaprilaire.const import Attribute from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AprilaireConfigEntry, AprilaireCoordinator from .entity import BaseAprilaireEntity @@ -24,7 +24,7 @@ FRESH_AIR_MODE_MAP = {0: "off", 1: "automatic"} async def async_setup_entry( hass: HomeAssistant, config_entry: AprilaireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Aprilaire select devices.""" diff --git a/homeassistant/components/aprilaire/sensor.py b/homeassistant/components/aprilaire/sensor.py index e1909746364..bf3bd12f43d 100644 --- a/homeassistant/components/aprilaire/sensor.py +++ b/homeassistant/components/aprilaire/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import AprilaireConfigEntry, AprilaireCoordinator @@ -75,7 +75,7 @@ def get_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: AprilaireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Aprilaire sensor devices.""" diff --git a/homeassistant/components/apsystems/binary_sensor.py b/homeassistant/components/apsystems/binary_sensor.py index 863a50ca455..202d878014d 100644 --- a/homeassistant/components/apsystems/binary_sensor.py +++ b/homeassistant/components/apsystems/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ApSystemsConfigEntry, ApSystemsData, ApSystemsDataCoordinator @@ -63,7 +63,7 @@ BINARY_SENSORS: tuple[ApsystemsLocalApiBinarySensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ApSystemsConfigEntry, - add_entities: AddEntitiesCallback, + add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform.""" config = config_entry.runtime_data diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py index f7bdc7c2711..b43b21f2b71 100644 --- a/homeassistant/components/apsystems/number.py +++ b/homeassistant/components/apsystems/number.py @@ -7,7 +7,7 @@ from aiohttp import ClientConnectorError from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType from .coordinator import ApSystemsConfigEntry, ApSystemsData @@ -17,7 +17,7 @@ from .entity import ApSystemsEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ApSystemsConfigEntry, - add_entities: AddEntitiesCallback, + add_entities: AddConfigEntryEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" diff --git a/homeassistant/components/apsystems/sensor.py b/homeassistant/components/apsystems/sensor.py index 673dba05acc..6e654cfbf61 100644 --- a/homeassistant/components/apsystems/sensor.py +++ b/homeassistant/components/apsystems/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -109,7 +109,7 @@ SENSORS: tuple[ApsystemsLocalApiSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ApSystemsConfigEntry, - add_entities: AddEntitiesCallback, + add_entities: AddConfigEntryEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index 2d3b0cfd08f..e1017f95448 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -9,7 +9,7 @@ from APsystemsEZ1 import InverterReturnedError from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ApSystemsConfigEntry, ApSystemsData from .entity import ApSystemsEntity @@ -18,7 +18,7 @@ from .entity import ApSystemsEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ApSystemsConfigEntry, - add_entities: AddEntitiesCallback, + add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch platform.""" diff --git a/homeassistant/components/aquacell/sensor.py b/homeassistant/components/aquacell/sensor.py index a76d26244ad..77cd3cdd60a 100644 --- a/homeassistant/components/aquacell/sensor.py +++ b/homeassistant/components/aquacell/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import AquacellConfigEntry, AquacellCoordinator @@ -83,7 +83,7 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AquacellConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" softeners = config_entry.runtime_data.data diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index b5187cba1f4..c5750de1c12 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AranetConfigEntry from .const import ARANET_MANUFACTURER_NAME @@ -176,7 +176,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: AranetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Aranet sensors.""" processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 7a133777a0a..cd4ed7bbb05 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ArcamFmjConfigEntry from .const import ( @@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ArcamFmjConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" diff --git a/homeassistant/components/arve/sensor.py b/homeassistant/components/arve/sensor.py index 64d9f6f8874..dea7110f611 100644 --- a/homeassistant/components/arve/sensor.py +++ b/homeassistant/components/arve/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ArveConfigEntry from .entity import ArveDeviceEntity @@ -83,7 +83,9 @@ SENSORS: tuple[ArveDeviceEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ArveConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ArveConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Arve device based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index c8cc31dc795..15c72614ee1 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AsekoConfigEntry from .entity import AsekoEntity @@ -37,7 +37,7 @@ BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AsekoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Aseko Pool Live binary sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index 3fe7cdd5272..f9a7287a9f1 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import AsekoConfigEntry @@ -86,7 +86,7 @@ SENSORS: list[AsekoSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: AsekoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Aseko Pool Live sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 95d2e4c8000..ee6c3f96fc4 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AsusWrtConfigEntry from .router import AsusWrtDevInfo, AsusWrtRouter @@ -18,7 +18,7 @@ DEFAULT_DEVICE_NAME = "Unknown device" async def async_setup_entry( hass: HomeAssistant, entry: AsusWrtConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for AsusWrt component.""" router = entry.runtime_data @@ -38,7 +38,9 @@ async def async_setup_entry( @callback def add_entities( - router: AsusWrtRouter, async_add_entities: AddEntitiesCallback, tracked: set[str] + router: AsusWrtRouter, + async_add_entities: AddConfigEntryEntitiesCallback, + tracked: set[str], ) -> None: """Add new tracker entities from the router.""" new_tracked = [] diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index fb43e574379..c4bd5e4bded 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -246,7 +246,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AsusWrtConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" router = entry.runtime_data diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index a362b71fbc8..8f1ded150f1 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -14,7 +14,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.enum import try_parse_enum from .coordinator import AtagConfigEntry, AtagDataUpdateCoordinator @@ -32,7 +32,9 @@ HVAC_MODES = [HVACMode.AUTO, HVACMode.HEAT] async def async_setup_entry( - hass: HomeAssistant, entry: AtagConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AtagConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load a config entry.""" async_add_entities([AtagThermostat(entry.runtime_data, "climate")]) diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py index bd39f0b3458..ca5bbd5e614 100644 --- a/homeassistant/components/atag/sensor.py +++ b/homeassistant/components/atag/sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AtagConfigEntry, AtagDataUpdateCoordinator from .entity import AtagEntity @@ -28,7 +28,7 @@ SENSORS = { async def async_setup_entry( hass: HomeAssistant, config_entry: AtagConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize sensor platform from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py index 6b013b36885..00761f47324 100644 --- a/homeassistant/components/atag/water_heater.py +++ b/homeassistant/components/atag/water_heater.py @@ -9,7 +9,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AtagConfigEntry from .entity import AtagEntity @@ -20,7 +20,7 @@ OPERATION_LIST = [STATE_OFF, STATE_ECO, STATE_PERFORMANCE] async def async_setup_entry( hass: HomeAssistant, config_entry: AtagConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize DHW device from config entry.""" async_add_entities( diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index fb877252010..b4c440599c4 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from . import AugustConfigEntry, AugustData @@ -92,7 +92,7 @@ SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, config_entry: AugustConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the August binary sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py index 79f2b67888a..4971d0cccf5 100644 --- a/homeassistant/components/august/button.py +++ b/homeassistant/components/august/button.py @@ -2,7 +2,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AugustConfigEntry from .entity import AugustEntity @@ -11,7 +11,7 @@ from .entity import AugustEntity async def async_setup_entry( hass: HomeAssistant, config_entry: AugustConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up August lock wake buttons.""" data = config_entry.runtime_data diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index f4398455256..7b013022299 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -12,7 +12,7 @@ from yalexs.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AugustConfigEntry, AugustData from .const import DEFAULT_NAME, DEFAULT_TIMEOUT @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: AugustConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up August cameras.""" data = config_entry.runtime_data diff --git a/homeassistant/components/august/event.py b/homeassistant/components/august/event.py index 49b14630337..0abc840bc69 100644 --- a/homeassistant/components/august/event.py +++ b/homeassistant/components/august/event.py @@ -16,7 +16,7 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AugustConfigEntry, AugustData from .entity import AugustDescriptionEntity @@ -59,7 +59,7 @@ TYPES_DOORBELL: tuple[AugustEventEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AugustConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the august event platform.""" data = config_entry.runtime_data diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index c681cc98808..4a37149772a 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -14,7 +14,7 @@ from yalexs.util import get_latest_activity, update_lock_detail_from_activity from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -29,7 +29,7 @@ LOCK_JAMMED_ERR = 531 async def async_setup_entry( hass: HomeAssistant, config_entry: AugustConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up August locks.""" data = config_entry.runtime_data diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index b7c0d618492..94a5461149f 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AugustConfigEntry from .const import ( @@ -82,7 +82,7 @@ SENSOR_TYPE_KEYPAD_BATTERY = AugustSensorEntityDescription[KeypadDetail]( async def async_setup_entry( hass: HomeAssistant, config_entry: AugustConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the August sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index 648f6de08c9..73e732dc44a 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AuroraConfigEntry from .entity import AuroraEntity @@ -13,7 +13,7 @@ from .entity import AuroraEntity async def async_setup_entry( hass: HomeAssistant, entry: AuroraConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary_sensor platform.""" async_add_entities( diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index ec1b82c3c4d..d424b7e98ab 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AuroraConfigEntry from .entity import AuroraEntity @@ -14,7 +14,7 @@ from .entity import AuroraEntity async def async_setup_entry( hass: HomeAssistant, entry: AuroraConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 29d5cab2667..d35d8a2d8cb 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -130,7 +130,7 @@ SENSOR_TYPES = [ async def async_setup_entry( hass: HomeAssistant, config_entry: AuroraAbbConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up aurora_abb_powerone sensor based on a config entry.""" diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index 49da78da8de..41a2f164095 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfInformation, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -123,7 +123,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AussieBroadbandConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Aussie Broadband sensor platform from a config entry.""" diff --git a/homeassistant/components/autarco/sensor.py b/homeassistant/components/autarco/sensor.py index b7c4312815b..1635adefdb8 100644 --- a/homeassistant/components/autarco/sensor.py +++ b/homeassistant/components/autarco/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -172,7 +172,7 @@ SENSORS_INVERTER: tuple[AutarcoInverterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AutarcoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Autarco sensors based on a config entry.""" entities: list[SensorEntity] = [] diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index c92009d9b1b..a0c4b5ba8fe 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -133,7 +133,7 @@ SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AwairConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Awair sensor entity based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index d6f132874b6..6933380c094 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from . import AxisConfigEntry @@ -178,7 +178,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AxisConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Axis binary sensor.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index a5a00bcd1ab..089a018ee5b 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -7,7 +7,7 @@ from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.const import HTTP_DIGEST_AUTHENTICATION from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AxisConfigEntry from .const import DEFAULT_STREAM_PROFILE, DEFAULT_VIDEO_SOURCE @@ -18,7 +18,7 @@ from .hub import AxisHub async def async_setup_entry( hass: HomeAssistant, config_entry: AxisConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Axis camera video stream.""" filter_urllib3_logging() diff --git a/homeassistant/components/axis/light.py b/homeassistant/components/axis/light.py index d0d144a28fa..0c6015efced 100644 --- a/homeassistant/components/axis/light.py +++ b/homeassistant/components/axis/light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( LightEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AxisConfigEntry from .entity import TOPIC_TO_EVENT_TYPE, AxisEventDescription, AxisEventEntity @@ -46,7 +46,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AxisConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Axis light platform.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py index 17824302871..55250b5f489 100644 --- a/homeassistant/components/axis/switch.py +++ b/homeassistant/components/axis/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AxisConfigEntry from .entity import AxisEventDescription, AxisEventEntity @@ -39,7 +39,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: AxisConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Axis switch platform.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index 1d590032a85..55c821a119e 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -145,7 +145,7 @@ def parse_datetime(value: str | None) -> datetime | None: async def async_setup_entry( hass: HomeAssistant, entry: AzureDevOpsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Azure DevOps sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py index 7c855711712..e12bfd8b90c 100644 --- a/homeassistant/components/baf/binary_sensor.py +++ b/homeassistant/components/baf/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BAFConfigEntry from .entity import BAFDescriptionEntity @@ -41,7 +41,7 @@ OCCUPANCY_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, entry: BAFConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BAF binary sensors.""" device = entry.runtime_data diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py index c30d49e8c9d..abcc2afe254 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BAFConfigEntry from .entity import BAFEntity @@ -21,7 +21,7 @@ from .entity import BAFEntity async def async_setup_entry( hass: HomeAssistant, entry: BAFConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BAF fan auto comfort.""" device = entry.runtime_data diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index 8f7aab40b79..c990a248588 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -14,7 +14,7 @@ from homeassistant.components.fan import ( FanEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -28,7 +28,7 @@ from .entity import BAFEntity async def async_setup_entry( hass: HomeAssistant, entry: BAFConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SenseME fans.""" device = entry.runtime_data diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py index 4c0b1e353fe..e8298a8e4d4 100644 --- a/homeassistant/components/baf/light.py +++ b/homeassistant/components/baf/light.py @@ -13,7 +13,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BAFConfigEntry from .entity import BAFEntity @@ -22,7 +22,7 @@ from .entity import BAFEntity async def async_setup_entry( hass: HomeAssistant, entry: BAFConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BAF lights.""" device = entry.runtime_data diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index a2e5e704e4d..87b5cdc095b 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BAFConfigEntry from .const import HALF_DAY_SECS, ONE_DAY_SECS, ONE_MIN_SECS, SPEED_RANGE @@ -116,7 +116,7 @@ LIGHT_NUMBER_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: BAFConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BAF numbers.""" device = entry.runtime_data diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index 7e664254a38..e9b8965b7c4 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BAFConfigEntry from .entity import BAFDescriptionEntity @@ -93,7 +93,7 @@ FAN_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, entry: BAFConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BAF fan sensors.""" device = entry.runtime_data diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py index e18e26ddcaa..50bd90a6107 100644 --- a/homeassistant/components/baf/switch.py +++ b/homeassistant/components/baf/switch.py @@ -11,7 +11,7 @@ from aiobafi6 import Device from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BAFConfigEntry from .entity import BAFDescriptionEntity @@ -103,7 +103,7 @@ LIGHT_SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, entry: BAFConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BAF fan switches.""" device = entry.runtime_data diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index b8c62ce8abf..437a01866b8 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BalboaConfigEntry from .entity import BalboaEntity @@ -22,7 +22,7 @@ from .entity import BalboaEntity async def async_setup_entry( hass: HomeAssistant, entry: BalboaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the spa's binary sensors.""" spa = entry.runtime_data diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index 76b02f0e165..3fb2457d610 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BalboaConfigEntry from .const import DOMAIN @@ -47,7 +47,7 @@ TEMPERATURE_UNIT_MAP = { async def async_setup_entry( hass: HomeAssistant, entry: BalboaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the spa climate entity.""" async_add_entities([BalboaClimateEntity(entry.runtime_data)]) diff --git a/homeassistant/components/balboa/fan.py b/homeassistant/components/balboa/fan.py index 3ecfec53a1e..b0d4379594b 100644 --- a/homeassistant/components/balboa/fan.py +++ b/homeassistant/components/balboa/fan.py @@ -10,7 +10,7 @@ from pybalboa.enums import OffOnState, UnknownState from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -23,7 +23,7 @@ from .entity import BalboaEntity async def async_setup_entry( hass: HomeAssistant, entry: BalboaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the spa's pumps.""" spa = entry.runtime_data diff --git a/homeassistant/components/balboa/light.py b/homeassistant/components/balboa/light.py index 21e4dfc5e08..2f48747c084 100644 --- a/homeassistant/components/balboa/light.py +++ b/homeassistant/components/balboa/light.py @@ -9,7 +9,7 @@ from pybalboa.enums import OffOnState, UnknownState from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BalboaConfigEntry from .entity import BalboaEntity @@ -18,7 +18,7 @@ from .entity import BalboaEntity async def async_setup_entry( hass: HomeAssistant, entry: BalboaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the spa's lights.""" spa = entry.runtime_data diff --git a/homeassistant/components/balboa/select.py b/homeassistant/components/balboa/select.py index e88e40ab063..ea82760744c 100644 --- a/homeassistant/components/balboa/select.py +++ b/homeassistant/components/balboa/select.py @@ -5,7 +5,7 @@ from pybalboa.enums import LowHighRange from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BalboaConfigEntry from .entity import BalboaEntity @@ -14,7 +14,7 @@ from .entity import BalboaEntity async def async_setup_entry( hass: HomeAssistant, entry: BalboaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the spa select entity.""" spa = entry.runtime_data diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index 99e5c8bb6fd..91e04b92330 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -6,7 +6,7 @@ from homeassistant.components.event import EventDeviceClass, EventEntity from homeassistant.const import CONF_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BangOlufsenConfigEntry from .const import ( @@ -25,7 +25,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: BangOlufsenConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensor entities from config entry.""" diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 282ecdd2ae5..efb6843356b 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -64,7 +64,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.util.dt import utcnow @@ -118,7 +118,7 @@ BANG_OLUFSEN_FEATURES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BangOlufsenConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Media Player entity from config entry.""" # Add MediaPlayer entity diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py index 2aa86059ee2..b9032e6e705 100644 --- a/homeassistant/components/blebox/binary_sensor.py +++ b/homeassistant/components/blebox/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BleBoxConfigEntry from .entity import BleBoxEntity @@ -24,7 +24,7 @@ BINARY_SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BleBoxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a BleBox entry.""" entities = [ diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py index 90356c8ae14..15867f84029 100644 --- a/homeassistant/components/blebox/button.py +++ b/homeassistant/components/blebox/button.py @@ -6,7 +6,7 @@ import blebox_uniapi.button from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BleBoxConfigEntry from .entity import BleBoxEntity @@ -15,7 +15,7 @@ from .entity import BleBoxEntity async def async_setup_entry( hass: HomeAssistant, config_entry: BleBoxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a BleBox button entry.""" entities = [ diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index 2c528d50e3e..dbf4a326990 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BleBoxConfigEntry from .entity import BleBoxEntity @@ -38,7 +38,7 @@ BLEBOX_TO_HVACACTION = { async def async_setup_entry( hass: HomeAssistant, config_entry: BleBoxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a BleBox climate entity.""" entities = [ diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 4f2a7eeef11..c52c551bbac 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -16,7 +16,7 @@ from homeassistant.components.cover import ( CoverState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BleBoxConfigEntry from .entity import BleBoxEntity @@ -45,7 +45,7 @@ BLEBOX_TO_HASS_COVER_STATES = { async def async_setup_entry( hass: HomeAssistant, config_entry: BleBoxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a BleBox entry.""" entities = [ diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index c3c9de8be51..86ec8993779 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -21,7 +21,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import BleBoxConfigEntry @@ -35,7 +35,7 @@ SCAN_INTERVAL = timedelta(seconds=5) async def async_setup_entry( hass: HomeAssistant, config_entry: BleBoxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a BleBox entry.""" entities = [ diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index c0abff31257..5120a7a3c98 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BleBoxConfigEntry from .entity import BleBoxEntity @@ -116,7 +116,7 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BleBoxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a BleBox entry.""" entities = [ diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py index c6f439e27c5..1598d4db6fa 100644 --- a/homeassistant/components/blebox/switch.py +++ b/homeassistant/components/blebox/switch.py @@ -7,7 +7,7 @@ import blebox_uniapi.switch from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BleBoxConfigEntry from .entity import BleBoxEntity @@ -18,7 +18,7 @@ SCAN_INTERVAL = timedelta(seconds=5) async def async_setup_entry( hass: HomeAssistant, config_entry: BleBoxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a BleBox switch entity.""" entities = [ diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index bfb8aa9a3a0..17fd003742f 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: BlinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Blink Alarm Control Panels.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index c11d4cfea23..3d5430c8d1f 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -48,7 +48,7 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BlinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the blink binary sensors.""" diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index e35dd20eea7..04bd125d249 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -38,7 +38,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: BlinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Blink Camera.""" diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index e0b5989cc80..1df708c3a10 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH @@ -47,7 +47,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BlinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize a Blink sensor.""" diff --git a/homeassistant/components/blink/switch.py b/homeassistant/components/blink/switch.py index 8eabd5c0e59..4f490e28310 100644 --- a/homeassistant/components/blink/switch.py +++ b/homeassistant/components/blink/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_BRAND, DOMAIN, TYPE_CAMERA_ARMED @@ -30,7 +30,7 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BlinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Blink switches.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/blue_current/sensor.py b/homeassistant/components/blue_current/sensor.py index be39e9571ec..9ea444f9ec2 100644 --- a/homeassistant/components/blue_current/sensor.py +++ b/homeassistant/components/blue_current/sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BlueCurrentConfigEntry, Connector from .const import DOMAIN @@ -212,7 +212,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: BlueCurrentConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Blue Current sensors.""" connector = entry.runtime_data diff --git a/homeassistant/components/bluemaestro/sensor.py b/homeassistant/components/bluemaestro/sensor.py index 57702d4ff31..1163f8a1ff6 100644 --- a/homeassistant/components/bluemaestro/sensor.py +++ b/homeassistant/components/bluemaestro/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import BlueMaestroConfigEntry @@ -116,7 +116,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: BlueMaestroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the BlueMaestro BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 6bb3c101cd1..135d1b5d27e 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -32,7 +32,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -61,7 +61,7 @@ POLL_TIMEOUT = 120 async def async_setup_entry( hass: HomeAssistant, config_entry: BluesoundConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Bluesound entry.""" bluesound_player = BluesoundPlayer( diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 5a58c707d6a..01cdbbdc94d 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_system import UnitSystem from . import BMWConfigEntry @@ -200,7 +200,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BMWConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the BMW binary sensors from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index a7c31d0ef79..f8980201f3f 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -14,7 +14,7 @@ from bimmer_connected.vehicle.remote_services import RemoteServiceStatus from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .entity import BMWBaseEntity @@ -69,7 +69,7 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BMWConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the BMW buttons from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 74df8693f7a..23273cc8ba9 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -9,7 +9,7 @@ from bimmer_connected.vehicle import MyBMWVehicle from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BMWConfigEntry from .const import ATTR_DIRECTION @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: BMWConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the MyBMW tracker from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 4bec12e796b..9d8965d6ebf 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -12,7 +12,7 @@ from bimmer_connected.vehicle.doors_windows import LockState from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: BMWConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the MyBMW lock from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index c6a328ecc20..8361306ba9d 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -16,7 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator @@ -58,7 +58,7 @@ NUMBER_TYPES: list[BMWNumberEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: BMWConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the MyBMW number from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 385b45fd9fa..f144d3a71df 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -13,7 +13,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.const import UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator @@ -65,7 +65,7 @@ SELECT_TYPES: tuple[BMWSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BMWConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the MyBMW lock from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index b7be367d57d..114412ef9f2 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import BMWConfigEntry @@ -190,7 +190,7 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: BMWConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the MyBMW sensors from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index 600ad41165a..f46969f3e9b 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -12,7 +12,7 @@ from bimmer_connected.vehicle.fuel_and_battery import ChargingState from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry from .coordinator import BMWDataUpdateCoordinator @@ -66,7 +66,7 @@ NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: BMWConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the MyBMW switch from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 42915c7dc0b..47c8356d08e 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -8,7 +8,7 @@ from bond_async import Action from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BondConfigEntry from .entity import BondEntity @@ -257,7 +257,7 @@ BUTTONS: tuple[BondButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: BondConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Bond button devices.""" data = entry.runtime_data diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index 66344a1913d..d2a78819fae 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -13,7 +13,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BondConfigEntry from .entity import BondEntity @@ -34,7 +34,7 @@ def _hass_to_bond_position(hass_position: int) -> int: async def async_setup_entry( hass: HomeAssistant, entry: BondConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Bond cover devices.""" data = entry.runtime_data diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 76a0daa46f9..c228c7355dd 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -19,7 +19,7 @@ from homeassistant.components.fan import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -40,7 +40,7 @@ PRESET_MODE_BREEZE = "Breeze" async def async_setup_entry( hass: HomeAssistant, entry: BondConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Bond fan devices.""" data = entry.runtime_data diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index c3cf23e4fad..9c51165ebdb 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BondConfigEntry from .const import ( @@ -42,7 +42,7 @@ ENTITY_SERVICES = [ async def async_setup_entry( hass: HomeAssistant, entry: BondConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Bond light devices.""" data = entry.runtime_data diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index ace6d307e6d..fa2ccd2ca93 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BondConfigEntry from .const import ATTR_POWER_STATE, SERVICE_SET_POWER_TRACKED_STATE @@ -22,7 +22,7 @@ from .entity import BondEntity async def async_setup_entry( hass: HomeAssistant, entry: BondConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Bond generic devices.""" data = entry.runtime_data diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index dd0f31ea6f9..30d823fd608 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BoschConfigEntry from .entity import SHCEntity @@ -19,7 +19,7 @@ from .entity import SHCEntity async def async_setup_entry( hass: HomeAssistant, config_entry: BoschConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SHC binary sensor platform.""" session = config_entry.runtime_data diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index 55d6bfc35de..766dcf37ce9 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BoschConfigEntry from .entity import SHCEntity @@ -20,7 +20,7 @@ from .entity import SHCEntity async def async_setup_entry( hass: HomeAssistant, config_entry: BoschConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SHC cover platform.""" session = config_entry.runtime_data diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 6408e21654e..885908804c0 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import BoschConfigEntry @@ -126,7 +126,7 @@ SENSOR_DESCRIPTIONS: dict[str, SHCSensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: BoschConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SHC sensor platform.""" session = config_entry.runtime_data diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 76b1da3e534..bf1d5d39ee5 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -21,7 +21,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import BoschConfigEntry @@ -79,7 +79,7 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: BoschConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SHC switch platform.""" session = config_entry.runtime_data diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 626e5a225b7..20250949bcb 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -12,7 +12,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator from .entity import BraviaTVEntity @@ -44,7 +44,7 @@ BUTTONS: tuple[BraviaTVButtonDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BraviaTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Bravia TV Button entities.""" diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index ca48c6ee639..fe9c386b060 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -16,7 +16,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SourceType from .coordinator import BraviaTVConfigEntry @@ -26,7 +26,7 @@ from .entity import BraviaTVEntity async def async_setup_entry( hass: HomeAssistant, config_entry: BraviaTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Bravia TV Media Player from a config_entry.""" diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py index 9f4a573827b..0611e367445 100644 --- a/homeassistant/components/braviatv/remote.py +++ b/homeassistant/components/braviatv/remote.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import BraviaTVConfigEntry from .entity import BraviaTVEntity @@ -16,7 +16,7 @@ from .entity import BraviaTVEntity async def async_setup_entry( hass: HomeAssistant, config_entry: BraviaTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Bravia TV Remote from a config entry.""" diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index 699dba9015a..08d06b596b8 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -9,7 +9,7 @@ from bring_api import ActivityType, BringList from homeassistant.components.event import EventEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BringConfigEntry from .coordinator import BringDataUpdateCoordinator @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: BringConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the event platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index bfe93619dbb..2a09d574607 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator @@ -85,7 +85,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: BringConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 4de306273f3..d1eb9e78341 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -24,7 +24,7 @@ from homeassistant.components.todo import ( 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 +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_ITEM_NAME, @@ -41,7 +41,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: BringConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py index 25a6bbd60a5..5be04c24f0d 100644 --- a/homeassistant/components/broadlink/climate.py +++ b/homeassistant/components/broadlink/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PRECISION_HALVES, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, DOMAINS_AND_TYPES from .device import BroadlinkDevice @@ -31,7 +31,7 @@ class SensorMode(IntEnum): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink climate entities.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index 39d6caaa49f..64698e57249 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import BroadlinkEntity @@ -29,7 +29,7 @@ BROADLINK_COLOR_MODE_SCENES = 2 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink light.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 18a3a82017c..c1196b03310 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -37,7 +37,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, STATE_OFF from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util @@ -92,7 +92,7 @@ SERVICE_DELETE_SCHEMA = COMMAND_SCHEMA.extend( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Broadlink remote.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] diff --git a/homeassistant/components/broadlink/select.py b/homeassistant/components/broadlink/select.py index 6253adc308a..661fc62600d 100644 --- a/homeassistant/components/broadlink/select.py +++ b/homeassistant/components/broadlink/select.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BroadlinkDevice from .const import DOMAIN @@ -28,7 +28,7 @@ DAY_NAME_TO_ID = {v: k for k, v in DAY_ID_TO_NAME.items()} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink select.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index b7ae71ff803..e7d420f0c0e 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import BroadlinkEntity @@ -86,7 +86,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink sensor.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 9098440a5c4..d6869ac4c9c 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -30,7 +30,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -85,7 +88,7 @@ async def async_setup_platform( if switches := config.get(CONF_SWITCHES): platform_data = hass.data[DOMAIN].platforms.get(Platform.SWITCH, {}) - async_add_entities_config_entry: AddEntitiesCallback + async_add_entities_config_entry: AddConfigEntryEntitiesCallback device: BroadlinkDevice async_add_entities_config_entry, device = platform_data.get( mac_addr, (None, None) @@ -111,7 +114,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink switch.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] diff --git a/homeassistant/components/broadlink/time.py b/homeassistant/components/broadlink/time.py index 3dcb045fead..4687df6b8b6 100644 --- a/homeassistant/components/broadlink/time.py +++ b/homeassistant/components/broadlink/time.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.time import TimeEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import BroadlinkDevice @@ -19,7 +19,7 @@ from .entity import BroadlinkEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink time.""" device = hass.data[DOMAIN].devices[config_entry.entry_id] diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 087a971f928..a09fe8ebc60 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -303,7 +303,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: BrotherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Brother entities from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index 6725a32bb40..60f9a8163de 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_APP_ID, CONF_AREA, DOMAIN, LOGGER @@ -21,7 +21,9 @@ SCAN_INTERVAL = timedelta(minutes=30) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Brottsplatskartan sensor entry.""" diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index bb97f42bd36..95931d3449e 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -16,7 +16,7 @@ from homeassistant.components.cover import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -34,7 +34,7 @@ from .coordinator import BruntConfigEntry, BruntCoordinator async def async_setup_entry( hass: HomeAssistant, entry: BruntConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the brunt platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/bryant_evolution/climate.py b/homeassistant/components/bryant_evolution/climate.py index 2d54ced8217..bd053229a1a 100644 --- a/homeassistant/components/bryant_evolution/climate.py +++ b/homeassistant/components/bryant_evolution/climate.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BryantEvolutionConfigEntry, names from .const import CONF_SYSTEM_ZONE, DOMAIN @@ -31,7 +31,7 @@ SCAN_INTERVAL = timedelta(seconds=60) async def async_setup_entry( hass: HomeAssistant, config_entry: BryantEvolutionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 2833d6549b4..bef0388a57d 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -19,7 +19,7 @@ from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.enum import try_parse_enum from . import BSBLanConfigEntry, BSBLanData @@ -43,7 +43,7 @@ PRESET_MODES = [ async def async_setup_entry( hass: HomeAssistant, entry: BSBLanConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BSBLAN device based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index c13b4ad7650..6a6784a4542 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import BSBLanConfigEntry, BSBLanData @@ -51,7 +51,7 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: BSBLanConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BSB-Lan sensor based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index 318408a9124..a3aee4cdc15 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BSBLanConfigEntry, BSBLanData from .const import DOMAIN @@ -37,7 +37,7 @@ OPERATION_MODES_REVERSE = {v: k for k, v in OPERATION_MODES.items()} async def async_setup_entry( hass: HomeAssistant, entry: BSBLanConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BSBLAN water heater based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py index bcc420df4a8..97ed85c1204 100644 --- a/homeassistant/components/bthome/binary_sensor.py +++ b/homeassistant/components/bthome/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .coordinator import BTHomePassiveBluetoothDataProcessor @@ -169,7 +169,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: BTHomeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the BTHome BLE binary sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/bthome/event.py b/homeassistant/components/bthome/event.py index a6ee79f4e05..99799819e43 100644 --- a/homeassistant/components/bthome/event.py +++ b/homeassistant/components/bthome/event.py @@ -12,7 +12,7 @@ from homeassistant.components.event import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import format_discovered_event_class, format_event_dispatcher_name from .const import ( @@ -104,7 +104,7 @@ class BTHomeEventEntity(EventEntity): async def async_setup_entry( hass: HomeAssistant, entry: BTHomeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up BTHome event.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 23a058b0b0c..7025929abd8 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -42,7 +42,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .coordinator import BTHomePassiveBluetoothDataProcessor @@ -423,7 +423,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: BTHomeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the BTHome BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 45ff2d6de52..15d08281911 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -13,7 +13,7 @@ from homeassistant.components.camera import Camera from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import BuienRadarConfigEntry @@ -31,7 +31,7 @@ SUPPORTED_COUNTRY_CODES = ["NL", "BE"] async def async_setup_entry( hass: HomeAssistant, entry: BuienRadarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buienradar radar-loop camera component.""" config = entry.data diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 712f765237e..f9a110586ba 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -45,7 +45,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import BuienRadarConfigEntry @@ -691,7 +691,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: BuienRadarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the buienradar sensor.""" config = entry.data diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 8b71032bace..4b71024c241 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -51,7 +51,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BuienRadarConfigEntry from .const import DEFAULT_TIMEFRAME @@ -94,7 +94,7 @@ CONDITION_MAP = { async def async_setup_entry( hass: HomeAssistant, entry: BuienRadarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the buienradar platform.""" config = entry.data diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 7a426112d04..be909a02ea5 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -25,7 +25,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -143,7 +146,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: CalDavConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the CalDav calendar platform for a config entry.""" calendars = await async_get_calendars(hass, entry.runtime_data, SUPPORTED_COMPONENT) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index cbd7963b595..fada4693cf0 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -20,7 +20,7 @@ from homeassistant.components.todo import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import CalDavConfigEntry @@ -46,7 +46,7 @@ TODO_STATUS_MAP_INV: dict[TodoItemStatus, str] = { async def async_setup_entry( hass: HomeAssistant, entry: CalDavConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the CalDav todo platform for a config entry.""" calendars = await async_get_calendars(hass, entry.runtime_data, SUPPORTED_COMPONENT) diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index 042178d5781..d18898fa916 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -23,7 +23,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import CambridgeAudioConfigEntry, media_browser from .const import ( @@ -65,7 +65,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: CambridgeAudioConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Cambridge Audio device based on a config entry.""" client: StreamMagicClient = entry.runtime_data diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py index 6bfe83c2539..e7d9136711f 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -9,7 +9,7 @@ from aiostreammagic.models import DisplayBrightness from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import CambridgeAudioConfigEntry from .entity import CambridgeAudioEntity, command @@ -82,7 +82,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: CambridgeAudioConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Cambridge Audio select entities based on a config entry.""" diff --git a/homeassistant/components/cambridge_audio/switch.py b/homeassistant/components/cambridge_audio/switch.py index 065a1da4f94..0cebe8266c4 100644 --- a/homeassistant/components/cambridge_audio/switch.py +++ b/homeassistant/components/cambridge_audio/switch.py @@ -9,7 +9,7 @@ from aiostreammagic import StreamMagicClient from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import CambridgeAudioConfigEntry from .entity import CambridgeAudioEntity, command @@ -46,7 +46,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: CambridgeAudioConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Cambridge Audio switch entities based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 443944da8c3..9fe2dfb598d 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -13,7 +13,7 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator @@ -22,7 +22,7 @@ from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: CanaryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Canary alarm control panels based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 8f4a01c9968..07645f2f403 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -48,7 +48,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: CanaryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Canary sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 22f3eada2cb..d92166926e9 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -64,7 +64,7 @@ STATE_AIR_QUALITY_VERY_ABNORMAL: Final = "very_abnormal" async def async_setup_entry( hass: HomeAssistant, entry: CanaryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Canary sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 3cc17fae43b..8ff078dfafd 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -51,7 +51,7 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.network import NoURLAvailableError, get_url, is_hass_url from homeassistant.util import dt as dt_util from homeassistant.util.logging import async_create_catching_coro @@ -140,7 +140,7 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Cast from a config entry.""" hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set()) diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index 099b91ec02c..df321395b9e 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -20,7 +20,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONST_CMD_FAN_MAP, CONST_CMD_STATE_MAP, DOMAIN @@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: CCM15ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all climate.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index a875e664fdd..3854dfc109e 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -7,7 +7,7 @@ from datetime import datetime from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CertExpiryConfigEntry, CertExpiryDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import CertExpiryEntity async def async_setup_entry( hass: HomeAssistant, entry: CertExpiryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add cert-expiry entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/chacon_dio/cover.py b/homeassistant/components/chacon_dio/cover.py index 3a4955adf5c..ea80116be8a 100644 --- a/homeassistant/components/chacon_dio/cover.py +++ b/homeassistant/components/chacon_dio/cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ChaconDioConfigEntry from .entity import ChaconDioEntity @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ChaconDioConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Chacon Dio cover devices.""" data = config_entry.runtime_data diff --git a/homeassistant/components/chacon_dio/switch.py b/homeassistant/components/chacon_dio/switch.py index be178c3c3b5..05b55552615 100644 --- a/homeassistant/components/chacon_dio/switch.py +++ b/homeassistant/components/chacon_dio/switch.py @@ -7,7 +7,7 @@ from dio_chacon_wifi_api.const import DeviceTypeEnum from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ChaconDioConfigEntry from .entity import ChaconDioEntity @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ChaconDioConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Chacon Dio switch devices.""" data = config_entry.runtime_data diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index 75cbd3c9f3d..0df13fe4c7b 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .client import CloudClient from .const import DATA_CLOUD, DISPATCHER_REMOTE_UPDATE @@ -26,7 +26,7 @@ WAIT_UNTIL_CHANGE = 3 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Home Assistant Cloud binary sensors.""" cloud = hass.data[DATA_CLOUD] diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index b2154448d3a..df377c9a410 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -22,7 +22,7 @@ from homeassistant.components.stt import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_when_setup from .assist_pipeline import async_migrate_cloud_pipeline_engine @@ -35,7 +35,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Home Assistant Cloud speech platform via config entry.""" stt_platform_loaded = hass.data[DATA_PLATFORMS_SETUP][Platform.STT] diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 63f36554c65..3ac3f3d1c2d 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant, async_get_hass, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_when_setup @@ -256,7 +256,7 @@ async def async_get_engine( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Home Assistant Cloud text-to-speech platform.""" tts_platform_loaded = hass.data[DATA_PLATFORMS_SETUP][Platform.TTS] diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 92f88b8ae82..a8e962532b8 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN @@ -54,7 +54,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, entry: CO2SignalConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the CO2signal sensor.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 37509160247..578877e7d90 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -7,7 +7,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import CoinbaseConfigEntry, CoinbaseData from .const import ( @@ -45,7 +45,7 @@ ATTRIBUTION = "Data provided by coinbase.com" async def async_setup_entry( hass: HomeAssistant, config_entry: CoinbaseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Coinbase sensor platform.""" instance = config_entry.runtime_data diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index f694c2b392b..6ea4e97f12e 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitVedoSystem @@ -48,7 +48,7 @@ ALARM_AREA_ARMED_STATUS: dict[str, int] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Comelit VEDO system alarm control panel devices.""" diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index fa51e0b1fda..a895f8dc511 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitVedoSystem @@ -21,7 +21,7 @@ from .coordinator import ComelitConfigEntry, ComelitVedoSystem async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Comelit VEDO presence sensors.""" diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 1baa777bf99..6906c9bf735 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -17,7 +17,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge @@ -71,7 +71,7 @@ MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Comelit climates.""" diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index abb84824621..64412569f95 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -9,7 +9,7 @@ from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -19,7 +19,7 @@ from .coordinator import ComelitConfigEntry, ComelitSerialBridge async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Comelit covers.""" diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index d8058074c16..5daf2297782 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -18,7 +18,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -55,7 +55,7 @@ MODE_TO_ACTION: dict[str, HumidifierComelitCommand] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Comelit humidifiers.""" diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 9736c9ac2a0..45f4146ece6 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -9,7 +9,7 @@ from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge @@ -18,7 +18,7 @@ from .coordinator import ComelitConfigEntry, ComelitSerialBridge async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Comelit lights.""" diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index efb2418244e..9200d99262f 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_TYPE, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -42,7 +42,7 @@ SENSOR_VEDO_TYPES: Final = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Comelit sensors.""" @@ -55,7 +55,7 @@ async def async_setup_entry( async def async_setup_bridge_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Comelit Bridge sensors.""" @@ -75,7 +75,7 @@ async def async_setup_bridge_entry( async def async_setup_vedo_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Comelit VEDO sensors.""" diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 26d3b81ebde..e89ee74c1be 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -9,7 +9,7 @@ from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge @@ -18,7 +18,7 @@ from .coordinator import ComelitConfigEntry, ComelitSerialBridge async def async_setup_entry( hass: HomeAssistant, config_entry: ComelitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Comelit switches.""" diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index cedfbeb49c3..d2d0f85f476 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category @@ -36,7 +36,7 @@ CONTROL4_DIMMER_VARS = ["LIGHT_LEVEL", "Brightness Percent"] async def async_setup_entry( hass: HomeAssistant, entry: Control4ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Control4 lights from a config entry.""" runtime_data = entry.runtime_data diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index bd8e3fb38fe..824ce431aea 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -19,7 +19,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import Control4ConfigEntry, Control4RuntimeData @@ -77,7 +77,7 @@ async def get_rooms(hass: HomeAssistant, entry: Control4ConfigEntry): async def async_setup_entry( hass: HomeAssistant, entry: Control4ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Control4 rooms from a config entry.""" runtime_data = entry.runtime_data diff --git a/homeassistant/components/cookidoo/button.py b/homeassistant/components/cookidoo/button.py index b292a7309ba..97136deb031 100644 --- a/homeassistant/components/cookidoo/button.py +++ b/homeassistant/components/cookidoo/button.py @@ -8,7 +8,7 @@ from cookidoo_api import Cookidoo, CookidooException from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator @@ -35,7 +35,7 @@ TODO_CLEAR = CookidooButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: CookidooConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Cookidoo button entities based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/cookidoo/sensor.py b/homeassistant/components/cookidoo/sensor.py index 7fbacea18bc..6df41383a75 100644 --- a/homeassistant/components/cookidoo/sensor.py +++ b/homeassistant/components/cookidoo/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -73,7 +73,7 @@ SENSOR_DESCRIPTIONS: tuple[CookidooSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: CookidooConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py index 3d5264f4e01..c577b845657 100644 --- a/homeassistant/components/cookidoo/todo.py +++ b/homeassistant/components/cookidoo/todo.py @@ -18,7 +18,7 @@ from homeassistant.components.todo import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: CookidooConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the todo list from a config entry created in the integrations UI.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/coolmaster/binary_sensor.py b/homeassistant/components/coolmaster/binary_sensor.py index ab2718b9352..5c1f19fd14c 100644 --- a/homeassistant/components/coolmaster/binary_sensor.py +++ b/homeassistant/components/coolmaster/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import CoolmasterConfigEntry from .entity import CoolmasterEntity @@ -18,7 +18,7 @@ from .entity import CoolmasterEntity async def async_setup_entry( hass: HomeAssistant, config_entry: CoolmasterConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the CoolMasterNet binary_sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/coolmaster/button.py b/homeassistant/components/coolmaster/button.py index 5463566d1ef..7cc8fc56c80 100644 --- a/homeassistant/components/coolmaster/button.py +++ b/homeassistant/components/coolmaster/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import CoolmasterConfigEntry from .entity import CoolmasterEntity @@ -14,7 +14,7 @@ from .entity import CoolmasterEntity async def async_setup_entry( hass: HomeAssistant, config_entry: CoolmasterConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the CoolMasterNet button platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index cd1659e1666..52fdfaaca3f 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_SUPPORTED_MODES from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator @@ -39,7 +39,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: CoolmasterConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the CoolMasterNet climate platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/coolmaster/sensor.py b/homeassistant/components/coolmaster/sensor.py index 2b835565bae..32dceb83c5f 100644 --- a/homeassistant/components/coolmaster/sensor.py +++ b/homeassistant/components/coolmaster/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import CoolmasterConfigEntry from .entity import CoolmasterEntity @@ -14,7 +14,7 @@ from .entity import CoolmasterEntity async def async_setup_entry( hass: HomeAssistant, config_entry: CoolmasterConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the CoolMasterNet sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index 6a14f7ad13f..11f683b1434 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfFrequency from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -24,7 +24,7 @@ HZ_ADVERTISED = "hz_advertised" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" async_add_entities([CPUSpeedSensor(entry)], True) diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py index 70b7631fe6b..dc29ad93072 100644 --- a/homeassistant/components/crownstone/light.py +++ b/homeassistant/components/crownstone/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CROWNSTONE_INCLUDE_TYPES, @@ -30,7 +30,7 @@ from .helpers import map_from_to async def async_setup_entry( hass: HomeAssistant, config_entry: CrownstoneConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up crownstones from a config entry.""" manager = config_entry.runtime_data diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 06ee0a03860..648a65c0d30 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -21,7 +21,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_INSIDE_TEMPERATURE, @@ -83,7 +83,7 @@ DAIKIN_ATTR_ADVANCED = "adv" async def async_setup_entry( hass: HomeAssistant, entry: DaikinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" daikin_api = entry.runtime_data diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 982aac1f3f2..c1aa28fbe67 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_COMPRESSOR_FREQUENCY, @@ -134,7 +134,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: DaikinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" daikin_api = entry.runtime_data diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 8a3a15d367f..20a56ac321c 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import DaikinConfigEntry, DaikinCoordinator from .entity import DaikinEntity @@ -19,7 +19,7 @@ DAIKIN_ATTR_MODE = "mode" async def async_setup_entry( hass: HomeAssistant, entry: DaikinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Daikin climate based on config_entry.""" daikin_api = entry.runtime_data diff --git a/homeassistant/components/deako/light.py b/homeassistant/components/deako/light.py index 75b01935c9a..12f42c36f29 100644 --- a/homeassistant/components/deako/light.py +++ b/homeassistant/components/deako/light.py @@ -7,7 +7,7 @@ from pydeako import Deako from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeakoConfigEntry from .const import DOMAIN @@ -20,7 +20,7 @@ MODEL_DIMMER = "dimmer" async def async_setup_entry( hass: HomeAssistant, config: DeakoConfigEntry, - add_entities: AddEntitiesCallback, + add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure the platform.""" client = config.runtime_data diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 94f4cd1ddd6..85ca32d76e6 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -17,7 +17,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .entity import DeconzDevice @@ -48,7 +48,7 @@ def get_alarm_system_id_for_unique_id(hub: DeconzHub, unique_id: str) -> str | N async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the deCONZ alarm control panel devices.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index e3b0fc2f2c0..fcbb61a4e4f 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_TEMPERATURE, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .const import ATTR_DARK, ATTR_ON @@ -161,7 +161,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzBinarySensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the deCONZ binary sensor.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index 9fea1d02ab8..1d96f9867a7 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .entity import DeconzDevice, DeconzSceneMixin @@ -47,7 +47,7 @@ ENTITY_DESCRIPTIONS = { async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the deCONZ button entity.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index aa274e6c0c1..26597c195e7 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -30,7 +30,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE @@ -77,7 +77,7 @@ DECONZ_TO_PRESET_MODE = {value: key for key, value in PRESET_MODE_TO_DECONZ.item async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the deCONZ climate devices.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 6dee00248ff..d68e0fec09c 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .entity import DeconzDevice @@ -34,7 +34,7 @@ DECONZ_TYPE_TO_DEVICE_CLASS = { async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up covers for deCONZ component.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index aec078f771f..324ada807e0 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -13,7 +13,7 @@ from homeassistant.components.fan import ( FanEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -34,7 +34,7 @@ ORDERED_NAMED_FAN_SPEEDS: list[LightFanSpeed] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up fans for deCONZ component.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 72ba7035c8e..b61a1d39333 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -30,7 +30,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import ( color_hs_to_xy, color_temperature_kelvin_to_mired, @@ -142,7 +142,7 @@ def update_color_state( async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the deCONZ lights and groups from a config entry.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index e5e2faf1d57..77b9ea435c7 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -10,7 +10,7 @@ from pydeconz.models.sensor.door_lock import DoorLock from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .entity import DeconzDevice @@ -19,7 +19,7 @@ from .entity import DeconzDevice async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up locks for deCONZ component.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index 9de86c1c79b..d5ba8cc28d5 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -19,7 +19,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .entity import DeconzDevice @@ -70,7 +70,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the deCONZ number entity.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index 3f29b12b05f..0aff2b3ca8c 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -8,7 +8,7 @@ from pydeconz.models.event import EventType from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, Scene from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .entity import DeconzSceneMixin @@ -17,7 +17,7 @@ from .entity import DeconzSceneMixin async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up scenes for deCONZ integration.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/select.py b/homeassistant/components/deconz/select.py index a3109a278fc..4d92b465cdc 100644 --- a/homeassistant/components/deconz/select.py +++ b/homeassistant/components/deconz/select.py @@ -14,7 +14,7 @@ from pydeconz.models.sensor.presence import ( from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .entity import DeconzDevice @@ -30,7 +30,7 @@ DECONZ_TO_SENSITIVITY = {value: key for key, value in SENSITIVITY_TO_DECONZ.item async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the deCONZ button entity.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 3003fb1008d..d318db6e2bf 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -50,7 +50,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -332,7 +332,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the deCONZ sensors.""" hub = config_entry.runtime_data @@ -468,7 +468,7 @@ class DeconzBatteryTracker: sensor_id: str, hub: DeconzHub, description: DeconzSensorDescription, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tracker.""" self.sensor = hub.api.sensors[sensor_id] diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py index 28b606e30ba..4c15cf8ccfe 100644 --- a/homeassistant/components/deconz/siren.py +++ b/homeassistant/components/deconz/siren.py @@ -14,7 +14,7 @@ from homeassistant.components.siren import ( SirenEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .entity import DeconzDevice @@ -23,7 +23,7 @@ from .entity import DeconzDevice async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sirens for deCONZ component.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index cd28871e35b..49904642804 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -9,7 +9,7 @@ from pydeconz.models.light.light import Light from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeconzConfigEntry from .const import POWER_PLUGS @@ -19,7 +19,7 @@ from .entity import DeconzDevice async def async_setup_entry( hass: HomeAssistant, config_entry: DeconzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for deCONZ component. diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 24d5ce9ec61..d6809967703 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import STATE_IDLE, Platform, UnitOfDataRate from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DelugeGetSessionStatusKeys, DelugeSensorType @@ -116,7 +116,7 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: DelugeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Deluge sensor.""" async_add_entities( diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index 1ec0cd7a7df..342442ee727 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import DelugeConfigEntry, DelugeDataUpdateCoordinator from .entity import DelugeEntity @@ -16,7 +16,7 @@ from .entity import DelugeEntity async def async_setup_entry( hass: HomeAssistant, entry: DelugeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Deluge switch.""" async_add_entities([DelugeSwitch(entry.runtime_data)]) diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py index 551f2c8e88a..4e247812efe 100644 --- a/homeassistant/components/demo/air_quality.py +++ b/homeassistant/components/demo/air_quality.py @@ -5,13 +5,13 @@ from __future__ import annotations from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index d34830042d7..64474b4beb6 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -9,13 +9,13 @@ from homeassistant.components.manual.alarm_control_panel import ManualAlarm from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ARMING_TIME, CONF_DELAY_TIME, CONF_TRIGGER_TIME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py index bc1d7b9daf2..b210e726205 100644 --- a/homeassistant/components/demo/binary_sensor.py +++ b/homeassistant/components/demo/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -17,7 +17,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo binary sensor platform.""" async_add_entities( diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py index a3b8dd9ff0c..25212f38989 100644 --- a/homeassistant/components/demo/button.py +++ b/homeassistant/components/demo/button.py @@ -7,7 +7,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -15,7 +15,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo button platform.""" async_add_entities( diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py index 4e2fa7b3460..b0e82acfa61 100644 --- a/homeassistant/components/demo/calendar.py +++ b/homeassistant/components/demo/calendar.py @@ -7,14 +7,14 @@ import datetime from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo Calendar config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 9fae6468207..69ba7efda01 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -7,13 +7,13 @@ from pathlib import Path from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index d5b763caa5a..f68714695f3 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -27,7 +27,7 @@ SUPPORT_FLAGS = ClimateEntityFeature(0) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo climate platform.""" async_add_entities( diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index adddb6a3a7d..ed13f24cfd7 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_utc_time_change from . import DOMAIN @@ -24,7 +24,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo cover platform.""" async_add_entities( diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py index b67c4248123..875075a381d 100644 --- a/homeassistant/components/demo/date.py +++ b/homeassistant/components/demo/date.py @@ -8,7 +8,7 @@ from homeassistant.components.date import DateEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -16,7 +16,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo date platform.""" async_add_entities( diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py index 920bc14cdc5..353ed8311bb 100644 --- a/homeassistant/components/demo/datetime.py +++ b/homeassistant/components/demo/datetime.py @@ -8,7 +8,7 @@ from homeassistant.components.datetime import DateTimeEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -16,7 +16,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo datetime platform.""" async_add_entities( diff --git a/homeassistant/components/demo/event.py b/homeassistant/components/demo/event.py index c58b5f5fc2e..f593a833123 100644 --- a/homeassistant/components/demo/event.py +++ b/homeassistant/components/demo/event.py @@ -6,7 +6,7 @@ from homeassistant.components.event import EventDeviceClass, EventEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -14,7 +14,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo event platform.""" async_add_entities([DemoEvent()]) diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 42e7f9e2434..9f48628688e 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback PRESET_MODE_AUTO = "auto" PRESET_MODE_SMART = "smart" @@ -29,7 +29,7 @@ LIMITED_SUPPORT = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py index 7245d96eaf0..2bdbd22eef8 100644 --- a/homeassistant/components/demo/humidifier.py +++ b/homeassistant/components/demo/humidifier.py @@ -12,7 +12,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback SUPPORT_FLAGS = HumidifierEntityFeature(0) @@ -20,7 +20,7 @@ SUPPORT_FLAGS = HumidifierEntityFeature(0) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo humidifier devices config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index ec98a056b3e..c00f2b42828 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -22,7 +22,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -39,7 +39,7 @@ SUPPORT_DEMO_HS_WHITE = {ColorMode.HS, ColorMode.WHITE} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo light platform.""" async_add_entities( diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index 1f25445af7f..081e1cf1d53 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature, LockState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback LOCK_UNLOCK_DELAY = 2 # Used to give a realistic lock/unlock experience in frontend @@ -16,7 +16,7 @@ LOCK_UNLOCK_DELAY = 2 # Used to give a realistic lock/unlock experience in fron async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index fa3c3e3b2fc..de2a2cb3937 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -15,14 +15,14 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/notify.py b/homeassistant/components/demo/notify.py index 7524517e6e8..d26e13cc541 100644 --- a/homeassistant/components/demo/notify.py +++ b/homeassistant/components/demo/notify.py @@ -10,7 +10,7 @@ from homeassistant.components.notify import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback EVENT_NOTIFY = "notify" @@ -18,7 +18,7 @@ EVENT_NOTIFY = "notify" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo entity platform.""" async_add_entities([DemoNotifyEntity(unique_id="notify", device_name="Notifier")]) diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 8c3f5ec3477..c7b62bdc3e0 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -15,7 +15,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo number platform.""" async_add_entities( diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index 774f375dd27..ffd6fd6e609 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -9,13 +9,13 @@ from homeassistant.components.remote import RemoteEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py index ff664a31d2f..fce90bc9b4f 100644 --- a/homeassistant/components/demo/select.py +++ b/homeassistant/components/demo/select.py @@ -6,7 +6,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -14,7 +14,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo select platform.""" async_add_entities( diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 0c61faae00e..ae9ff26eca9 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from . import DOMAIN @@ -33,7 +33,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo sensor platform.""" async_add_entities( diff --git a/homeassistant/components/demo/siren.py b/homeassistant/components/demo/siren.py index 235d98f5875..ddaa5101e0f 100644 --- a/homeassistant/components/demo/siren.py +++ b/homeassistant/components/demo/siren.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.siren import SirenEntity, SirenEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback SUPPORT_FLAGS = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON @@ -15,7 +15,7 @@ SUPPORT_FLAGS = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo siren devices config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 95eebe44588..1757e4a8b88 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -17,7 +17,7 @@ from homeassistant.components.stt import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback SUPPORT_LANGUAGES = ["en", "de"] @@ -25,7 +25,7 @@ SUPPORT_LANGUAGES = ["en", "de"] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Demo speech platform via config entry.""" async_add_entities([DemoProviderEntity()]) diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index 5dc05398bf1..dd288f285af 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -16,7 +16,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo switch platform.""" async_add_entities( diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py index 1730f414fdf..3219821ef98 100644 --- a/homeassistant/components/demo/text.py +++ b/homeassistant/components/demo/text.py @@ -6,7 +6,7 @@ from homeassistant.components.text import TextEntity, TextMode from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -14,7 +14,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo text platform.""" async_add_entities( diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py index f5f0322f9be..296155e9bec 100644 --- a/homeassistant/components/demo/time.py +++ b/homeassistant/components/demo/time.py @@ -8,7 +8,7 @@ from homeassistant.components.time import TimeEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -16,7 +16,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo time platform.""" async_add_entities([DemoTime("time", "Time", time(12, 0, 0), False)]) diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py index 3fa037f6b02..916646416e9 100644 --- a/homeassistant/components/demo/update.py +++ b/homeassistant/components/demo/update.py @@ -13,7 +13,7 @@ from homeassistant.components.update import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -23,7 +23,7 @@ FAKE_INSTALL_SLEEP_TIME = 0.5 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up demo update platform.""" async_add_entities( diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 3dd945ab82e..38019cff3c1 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -14,7 +14,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import event -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF @@ -63,7 +63,7 @@ DEMO_VACUUM_NONE = "4_Fourth_floor" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py index f295780b190..7bc558a2ae4 100644 --- a/homeassistant/components/demo/water_heater.py +++ b/homeassistant/components/demo/water_heater.py @@ -11,7 +11,7 @@ from homeassistant.components.water_heater import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback SUPPORT_FLAGS_HEATER = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE @@ -24,7 +24,7 @@ SUPPORT_FLAGS_HEATER = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index 2468c54dde3..d1f829fee1b 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -26,7 +26,7 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import dt as dt_util @@ -58,7 +58,7 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=30) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 818d530ddab..a67c76f6525 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -39,7 +39,7 @@ from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DenonavrConfigEntry from .const import ( @@ -109,7 +109,7 @@ DENON_STATE_MAPPING = { async def async_setup_entry( hass: HomeAssistant, config_entry: DenonavrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DenonAVR receiver from a config entry.""" entities = [] diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 988da5e938b..90f8a95919d 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -28,7 +28,10 @@ from homeassistant.core import Event, EventStateChangedData, HomeAssistant, call 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 -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -83,7 +86,7 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Derivative config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py index 04ec58723cf..6fa6d40e17d 100644 --- a/homeassistant/components/devialet/media_player.py +++ b/homeassistant/components/devialet/media_player.py @@ -12,7 +12,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, SOUND_MODES @@ -38,7 +38,7 @@ DEVIALET_TO_HA_FEATURE_MAP = { async def async_setup_entry( hass: HomeAssistant, entry: DevialetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Devialet entry.""" async_add_entities([DevialetMediaPlayerEntity(entry.runtime_data)]) diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index d24033a80b9..7a88b12c48a 100644 --- a/homeassistant/components/devolo_home_control/binary_sensor.py +++ b/homeassistant/components/devolo_home_control/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry from .entity import DevoloDeviceEntity @@ -29,7 +29,7 @@ DEVICE_CLASS_MAPPING = { async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all binary sensor and multi level sensor devices and setup them via config entry.""" entities: list[BinarySensorEntity] = [] diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 1f407eb6804..3fdfa60870a 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import PRECISION_HALVES, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity @@ -24,7 +24,7 @@ from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all cover devices and setup them via config entry.""" diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py index f49a9d0f0be..f23244f1b50 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -10,7 +10,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity @@ -19,7 +19,7 @@ from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all cover devices and setup them via config entry.""" diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index c855574b83a..8a88081ed05 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -9,7 +9,7 @@ from devolo_home_control_api.homecontrol import HomeControl from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity @@ -18,7 +18,7 @@ from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all light devices and setup them via config entry.""" diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 8d0a7f0313c..22581267eea 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry from .entity import DevoloDeviceEntity @@ -40,7 +40,7 @@ STATE_CLASS_MAPPING = { async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all sensor devices and setup them via config entry.""" entities: list[SensorEntity] = [] diff --git a/homeassistant/components/devolo_home_control/siren.py b/homeassistant/components/devolo_home_control/siren.py index e896f4d3ed8..5e4df944b3c 100644 --- a/homeassistant/components/devolo_home_control/siren.py +++ b/homeassistant/components/devolo_home_control/siren.py @@ -7,7 +7,7 @@ from devolo_home_control_api.homecontrol import HomeControl from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity @@ -16,7 +16,7 @@ from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all binary sensor and multi level sensor devices and setup them via config entry.""" diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index a6f16229046..378e23a5f5f 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -9,7 +9,7 @@ from devolo_home_control_api.homecontrol import HomeControl from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry from .entity import DevoloDeviceEntity @@ -18,7 +18,7 @@ from .entity import DevoloDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all devices and setup the switch devices via config entry.""" diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index 5752956ffb5..2c258d758da 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeNetworkConfigEntry from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER @@ -54,7 +54,7 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" coordinators = entry.runtime_data.coordinators diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py index 06822ff199e..fe6b1786363 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS @@ -59,7 +59,7 @@ BUTTON_TYPES: dict[str, DevoloButtonEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all devices and buttons and setup them via config entry.""" device = entry.runtime_data.device diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 583f022df84..c5862738bd1 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import STATE_UNKNOWN, UnitOfFrequency from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DevoloHomeNetworkConfigEntry @@ -25,7 +25,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" device = entry.runtime_data.device diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 91e8dd83b7d..46a3eb3426a 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -12,7 +12,7 @@ from devolo_plc_api.device_api import WifiGuestAccessGet from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import DevoloHomeNetworkConfigEntry @@ -42,7 +42,7 @@ IMAGE_TYPES: dict[str, DevoloImageEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" coordinators = entry.runtime_data.coordinators diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 220ab66312a..d9a6f3f1110 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from . import DevoloHomeNetworkConfigEntry @@ -123,7 +123,7 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any, Any]] = { async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" device = entry.runtime_data.device diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index 8ff35dcc4b6..0271270fa09 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS @@ -55,7 +55,7 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" device = entry.runtime_data.device diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 5091ce8e1e7..aaaf72af359 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -19,7 +19,7 @@ from homeassistant.components.update import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeNetworkConfigEntry from .const import DOMAIN, REGULAR_FIRMWARE @@ -51,7 +51,7 @@ UPDATE_TYPES: dict[str, DevoloUpdateEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: DevoloHomeNetworkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Get all devices and sensors and setup them via config entry.""" coordinators = entry.runtime_data.coordinators diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index cdb1894b675..eac0134f010 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import CONF_USERNAME, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -26,7 +26,7 @@ TRENDS = { async def async_setup_entry( hass: HomeAssistant, config_entry: DexcomConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Dexcom sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 8998e050a75..91934a2da3a 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import DirecTVConfigEntry @@ -55,7 +55,7 @@ SUPPORT_DTV_CLIENT = ( async def async_setup_entry( hass: HomeAssistant, entry: DirecTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DirecTV config entry.""" dtv = entry.runtime_data diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index dbaab5fa4e6..c9aacaae4d3 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -11,7 +11,7 @@ from directv import DIRECTV, DIRECTVError from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DirecTVConfigEntry from .entity import DIRECTVEntity @@ -24,7 +24,7 @@ SCAN_INTERVAL = timedelta(minutes=2) async def async_setup_entry( hass: HomeAssistant, entry: DirecTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load DirecTV remote based on a config entry.""" dtv = entry.runtime_data diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 65b1722e0d8..7d4bb6cb052 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -166,7 +166,7 @@ ADDITIONAL_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: DiscovergyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Discovergy sensors.""" entities: list[DiscovergySensor] = [] diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index 54322cc6875..ef1348f613d 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DLinkConfigEntry from .const import ATTR_TOTAL_CONSUMPTION @@ -24,7 +24,7 @@ SWITCH_TYPE = SwitchEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: DLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the D-Link Power Plug switch.""" async_add_entities([SmartPlugSwitch(entry, SWITCH_TYPE)], True) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 563ed209b7d..d93d55e62be 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -32,7 +32,7 @@ from homeassistant.components.media_player import ( from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import ( @@ -92,7 +92,7 @@ def catch_request_errors[_DlnaDmrEntityT: DlnaDmrEntity, **_P, _R]( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DlnaDmrEntity from a config entry.""" _LOGGER.debug("media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title) diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index 34730e934a0..6708baefe8c 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_HOSTNAME, @@ -45,7 +45,9 @@ def sort_ips(ips: list, querytype: str) -> list: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the dnsip sensor entry.""" diff --git a/homeassistant/components/doorbird/button.py b/homeassistant/components/doorbird/button.py index 62631e51abc..173c2e923e4 100644 --- a/homeassistant/components/doorbird/button.py +++ b/homeassistant/components/doorbird/button.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .device import ConfiguredDoorBird, async_reset_device_favorites from .entity import DoorBirdEntity @@ -45,7 +45,7 @@ BUTTON_DESCRIPTIONS: tuple[DoorbirdButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: DoorBirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DoorBird button platform.""" door_bird_data = config_entry.runtime_data diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index 45f37527ac1..a41e7c41b28 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -9,7 +9,7 @@ import aiohttp from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .entity import DoorBirdEntity @@ -25,7 +25,7 @@ _TIMEOUT = 15 # seconds async def async_setup_entry( hass: HomeAssistant, config_entry: DoorBirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DoorBird camera platform.""" door_bird_data = config_entry.runtime_data diff --git a/homeassistant/components/doorbird/event.py b/homeassistant/components/doorbird/event.py index 4c20098fc80..688f8b2fbeb 100644 --- a/homeassistant/components/doorbird/event.py +++ b/homeassistant/components/doorbird/event.py @@ -9,7 +9,7 @@ from homeassistant.components.event import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .device import DoorbirdEvent @@ -35,7 +35,7 @@ EVENT_DESCRIPTIONS = { async def async_setup_entry( hass: HomeAssistant, config_entry: DoorBirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DoorBird event platform.""" door_bird_data = config_entry.runtime_data diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index 56b991bf908..a8870ed224b 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity @@ -45,7 +45,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: DormakabaDkeyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform for Dormakaba dKey.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py index 352e7cbe0ac..12a553adba3 100644 --- a/homeassistant/components/dormakaba_dkey/lock.py +++ b/homeassistant/components/dormakaba_dkey/lock.py @@ -8,7 +8,7 @@ from py_dormakaba_dkey.commands import UnlockStatus from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity @@ -17,7 +17,7 @@ from .entity import DormakabaDkeyEntity async def async_setup_entry( hass: HomeAssistant, entry: DormakabaDkeyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the lock platform for Dormakaba dKey.""" async_add_entities([DormakabaDkeyLock(entry.runtime_data)]) diff --git a/homeassistant/components/dormakaba_dkey/sensor.py b/homeassistant/components/dormakaba_dkey/sensor.py index b1e941bc7e1..413ea1c56b1 100644 --- a/homeassistant/components/dormakaba_dkey/sensor.py +++ b/homeassistant/components/dormakaba_dkey/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator from .entity import DormakabaDkeyEntity @@ -28,7 +28,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: DormakabaDkeyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the lock platform for Dormakaba dKey.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py index 972945a84bb..923bcdad09c 100644 --- a/homeassistant/components/dremel_3d_printer/binary_sensor.py +++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import DremelConfigEntry from .entity import Dremel3DPrinterEntity @@ -43,7 +43,7 @@ BINARY_SENSOR_TYPES: tuple[Dremel3DPrinterBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, config_entry: DremelConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available Dremel binary sensors.""" async_add_entities( diff --git a/homeassistant/components/dremel_3d_printer/button.py b/homeassistant/components/dremel_3d_printer/button.py index f91c1b0ea51..880b179650f 100644 --- a/homeassistant/components/dremel_3d_printer/button.py +++ b/homeassistant/components/dremel_3d_printer/button.py @@ -10,7 +10,7 @@ from dremel3dpy import Dremel3DPrinter from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import DremelConfigEntry from .entity import Dremel3DPrinterEntity @@ -45,7 +45,7 @@ BUTTON_TYPES: tuple[Dremel3DPrinterButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: DremelConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Dremel 3D Printer control buttons.""" async_add_entities( diff --git a/homeassistant/components/dremel_3d_printer/camera.py b/homeassistant/components/dremel_3d_printer/camera.py index f4293915a25..ccb7eeaa658 100644 --- a/homeassistant/components/dremel_3d_printer/camera.py +++ b/homeassistant/components/dremel_3d_printer/camera.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.camera import CameraEntityDescription from homeassistant.components.mjpeg import MjpegCamera from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import Dremel3DPrinterDataUpdateCoordinator, DremelConfigEntry from .entity import Dremel3DPrinterEntity @@ -19,7 +19,7 @@ CAMERA_TYPE = CameraEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: DremelConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a MJPEG IP Camera for the 3D45 Model. The 3D20 and 3D40 models don't have built in cameras.""" async_add_entities([Dremel3D45Camera(config_entry.runtime_data, CAMERA_TYPE)]) diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py index 002a5fc4adb..1f02b1fe239 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from homeassistant.util.variance import ignore_variance @@ -235,7 +235,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: DremelConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available Dremel 3D Printer sensors.""" async_add_entities( diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py index bc8cf900610..f133be431f0 100644 --- a/homeassistant/components/drop_connect/binary_sensor.py +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_DEVICE_TYPE, @@ -105,7 +105,7 @@ DEVICE_BINARY_SENSORS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: DROPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DROP binary sensors from config entry.""" _LOGGER.debug( diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py index 9e4c74b67e6..e198033d0f7 100644 --- a/homeassistant/components/drop_connect/select.py +++ b/homeassistant/components/drop_connect/select.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_DEVICE_TYPE, DEV_HUB from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator @@ -50,7 +50,7 @@ DEVICE_SELECTS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: DROPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DROP selects from config entry.""" _LOGGER.debug( diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index 5ec47ed9eb1..c69e2e12ea0 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_DEVICE_TYPE, @@ -242,7 +242,7 @@ DEVICE_SENSORS: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: DROPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DROP sensors from config entry.""" _LOGGER.debug( diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py index 404059d3196..d52d17c5ea0 100644 --- a/homeassistant/components/drop_connect/switch.py +++ b/homeassistant/components/drop_connect/switch.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_DEVICE_TYPE, @@ -65,7 +65,7 @@ DEVICE_SWITCHES: dict[str, list[str]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: DROPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DROP switches from config entry.""" _LOGGER.debug( diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index e05785b8b26..245a28c62db 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -43,7 +43,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle @@ -549,7 +549,9 @@ def get_dsmr_object( async def async_setup_entry( - hass: HomeAssistant, entry: DsmrConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: DsmrConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the DSMR sensor.""" dsmr_version = entry.data[CONF_DSMR_VERSION] diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 784a4cdec51..c9bd9c9fff2 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import slugify @@ -18,7 +18,7 @@ from .definitions import SENSORS, DSMRReaderSensorEntityDescription async def async_setup_entry( _: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up DSMR Reader sensors from config entry.""" async_add_entities(DSMRSensor(description, config_entry) for description in SENSORS) diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index db903cac2bf..b3093221385 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -17,7 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DuneHDConfigEntry from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN @@ -39,7 +39,7 @@ DUNEHD_PLAYER_SUPPORT: Final[MediaPlayerEntityFeature] = ( async def async_setup_entry( hass: HomeAssistant, entry: DuneHDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Dune HD entities from a config_entry.""" async_add_entities( diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py index aadef47b998..e2431b5eade 100644 --- a/homeassistant/components/duotecno/binary_sensor.py +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -6,7 +6,7 @@ from duotecno.unit import ControlUnit, VirtualUnit from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DuotecnoConfigEntry from .entity import DuotecnoEntity @@ -15,7 +15,7 @@ from .entity import DuotecnoEntity async def async_setup_entry( hass: HomeAssistant, entry: DuotecnoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Duotecno binary sensor on config_entry.""" async_add_entities( diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 83a211d97f5..0ae6735feb5 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call @@ -32,7 +32,7 @@ PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()} async def async_setup_entry( hass: HomeAssistant, entry: DuotecnoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Duotecno climate based on config_entry.""" async_add_entities( diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py index 7d879741555..e184cf7ffb3 100644 --- a/homeassistant/components/duotecno/cover.py +++ b/homeassistant/components/duotecno/cover.py @@ -8,7 +8,7 @@ from duotecno.unit import DuoswitchUnit from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call @@ -17,7 +17,7 @@ from .entity import DuotecnoEntity, api_call async def async_setup_entry( hass: HomeAssistant, entry: DuotecnoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the duoswitch endities.""" async_add_entities( diff --git a/homeassistant/components/duotecno/light.py b/homeassistant/components/duotecno/light.py index 7b41cbaef22..38617c92748 100644 --- a/homeassistant/components/duotecno/light.py +++ b/homeassistant/components/duotecno/light.py @@ -6,7 +6,7 @@ from duotecno.unit import DimUnit from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call @@ -15,7 +15,7 @@ from .entity import DuotecnoEntity, api_call async def async_setup_entry( hass: HomeAssistant, entry: DuotecnoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Duotecno light based on config_entry.""" async_add_entities( diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py index 0c01a6ca4de..cef7715e946 100644 --- a/homeassistant/components/duotecno/switch.py +++ b/homeassistant/components/duotecno/switch.py @@ -6,7 +6,7 @@ from duotecno.unit import SwitchUnit from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DuotecnoConfigEntry from .entity import DuotecnoEntity, api_call @@ -15,7 +15,7 @@ from .entity import DuotecnoEntity, api_call async def async_setup_entry( hass: HomeAssistant, entry: DuotecnoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" async_add_entities( diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 0aaf1f2a801..1c2817350a5 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -16,7 +16,7 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -55,7 +55,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: DwdWeatherWarningsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities from config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 17adf1947ec..2cd473a2977 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -8,7 +8,7 @@ from homeassistant.components.cover import ( CoverEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.enum import try_parse_enum from .bridge import DynaliteBridge, DynaliteConfigEntry @@ -18,7 +18,7 @@ from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( hass: HomeAssistant, config_entry: DynaliteConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index ea2bc2bc96f..e9816c828db 100644 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -4,7 +4,7 @@ from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import DynaliteConfigEntry from .entity import DynaliteBase, async_setup_entry_base @@ -13,7 +13,7 @@ from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( hass: HomeAssistant, config_entry: DynaliteConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" async_setup_entry_base( diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py index dd6aad8670c..29f78ecbc20 100644 --- a/homeassistant/components/dynalite/switch.py +++ b/homeassistant/components/dynalite/switch.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import DynaliteConfigEntry from .entity import DynaliteBase, async_setup_entry_base @@ -14,7 +14,7 @@ from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( hass: HomeAssistant, config_entry: DynaliteConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" async_setup_entry_base( diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index d9b18cbc663..5d0af596521 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -20,7 +20,7 @@ UNIT_MAPPING = { async def async_setup_entry( hass: HomeAssistant, config_entry: EafmConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up UK Flood Monitoring Sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 6976a38da49..35fab870af3 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES @@ -213,7 +213,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: async def async_setup_entry( hass: HomeAssistant, entry: EasyEnergyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up easyEnergy sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py index 9c9f2192f43..76b3399ec6e 100644 --- a/homeassistant/components/ecobee/binary_sensor.py +++ b/homeassistant/components/ecobee/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcobeeConfigEntry from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER @@ -17,7 +17,7 @@ from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER async def async_setup_entry( hass: HomeAssistant, config_entry: EcobeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ecobee binary (occupancy) sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 743e2e1ba4b..26fb362ba03 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -38,7 +38,7 @@ from homeassistant.helpers import ( entity_platform, ) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter from . import EcobeeConfigEntry, EcobeeData @@ -204,7 +204,7 @@ SUPPORT_FLAGS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcobeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ecobee thermostat.""" diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py index 982cbdd07f2..ab6831d8f26 100644 --- a/homeassistant/components/ecobee/humidifier.py +++ b/homeassistant/components/ecobee/humidifier.py @@ -14,7 +14,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcobeeConfigEntry from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER @@ -28,7 +28,7 @@ MODE_OFF = "off" async def async_setup_entry( hass: HomeAssistant, config_entry: EcobeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ecobee thermostat humidifier entity.""" data = config_entry.runtime_data diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index 7c70d7ae4ac..2cf6a30acd7 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.notify import NotifyEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcobeeConfigEntry, EcobeeData from .entity import EcobeeBaseEntity @@ -13,7 +13,7 @@ from .entity import EcobeeBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: EcobeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ecobee thermostat.""" data = config_entry.runtime_data diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index f047ea8f896..50e9170394d 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcobeeConfigEntry, EcobeeData from .entity import EcobeeBaseEntity @@ -53,7 +53,7 @@ VENTILATOR_NUMBERS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcobeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ecobee thermostat number entity.""" data = config_entry.runtime_data diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 1b50fc21edf..759f167ec1c 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcobeeConfigEntry from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER @@ -74,7 +74,7 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcobeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ecobee sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py index c92082b7b58..e0848913b39 100644 --- a/homeassistant/components/ecobee/switch.py +++ b/homeassistant/components/ecobee/switch.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.climate import HVACMode from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import EcobeeConfigEntry, EcobeeData @@ -25,7 +25,7 @@ DATE_FORMAT = "%Y-%m-%d %H:%M:%S" async def async_setup_entry( hass: HomeAssistant, config_entry: EcobeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ecobee thermostat switch entity.""" data = config_entry.runtime_data diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py index 39b2d30ddd8..126f8dd5061 100644 --- a/homeassistant/components/ecobee/weather.py +++ b/homeassistant/components/ecobee/weather.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import EcobeeConfigEntry @@ -40,7 +40,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcobeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ecobee weather platform.""" data = config_entry.runtime_data diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py index 878c150343e..c1d5f5f3055 100644 --- a/homeassistant/components/ecoforest/number.py +++ b/homeassistant/components/ecoforest/number.py @@ -9,7 +9,7 @@ from pyecoforest.models.device import Device from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity @@ -37,7 +37,7 @@ NUMBER_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcoforestConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ecoforest number platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index 0babb476ab6..c1d4aca6f0c 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import EcoforestConfigEntry @@ -143,7 +143,7 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: EcoforestConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ecoforest sensor platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py index de52248e751..bd83bfc9ee5 100644 --- a/homeassistant/components/ecoforest/switch.py +++ b/homeassistant/components/ecoforest/switch.py @@ -11,7 +11,7 @@ from pyecoforest.models.device import Device from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EcoforestConfigEntry from .entity import EcoforestEntity @@ -38,7 +38,7 @@ SWITCH_TYPES: tuple[EcoforestSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcoforestConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ecoforest switch platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index 13ef8c4713b..0d041dfca5a 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EconetConfigEntry from .entity import EcoNetEntity @@ -42,7 +42,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: EconetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EcoNet binary sensor based on a config entry.""" equipment = entry.runtime_data diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index d46dbd8750a..b9673869046 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -22,7 +22,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from . import EconetConfigEntry @@ -57,7 +57,7 @@ SUPPORT_FLAGS_THERMOSTAT = ( async def async_setup_entry( hass: HomeAssistant, entry: EconetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EcoNet thermostat based on a config entry.""" equipment = entry.runtime_data diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index 510906d699c..1cc806ca8d5 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EconetConfigEntry from .entity import EcoNetEntity @@ -83,7 +83,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: EconetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EcoNet sensor based on a config entry.""" diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py index 9fcd38c860e..ff7f017b49f 100644 --- a/homeassistant/components/econet/switch.py +++ b/homeassistant/components/econet/switch.py @@ -10,7 +10,7 @@ from pyeconet.equipment.thermostat import Thermostat, ThermostatOperationMode from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EconetConfigEntry from .entity import EcoNetEntity @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: EconetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ecobee thermostat switch entity.""" equipment = entry.runtime_data diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index cfbff70b580..fb74ae8b4a5 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -19,7 +19,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EconetConfigEntry from .entity import EcoNetEntity @@ -48,7 +48,7 @@ SUPPORT_FLAGS_HEATER = ( async def async_setup_entry( hass: HomeAssistant, entry: EconetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EcoNet water heater based on a config entry.""" equipment = entry.runtime_data diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index d755d01a4ae..552a8152cc5 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT @@ -45,7 +45,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" async_add_entities( diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py index 2759ca972df..04eb0af02e6 100644 --- a/homeassistant/components/ecovacs/button.py +++ b/homeassistant/components/ecovacs/button.py @@ -13,7 +13,7 @@ from deebot_client.events import LifeSpan from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS @@ -81,7 +81,7 @@ LIFESPAN_ENTITY_DESCRIPTIONS = tuple( async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/ecovacs/event.py b/homeassistant/components/ecovacs/event.py index 3249b466c77..488767b472d 100644 --- a/homeassistant/components/ecovacs/event.py +++ b/homeassistant/components/ecovacs/event.py @@ -7,7 +7,7 @@ from deebot_client.events import CleanJobStatus, ReportStatsEvent from homeassistant.components.event import EventEntity, EventEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import EcovacsEntity @@ -17,7 +17,7 @@ from .util import get_name_key async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py index d8b69084cec..f8a89b0cfa0 100644 --- a/homeassistant/components/ecovacs/image.py +++ b/homeassistant/components/ecovacs/image.py @@ -7,7 +7,7 @@ from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent from homeassistant.components.image import ImageEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import EcovacsEntity @@ -16,7 +16,7 @@ from .entity import EcovacsEntity async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/ecovacs/lawn_mower.py b/homeassistant/components/ecovacs/lawn_mower.py index bf773207dc5..b9af67fafcd 100644 --- a/homeassistant/components/ecovacs/lawn_mower.py +++ b/homeassistant/components/ecovacs/lawn_mower.py @@ -16,7 +16,7 @@ from homeassistant.components.lawn_mower import ( LawnMowerEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import EcovacsEntity @@ -37,7 +37,7 @@ _STATE_TO_MOWER_STATE = { async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ecovacs mowers.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index adf282560a9..7a74b02ceca 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -16,7 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import DEGREE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import ( @@ -83,7 +83,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index 3c3852f05ec..a7b9baf1c4a 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -11,7 +11,7 @@ from deebot_client.events import WaterInfoEvent, WorkModeEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT @@ -54,7 +54,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 0e906c6cb16..6c8ae080fc3 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import EcovacsConfigEntry @@ -192,7 +192,7 @@ LEGACY_LIFESPAN_SENSORS = tuple( async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py index 288d092d391..dd379dbb199 100644 --- a/homeassistant/components/ecovacs/switch.py +++ b/homeassistant/components/ecovacs/switch.py @@ -9,7 +9,7 @@ from deebot_client.events import EnableEvent from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry from .entity import ( @@ -105,7 +105,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index bc78981d1db..6570b80e920 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -21,7 +21,7 @@ from homeassistant.components.vacuum import ( from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify @@ -41,7 +41,7 @@ SERVICE_RAW_GET_POSITIONS = "raw_get_positions" async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ecovacs vacuums.""" diff --git a/homeassistant/components/ecowitt/binary_sensor.py b/homeassistant/components/ecowitt/binary_sensor.py index 5bc782e3589..a2ed279f601 100644 --- a/homeassistant/components/ecowitt/binary_sensor.py +++ b/homeassistant/components/ecowitt/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcowittConfigEntry from .entity import EcowittEntity @@ -32,7 +32,7 @@ ECOWITT_BINARYSENSORS_MAPPING: Final = { async def async_setup_entry( hass: HomeAssistant, entry: EcowittConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors if new.""" ecowitt = entry.runtime_data diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 23af2f2a3af..b7816de0f35 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM @@ -218,7 +218,7 @@ ECOWITT_SENSORS_MAPPING: Final = { async def async_setup_entry( hass: HomeAssistant, entry: EcowittConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors if new.""" ecowitt = entry.runtime_data diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 62d06a8a535..3194781d71c 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -30,7 +30,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from .const import ( @@ -289,7 +289,7 @@ SENSOR_UNIT_MAPPING = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the EDL21 sensor.""" api = EDL21(hass, config_entry.data, async_add_entities) @@ -317,7 +317,7 @@ class EDL21: self, hass: HomeAssistant, config: Mapping[str, Any], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize an EDL21 object.""" self._registered_obis: set[tuple[str, str]] = set() diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 419c4da591d..6b54e4779a0 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import EfergyConfigEntry @@ -108,7 +108,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: EfergyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Efergy sensors.""" api = entry.runtime_data diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index f0038982965..0af7eb0c623 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator @@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: EheimDigitalConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the callbacks for the coordinator so climate entities can be added as devices are found.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py index 25498cf3af1..02062831fd3 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE @@ -32,7 +32,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: EheimDigitalConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the callbacks for the coordinator so lights can be added as devices are found.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 84def436dfb..bdf94f606db 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -28,7 +28,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElectraSmartConfigEntry from .const import ( @@ -91,7 +91,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: ElectraSmartConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Electra AC devices.""" api = entry.runtime_data diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index 30e02b5c5b9..38dc595b087 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -7,7 +7,7 @@ import logging from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION @@ -26,7 +26,7 @@ HOP_SELECT = SelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: ElectricKiwiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Electric Kiwi select setup.""" hop_coordinator = entry.runtime_data.hop diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 410d70808c3..291208b74b8 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -130,7 +130,7 @@ HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ElectricKiwiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Electric Kiwi Sensors Setup.""" account_coordinator = entry.runtime_data.account diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index 008cd106615..efcadb3f440 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -20,7 +20,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElevenLabsConfigEntry from .const import ( @@ -58,7 +58,7 @@ def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings: async def async_setup_entry( hass: HomeAssistant, config_entry: ElevenLabsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ElevenLabs tts platform via config entry.""" client = config_entry.runtime_data.client diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 505eff36b44..23ed65ded33 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity @@ -50,7 +50,7 @@ BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, entry: ElgatoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Elgato button based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 990a0606fce..7d2010f7ba4 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.util import color as color_util @@ -31,7 +31,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: ElgatoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Elgato Light based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 529d2f7c76e..02dbc2aeef6 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity @@ -104,7 +104,7 @@ SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: ElgatoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Elgato sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 3b2420b0ace..1b24f621807 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity @@ -54,7 +54,7 @@ SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, entry: ElgatoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Elgato switches based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index ab51b6fe281..8113a4d99a6 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -20,7 +20,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import VolDictType @@ -55,7 +55,7 @@ SERVICE_ALARM_CLEAR_BYPASS = "alarm_clear_bypass" async def async_setup_entry( hass: HomeAssistant, config_entry: ElkM1ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ElkM1 alarm platform.""" elk_data = config_entry.runtime_data diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py index 73f6b925e8c..ba6a375c29b 100644 --- a/homeassistant/components/elkm1/binary_sensor.py +++ b/homeassistant/components/elkm1/binary_sensor.py @@ -10,7 +10,7 @@ from elkm1_lib.zones import Zone from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElkM1ConfigEntry from .entity import ElkAttachedEntity, ElkEntity @@ -19,7 +19,7 @@ from .entity import ElkAttachedEntity, ElkEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ElkM1ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" elk_data = config_entry.runtime_data diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 1448acc6079..55af0cfa29c 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import PRECISION_WHOLE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from . import ElkM1ConfigEntry @@ -60,7 +60,7 @@ ELK_TO_HASS_FAN_MODES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ElkM1ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Elk-M1 thermostat platform.""" elk_data = config_entry.runtime_data diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py index c041c9c9d65..b5e2f0acacf 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -10,7 +10,7 @@ from elkm1_lib.lights import Light from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElkM1ConfigEntry from .entity import ElkEntity, create_elk_entities @@ -20,7 +20,7 @@ from .models import ELKM1Data async def async_setup_entry( hass: HomeAssistant, config_entry: ElkM1ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Elk light platform.""" elk_data = config_entry.runtime_data diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py index d8a1d83f326..5da240aee2d 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -8,7 +8,7 @@ from elkm1_lib.tasks import Task from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElkM1ConfigEntry from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities @@ -17,7 +17,7 @@ from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities async def async_setup_entry( hass: HomeAssistant, config_entry: ElkM1ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Elk-M1 scene platform.""" elk_data = config_entry.runtime_data diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 2ca932ec134..328672edbed 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import EntityCategory, UnitOfElectricPotential from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import ElkM1ConfigEntry @@ -40,7 +40,7 @@ ELK_SET_COUNTER_SERVICE_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: ElkM1ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" elk_data = config_entry.runtime_data diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index 3e0f4849518..d91d65512a2 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -12,7 +12,7 @@ from elkm1_lib.thermostats import Thermostat from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElkM1ConfigEntry from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities @@ -22,7 +22,7 @@ from .models import ELKM1Data async def async_setup_entry( hass: HomeAssistant, config_entry: ElkM1ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Elk-M1 switch platform.""" elk_data = config_entry.runtime_data diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index 139c9080c15..a90c8f2652c 100644 --- a/homeassistant/components/elmax/alarm_control_panel.py +++ b/homeassistant/components/elmax/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, InvalidStateError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ElmaxConfigEntry @@ -25,7 +25,7 @@ from .entity import ElmaxEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ElmaxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Elmax area platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index 351c386a084..d9ec3e75901 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity @@ -18,7 +18,7 @@ from .entity import ElmaxEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ElmaxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Elmax sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index e98477fe496..6993d5e44be 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -10,7 +10,7 @@ from elmax_api.model.cover_status import CoverStatus from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity @@ -27,7 +27,7 @@ _COMMAND_BY_MOTION_STATUS = { # Maps the stop command to use for every cover mo async def async_setup_entry( hass: HomeAssistant, config_entry: ElmaxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Elmax cover platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index 70faa44cf01..28a97fefd91 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -9,7 +9,7 @@ from elmax_api.model.panel import PanelStatus from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ElmaxConfigEntry from .entity import ElmaxEntity @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ElmaxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Elmax switch platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index e3483d3f5d7..6321ccfafcd 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -39,7 +39,10 @@ from homeassistant.const import ( from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -289,7 +292,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: EmonCMSConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the emoncms sensors.""" name = sensor_name(entry.data[CONF_URL]) diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index 39ed61741ae..be9e2ecb4cc 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -38,7 +38,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EmonitorConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/energenie_power_sockets/switch.py b/homeassistant/components/energenie_power_sockets/switch.py index e4fb7653e5e..825566887bc 100644 --- a/homeassistant/components/energenie_power_sockets/switch.py +++ b/homeassistant/components/energenie_power_sockets/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EnergenieConfigEntry from .const import DOMAIN @@ -19,7 +19,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: EnergenieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add EGPS sockets for passed config_entry in HA.""" powerstrip = config_entry.runtime_data diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 141ac793fba..38349b89ff7 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES @@ -147,7 +147,7 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: async def async_setup_entry( hass: HomeAssistant, entry: EnergyZeroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EnergyZero Sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 9a2a4564d1c..a3cdd1858ed 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import Enigma2ConfigEntry, Enigma2UpdateCoordinator @@ -31,7 +31,7 @@ _LOGGER = getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: Enigma2ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Enigma2 media player platform.""" async_add_entities([Enigma2Device(entry.runtime_data)]) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 0258281661a..dcffef8271b 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator @@ -75,7 +75,7 @@ ENPOWER_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EnphaseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up envoy binary sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index a62913a4c0b..a88c282281b 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -19,7 +19,7 @@ from homeassistant.components.number import ( from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator @@ -73,7 +73,7 @@ STORAGE_RESERVE_SOC_ENTITY = EnvoyStorageSettingsNumberEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: EnphaseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Enphase Envoy number platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 7dc275aab37..546470a19d5 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -14,7 +14,7 @@ from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator @@ -130,7 +130,7 @@ STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: EnphaseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Enphase Envoy select platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index dcf062a5417..594f5f34088 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -49,7 +49,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -806,7 +806,7 @@ AGGREGATE_BATTERY_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: EnphaseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up envoy sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 8a3ca493562..bb4ed874b1d 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -14,7 +14,7 @@ from pyenphase.models.tariff import EnvoyStorageSettings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator @@ -78,7 +78,7 @@ CHARGE_FROM_GRID_SWITCH = EnvoyStorageSettingsSwitchEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: EnphaseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Enphase Envoy switch platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 3ba059e2206..b051c572816 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components.camera import Camera from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.typing import VolDictType @@ -26,7 +26,7 @@ SET_RADAR_TYPE_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: ECConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" coordinator = config_entry.runtime_data.radar_coordinator diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 989667fb1ac..93f3a0f0d80 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_STATION @@ -253,7 +253,7 @@ ALERT_TYPES: tuple[ECSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ECConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" weather_coordinator = config_entry.runtime_data.weather_coordinator diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 156b9f4152b..c7e51a32f68 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ECConfigEntry, ECDataUpdateCoordinator @@ -63,7 +63,7 @@ ICON_CONDITION_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ECConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/epic_games_store/calendar.py b/homeassistant/components/epic_games_store/calendar.py index 5df1d6b756d..41edb5e31a7 100644 --- a/homeassistant/components/epic_games_store/calendar.py +++ b/homeassistant/components/epic_games_store/calendar.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, CalendarType @@ -21,7 +21,7 @@ DateRange = namedtuple("DateRange", ["start", "end"]) # noqa: PYI024 async def async_setup_entry( hass: HomeAssistant, entry: EGSConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the local calendar platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py index 78027813ffa..360c1f1d8a7 100644 --- a/homeassistant/components/epion/sensor.py +++ b/homeassistant/components/epion/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -59,7 +59,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: EpionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add an Epion entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index e0eac4a1cfb..c1582d6f0e5 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -43,7 +43,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EpsonConfigEntry from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE @@ -54,7 +54,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: EpsonConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Epson projector from a config entry.""" projector_entity = EpsonProjectorMediaPlayer( diff --git a/homeassistant/components/eq3btsmart/binary_sensor.py b/homeassistant/components/eq3btsmart/binary_sensor.py index 27525d47972..55b1f4d6ced 100644 --- a/homeassistant/components/eq3btsmart/binary_sensor.py +++ b/homeassistant/components/eq3btsmart/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Eq3ConfigEntry from .const import ENTITY_KEY_BATTERY, ENTITY_KEY_DST, ENTITY_KEY_WINDOW @@ -51,7 +51,7 @@ BINARY_SENSOR_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: Eq3ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the entry.""" diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index ae01d0fc9a7..738efa99187 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Eq3ConfigEntry from .const import ( @@ -37,7 +37,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: Eq3ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Handle config entry setup.""" diff --git a/homeassistant/components/eq3btsmart/number.py b/homeassistant/components/eq3btsmart/number.py index 2e069180fa3..c3cbd8eae31 100644 --- a/homeassistant/components/eq3btsmart/number.py +++ b/homeassistant/components/eq3btsmart/number.py @@ -21,7 +21,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Eq3ConfigEntry from .const import ( @@ -109,7 +109,7 @@ NUMBER_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: Eq3ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the entry.""" diff --git a/homeassistant/components/eq3btsmart/sensor.py b/homeassistant/components/eq3btsmart/sensor.py index bd2605042f4..aab3cbf1925 100644 --- a/homeassistant/components/eq3btsmart/sensor.py +++ b/homeassistant/components/eq3btsmart/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.components.sensor.const import SensorStateClass from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Eq3ConfigEntry from .const import ENTITY_KEY_AWAY_UNTIL, ENTITY_KEY_VALVE @@ -51,7 +51,7 @@ SENSOR_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: Eq3ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the entry.""" diff --git a/homeassistant/components/eq3btsmart/switch.py b/homeassistant/components/eq3btsmart/switch.py index 7525d8ca494..61da133cb71 100644 --- a/homeassistant/components/eq3btsmart/switch.py +++ b/homeassistant/components/eq3btsmart/switch.py @@ -9,7 +9,7 @@ from eq3btsmart.models import Status from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Eq3ConfigEntry from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK @@ -49,7 +49,7 @@ SWITCH_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: Eq3ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the entry.""" diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index c3fb0015e68..a1ac83844a2 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTempera from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DATA_DISCOVERY_SERVICE, @@ -47,7 +47,7 @@ _HA_FAN_TO_ESCEA = {v: k for k, v in _ESCEA_FAN_TO_HA.items()} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize an Escea Controller.""" discovery_service = hass.data[DATA_DISCOVERY_SERVICE] diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index f60668b0a06..016b1c3494d 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -39,7 +39,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import EsphomeAssistEntity @@ -87,7 +87,7 @@ _CONFIG_TIMEOUT_SEC = 5 async def async_setup_entry( hass: HomeAssistant, entry: ESPHomeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Assist satellite entity.""" entry_data = entry.runtime_data diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index ac759aa7b17..02b13748fb6 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.enum import try_parse_enum from .const import DOMAIN @@ -24,7 +24,7 @@ from .entry_data import ESPHomeConfigEntry async def async_setup_entry( hass: HomeAssistant, entry: ESPHomeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ESPHome binary sensors based on a config entry.""" await platform_async_setup_entry( diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 71a21186d3d..67bcbbbd221 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -13,7 +13,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import restore_state -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import ( @@ -29,7 +29,7 @@ from .entry_data import ESPHomeConfigEntry, RuntimeEntryData async def async_setup_entry( hass: HomeAssistant, entry: ESPHomeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up esphome selects based on a config entry.""" await platform_async_setup_entry( diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 670c92d291e..26f33f4fb47 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -22,7 +22,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -31,7 +31,9 @@ from .enum_mapper import EsphomeEnumMapper async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up esphome sensors based on a config entry.""" await platform_async_setup_entry( diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 2b593051742..60d4989063b 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -23,7 +23,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.enum import try_parse_enum @@ -46,7 +46,7 @@ NO_FEATURES = UpdateEntityFeature(0) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ESPHome update based on a config entry.""" await platform_async_setup_entry( diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index d9cef45ce4d..0c65be89879 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfMass from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .models import EufyLifeConfigEntry, EufyLifeData @@ -27,7 +27,7 @@ IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN} async def async_setup_entry( hass: HomeAssistant, entry: EufyLifeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the EufyLife sensors.""" data = entry.runtime_data diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index a6d1d9531b5..3dd9b763ae1 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -8,7 +8,7 @@ from typing import Any, cast from homeassistant.components import light from homeassistant.components.light import ColorMode, LightEntity, LightEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator from .entity import EvilGeniusEntity @@ -21,7 +21,7 @@ FIB_NO_EFFECT = "Solid Color" async def async_setup_entry( hass: HomeAssistant, config_entry: EvilGeniusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Evil Genius light platform.""" async_add_entities([EvilGeniusLight(config_entry.runtime_data)]) diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index 66a76df2cdc..08fa0a68ee8 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -18,7 +18,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator @@ -50,7 +50,7 @@ ALARM_TYPE = EzvizAlarmControlPanelEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ezviz alarm control panel.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index 6f0d87c8218..5e069e0277a 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -34,7 +34,7 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index b99674b0693..6dbb419c903 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -13,7 +13,7 @@ from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -68,7 +68,7 @@ BUTTON_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ button based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index d96fc949c86..54879fd6a9b 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery_flow from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -36,7 +36,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ cameras based on a config entry.""" diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py index d4c7a267b1e..f335406a367 100644 --- a/homeassistant/components/ezviz/image.py +++ b/homeassistant/components/ezviz/image.py @@ -11,7 +11,7 @@ from homeassistant.components.image import Image, ImageEntity, ImageEntityDescri from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -29,7 +29,7 @@ IMAGE_TYPE = ImageEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ image entities based on a config entry.""" diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py index 145c8b1ca20..ba398dd3ed4 100644 --- a/homeassistant/components/ezviz/light.py +++ b/homeassistant/components/ezviz/light.py @@ -10,7 +10,7 @@ from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -26,7 +26,7 @@ BRIGHTNESS_RANGE = (1, 255) async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ lights based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 9e8a20f36dd..9bdd1feb81d 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -19,7 +19,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizBaseEntity @@ -51,7 +51,7 @@ NUMBER_TYPE = EzvizNumberEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index 8e037fe6c33..486564bff6e 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -38,7 +38,7 @@ SELECT_TYPE = EzvizSelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ select entities based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index f3d50836bc7..c441b34b42d 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -72,7 +72,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py index 5a612aa0772..a2c88f58972 100644 --- a/homeassistant/components/ezviz/siren.py +++ b/homeassistant/components/ezviz/siren.py @@ -17,7 +17,7 @@ from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import event as evt -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator @@ -35,7 +35,7 @@ SIREN_ENTITY_TYPE = SirenEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 1a347c931a6..01f7cac1a55 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -107,7 +107,7 @@ SWITCH_TYPES: dict[int, EzvizSwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ switch based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index 3027e048688..c9f8038b336 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -14,7 +14,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator from .entity import EzvizEntity @@ -30,7 +30,7 @@ UPDATE_ENTITY_TYPES = UpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up EZVIZ sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 0fbc028f111..6822e2620fd 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FAAConfigEntry, FAADataUpdateCoordinator @@ -83,7 +83,9 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: FAAConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FAAConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a FAA sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index b633cb25628..5b6429b0754 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -20,7 +20,7 @@ from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: FastdotcomConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" async_add_entities([SpeedtestSensor(entry.entry_id, entry.runtime_data)]) diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py index ad6aed0fc76..578b5b1e175 100644 --- a/homeassistant/components/feedreader/event.py +++ b/homeassistant/components/feedreader/event.py @@ -10,7 +10,7 @@ from feedparser import FeedParserDict from homeassistant.components.event import EventEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FeedReaderConfigEntry @@ -28,7 +28,7 @@ ATTR_TITLE = "title" async def async_setup_entry( hass: HomeAssistant, entry: FeedReaderConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up event entities for feedreader.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 16e79c0c1d0..14c8f03f3ec 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FibaroConfigEntry from .entity import FibaroEntity @@ -42,7 +42,7 @@ SENSOR_TYPES = { async def async_setup_entry( hass: HomeAssistant, entry: FibaroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" controller = entry.runtime_data diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 45f700026a0..d601450a70f 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FibaroConfigEntry from .entity import FibaroEntity @@ -110,7 +110,7 @@ OP_MODE_ACTIONS = ("setMode", "setOperatingMode", "setThermostatMode") async def async_setup_entry( hass: HomeAssistant, entry: FibaroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" controller = entry.runtime_data diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index bfebbf87bd2..0008b56345e 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FibaroConfigEntry from .entity import FibaroEntity @@ -24,7 +24,7 @@ from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, entry: FibaroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fibaro covers.""" controller = entry.runtime_data diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index a2d5da7f877..0beea2e336e 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -12,7 +12,7 @@ from homeassistant.components.event import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FibaroConfigEntry from .entity import FibaroEntity @@ -21,7 +21,7 @@ from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, entry: FibaroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fibaro event entities.""" controller = entry.runtime_data diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index d40e26244f3..446b9b9f7ff 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -19,7 +19,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FibaroConfigEntry from .entity import FibaroEntity @@ -51,7 +51,7 @@ def scaleto99(value: int | None) -> int: async def async_setup_entry( hass: HomeAssistant, entry: FibaroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" controller = entry.runtime_data diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 62a9dfa43b1..a1e76109e2d 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -9,7 +9,7 @@ from pyfibaro.fibaro_device import DeviceModel from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FibaroConfigEntry from .entity import FibaroEntity @@ -18,7 +18,7 @@ from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, entry: FibaroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fibaro locks.""" controller = entry.runtime_data diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index a4c0f1bd7f1..8a594506f27 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -9,7 +9,7 @@ from pyfibaro.fibaro_scene import SceneModel from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from . import FibaroConfigEntry, FibaroController @@ -19,7 +19,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: FibaroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Fibaro scenes.""" controller = entry.runtime_data diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 245a0d087d8..9034bd7d05e 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import convert from . import FibaroConfigEntry @@ -102,7 +102,7 @@ FIBARO_TO_HASS_UNIT: dict[str, str] = { async def async_setup_entry( hass: HomeAssistant, entry: FibaroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fibaro controller devices.""" diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index f67683dff6a..8d77685c1e7 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -9,7 +9,7 @@ from pyfibaro.fibaro_device import DeviceModel from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FibaroConfigEntry from .entity import FibaroEntity @@ -18,7 +18,7 @@ from .entity import FibaroEntity async def async_setup_entry( hass: HomeAssistant, entry: FibaroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fibaro switches.""" controller = entry.runtime_data diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 3d61dbb04e0..90af1677bce 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILE_PATH, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON @@ -23,7 +23,7 @@ from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up notify entity.""" unique_id = entry.entry_id diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index 879c06e29f3..b8d174afe2c 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.template import Template from .const import DEFAULT_NAME, FILE_ICON @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the file sensor.""" config = dict(entry.data) diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index bd8b9b6c462..966e253660d 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -59,7 +59,7 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: FileSizeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config entry.""" async_add_entities( diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 330e61f499e..eb1337002e4 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -44,7 +44,10 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.start import async_at_started @@ -201,7 +204,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Filter sensor entry.""" name: str = entry.options[CONF_NAME] diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index b6d3aa67a0a..b8a542cf37c 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN @@ -15,7 +15,9 @@ from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up FireServiceRota binary sensor based on a config entry.""" diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index b09d1295025..682c7bcc0fd 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import DATA_CLIENT, DOMAIN as FIRESERVICEROTA_DOMAIN @@ -17,7 +17,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up FireServiceRota sensor based on a config entry.""" client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT] diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py index affd46c91bd..602a02a8e4a 100644 --- a/homeassistant/components/fireservicerota/switch.py +++ b/homeassistant/components/fireservicerota/switch.py @@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator @@ -16,7 +16,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up FireServiceRota switch based on a config entry.""" client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT] diff --git a/homeassistant/components/firmata/binary_sensor.py b/homeassistant/components/firmata/binary_sensor.py index 4973afa6960..de79f676ae6 100644 --- a/homeassistant/components/firmata/binary_sensor.py +++ b/homeassistant/components/firmata/binary_sensor.py @@ -5,7 +5,7 @@ import logging from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FirmataConfigEntry from .const import CONF_NEGATE_STATE, CONF_PIN_MODE @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: FirmataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Firmata binary sensors.""" new_entities = [] diff --git a/homeassistant/components/firmata/light.py b/homeassistant/components/firmata/light.py index 4f27143b774..f866ce9dbe5 100644 --- a/homeassistant/components/firmata/light.py +++ b/homeassistant/components/firmata/light.py @@ -9,7 +9,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FirmataConfigEntry from .board import FirmataPinType @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: FirmataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Firmata lights.""" new_entities = [] diff --git a/homeassistant/components/firmata/sensor.py b/homeassistant/components/firmata/sensor.py index 569d97fe1ec..7b49950e948 100644 --- a/homeassistant/components/firmata/sensor.py +++ b/homeassistant/components/firmata/sensor.py @@ -5,7 +5,7 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.const import CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FirmataConfigEntry from .const import CONF_DIFFERENTIAL, CONF_PIN_MODE @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: FirmataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Firmata sensors.""" new_entities = [] diff --git a/homeassistant/components/firmata/switch.py b/homeassistant/components/firmata/switch.py index 33953b78974..e84b18e9c74 100644 --- a/homeassistant/components/firmata/switch.py +++ b/homeassistant/components/firmata/switch.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME, CONF_PIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FirmataConfigEntry from .const import CONF_INITIAL_STATE, CONF_NEGATE_STATE, CONF_PIN_MODE @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: FirmataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Firmata switches.""" new_entities = [] diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index bbb3da46e52..f5c8a81ca26 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -524,7 +524,7 @@ FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: FitbitConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fitbit sensor platform.""" diff --git a/homeassistant/components/fivem/binary_sensor.py b/homeassistant/components/fivem/binary_sensor.py index 42119939d4a..92aab58349b 100644 --- a/homeassistant/components/fivem/binary_sensor.py +++ b/homeassistant/components/fivem/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NAME_STATUS from .coordinator import FiveMConfigEntry @@ -34,7 +34,7 @@ BINARY_SENSORS: tuple[FiveMBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: FiveMConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FiveM binary sensor platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index 88290171756..c4f5856c636 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -50,7 +50,7 @@ SENSORS: tuple[FiveMSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: FiveMConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FiveM sensor platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 93886a2ac6a..eed2c6058bf 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import async_setup_entry_platform @@ -49,7 +49,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors dynamically through discovery.""" diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index 540a7dd410d..ac9a15017cb 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.percentage import ( ordered_list_item_to_percentage, @@ -52,7 +52,7 @@ class UnsupportedPreset(HomeAssistantError): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors dynamically through discovery.""" diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py index f0083591d4d..6ac6017f3ee 100644 --- a/homeassistant/components/fjaraskupan/light.py +++ b/homeassistant/components/fjaraskupan/light.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import async_setup_entry_platform @@ -19,7 +19,7 @@ from .coordinator import FjaraskupanCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py index 1828c4cdea5..a69c31a5587 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -8,7 +8,7 @@ from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import async_setup_entry_platform @@ -18,7 +18,7 @@ from .coordinator import FjaraskupanCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities dynamically through discovery.""" diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py index 36db4d7ed9f..21d524e6534 100644 --- a/homeassistant/components/fjaraskupan/sensor.py +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCatego from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +25,7 @@ from .coordinator import FjaraskupanCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors dynamically through discovery.""" diff --git a/homeassistant/components/flexit_bacnet/binary_sensor.py b/homeassistant/components/flexit_bacnet/binary_sensor.py index 901cc52de47..e122d0321e6 100644 --- a/homeassistant/components/flexit_bacnet/binary_sensor.py +++ b/homeassistant/components/flexit_bacnet/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FlexitCoordinator from .const import DOMAIN @@ -39,7 +39,7 @@ SENSOR_TYPES: tuple[FlexitBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Flexit (bacnet) binary sensor from a config entry.""" coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index a2291dea9d6..0a97500afb1 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -39,7 +39,7 @@ from .entity import FlexitEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flexit Nordic unit.""" coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index 30df5370868..e8fbce54b74 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FlexitCoordinator from .const import DOMAIN @@ -197,7 +197,7 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Flexit (bacnet) number from a config entry.""" coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py index be5f12e480e..a14c7559945 100644 --- a/homeassistant/components/flexit_bacnet/sensor.py +++ b/homeassistant/components/flexit_bacnet/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import FlexitCoordinator @@ -153,7 +153,7 @@ SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Flexit (bacnet) sensor from a config entry.""" coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index 7f12a7524b6..1ceb6aefcdd 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FlexitCoordinator from .const import DOMAIN @@ -53,7 +53,7 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Flexit (bacnet) switch from a config entry.""" coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py index 73b6f8793fb..636d12525ad 100644 --- a/homeassistant/components/flick_electric/sensor.py +++ b/homeassistant/components/flick_electric/sensor.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.sensor import SensorEntity from homeassistant.const import CURRENCY_CENT, UnitOfEnergy from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT @@ -21,7 +21,7 @@ SCAN_INTERVAL = timedelta(minutes=5) async def async_setup_entry( hass: HomeAssistant, entry: FlickConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Flick Sensor Setup.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index 07357b81af0..899d045ad86 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FliprConfigEntry from .entity import FliprEntity @@ -30,7 +30,7 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: FliprConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup of flipr binary sensors.""" diff --git a/homeassistant/components/flipr/select.py b/homeassistant/components/flipr/select.py index 79515be6ed4..c10e9c6e91b 100644 --- a/homeassistant/components/flipr/select.py +++ b/homeassistant/components/flipr/select.py @@ -4,7 +4,7 @@ import logging from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FliprConfigEntry from .entity import FliprEntity @@ -23,7 +23,7 @@ SELECT_TYPES: tuple[SelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: FliprConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select for Flipr hub mode.""" coordinators = config_entry.runtime_data.hub_coordinators diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 2594186f24a..296bcaac68d 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FliprConfigEntry from .entity import FliprEntity @@ -57,7 +57,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: FliprConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinators = config_entry.runtime_data.flipr_coordinators diff --git a/homeassistant/components/flipr/switch.py b/homeassistant/components/flipr/switch.py index 03df7f34d12..4db8b54af8a 100644 --- a/homeassistant/components/flipr/switch.py +++ b/homeassistant/components/flipr/switch.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FliprConfigEntry from .entity import FliprEntity @@ -23,7 +23,7 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: FliprConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch for Flipr hub.""" coordinators = config_entry.runtime_data.hub_coordinators diff --git a/homeassistant/components/flo/binary_sensor.py b/homeassistant/components/flo/binary_sensor.py index 20f5d7822d2..b510bff84d7 100644 --- a/homeassistant/components/flo/binary_sensor.py +++ b/homeassistant/components/flo/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as FLO_DOMAIN from .coordinator import FloDeviceDataUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import FloEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flo sensors from config entry.""" devices: list[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][ diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index 7419b0a1c3b..71e5f921067 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as FLO_DOMAIN from .coordinator import FloDeviceDataUpdateCoordinator @@ -26,7 +26,7 @@ from .entity import FloEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flo sensors from config entry.""" devices: list[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][ diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index f0460839837..076dcc5e21c 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as FLO_DOMAIN from .coordinator import FloDeviceDataUpdateCoordinator @@ -28,7 +28,7 @@ SERVICE_RUN_HEALTH_TEST = "run_health_test" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flo switches from config entry.""" devices: list[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][ diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index cb0add90443..2c2dc285036 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( FLUME_TYPE_BRIDGE, @@ -69,7 +69,7 @@ FLUME_BINARY_NOTIFICATION_SENSORS: tuple[FlumeBinarySensorEntityDescription, ... async def async_setup_entry( hass: HomeAssistant, config_entry: FlumeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Flume binary sensor..""" flume_domain_data = config_entry.runtime_data diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index aea0aa60093..0f0213ec984 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -108,7 +108,7 @@ def make_flume_datas( async def async_setup_entry( hass: HomeAssistant, config_entry: FlumeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flume sensor.""" diff --git a/homeassistant/components/flux_led/button.py b/homeassistant/components/flux_led/button.py index 90918a55bb2..58844a20397 100644 --- a/homeassistant/components/flux_led/button.py +++ b/homeassistant/components/flux_led/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import FluxLedUpdateCoordinator @@ -35,7 +35,7 @@ UNPAIR_REMOTES_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Magic Home button based on a config entry.""" coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 2a0b5795970..2dd719a1fc4 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -26,7 +26,7 @@ from homeassistant.components.light import ( from homeassistant.const import CONF_EFFECT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -135,7 +135,7 @@ SET_ZONES_DICT: VolDictType = { async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flux lights.""" coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py index 93687c0c579..7ca3ccbb38b 100644 --- a/homeassistant/components/flux_led/number.py +++ b/homeassistant/components/flux_led/number.py @@ -23,7 +23,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -39,7 +39,7 @@ DEBOUNCE_TIME = 1 async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flux lights.""" coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/flux_led/select.py b/homeassistant/components/flux_led/select.py index 33329ebb3f3..2b489d8ec53 100644 --- a/homeassistant/components/flux_led/select.py +++ b/homeassistant/components/flux_led/select.py @@ -17,7 +17,7 @@ from homeassistant import config_entries from homeassistant.components.select import SelectEntity from homeassistant.const import CONF_NAME, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_WHITE_CHANNEL_TYPE, DOMAIN, FLUX_COLOR_MODE_RGBW from .coordinator import FluxLedUpdateCoordinator @@ -40,7 +40,7 @@ async def _async_delayed_reload( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flux selects.""" coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/flux_led/sensor.py b/homeassistant/components/flux_led/sensor.py index 5a6633669ae..8e5e1b742f1 100644 --- a/homeassistant/components/flux_led/sensor.py +++ b/homeassistant/components/flux_led/sensor.py @@ -6,7 +6,7 @@ from homeassistant import config_entries from homeassistant.components.sensor import SensorEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import FluxLedUpdateCoordinator @@ -16,7 +16,7 @@ from .entity import FluxEntity async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Magic Home sensors.""" coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/flux_led/switch.py b/homeassistant/components/flux_led/switch.py index 3adcd9a9da9..54311a08a34 100644 --- a/homeassistant/components/flux_led/switch.py +++ b/homeassistant/components/flux_led/switch.py @@ -12,7 +12,7 @@ from homeassistant import config_entries from homeassistant.components.switch import SwitchEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -29,7 +29,7 @@ from .entity import FluxBaseEntity, FluxEntity, FluxOnOffEntity async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flux lights.""" coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/folder_watcher/event.py b/homeassistant/components/folder_watcher/event.py index 7158930e116..472599c4ead 100644 --- a/homeassistant/components/folder_watcher/event.py +++ b/homeassistant/components/folder_watcher/event.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -25,7 +25,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Folder Watcher event.""" diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index c1fa971a89d..13a4d5c2d23 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -135,7 +135,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ForecastSolarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 8e61df3de45..6bc69a64eaa 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -36,7 +36,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from .browse_media import ( @@ -82,7 +82,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up forked-daapd from a config entry.""" host = config_entry.data[CONF_HOST] diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index ed5ba1d4c21..353c7397d81 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -10,7 +10,7 @@ from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_RTSP_PORT, CONF_STREAM, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET from .coordinator import FoscamConfigEntry, FoscamCoordinator @@ -49,7 +49,7 @@ PTZ_GOTO_PRESET_COMMAND = "ptz_goto_preset" async def async_setup_entry( hass: HomeAssistant, config_entry: FoscamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a Foscam IP camera from a config entry.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py index 189271d2746..24b05b5aeaa 100644 --- a/homeassistant/components/foscam/switch.py +++ b/homeassistant/components/foscam/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER from .coordinator import FoscamConfigEntry, FoscamCoordinator @@ -17,7 +17,7 @@ from .entity import FoscamEntity async def async_setup_entry( hass: HomeAssistant, config_entry: FoscamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up foscam switch from a config entry.""" diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py index 9d8e85a14ca..89462b33a2f 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -9,7 +9,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, FreeboxHomeCategory from .entity import FreeboxHomeEntity @@ -28,7 +28,9 @@ FREEBOX_TO_STATUS = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up alarm panel.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 20c124efea6..9fc9929b869 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, FreeboxHomeCategory from .entity import FreeboxHomeEntity @@ -34,7 +34,9 @@ RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index 79e3c98b8b7..4f676fd46a1 100644 --- a/homeassistant/components/freebox/button.py +++ b/homeassistant/components/freebox/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .router import FreeboxRouter @@ -44,7 +44,9 @@ BUTTON_DESCRIPTIONS: tuple[FreeboxButtonEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the buttons.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index 33919df74f6..45bb5a34063 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_DETECTION, DOMAIN, FreeboxHomeCategory from .entity import FreeboxHomeEntity @@ -27,7 +27,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cameras.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] @@ -49,7 +51,7 @@ async def async_setup_entry( def add_entities( hass: HomeAssistant, router: FreeboxRouter, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], ) -> None: """Add new cameras from the router.""" diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index 1fa37ebc270..dcb6eb104b2 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -9,14 +9,16 @@ from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEFAULT_DEVICE_NAME, DEVICE_ICONS, DOMAIN from .router import FreeboxRouter async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Freebox component.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] @@ -36,7 +38,9 @@ async def async_setup_entry( @callback def add_entities( - router: FreeboxRouter, async_add_entities: AddEntitiesCallback, tracked: set[str] + router: FreeboxRouter, + async_add_entities: AddConfigEntryEntitiesCallback, + tracked: set[str], ) -> None: """Add new tracker entities from the router.""" new_tracked = [] diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 588992a7f21..cc62de9ae0d 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import PERCENTAGE, UnitOfDataRate, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -60,7 +60,9 @@ DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index 96c3bcc2496..c4618b014bf 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .router import FreeboxRouter @@ -29,7 +29,9 @@ SWITCH_DESCRIPTIONS = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch.""" router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/freedompro/binary_sensor.py b/homeassistant/components/freedompro/binary_sensor.py index 840150e807d..9ff62446176 100644 --- a/homeassistant/components/freedompro/binary_sensor.py +++ b/homeassistant/components/freedompro/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -34,7 +34,7 @@ SUPPORTED_SENSORS = {"smokeSensor", "occupancySensor", "motionSensor", "contactS async def async_setup_entry( hass: HomeAssistant, entry: FreedomproConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Freedompro binary_sensor.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index a0146dc70b3..0145dea27bb 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -19,7 +19,7 @@ from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, UnitOfTemperatur from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -45,7 +45,7 @@ SUPPORTED_HVAC_MODES = [ async def async_setup_entry( hass: HomeAssistant, entry: FreedomproConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Freedompro climate.""" api_key: str = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py index ee61612428c..01e1b39d08f 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -35,7 +35,7 @@ SUPPORTED_SENSORS = {"windowCovering", "gate", "garageDoor", "door", "window"} async def async_setup_entry( hass: HomeAssistant, entry: FreedomproConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Freedompro cover.""" api_key: str = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index ad520ac8eb8..c65afb3a0e2 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -22,7 +22,7 @@ from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: FreedomproConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Freedompro fan.""" api_key: str = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py index c1b2e0ea17b..f9d90420c5d 100644 --- a/homeassistant/components/freedompro/light.py +++ b/homeassistant/components/freedompro/light.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -27,7 +27,7 @@ from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: FreedomproConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Freedompro light.""" api_key: str = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py index 70423bb9514..4aee252abbe 100644 --- a/homeassistant/components/freedompro/lock.py +++ b/homeassistant/components/freedompro/lock.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -20,7 +20,7 @@ from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: FreedomproConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Freedompro lock.""" api_key: str = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py index eaa96ac9fed..dbe1449d6e5 100644 --- a/homeassistant/components/freedompro/sensor.py +++ b/homeassistant/components/freedompro/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -42,7 +42,7 @@ SUPPORTED_SENSORS = {"temperatureSensor", "humiditySensor", "lightSensor"} async def async_setup_entry( hass: HomeAssistant, entry: FreedomproConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Freedompro sensor.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py index 12346825474..bda13b147b1 100644 --- a/homeassistant/components/freedompro/switch.py +++ b/homeassistant/components/freedompro/switch.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -20,7 +20,7 @@ from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: FreedomproConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Freedompro switch.""" api_key: str = entry.data[CONF_API_KEY] diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 7553328a64c..6bc8bb571d4 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ConnectionInfo, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription @@ -51,7 +51,7 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box binary sensors") diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index f3ffbe42099..74e8ab5e43e 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles from .coordinator import ( @@ -72,7 +72,7 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" _LOGGER.debug("Setting up buttons") diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index ba3c9a5aab6..e066219342e 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -8,7 +8,7 @@ import logging from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ( FRITZ_DATA_KEY, @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for FRITZ!Box component.""" _LOGGER.debug("Starting FRITZ!Box device tracker") @@ -48,7 +48,7 @@ async def async_setup_entry( @callback def _async_add_entities( avm_wrapper: AvmWrapper, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, data_fritz: FritzData, ) -> None: """Add new tracker entities from the AVM device.""" diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py index d305551b097..d329ec318c5 100644 --- a/homeassistant/components/fritz/image.py +++ b/homeassistant/components/fritz/image.py @@ -10,7 +10,7 @@ from requests.exceptions import RequestException from homeassistant.components.image import ImageEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify from .coordinator import AvmWrapper, FritzConfigEntry @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up guest WiFi QR code for device.""" avm_wrapper = entry.runtime_data diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 81b50bd21ac..bcee590460f 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfInformation, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -268,7 +268,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up FRITZ!Box sensors") diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 9c12fe0cecc..1548f8fc755 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -222,7 +222,7 @@ async def async_all_entities_list( async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up switches") diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index ad23a076ca6..5d064dc3035 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -13,7 +13,7 @@ from homeassistant.components.update import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AvmWrapper, FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription @@ -29,7 +29,7 @@ class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescripti async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AVM FRITZ!Box update entities.""" _LOGGER.debug("Setting up AVM FRITZ!Box update entities") diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 3c9cb6ada5c..75683017cb7 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FritzboxConfigEntry from .entity import FritzBoxDeviceEntity @@ -66,7 +66,7 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, entry: FritzboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome binary sensor from ConfigEntry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index 44a6697e1c0..54baa97b11a 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -5,7 +5,7 @@ from pyfritzhome.devicetypes import FritzhomeTemplate from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import FritzboxConfigEntry @@ -15,7 +15,7 @@ from .entity import FritzBoxEntity async def async_setup_entry( hass: HomeAssistant, entry: FritzboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome template from ConfigEntry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 87a87ac691f..c25113f1bca 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_STATE_BATTERY_LOW, @@ -59,7 +59,7 @@ OFF_REPORT_SET_TEMPERATURE = 0.0 async def async_setup_entry( hass: HomeAssistant, entry: FritzboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome thermostat from ConfigEntry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index 070bb868298..c7ecfef6a32 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FritzboxConfigEntry from .entity import FritzBoxDeviceEntity @@ -20,7 +20,7 @@ from .entity import FritzBoxDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: FritzboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome cover from ConfigEntry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 94d7d320704..8603840630c 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import COLOR_MODE, LOGGER from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator @@ -22,7 +22,7 @@ from .entity import FritzBoxDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: FritzboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome light from ConfigEntry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index e610fd80f3e..bed7004bd6a 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp @@ -229,7 +229,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, entry: FritzboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome sensor from ConfigEntry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index d83793c77dc..c2679ef5243 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import FritzboxConfigEntry @@ -17,7 +17,7 @@ from .entity import FritzBoxDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: FritzboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome switch from ConfigEntry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index df18ae5702a..574ae9ef7f2 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FritzBoxCallMonitorConfigEntry from .base import Contact, FritzBoxPhonebook @@ -48,7 +48,7 @@ class CallState(StrEnum): async def async_setup_entry( hass: HomeAssistant, config_entry: FritzBoxCallMonitorConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fritzbox_callmonitor sensor from config_entry.""" fritzbox_phonebook = config_entry.runtime_data diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index c6c3ff4b602..c65f6072ba6 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -64,7 +64,7 @@ ENERGY_VOLT_AMPERE_REACTIVE_HOUR: Final = "varh" async def async_setup_entry( hass: HomeAssistant, config_entry: FroniusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Fronius sensor entities based on a config entry.""" solar_net = config_entry.runtime_data diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 6b0f987baa2..4f5e55d1536 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -22,7 +22,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FrontierSiliconConfigEntry from .browse_media import browse_node, browse_top_level @@ -34,7 +34,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: FrontierSiliconConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Frontier Silicon entity.""" diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index 5df6573e638..bf1df07823c 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -25,7 +25,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity @@ -60,7 +60,7 @@ FUJI_TO_HA_SWING = {value: key for key, value in HA_TO_FUJI_SWING.items()} async def async_setup_entry( hass: HomeAssistant, entry: FGLairConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up one Fujitsu HVAC device.""" async_add_entities( diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py index e095a566dcb..0ad5bec3117 100644 --- a/homeassistant/components/fujitsu_fglair/sensor.py +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FGLairConfigEntry, FGLairCoordinator from .entity import FGLairEntity @@ -18,7 +18,7 @@ from .entity import FGLairEntity async def async_setup_entry( hass: HomeAssistant, entry: FGLairConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up one Fujitsu HVAC device.""" async_add_entities( diff --git a/homeassistant/components/fully_kiosk/binary_sensor.py b/homeassistant/components/fully_kiosk/binary_sensor.py index c039baa0397..8a25376f635 100644 --- a/homeassistant/components/fully_kiosk/binary_sensor.py +++ b/homeassistant/components/fully_kiosk/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator @@ -38,7 +38,7 @@ SENSORS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: FullyKioskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser sensor.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index 4b172d45ae2..112ead983b9 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -15,7 +15,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator @@ -68,7 +68,7 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: FullyKioskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser button entities.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/fully_kiosk/camera.py b/homeassistant/components/fully_kiosk/camera.py index 7dfbe9e9257..6357660f8e8 100644 --- a/homeassistant/components/fully_kiosk/camera.py +++ b/homeassistant/components/fully_kiosk/camera.py @@ -7,7 +7,7 @@ from fullykiosk import FullyKioskError from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import FullyKioskEntity async def async_setup_entry( hass: HomeAssistant, entry: FullyKioskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cameras.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py index e1a4240c9e9..158eae8671c 100644 --- a/homeassistant/components/fully_kiosk/image.py +++ b/homeassistant/components/fully_kiosk/image.py @@ -11,7 +11,7 @@ from fullykiosk import FullyKiosk, FullyKioskError from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import FullyKioskConfigEntry @@ -38,7 +38,7 @@ IMAGES: tuple[FullyImageEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: FullyKioskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser image entities.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index 24f002a7544..f6333a2941d 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullyKioskConfigEntry from .const import AUDIOMANAGER_STREAM_MUSIC, MEDIA_SUPPORT_FULLYKIOSK @@ -25,7 +25,7 @@ from .entity import FullyKioskEntity async def async_setup_entry( hass: HomeAssistant, config_entry: FullyKioskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser media player entity.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/fully_kiosk/notify.py b/homeassistant/components/fully_kiosk/notify.py index bddc07439b3..0a0c24c60e2 100644 --- a/homeassistant/components/fully_kiosk/notify.py +++ b/homeassistant/components/fully_kiosk/notify.py @@ -9,7 +9,7 @@ from fullykiosk import FullyKioskError from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator @@ -40,7 +40,7 @@ NOTIFIERS: tuple[FullyNotifyEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: FullyKioskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser notify entities.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py index ef25a69f1ee..8c386e85418 100644 --- a/homeassistant/components/fully_kiosk/number.py +++ b/homeassistant/components/fully_kiosk/number.py @@ -7,7 +7,7 @@ from contextlib import suppress from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator @@ -54,7 +54,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: FullyKioskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser number entities.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index d92c5c17341..6094a6c4c23 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import FullyKioskConfigEntry @@ -114,7 +114,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: FullyKioskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser sensor.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index 4adf8e8c924..804233dcc9e 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -11,7 +11,7 @@ from fullykiosk import FullyKiosk from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullyKioskConfigEntry from .coordinator import FullyKioskDataUpdateCoordinator @@ -84,7 +84,7 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: FullyKioskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fully Kiosk Browser switch.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/fyta/binary_sensor.py b/homeassistant/components/fyta/binary_sensor.py index 66e5b2feeca..ac092f1d9cb 100644 --- a/homeassistant/components/fyta/binary_sensor.py +++ b/homeassistant/components/fyta/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FytaConfigEntry from .entity import FytaPlantEntity @@ -83,7 +83,9 @@ BINARY_SENSORS: Final[list[FytaBinarySensorEntityDescription]] = [ async def async_setup_entry( - hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FytaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FYTA binary sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py index 4a0b32f605b..326f2ddf570 100644 --- a/homeassistant/components/fyta/image.py +++ b/homeassistant/components/fyta/image.py @@ -7,14 +7,16 @@ from datetime import datetime from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import FytaConfigEntry, FytaCoordinator from .entity import FytaPlantEntity async def async_setup_entry( - hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FytaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FYTA plant images.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 66c96ab697b..622945ae102 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import FytaConfigEntry, FytaCoordinator @@ -154,7 +154,9 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ async def async_setup_entry( - hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FytaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the FYTA sensors.""" coordinator: FytaCoordinator = entry.runtime_data diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py index cf4b29f0af8..6cfd68c8a00 100644 --- a/homeassistant/components/garages_amsterdam/binary_sensor.py +++ b/homeassistant/components/garages_amsterdam/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ( GaragesAmsterdamConfigEntry, @@ -42,7 +42,7 @@ BINARY_SENSORS: tuple[GaragesAmsterdamBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: GaragesAmsterdamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index 8c16260c58b..5467ae73b1e 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import ( @@ -59,7 +59,7 @@ SENSORS: tuple[GaragesAmsterdamSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: GaragesAmsterdamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index 4ee3dd511e9..b41988afd8c 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import GardenaBluetoothConfigEntry from .entity import GardenaBluetoothDescriptorEntity @@ -53,7 +53,7 @@ DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 8390baa5943..6a4f0395fe0 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -10,7 +10,7 @@ from gardena_bluetooth.parse import CharacteristicBool from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import GardenaBluetoothConfigEntry from .entity import GardenaBluetoothDescriptorEntity @@ -42,7 +42,7 @@ DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index eb95d9ff814..41b4f1e79ba 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -19,7 +19,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity @@ -105,7 +105,7 @@ DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entity based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 29d1a3155de..602f5bdfd6e 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator @@ -95,7 +95,7 @@ DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Gardena Bluetooth sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py index 73c4867d040..de1fbe22470 100644 --- a/homeassistant/components/gardena_bluetooth/switch.py +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -9,7 +9,7 @@ from gardena_bluetooth.const import Valve from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothEntity @@ -18,7 +18,7 @@ from .entity import GardenaBluetoothEntity async def async_setup_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index e51e5aa22ca..4138c7c4472 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -8,7 +8,7 @@ from gardena_bluetooth.const import Valve from homeassistant.components.valve import ValveEntity, ValveEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator from .entity import GardenaBluetoothEntity @@ -19,7 +19,7 @@ FALLBACK_WATERING_TIME_IN_SECONDS = 60 * 60 async def async_setup_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 3f693241b24..d277ee54f6b 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -15,7 +15,7 @@ from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -52,7 +52,9 @@ SOURCE = "gdacs" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GDACS Feed platform.""" manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index c8205730da4..a204addd414 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import GdacsFeedEntityManager @@ -37,7 +37,9 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GDACS Feed platform.""" manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index edefbc55ca6..6821300fadf 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -29,7 +29,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template import Template @@ -47,7 +47,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a generic IP Camera.""" diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 69c4fb3cdf4..6e699745279 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -43,7 +43,10 @@ from homeassistant.core import ( ) from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import ( async_track_state_change_event, async_track_state_report_event, @@ -94,7 +97,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" @@ -116,7 +119,7 @@ async def _async_setup_config( hass: HomeAssistant, config: Mapping[str, Any], unique_id: str | None, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, ) -> None: name: str = config[CONF_NAME] switch_entity_id: str = config[CONF_HUMIDIFIER] diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index fe6f0253f48..190caa58b3f 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -49,7 +49,10 @@ from homeassistant.core import ( from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import ( async_track_state_change_event, async_track_time_interval, @@ -123,7 +126,7 @@ PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend(PLATFORM_SCHEMA_COMMON.schema) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" await _async_setup_config( @@ -152,7 +155,7 @@ async def _async_setup_config( hass: HomeAssistant, config: Mapping[str, Any], unique_id: str | None, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, ) -> None: """Set up the generic thermostat platform.""" diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py index 01ccc950fd6..c2f25532453 100644 --- a/homeassistant/components/geniushub/binary_sensor.py +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import GeniusHubConfigEntry from .entity import GeniusDevice @@ -16,7 +16,7 @@ GH_TYPE = "Receiver" async def async_setup_entry( hass: HomeAssistant, entry: GeniusHubConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Genius Hub binary sensor entities.""" diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index e20d649541e..3c5cc4d4ad9 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -11,7 +11,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import GeniusHubConfigEntry from .entity import GeniusHeatingZone @@ -29,7 +29,7 @@ GH_ZONES = ["radiator", "wet underfloor"] async def async_setup_entry( hass: HomeAssistant, entry: GeniusHubConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Genius Hub climate entities.""" diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index a558ad18672..de7c047e934 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import GeniusHubConfigEntry @@ -26,7 +26,7 @@ GH_LEVEL_MAPPING = { async def async_setup_entry( hass: HomeAssistant, entry: GeniusHubConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Genius Hub sensor entities.""" diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index 3af82eb4e92..890ca1578be 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import ATTR_DURATION, GeniusHubConfigEntry @@ -31,7 +31,7 @@ SET_SWITCH_OVERRIDE_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, entry: GeniusHubConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Genius Hub switch entities.""" diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index 2807bd60611..60acf8f2cca 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -8,7 +8,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import GeniusHubConfigEntry from .entity import GeniusHeatingZone @@ -36,7 +36,7 @@ GH_HEATERS = ["hot water temperature"] async def async_setup_entry( hass: HomeAssistant, entry: GeniusHubConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Genius Hub water heater entities.""" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index e0067bcfdc9..dce4aac1630 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import GeoJsonFeedEntityManager from .const import ( @@ -28,7 +28,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoJSON Events platform.""" manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index c082e5308a1..c7894afc5ac 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -64,7 +64,9 @@ SENSORS: tuple[GeocachingSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Geocaching sensor entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 2ad3c1772de..c74dad1cebb 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE @@ -16,7 +16,7 @@ from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Geofency config entry.""" diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index 78313e102e0..96a1c3c09b2 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_TIME, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -38,7 +38,9 @@ SOURCE = "geonetnz_quakes" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoNet NZ Quakes Feed platform.""" manager: GeonetnzQuakesFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id] diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 2fce3e93d12..b8a1e2dd4db 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DOMAIN, FEED @@ -31,7 +31,9 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoNet NZ Quakes Feed platform.""" manager = hass.data[DOMAIN][FEED][entry.entry_id] diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 980679cc64f..bde04acb895 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter @@ -31,7 +31,9 @@ ATTR_LAST_UPDATE_SUCCESSFUL = "feed_last_update_successful" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoNet NZ Volcano Feed platform.""" manager = hass.data[DOMAIN][FEED][entry.entry_id] diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 096ea838a41..67997a01dc6 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_N from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -158,7 +158,9 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: GiosConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: GiosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a GIOS entities from a config_entry.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index a7ecb4ec8da..35985ed50d5 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -139,7 +139,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: GithubConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up GitHub sensor based on a config entry.""" repositories = entry.runtime_data diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 61d88b744bf..67f57ee0fbf 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CPU_ICON, DOMAIN @@ -288,7 +288,7 @@ SENSOR_TYPES = { async def async_setup_entry( hass: HomeAssistant, config_entry: GlancesConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Glances sensors.""" diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index 6bd061879eb..86287dc35eb 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import GoalZeroConfigEntry from .entity import GoalZeroEntity @@ -44,7 +44,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: GoalZeroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Goal Zero Yeti sensor.""" async_add_entities( diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index f565c216745..7b5f8955947 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import GoalZeroConfigEntry @@ -131,7 +131,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: GoalZeroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Goal Zero Yeti sensor.""" async_add_entities( diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index daff4ee5fec..00a1ad936d8 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -6,7 +6,7 @@ from typing import Any, cast from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import GoalZeroConfigEntry from .entity import GoalZeroEntity @@ -30,7 +30,7 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: GoalZeroConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Goal Zero Yeti switch.""" async_add_entities( diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 6bd38a0bc01..9492108d4b2 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import cover_unique_id, get_data_update_coordinator from .coordinator import DeviceDataUpdateCoordinator @@ -28,7 +28,7 @@ from .entity import GoGoGate2Entity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the config entry.""" data_update_coordinator = get_data_update_coordinator(hass, config_entry) diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index c7740e24825..ce86ca9ac43 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import get_data_update_coordinator, sensor_unique_id from .coordinator import DeviceDataUpdateCoordinator @@ -26,7 +26,7 @@ SENSOR_ID_WIRED = "WIRE" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the config entry.""" data_update_coordinator = get_data_update_coordinator(hass, config_entry) diff --git a/homeassistant/components/goodwe/button.py b/homeassistant/components/goodwe/button.py index d3d96a19a76..e93b23570db 100644 --- a/homeassistant/components/goodwe/button.py +++ b/homeassistant/components/goodwe/button.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER @@ -37,7 +37,7 @@ SYNCHRONIZE_CLOCK = GoodweButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter button entities from a config entry.""" inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index ce36bb35bf9..0a61ac19d64 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER @@ -87,7 +87,7 @@ NUMBERS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter select entities from a config entry.""" inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index 4fa84c8401f..340e10bfa0f 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER @@ -40,7 +40,7 @@ OPERATION_MODE = SelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the inverter select entities from a config entry.""" inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 5a88ac612da..d2dce2770e4 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -166,7 +166,7 @@ TEXT_SENSOR = GoodweSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GoodWe inverter from a config entry.""" entities: list[InverterSensor] = [] diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 82208420b8c..4f8ffba1d19 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -43,7 +43,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -192,7 +192,7 @@ def _get_entity_descriptions( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the google calendar platform.""" calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE] diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py index cf3a42e251b..58560d7b8d1 100644 --- a/homeassistant/components/google_assistant/button.py +++ b/homeassistant/components/google_assistant/button.py @@ -8,7 +8,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import CONF_PROJECT_ID, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN @@ -18,7 +18,7 @@ from .http import GoogleConfig async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform.""" yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index ebca586d1a3..41c5a6710b7 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -22,7 +22,7 @@ from homeassistant.components.stt import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_SERVICE_ACCOUNT_INFO, @@ -38,7 +38,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Google Cloud speech platform via config entry.""" service_account_info = config_entry.data[CONF_SERVICE_ACCOUNT_INFO] diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 7f22dda4faf..1f5f838b593 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -21,7 +21,7 @@ from homeassistant.components.tts import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -88,7 +88,7 @@ async def async_get_engine( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Google Cloud text-to-speech.""" service_account_info = config_entry.data[CONF_SERVICE_ACCOUNT_INFO] diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 0f26c93da25..4e0dc92f140 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, device_registry as dr, intent, llm -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_CHAT_MODEL, @@ -49,7 +49,7 @@ MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" agent = GoogleGenerativeAIConversationEntity(config_entry) diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index c832104d719..781ea9192f0 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import GoogleMailConfigEntry from .entity import GoogleMailEntity @@ -29,7 +29,7 @@ SENSOR_TYPE = SensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: GoogleMailConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Google Mail sensor.""" async_add_entities([GoogleMailSensor(entry.runtime_data, SENSOR_TYPE)], True) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 6d1969d9a8a..d8e3c64bad8 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -12,7 +12,7 @@ from homeassistant.components.todo import ( TodoListEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -68,7 +68,7 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem: async def async_setup_entry( hass: HomeAssistant, entry: GoogleTasksConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Google Tasks todo platform.""" async_add_entities( diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index 13e0ca4c273..201300d95b4 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -19,7 +19,7 @@ from homeassistant.components.tts import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -55,7 +55,7 @@ async def async_get_engine( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Google Translate speech platform via config entry.""" default_language = config_entry.data[CONF_LANG] diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index a3f9c236136..cac792dca53 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.location import find_coordinates from homeassistant.util import dt as dt_util @@ -55,7 +55,7 @@ def convert_time_to_utc(timestr): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Google travel time sensor entry.""" api_key = config_entry.data[CONF_API_KEY] diff --git a/homeassistant/components/govee_ble/binary_sensor.py b/homeassistant/components/govee_ble/binary_sensor.py index 7b7a1fb5a50..c3c71714e90 100644 --- a/homeassistant/components/govee_ble/binary_sensor.py +++ b/homeassistant/components/govee_ble/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .coordinator import GoveeBLEPassiveBluetoothDataProcessor @@ -76,7 +76,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the govee-ble BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/govee_ble/event.py b/homeassistant/components/govee_ble/event.py index 5e5aa6354be..03f74f37f6a 100644 --- a/homeassistant/components/govee_ble/event.py +++ b/homeassistant/components/govee_ble/event.py @@ -16,7 +16,7 @@ from homeassistant.components.event import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import GoveeBLEConfigEntry, format_event_dispatcher_name @@ -90,7 +90,7 @@ class GoveeBluetoothEventEntity(EventEntity): async def async_setup_entry( hass: HomeAssistant, entry: GoveeBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a govee ble event.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py index 383f50e5c46..fa0b828176c 100644 --- a/homeassistant/components/govee_ble/sensor.py +++ b/homeassistant/components/govee_ble/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .coordinator import GoveeBLEConfigEntry, GoveeBLEPassiveBluetoothDataProcessor @@ -105,7 +105,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: GoveeBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Govee BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index c7799a7ffc4..11ca53b53a1 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: GoveeLocalConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Govee light setup.""" diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 86d3ab7cc04..cc2257c88f7 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -156,7 +156,7 @@ SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: GPSDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GPSD component.""" async_add_entities( diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 3ed68ed1b06..be38382098d 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE @@ -26,7 +26,9 @@ from .const import ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure a dispatcher connection based on a config entry.""" diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index f197f21a4e1..f703ded1ea2 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -40,7 +40,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( COORDINATORS, @@ -88,7 +88,7 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH] async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index c1612ce99de..67dc10138d1 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import GreeEntity @@ -93,7 +93,7 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Gree HVAC device from a config entry.""" diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 06c810c2643..fa1777d5510 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -26,7 +26,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity @@ -70,7 +73,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Binary Sensor Group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/button.py b/homeassistant/components/group/button.py index a18e074b775..c96d60067a1 100644 --- a/homeassistant/components/group/button.py +++ b/homeassistant/components/group/button.py @@ -22,7 +22,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity @@ -62,7 +65,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize button group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index b2e5c6eef37..64baba6d1e8 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -37,7 +37,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity @@ -80,7 +83,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Cover Group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py index e7f7938edf3..4009c788362 100644 --- a/homeassistant/components/group/event.py +++ b/homeassistant/components/group/event.py @@ -27,7 +27,10 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -68,7 +71,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize event group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 87d9cb281f4..78745cb74c6 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -37,7 +37,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity @@ -82,7 +85,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Fan Group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 228645df974..259832d6152 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -48,7 +48,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity @@ -98,7 +101,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Light Group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index e22e1ecd85c..7b460aa4632 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -28,7 +28,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity @@ -70,7 +73,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Lock Group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index ab8ee64b3e1..3371e56b1dc 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -54,7 +54,10 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -99,7 +102,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize MediaPlayer Group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index d6a9a6fd3c7..e710485c46f 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity @@ -129,7 +129,7 @@ class GroupNotifyPlatform(BaseNotificationService): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Notify Group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 4a3e191e511..9f0cc64ecf0 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -44,7 +44,10 @@ from homeassistant.helpers.entity import ( get_device_class, get_unit_of_measurement, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -130,7 +133,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Switch Group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 101c42d354f..29e625ca8e3 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -26,7 +26,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .entity import GroupEntity @@ -71,7 +74,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Switch Group config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index e77660e6a3a..2794403811d 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAM from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle, dt as dt_util from ..const import ( @@ -61,7 +61,7 @@ def get_device_list(api, config): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Growatt sensor.""" config = {**config_entry.data} diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 84bb61da0e5..7d5f97bdb65 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import GuardianData from .const import ( @@ -86,7 +86,9 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" data: GuardianData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index f4881a9d94b..01bac63c6e3 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import GuardianData from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN @@ -68,7 +68,9 @@ BUTTON_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian buttons based on a config entry.""" data: GuardianData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 3f9547e652a..13dd8e01296 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import GuardianData @@ -137,7 +137,9 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" data: GuardianData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index fccf4f55a1f..a2c9ca282be 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import GuardianData from .const import API_VALVE_STATUS, API_WIFI_STATUS, DOMAIN @@ -110,7 +110,9 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" data: GuardianData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/guardian/valve.py b/homeassistant/components/guardian/valve.py index 8c9749958bf..6847b3211c5 100644 --- a/homeassistant/components/guardian/valve.py +++ b/homeassistant/components/guardian/valve.py @@ -17,7 +17,7 @@ from homeassistant.components.valve import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import GuardianData from .const import API_VALVE_STATUS, DOMAIN @@ -109,7 +109,9 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Guardian switches based on a config entry.""" data: GuardianData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index 6198ed14de8..c6f7ee0fb83 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ASSETS_URL from .coordinator import HabiticaConfigEntry @@ -56,7 +56,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the habitica binary sensors.""" diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py index 40325c49a7b..c57ba39fb6a 100644 --- a/homeassistant/components/habitica/button.py +++ b/homeassistant/components/habitica/button.py @@ -25,7 +25,7 @@ from homeassistant.components.button import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ASSETS_URL, DOMAIN from .coordinator import ( @@ -280,7 +280,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: HabiticaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons from a config entry.""" diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index 5ef9cd2eba1..b87a49670b0 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -18,7 +18,7 @@ from homeassistant.components.calendar import ( CalendarEvent, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator @@ -40,7 +40,7 @@ class HabiticaCalendar(StrEnum): async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the calendar platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index 1e21cd73fdc..f1ade2cac44 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -8,7 +8,7 @@ from habiticalib import Avatar, extract_avatar from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator @@ -26,7 +26,7 @@ class HabiticaImageEntity(StrEnum): async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the habitica image platform.""" diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index fa36025c5ce..e89bd0e7006 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -28,7 +28,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -305,7 +305,7 @@ def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the habitica sensors.""" diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py index fdad85ce3dc..fb98460f7e5 100644 --- a/homeassistant/components/habitica/switch.py +++ b/homeassistant/components/habitica/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ( HabiticaConfigEntry, @@ -55,7 +55,7 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: HabiticaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches from a config entry.""" diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index c46cf92c724..fd93f551916 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -26,7 +26,7 @@ from homeassistant.components.todo import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import ASSETS_URL, DOMAIN @@ -51,7 +51,7 @@ class HabiticaTodoList(StrEnum): async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 43bf0a348c0..d09dc3ff7e8 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -22,7 +22,7 @@ from homeassistant.components.remote import ( from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import VolDictType @@ -56,7 +56,7 @@ HARMONY_CHANGE_CHANNEL_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, entry: HarmonyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Harmony config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py index 731b6836386..3f45a23e26e 100644 --- a/homeassistant/components/harmony/select.py +++ b/homeassistant/components/harmony/select.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.select import SelectEntity from homeassistant.core import HassJob, HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ACTIVITY_POWER_OFF, DOMAIN from .data import HarmonyConfigEntry, HarmonyData @@ -21,7 +21,7 @@ TRANSLATABLE_POWER_OFF = "power_off" async def async_setup_entry( hass: HomeAssistant, entry: HarmonyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up harmony activities select.""" async_add_entities([HarmonyActivitySelect(entry.runtime_data)]) diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index 9d6e2ba19da..e7c7427d728 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS from .entity import HassioAddonEntity @@ -38,7 +38,7 @@ ADDON_ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Binary sensor set up for Hass.io config entry.""" coordinator = hass.data[ADDONS_COORDINATOR] diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index 039bf483682..9b62faaabcf 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ADDONS_COORDINATOR, @@ -111,7 +111,7 @@ HOST_ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Sensor set up for Hass.io config entry.""" coordinator = hass.data[ADDONS_COORDINATOR] diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 8e0585892f5..4ea703e87c3 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON, ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ADDONS_COORDINATOR, @@ -47,7 +47,7 @@ ENTITY_DESCRIPTION = UpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Supervisor update based on a config entry.""" coordinator = hass.data[ADDONS_COORDINATOR] diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index b53cb94d8e7..4dbaead67a7 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -35,7 +35,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow @@ -88,7 +88,9 @@ HA_HEOS_REPEAT_TYPE_MAP = {v: k for k, v in HEOS_HA_REPEAT_TYPE_MAP.items()} async def async_setup_entry( - hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: HeosConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add media players for a config entry.""" devices = [ diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 4d7566ef2e2..0f0cbb7d3cb 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -78,7 +78,7 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add HERE travel time entities from a config_entry.""" diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index 1dc1eaabcaa..cd9f3666e08 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -28,7 +28,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import CONF_IP_ADDRESS, DOMAIN @@ -121,7 +121,7 @@ def _build_entity(device): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AEH-W4A1 climate platform.""" # Priority 1: manual config diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index b25daf56598..6935b13bc3d 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -27,7 +27,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -116,7 +119,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: HistoryStatsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the History stats sensor entry.""" diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index 2b196ce820b..c2fe47642a0 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -11,7 +11,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HiveEntity @@ -27,7 +27,9 @@ HIVETOHA = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index d2938896f92..2076d592a7c 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HiveEntity @@ -68,7 +68,9 @@ SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index c76379cf940..bd7553faa1a 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import refresh_system from .const import ( @@ -58,7 +58,9 @@ _LOGGER = logging.getLogger() async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index e941087c6fb..80a81583429 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import refresh_system @@ -29,7 +29,9 @@ SCAN_INTERVAL = timedelta(seconds=15) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 00a2116e268..0609e43c4a9 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -89,7 +89,9 @@ SENSOR_TYPES: tuple[HiveSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" hive = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index 1421616db57..d4fefea5a56 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import refresh_system from .const import ATTR_MODE, DOMAIN @@ -33,7 +33,9 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index b038739d2ad..5f0a3d0f3fa 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import refresh_system from .const import ( @@ -45,7 +45,9 @@ SUPPORT_WATER_HEATER = [STATE_ECO, STATE_ON, STATE_OFF] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hive thermostat based on a config entry.""" diff --git a/homeassistant/components/hko/weather.py b/homeassistant/components/hko/weather.py index 6d3d12d8ab4..e746d4304d3 100644 --- a/homeassistant/components/hko/weather.py +++ b/homeassistant/components/hko/weather.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -28,7 +28,7 @@ from .coordinator import HKOUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a HKO weather entity from a config_entry.""" assert config_entry.unique_id is not None diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py index 3911dd6eab9..c6e6f7f5201 100644 --- a/homeassistant/components/hlk_sw16/switch.py +++ b/homeassistant/components/hlk_sw16/switch.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DATA_DEVICE_REGISTER from .const import DOMAIN @@ -26,7 +26,9 @@ def devices_from_entities(hass, entry): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HLK-SW16 platform.""" async_add_entities(devices_from_entities(hass, entry)) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py index 6dccd972164..1c01319129b 100644 --- a/homeassistant/components/holiday/calendar.py +++ b/homeassistant/components/holiday/calendar.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util @@ -69,7 +69,7 @@ def _get_obj_holidays_and_language( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Holiday Calendar config entry.""" country: str = config_entry.data[CONF_COUNTRY] diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 67e3d56e713..c0e978dbba4 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.components.script import scripts_with_entity from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -133,7 +133,7 @@ def _get_entities_for_appliance( async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Home Connect binary sensor.""" setup_home_connect_entry( diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index 6bd098a76fc..c27230c01d8 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -6,7 +6,7 @@ from typing import cast from aiohomeconnect.model import EventKey -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity @@ -18,7 +18,7 @@ def _handle_paired_or_connected_appliance( get_entities_for_appliance: Callable[ [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Handle a new paired appliance or an appliance that has been connected. @@ -59,7 +59,7 @@ def setup_home_connect_entry( get_entities_for_appliance: Callable[ [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] ], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the callbacks for paired and depaired appliances.""" known_entity_unique_ids: dict[str, str] = {} diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 05c154d9153..9f9016855e9 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .common import setup_home_connect_entry @@ -94,7 +94,7 @@ def _get_entities_for_appliance( async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Home Connect light.""" setup_home_connect_entry( diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index aa0c4e4ae3f..b0adea508c1 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -13,7 +13,7 @@ from homeassistant.components.number import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( @@ -94,7 +94,7 @@ def _get_entities_for_appliance( async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Home Connect number.""" setup_home_connect_entry( diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 13518c5dea2..165842abf1c 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -12,7 +12,7 @@ from aiohomeconnect.model.program import Execution 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 homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM @@ -88,7 +88,7 @@ def _get_entities_for_appliance( async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Home Connect select entities.""" setup_home_connect_entry( diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 545df1d68b6..971f87d72fd 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify from .common import setup_home_connect_entry @@ -272,7 +272,7 @@ def _get_entities_for_appliance( async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Home Connect sensor.""" setup_home_connect_entry( diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index e7fcd29e191..7dc375f430d 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -130,7 +130,7 @@ def _get_entities_for_appliance( async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Home Connect switch.""" setup_home_connect_entry( diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index 48f651857d2..3d16dd37e21 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -9,7 +9,7 @@ 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 homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry from .const import ( @@ -46,7 +46,7 @@ def _get_entities_for_appliance( async def async_setup_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Home Connect switch.""" setup_home_connect_entry( diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index 2e6f7babaff..a3695f7ade6 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeeConfigEntry from .entity import HomeeNodeEntity @@ -78,7 +78,7 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None: async def async_setup_entry( hass: HomeAssistant, config_entry: HomeeConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_devices: AddConfigEntryEntitiesCallback, ) -> None: """Add the homee platform for the cover integration.""" diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index da01c2aa5b9..237b80915aa 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeeConfigEntry from .const import ( @@ -262,7 +262,7 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: HomeeConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_devices: AddConfigEntryEntitiesCallback, ) -> None: """Add the homee platform for the sensor components.""" diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index b17f122dfa5..a0342203e4a 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice @@ -40,7 +40,7 @@ TARGET_STATE_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit alarm control panel.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 26e19c8944a..1c80da3cc9c 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice @@ -156,7 +156,7 @@ REJECT_CHAR_BY_TYPE = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit lighting.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index ac2133f61ca..730b3c8425d 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -20,7 +20,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES @@ -66,7 +66,7 @@ BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit buttons.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py index 4332032867a..36bf30e5bab 100644 --- a/homeassistant/components/homekit_controller/camera.py +++ b/homeassistant/components/homekit_controller/camera.py @@ -9,7 +9,7 @@ from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice @@ -39,7 +39,7 @@ class HomeKitCamera(AccessoryEntity, Camera): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit sensors.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index cbf4ad61c2f..7341bbd3a4a 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -41,7 +41,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -111,7 +111,7 @@ HASS_FAN_MODE_TO_HOMEKIT_ROTATION = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit climate.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 4fff32002e2..5ea990f55e6 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -19,7 +19,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice @@ -51,7 +51,7 @@ CURRENT_WINDOW_STATE_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit covers.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py index 890c12c9bab..b90d561d60d 100644 --- a/homeassistant/components/homekit_controller/event.py +++ b/homeassistant/components/homekit_controller/event.py @@ -14,7 +14,7 @@ from homeassistant.components.event import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice @@ -86,7 +86,7 @@ class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit event.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index b7f1842392b..9ba476a0ef3 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -17,7 +17,7 @@ from homeassistant.components.fan import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -210,7 +210,7 @@ ENTITY_TYPES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit fans.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index b2b0e0b1026..7906d5ec52b 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -20,7 +20,7 @@ from homeassistant.components.humidifier import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES @@ -165,7 +165,7 @@ class HomeKitDehumidifier(HomeKitBaseHumidifier): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit humidifer.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 04c75731731..5409df7c1a8 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import KNOWN_DEVICES @@ -31,7 +31,7 @@ from .entity import HomeKitEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit lightbulb.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 98974c4a514..06b8382c8af 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -11,7 +11,7 @@ from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice @@ -32,7 +32,7 @@ REVERSED_TARGET_STATE_MAP = {v: k for k, v in TARGET_STATE_MAP.items()} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit lock.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 4232d1b7649..5315c7c89f3 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -22,7 +22,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice @@ -41,7 +41,7 @@ HK_TO_HA_STATE = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit television.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index 340d31c91ae..96d6707d8eb 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -18,7 +18,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES @@ -68,7 +68,7 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit numbers.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index f672f293122..f174743b12f 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -15,7 +15,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES @@ -148,7 +148,7 @@ class EcobeeModeSelect(BaseHomeKitSelect): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit select entities.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 059be5bad99..c97b45152e0 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -43,7 +43,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES @@ -640,7 +640,7 @@ class RSSISensor(HomeKitEntity, SensorEntity): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit sensors.""" hkid = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 5abed2a5c79..c24a4edf545 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES @@ -224,7 +224,7 @@ ENTITY_TYPES: dict[str, type[HomeKitSwitch | HomeKitFaucet | HomeKitValve]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homekit switches.""" hkid: str = config_entry.data["AccessoryPairingID"] diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 4241316c2a4..d5b084644e3 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .hap import AsyncHome, HomematicipHAP @@ -27,7 +27,7 @@ CONST_ALARM_CONTROL_PANEL_NAME = "HmIP Alarm Control Panel" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP alrm control panel from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 38590e4505b..f0cd3732718 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -37,7 +37,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity @@ -76,7 +76,7 @@ SAM_DEVICE_ATTRIBUTES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud binary sensor from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index 244be47d7f6..fedc271714c 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -7,7 +7,7 @@ from homematicip.aio.device import AsyncWallMountedGarageDoorController from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity @@ -17,7 +17,7 @@ from .hap import HomematicipHAP async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP button from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index e7132fac83c..35bd18ff438 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -29,7 +29,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity @@ -57,7 +57,7 @@ HMIP_ECO_CM = "ECO" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP climate from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 1db536afd4f..27a84abb572 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -23,7 +23,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity @@ -38,7 +38,7 @@ HMIP_SLATS_CLOSED = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP cover from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index 8fb558b2b34..654f56bb47f 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -12,7 +12,7 @@ from homeassistant.components.event import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity @@ -37,7 +37,7 @@ EVENT_DESCRIPTIONS = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP cover from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index cf051103a10..ad946809fd4 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -30,7 +30,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity @@ -40,7 +40,7 @@ from .hap import HomematicipHAP async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud lights from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py index b00f42fc844..a054e95a80d 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -11,7 +11,7 @@ from homematicip.base.enums import LockState, MotorState from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity @@ -37,7 +37,7 @@ DEVICE_DLD_ATTRIBUTES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP locks from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 9ed9b33d7c7..0280f5bc7d5 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -57,7 +57,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -96,7 +96,7 @@ ILLUMINATION_DEVICE_ATTRIBUTES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud sensors from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 70bf14631cb..a9aa1c664d7 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -25,7 +25,7 @@ from homematicip.aio.group import AsyncExtendedLinkedSwitchingGroup, AsyncSwitch from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import ATTR_GROUP_MEMBER_UNREACHABLE, HomematicipGenericEntity @@ -35,7 +35,7 @@ from .hap import HomematicipHAP async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP switch from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index cbe7c2845b8..1125c73f8d4 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -25,7 +25,7 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import HomematicipGenericEntity @@ -53,7 +53,7 @@ HOME_WEATHER_CONDITION = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the HomematicIP weather sensor from a config entry.""" hap = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index d4484ee4be3..5a36cdb71f2 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -3,7 +3,7 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity @@ -15,7 +15,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: HomeWizardConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Identify button.""" if entry.runtime_data.data.device.supports_identify(): diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index e936657f254..a703043a63b 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.number import NumberEntity from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity @@ -17,7 +17,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: HomeWizardConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up numbers for device.""" if entry.runtime_data.data.device.supports_state(): diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index 5f3133fa9ba..dd557532240 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -690,7 +690,7 @@ EXTERNAL_SENSORS = { async def async_setup_entry( hass: HomeAssistant, entry: HomeWizardConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize sensors.""" diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 9f6b3ddd81f..1930b40583d 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator from .entity import HomeWizardEntity @@ -69,7 +69,7 @@ SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, entry: HomeWizardConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" async_add_entities( diff --git a/homeassistant/components/homeworks/binary_sensor.py b/homeassistant/components/homeworks/binary_sensor.py index f1ba3c02835..9bdea75479d 100644 --- a/homeassistant/components/homeworks/binary_sensor.py +++ b/homeassistant/components/homeworks/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeworksData, HomeworksKeypad from .const import ( @@ -31,7 +31,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homeworks binary sensors.""" data: HomeworksData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/homeworks/button.py b/homeassistant/components/homeworks/button.py index 6a13573ac88..d76c18985e9 100644 --- a/homeassistant/components/homeworks/button.py +++ b/homeassistant/components/homeworks/button.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeworksData from .const import ( @@ -27,7 +27,9 @@ from .entity import HomeworksEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homeworks buttons.""" data: HomeworksData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py index ac52c1f4974..f07758bbace 100644 --- a/homeassistant/components/homeworks/light.py +++ b/homeassistant/components/homeworks/light.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeworksData from .const import CONF_ADDR, CONF_CONTROLLER_ID, CONF_DIMMERS, CONF_RATE, DOMAIN @@ -23,7 +23,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Homeworks lights.""" data: HomeworksData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 1df5eb9601b..5fe84aadd75 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -36,7 +36,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter from . import HoneywellConfigEntry, HoneywellData @@ -98,7 +98,7 @@ SCAN_INTERVAL = datetime.timedelta(seconds=30) async def async_setup_entry( hass: HomeAssistant, entry: HoneywellConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell thermostat.""" cool_away_temp = entry.options.get(CONF_COOL_AWAY_TEMPERATURE) diff --git a/homeassistant/components/honeywell/humidifier.py b/homeassistant/components/honeywell/humidifier.py index e94ba465c30..77776f84a2e 100644 --- a/homeassistant/components/honeywell/humidifier.py +++ b/homeassistant/components/honeywell/humidifier.py @@ -15,7 +15,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HoneywellConfigEntry from .const import DOMAIN @@ -73,7 +73,7 @@ HUMIDIFIERS: dict[str, HoneywellHumidifierEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: HoneywellConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Honeywell (de)humidifier dynamically.""" data = config_entry.runtime_data diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index a9109d5d557..75ac6b1b6d3 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import HoneywellConfigEntry @@ -81,7 +81,7 @@ SENSOR_TYPES: tuple[HoneywellSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: HoneywellConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell thermostat.""" data = config_entry.runtime_data diff --git a/homeassistant/components/honeywell/switch.py b/homeassistant/components/honeywell/switch.py index 3602dd1ba10..06c79bf4b1d 100644 --- a/homeassistant/components/honeywell/switch.py +++ b/homeassistant/components/honeywell/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HoneywellConfigEntry, HoneywellData from .const import DOMAIN @@ -34,7 +34,7 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: HoneywellConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell switches.""" data = config_entry.runtime_data diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 06b859cea84..c3434dd0b64 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" router = hass.data[DOMAIN].routers[config_entry.entry_id] diff --git a/homeassistant/components/huawei_lte/button.py b/homeassistant/components/huawei_lte/button.py index 55b009d25bf..44b35d51dd4 100644 --- a/homeassistant/components/huawei_lte/button.py +++ b/homeassistant/components/huawei_lte/button.py @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + async_add_entities: entity_platform.AddConfigEntryEntitiesCallback, ) -> None: """Set up Huawei LTE buttons.""" router = hass.data[DOMAIN].routers[config_entry.entry_id] diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index df849d4f712..83e82bf17ff 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Router from .const import ( @@ -53,7 +53,7 @@ def _get_hosts( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" @@ -128,7 +128,7 @@ def _is_us(host: _HostType) -> bool: @callback def async_add_new_entities( router: Router, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], ) -> None: """Add new entities that are not already being tracked.""" diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py index d8a16ae2f79..3df6fa53320 100644 --- a/homeassistant/components/huawei_lte/select.py +++ b/homeassistant/components/huawei_lte/select.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED from . import Router @@ -38,7 +38,7 @@ class HuaweiSelectEntityDescription(SelectEntityDescription): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" router = hass.data[DOMAIN].routers[config_entry.entry_id] diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 86965e89dd0..3543433ca45 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import Router @@ -754,7 +754,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" router = hass.data[DOMAIN].routers[config_entry.entry_id] diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index 07fd89d0b6c..ac8bca4234c 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" router = hass.data[DOMAIN].routers[config_entry.entry_id] diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index 2cb8e8b5d90..ecaa6576775 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import HueBridge from .const import DOMAIN @@ -15,7 +15,7 @@ from .v2.binary_sensor import async_setup_entry as setup_entry_v2 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 64f3ccba9f9..249f81687c0 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -16,7 +16,7 @@ from homeassistant.components.event import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import HueBridge from .const import DEFAULT_BUTTON_EVENT_TYPES, DEVICE_SPECIFIC_EVENT_TYPES, DOMAIN @@ -26,7 +26,7 @@ from .v2.entity import HueBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up event platform from Hue button resources.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index c3168b5c8c1..9906c9bffa4 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import HueBridge from .const import DOMAIN @@ -16,7 +16,7 @@ from .v2.light import async_setup_entry as setup_entry_v2 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light entities.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 1d83804820d..0b9eb4efbd6 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -34,7 +34,7 @@ ATTR_BRIGHTNESS = "brightness" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up scene platform from Hue group scenes.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index 45cff053aef..227742fdbab 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import HueBridge from .const import DOMAIN @@ -15,7 +15,7 @@ from .v2.sensor import async_setup_entry as setup_entry_v2 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index b4bc57acf2d..b6b21686d25 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -22,7 +22,7 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import HueBridge from .const import DOMAIN @@ -32,7 +32,7 @@ from .v2.entity import HueBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue switch platform from Hue resources.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 5054ab6e817..6e4c7f98973 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -30,7 +30,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from ..bridge import HueBridge from ..const import DOMAIN @@ -49,7 +49,7 @@ type ControllerType = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue Sensors from Config Entry.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 17cd20b55aa..2f9f195df97 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -26,7 +26,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from ..bridge import HueBridge @@ -42,7 +42,7 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue groups on light platform.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 86d8cc93e54..fc3e000ab75 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -27,7 +27,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from ..bridge import HueBridge @@ -48,7 +48,7 @@ FALLBACK_KELVIN = 5800 # halfway async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue Light from Config Entry.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index bdf1db6df2e..ae6e456a8b4 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -28,7 +28,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from ..bridge import HueBridge from ..const import DOMAIN @@ -46,7 +46,7 @@ type ControllerType = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Hue Sensors from Config Entry.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index c024e3030fa..91c953b2182 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -219,7 +219,7 @@ SENSORS_INFO = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]] = hass.data[DOMAIN][ diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index adb3e177a8e..c0bcac3a7df 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -22,7 +22,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity @@ -74,7 +74,7 @@ BUTTONS_SHADE: Final = [ async def async_setup_entry( hass: HomeAssistant, entry: PowerviewConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the hunter douglas advanced feature buttons.""" pv_entry = entry.runtime_data diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 197fb4e6223..3f36a57891c 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -26,7 +26,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from .const import STATE_ATTRIBUTE_ROOM_NAME @@ -50,7 +50,7 @@ SCAN_INTERVAL = timedelta(minutes=10) async def async_setup_entry( hass: HomeAssistant, entry: PowerviewConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the hunter douglas shades.""" pv_entry = entry.runtime_data diff --git a/homeassistant/components/hunterdouglas_powerview/number.py b/homeassistant/components/hunterdouglas_powerview/number.py index fb8c9f76d79..216cdb2642a 100644 --- a/homeassistant/components/hunterdouglas_powerview/number.py +++ b/homeassistant/components/hunterdouglas_powerview/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity @@ -54,7 +54,7 @@ NUMBERS: Final = ( async def async_setup_entry( hass: HomeAssistant, entry: PowerviewConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the hunter douglas number entities.""" pv_entry = entry.runtime_data diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 2aaa255c5ab..5016b590f91 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -10,7 +10,7 @@ from aiopvapi.resources.scene import Scene as PvScene from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import STATE_ATTRIBUTE_ROOM_NAME from .coordinator import PowerviewShadeUpdateCoordinator @@ -25,7 +25,7 @@ RESYNC_DELAY = 60 async def async_setup_entry( hass: HomeAssistant, entry: PowerviewConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up powerview scene entries.""" pv_entry = entry.runtime_data diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index db850a0ddbf..932ff3ce3bd 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -12,7 +12,7 @@ from aiopvapi.resources.shade import BaseShade from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity @@ -54,7 +54,7 @@ DROPDOWNS: Final = [ async def async_setup_entry( hass: HomeAssistant, entry: PowerviewConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the hunter douglas select entities.""" pv_entry = entry.runtime_data diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index f5e3ddd5e12..6ebf8e2b278 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity @@ -79,7 +79,7 @@ SENSORS: Final = [ async def async_setup_entry( hass: HomeAssistant, entry: PowerviewConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the hunter douglas sensor entities.""" pv_entry = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py index 907d34e812a..1e5b9fac990 100644 --- a/homeassistant/components/husqvarna_automower/binary_sensor.py +++ b/homeassistant/components/husqvarna_automower/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.components.script import scripts_with_entity from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -71,7 +71,7 @@ MOWER_BINARY_SENSOR_TYPES: tuple[AutomowerBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 7e6e581cdf1..1f7ed7127e0 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -10,7 +10,7 @@ from aioautomower.session import AutomowerSession from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator @@ -54,7 +54,7 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index 9e2ea037afb..26e939ec7d9 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -7,7 +7,7 @@ from aioautomower.model import make_name_string from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import AutomowerConfigEntry @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lawn mower platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py index 2fd59b63014..78fad7c3610 100644 --- a/homeassistant/components/husqvarna_automower/device_tracker.py +++ b/homeassistant/components/husqvarna_automower/device_tracker.py @@ -2,7 +2,7 @@ from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator @@ -15,7 +15,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index dd75a8b9bc4..ee6007f089b 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -15,7 +15,7 @@ from homeassistant.components.lawn_mower import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .const import DOMAIN @@ -49,7 +49,7 @@ OVERRIDE_MODES = [MOW, PARK] async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lawn mower platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index d3666494646..cdcf4b45a2d 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -11,7 +11,7 @@ from aioautomower.session import AutomowerSession from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator @@ -107,7 +107,7 @@ WORK_AREA_NUMBER_TYPES: tuple[WorkAreaNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py index 03b1ac02587..9124a0705e1 100644 --- a/homeassistant/components/husqvarna_automower/select.py +++ b/homeassistant/components/husqvarna_automower/select.py @@ -8,7 +8,7 @@ from aioautomower.model import HeadlightModes from homeassistant.components.select import SelectEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator @@ -29,7 +29,7 @@ HEADLIGHT_MODES: list = [ async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index a2f4b5f4bab..2e1d4041e5a 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import AutomowerConfigEntry @@ -430,7 +430,7 @@ WORK_AREA_SENSOR_TYPES: tuple[WorkAreaSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index d55d51b42fe..69a3e670eda 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -7,7 +7,7 @@ from aioautomower.model import MowerModes, StayOutZones, Zone from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py index 980efc6f069..4b239394c2d 100644 --- a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py @@ -12,7 +12,7 @@ from homeassistant.components.lawn_mower import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER from .coordinator import HusqvarnaCoordinator @@ -22,7 +22,7 @@ from .entity import HusqvarnaAutomowerBleEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AutomowerLawnMower integration from a config entry.""" coordinator: HusqvarnaCoordinator = config_entry.runtime_data diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index 7e0e4ce5ef1..84173260d04 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -20,7 +20,7 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTempera from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Huum sauna with config flow.""" huum_handler = hass.data.setdefault(DOMAIN, {})[entry.entry_id] diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 913c61f91b4..622a8436e04 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -30,7 +30,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary_sensor platform.""" hub = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index 6ad61295d04..667893db8f2 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import ATTR_ID, CONF_OFFSET from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle from homeassistant.util.dt import get_time_zone, utcnow @@ -42,7 +42,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" hub = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 83e8a8325f9..b2862930933 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND @@ -78,7 +78,7 @@ SCHEMA_SUSPEND: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise binary_sensor platform.""" coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 96cc16832da..60bc1d7dc63 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -131,7 +131,7 @@ FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise sensor platform.""" coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 62cd81a0481..bc6b31e6d2e 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DEFAULT_WATERING_TIME, DOMAIN @@ -63,7 +63,7 @@ SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise switch platform.""" coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py index 37f196bc054..13aff22ccbf 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -14,7 +14,7 @@ from homeassistant.components.valve import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import HydrawiseUpdateCoordinators @@ -31,7 +31,7 @@ VALVE_TYPES: tuple[ValveEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Hydrawise valve platform.""" coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 23ce2715140..1260be20eb2 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -32,7 +32,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( get_hyperion_device_id, @@ -54,7 +54,7 @@ IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64," async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" entry_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 40d093430a5..f8932a682ab 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -25,7 +25,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import ( @@ -76,7 +76,7 @@ ICON_EFFECT = "mdi:lava-lamp" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" diff --git a/homeassistant/components/hyperion/sensor.py b/homeassistant/components/hyperion/sensor.py index ad972806ae5..42b41acea96 100644 --- a/homeassistant/components/hyperion/sensor.py +++ b/homeassistant/components/hyperion/sensor.py @@ -26,7 +26,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ( get_hyperion_device_id, @@ -63,7 +63,7 @@ def _sensor_unique_id(server_id: str, instance_num: int, suffix: str) -> str: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" entry_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 94cbf2aba29..8b66783e889 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -34,7 +34,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from . import ( @@ -90,7 +90,7 @@ def _component_to_translation_key(component: str) -> str: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Hyperion platform from config entry.""" entry_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index 4ae3787dc1d..e203f892c35 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -10,7 +10,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DATA_COORDINATOR, DOMAIN @@ -18,7 +18,9 @@ from .coordinator import IAlarmDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a iAlarm alarm control panel based on a config entry.""" coordinator: IAlarmDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 9e173dc36e0..8fe9d77fbe8 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as AQUALINK_DOMAIN from .entity import AqualinkEntity @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered binary sensors.""" async_add_entities( diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 53d1bce80de..d30700898c8 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -18,7 +18,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import refresh_system from .const import DOMAIN as AQUALINK_DOMAIN @@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered switches.""" async_add_entities( diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 59172c13576..e515c482158 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import refresh_system from .const import DOMAIN as AQUALINK_DOMAIN @@ -29,7 +29,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered lights.""" async_add_entities( diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index 881adb420bf..1b453f28d8f 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as AQUALINK_DOMAIN from .entity import AqualinkEntity @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered sensors.""" async_add_entities( diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index 601c5701a4a..e746cbb4f4b 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -9,7 +9,7 @@ from iaqualink.device import AqualinkSwitch from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import refresh_system from .const import DOMAIN as AQUALINK_DOMAIN @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up discovered switches.""" async_add_entities( diff --git a/homeassistant/components/ibeacon/device_tracker.py b/homeassistant/components/ibeacon/device_tracker.py index d002cb10f44..0d2ee0137cc 100644 --- a/homeassistant/components/ibeacon/device_tracker.py +++ b/homeassistant/components/ibeacon/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.components.device_tracker.config_entry import BaseTrackerEnti from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IBeaconConfigEntry from .const import SIGNAL_IBEACON_DEVICE_NEW @@ -20,7 +20,7 @@ from .entity import IBeaconEntity async def async_setup_entry( hass: HomeAssistant, entry: IBeaconConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for iBeacon Tracker component.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py index f73aef4b803..7e1fd371128 100644 --- a/homeassistant/components/ibeacon/sensor.py +++ b/homeassistant/components/ibeacon/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IBeaconConfigEntry from .const import SIGNAL_IBEACON_DEVICE_NEW @@ -69,7 +69,7 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: IBeaconConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for iBeacon Tracker component.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 11a18a10020..ca194143852 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .account import IcloudAccount, IcloudDevice from .const import ( @@ -21,7 +21,9 @@ from .const import ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for iCloud component.""" account: IcloudAccount = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index 53c9765f6cc..533605b8c7b 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import PERCENTAGE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from .account import IcloudAccount, IcloudDevice @@ -18,7 +18,9 @@ from .const import DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for iCloud component.""" account: IcloudAccount = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/idasen_desk/button.py b/homeassistant/components/idasen_desk/button.py index afd2f72917c..2e63e5e7cb8 100644 --- a/homeassistant/components/idasen_desk/button.py +++ b/homeassistant/components/idasen_desk/button.py @@ -8,7 +8,7 @@ from typing import Any, Final from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator from .entity import IdasenDeskEntity @@ -44,7 +44,7 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, entry: IdasenDeskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index b99eb67d8f5..b451f4d0156 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator from .entity import IdasenDeskEntity @@ -23,7 +23,7 @@ from .entity import IdasenDeskEntity async def async_setup_entry( hass: HomeAssistant, entry: IdasenDeskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cover platform for Idasen Desk.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py index f4ba163b123..22680b4fa7f 100644 --- a/homeassistant/components/idasen_desk/sensor.py +++ b/homeassistant/components/idasen_desk/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator from .entity import IdasenDeskEntity @@ -43,7 +43,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, entry: IdasenDeskConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Idasen Desk sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/igloohome/sensor.py b/homeassistant/components/igloohome/sensor.py index 7f25798e454..10a9ece6771 100644 --- a/homeassistant/components/igloohome/sensor.py +++ b/homeassistant/components/igloohome/sensor.py @@ -8,7 +8,7 @@ from igloohome_api import Api as IgloohomeApi, ApiException, GetDeviceInfoRespon from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IgloohomeConfigEntry from .entity import IgloohomeBaseEntity @@ -22,7 +22,7 @@ SCAN_INTERVAL = timedelta(hours=1) async def async_setup_entry( hass: HomeAssistant, entry: IgloohomeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 60892388252..01009e3d17b 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_USERNAME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ImapConfigEntry @@ -30,7 +30,9 @@ IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ImapConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ImapConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Imap sensor.""" diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 332c3bcedf8..33b82bbb43b 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfLength, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -59,7 +59,7 @@ SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ImgwPibConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a IMGW-PIB sensor entity from a config_entry.""" coordinator = entry.runtime_data.coordinator diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index 323ba7e6eee..356cee82e57 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import InComfortConfigEntry, InComfortDataCoordinator from .entity import IncomfortBoilerEntity @@ -70,7 +70,7 @@ SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: InComfortConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an InComfort/InTouch binary_sensor entity.""" incomfort_coordinator = entry.runtime_data diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index 3a4b4e56fd5..d44ba15507e 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN from .coordinator import InComfortConfigEntry, InComfortDataCoordinator @@ -27,7 +27,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: InComfortConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up InComfort/InTouch climate devices.""" incomfort_coordinator = entry.runtime_data diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 8507e9f9ebf..e344fb01aae 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import InComfortConfigEntry, InComfortDataCoordinator @@ -67,7 +67,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: InComfortConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up InComfort/InTouch sensor entities.""" incomfort_coordinator = entry.runtime_data diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 334fc187538..2a2c7cc47da 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -10,7 +10,7 @@ from incomfortclient import Heater as InComfortHeater from homeassistant.components.water_heater import WaterHeaterEntity from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import InComfortConfigEntry, InComfortDataCoordinator from .entity import IncomfortBoilerEntity @@ -25,7 +25,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: InComfortConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an InComfort/InTouch water_heater device.""" incomfort_coordinator = entry.runtime_data diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index 05b2ebbafa0..efda28b110d 100644 --- a/homeassistant/components/inkbird/sensor.py +++ b/homeassistant/components/inkbird/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -98,7 +98,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the INKBIRD BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index abb26b7f8e8..887c8fb64a3 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .entity import InsteonEntity @@ -46,7 +46,7 @@ SENSOR_TYPES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Insteon binary sensors from a config entry.""" diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 506841e7efb..eb33e3ab88c 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .entity import InsteonEntity @@ -55,7 +55,7 @@ FAN_MODES = {4: FAN_AUTO, 8: FAN_ONLY} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Insteon climate entities from a config entry.""" diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index fe4f484798d..679ee67a1de 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .entity import InsteonEntity @@ -22,7 +22,7 @@ from .utils import async_add_insteon_devices, async_add_insteon_entities async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Insteon covers from a config entry.""" diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index 0f1c70b9ea8..f4e0abf3d54 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -26,7 +26,7 @@ SPEED_RANGE = (1, 255) # off is not included async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Insteon fans from a config entry.""" diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index d19f3cca34a..e4f09fe5689 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .entity import InsteonEntity @@ -22,7 +22,7 @@ MAX_BRIGHTNESS = 255 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Insteon lights from a config entry.""" diff --git a/homeassistant/components/insteon/lock.py b/homeassistant/components/insteon/lock.py index d5f30eacbac..787b0c583cc 100644 --- a/homeassistant/components/insteon/lock.py +++ b/homeassistant/components/insteon/lock.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .entity import InsteonEntity @@ -17,7 +17,7 @@ from .utils import async_add_insteon_devices, async_add_insteon_entities async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Insteon locks from a config entry.""" diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py index 67ce5fa8c0d..e3f7cf3d7a9 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_ADD_ENTITIES from .entity import InsteonEntity @@ -17,7 +17,7 @@ from .utils import async_add_insteon_devices, async_add_insteon_entities async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Insteon switches from a config entry.""" diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 5b1d6379328..4ee859934d2 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -43,7 +43,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_CAT, @@ -415,7 +415,7 @@ def async_add_insteon_entities( hass: HomeAssistant, platform: Platform, entity_type: type[InsteonEntity], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_info: dict[str, Any], ) -> None: """Add an Insteon group to a platform.""" @@ -432,7 +432,7 @@ def async_add_insteon_devices( hass: HomeAssistant, platform: Platform, entity_type: type[InsteonEntity], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add all entities to a platform.""" for address in devices: diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 27aa74d0785..df5342111a7 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -42,7 +42,10 @@ from homeassistant.core import ( 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 -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import ( async_call_later, async_track_state_change_event, @@ -234,7 +237,7 @@ class IntegrationSensorExtraStoredData(SensorExtraStoredData): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Integration - Riemann sum integral config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index 7d00bdfc26d..3da1d2e3dc0 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IntellifireDataUpdateCoordinator from .const import DOMAIN @@ -152,7 +152,7 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a IntelliFire On/Off Sensor.""" coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index f72df254424..f067f2a849d 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IntellifireDataUpdateCoordinator from .const import DEFAULT_THERMOSTAT_TEMP, DOMAIN, LOGGER @@ -27,7 +27,7 @@ INTELLIFIRE_CLIMATES: tuple[ClimateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure the fan entry..""" coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index c5bec07faaa..174d964d357 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -17,7 +17,7 @@ from homeassistant.components.fan import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -58,7 +58,7 @@ INTELLIFIRE_FANS: tuple[IntellifireFanEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fans.""" coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index 5f25b5de823..0cf5c7774ed 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, LOGGER from .coordinator import IntellifireDataUpdateCoordinator @@ -85,7 +85,7 @@ class IntellifireLight(IntellifireEntity, LightEntity): async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fans.""" coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index 17ed3b7bd27..0776835833e 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -11,7 +11,7 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, LOGGER from .coordinator import IntellifireDataUpdateCoordinator @@ -21,7 +21,7 @@ from .entity import IntellifireEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fans.""" coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index eaff89d08e7..7763fb1b9b2 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from .const import DOMAIN @@ -141,7 +141,9 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Define setup entry call.""" diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index ac6096497b6..2185ad47cae 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IntellifireDataUpdateCoordinator from .const import DOMAIN @@ -53,7 +53,7 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure switch entities.""" coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/iometer/sensor.py b/homeassistant/components/iometer/sensor.py index 7d4c1155e8b..3dff3cc6ea9 100644 --- a/homeassistant/components/iometer/sensor.py +++ b/homeassistant/components/iometer/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import IOMeterCoordinator, IOmeterData @@ -111,7 +111,7 @@ SENSOR_TYPES: list[IOmeterEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sensors.""" coordinator: IOMeterCoordinator = config_entry.runtime_data diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index a97c2145919..a3c9876a884 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -14,7 +14,10 @@ from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -64,7 +67,7 @@ def setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up iOS from a config entry.""" async_add_entities( diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index c9af588c160..f5210f7fbba 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -114,7 +114,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors for passed config_entry in HA.""" coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/iotty/cover.py b/homeassistant/components/iotty/cover.py index 31d363868db..d8b11131f4f 100644 --- a/homeassistant/components/iotty/cover.py +++ b/homeassistant/components/iotty/cover.py @@ -16,7 +16,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .api import IottyProxy from .coordinator import IottyConfigEntry, IottyDataUpdateCoordinator @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: IottyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Activate the iotty Shutter component.""" _LOGGER.debug("Setup COVER entry id is %s", config_entry.entry_id) diff --git a/homeassistant/components/iotty/switch.py b/homeassistant/components/iotty/switch.py index a748ac10783..113a4439e85 100644 --- a/homeassistant/components/iotty/switch.py +++ b/homeassistant/components/iotty/switch.py @@ -20,7 +20,7 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .api import IottyProxy from .coordinator import IottyConfigEntry, IottyDataUpdateCoordinator @@ -45,7 +45,7 @@ ENTITIES: dict[str, SwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: IottyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Activate the iotty Switch component.""" _LOGGER.debug("Setup SWITCH entry id is %s", config_entry.entry_id) diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index 2a921cdbb04..78fd018cf9a 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -16,7 +16,7 @@ from pyipma.uv import UV from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle from .const import DATA_API, DATA_LOCATION, DOMAIN, MIN_TIME_BETWEEN_UPDATES @@ -86,7 +86,9 @@ SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the IPMA sensor platform.""" api = hass.data[DOMAIN][entry.entry_id][DATA_API] diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 855587eee2e..d285f9e1ad3 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle @@ -51,7 +51,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" api = hass.data[DOMAIN][config_entry.entry_id][DATA_API] diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 8efbd21707f..e16819a54ff 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_LOCATION, PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -87,7 +87,7 @@ PRINTER_SENSORS: tuple[IPPSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: IPPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up IPP sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index d04e0885454..64492c634e9 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_STATE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -127,7 +127,9 @@ INDEX_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up IQVIA sensors based on a config entry.""" sensors: list[ForecastSensor | IndexSensor] = [ diff --git a/homeassistant/components/iron_os/binary_sensor.py b/homeassistant/components/iron_os/binary_sensor.py index 81ba0e08c95..66e642c7aaa 100644 --- a/homeassistant/components/iron_os/binary_sensor.py +++ b/homeassistant/components/iron_os/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IronOSConfigEntry from .coordinator import IronOSLiveDataCoordinator @@ -29,7 +29,7 @@ class PinecilBinarySensor(StrEnum): async def async_setup_entry( hass: HomeAssistant, entry: IronOSConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors from a config entry.""" coordinator = entry.runtime_data.live_data diff --git a/homeassistant/components/iron_os/button.py b/homeassistant/components/iron_os/button.py index be16148a656..e069ddb1d9f 100644 --- a/homeassistant/components/iron_os/button.py +++ b/homeassistant/components/iron_os/button.py @@ -10,7 +10,7 @@ from pynecil import CharSetting from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IronOSConfigEntry from .coordinator import IronOSCoordinators @@ -53,7 +53,7 @@ BUTTON_DESCRIPTIONS: tuple[IronOSButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: IronOSConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button entities from a config entry.""" coordinators = entry.runtime_data diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 518c11372c4..b8bb3c7d999 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IronOSConfigEntry from .const import MAX_TEMP, MIN_TEMP @@ -327,7 +327,7 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: IronOSConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities from a config entry.""" coordinators = entry.runtime_data diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py index e9c7f81c208..a005bf29af2 100644 --- a/homeassistant/components/iron_os/select.py +++ b/homeassistant/components/iron_os/select.py @@ -23,7 +23,7 @@ from pynecil import ( from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IronOSConfigEntry from .coordinator import IronOSCoordinators @@ -154,7 +154,7 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: IronOSConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/iron_os/sensor.py b/homeassistant/components/iron_os/sensor.py index d178b46723f..79f1e54a6f4 100644 --- a/homeassistant/components/iron_os/sensor.py +++ b/homeassistant/components/iron_os/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import IronOSConfigEntry @@ -184,7 +184,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: IronOSConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors from a config entry.""" coordinator = entry.runtime_data.live_data diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py index d88e8cfdcb5..124b670048a 100644 --- a/homeassistant/components/iron_os/switch.py +++ b/homeassistant/components/iron_os/switch.py @@ -12,7 +12,7 @@ from pynecil import CharSetting, SettingsDataResponse from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IronOSConfigEntry from .coordinator import IronOSCoordinators @@ -100,7 +100,7 @@ SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: IronOSConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches from a config entry.""" diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index b431d321f24..4ec626ffc2a 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -9,7 +9,7 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IRON_OS_KEY, IronOSConfigEntry, IronOSLiveDataCoordinator from .coordinator import IronOSFirmwareUpdateCoordinator @@ -26,7 +26,7 @@ UPDATE_DESCRIPTION = UpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: IronOSConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up IronOS update platform.""" diff --git a/homeassistant/components/iskra/sensor.py b/homeassistant/components/iskra/sensor.py index a61951dedb9..10aa5555249 100644 --- a/homeassistant/components/iskra/sensor.py +++ b/homeassistant/components/iskra/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfReactivePower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_FREQUENCY, @@ -207,7 +207,7 @@ def get_counter_entity_description( async def async_setup_entry( hass: HomeAssistant, entry: IskraConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Iskra sensors based on config_entry.""" diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index c46b629d2d8..3d35786186e 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import IslamicPrayerTimesConfigEntry @@ -51,7 +51,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: IslamicPrayerTimesConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Islamic prayer times sensor platform.""" diff --git a/homeassistant/components/israel_rail/sensor.py b/homeassistant/components/israel_rail/sensor.py index d0c93da3451..86b2e135cfb 100644 --- a/homeassistant/components/israel_rail/sensor.py +++ b/homeassistant/components/israel_rail/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -73,7 +73,7 @@ SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: IsraelRailConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py index f4f91f0099e..b6e98e07f8a 100644 --- a/homeassistant/components/iss/sensor.py +++ b/homeassistant/components/iss/sensor.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" coordinator: DataUpdateCoordinator[IssData] = hass.data[DOMAIN] diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py index 59fd48a5fe9..ee54e502c26 100644 --- a/homeassistant/components/ista_ecotrend/sensor.py +++ b/homeassistant/components/ista_ecotrend/sensor.py @@ -30,7 +30,7 @@ from homeassistant.helpers.device_registry import ( DeviceEntryType, DeviceInfo, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -152,7 +152,7 @@ SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: IstaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ista EcoTrend sensors.""" diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 179944ad35f..8c9ce7dcc12 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity @@ -54,7 +54,9 @@ DEVICE_PARENT_REQUIRED = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY binary sensor platform.""" entities: list[ diff --git a/homeassistant/components/isy994/button.py b/homeassistant/components/isy994/button.py index b3b6aa40503..a895312c45a 100644 --- a/homeassistant/components/isy994/button.py +++ b/homeassistant/components/isy994/button.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_NETWORK, DOMAIN from .models import IsyData @@ -28,7 +28,7 @@ from .models import IsyData async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ISY/IoX button from config entry.""" isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index d5deba56284..57c1b6aa79d 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -37,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.enum import try_parse_enum from .const import ( @@ -61,7 +61,9 @@ from .models import IsyData async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY thermostat platform.""" diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py index b9d7ec44d27..6a660aaaf6f 100644 --- a/homeassistant/components/isy994/cover.py +++ b/homeassistant/components/isy994/cover.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE from .entity import ISYNodeEntity, ISYProgramEntity @@ -23,7 +23,9 @@ from .models import IsyData async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY cover platform.""" isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index fc0406e2d5f..aa6059abf49 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -27,7 +27,9 @@ SPEED_RANGE = (1, 255) # off is not included async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY fan platform.""" isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index b9b269d9ca3..29df8398f97 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, DOMAIN, UOM_PERCENTAGE @@ -24,7 +24,9 @@ ATTR_LAST_BRIGHTNESS = "last_brightness" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY light platform.""" isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index dc2da2a6ee2..d6866a8e00c 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -48,7 +48,9 @@ def async_setup_lock_services(hass: HomeAssistant) -> None: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY lock platform.""" isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py index c8feba1bf8d..fc30e6296d4 100644 --- a/homeassistant/components/isy994/number.py +++ b/homeassistant/components/isy994/number.py @@ -38,7 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -80,7 +80,7 @@ BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ISY/IoX number entities from config entry.""" isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py index 8befcf024d1..868c96375bb 100644 --- a/homeassistant/components/isy994/select.py +++ b/homeassistant/components/isy994/select.py @@ -34,7 +34,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import _LOGGER, DOMAIN, UOM_INDEX @@ -56,7 +56,7 @@ BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ISY/IoX select entities from config entry.""" isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 58ba3171bc8..2655f4d3c4e 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -33,7 +33,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( _LOGGER, @@ -108,7 +108,9 @@ ISY_CONTROL_TO_ENTITY_CATEGORY = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY sensor platform.""" isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index c05bd2ddbbb..946feddcd10 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -25,7 +25,7 @@ from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import ISYAuxControlEntity, ISYNodeEntity, ISYProgramEntity @@ -42,7 +42,9 @@ class ISYSwitchEntityDescription(SwitchEntityDescription): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ISY switch platform.""" isy_data: IsyData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py index 37796570c61..5f816709864 100644 --- a/homeassistant/components/ituran/device_tracker.py +++ b/homeassistant/components/ituran/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IturanConfigEntry from .coordinator import IturanDataUpdateCoordinator @@ -14,7 +14,7 @@ from .entity import IturanBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: IturanConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ituran tracker from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py index e962f5bd561..a115b2be89c 100644 --- a/homeassistant/components/ituran/sensor.py +++ b/homeassistant/components/ituran/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfSpeed, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import IturanConfigEntry @@ -87,7 +87,7 @@ SENSOR_TYPES: list[IturanSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: IturanConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ituran sensors from config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index e61917c825b..80c3a0384c1 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -33,7 +33,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType, VolDictType @@ -73,7 +73,9 @@ IZONE_SERVICE_AIRFLOW_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize an IZone Controller.""" disco = hass.data[DATA_DISCOVERY_SERVICE] diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index bb0d914162d..a8744b3e725 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -12,7 +12,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import parse_datetime from .browse_media import build_item_response, build_root_response @@ -25,7 +25,7 @@ from .entity import JellyfinClientEntity async def async_setup_entry( hass: HomeAssistant, entry: JellyfinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Jellyfin media_player from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/jellyfin/remote.py b/homeassistant/components/jellyfin/remote.py index 7c543813a13..27a0b131ca0 100644 --- a/homeassistant/components/jellyfin/remote.py +++ b/homeassistant/components/jellyfin/remote.py @@ -14,7 +14,7 @@ from homeassistant.components.remote import ( RemoteEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator @@ -24,7 +24,7 @@ from .entity import JellyfinClientEntity async def async_setup_entry( hass: HomeAssistant, entry: JellyfinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Jellyfin remote from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index 934f2eb4e32..e1100a9f43b 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator @@ -43,7 +43,7 @@ SENSOR_TYPES: tuple[JellyfinSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: JellyfinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Jellyfin sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 85519bf37b0..5ff3171b7de 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity @@ -62,7 +62,7 @@ BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Jewish Calendar binary sensors.""" async_add_entities( diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 5e02435ed06..eee1d966ae6 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import dt as dt_util @@ -168,7 +168,7 @@ TIME_SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Jewish calendar sensors .""" sensors = [ diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py index 383d0d590c4..69323884f61 100644 --- a/homeassistant/components/juicenet/number.py +++ b/homeassistant/components/juicenet/number.py @@ -13,7 +13,7 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR @@ -43,7 +43,7 @@ NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JuiceNet Numbers.""" juicenet_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 1f0b815cd97..7bf0639f5d0 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR from .entity import JuiceNetDevice @@ -70,7 +70,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JuiceNet Sensors.""" juicenet_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py index d800ac58c2c..9f34b7afdb3 100644 --- a/homeassistant/components/juicenet/switch.py +++ b/homeassistant/components/juicenet/switch.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR from .entity import JuiceNetDevice @@ -14,7 +14,7 @@ from .entity import JuiceNetDevice async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JuiceNet switches.""" juicenet_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index c2c22307371..1e288e272cd 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import JustNimbusCoordinator @@ -101,7 +101,9 @@ SENSOR_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JustNimbus sensor.""" coordinator: JustNimbusCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/jvc_projector/binary_sensor.py b/homeassistant/components/jvc_projector/binary_sensor.py index 0e1d8ce00a3..7ae76298839 100644 --- a/homeassistant/components/jvc_projector/binary_sensor.py +++ b/homeassistant/components/jvc_projector/binary_sensor.py @@ -6,7 +6,7 @@ from jvcprojector import const from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .entity import JvcProjectorEntity @@ -15,7 +15,9 @@ ON_STATUS = (const.ON, const.WARMING) async def async_setup_entry( - hass: HomeAssistant, entry: JVCConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: JVCConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JVC Projector platform from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index bbee5ca11f6..e1aff2fbb4c 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -12,7 +12,7 @@ from jvcprojector import const from homeassistant.components.remote import RemoteEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import JVCConfigEntry from .entity import JvcProjectorEntity @@ -54,7 +54,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: JVCConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: JVCConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JVC Projector platform from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/jvc_projector/select.py b/homeassistant/components/jvc_projector/select.py index 4b2cea3c3a0..b83695609cb 100644 --- a/homeassistant/components/jvc_projector/select.py +++ b/homeassistant/components/jvc_projector/select.py @@ -10,7 +10,7 @@ from jvcprojector import JvcProjector, const from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .entity import JvcProjectorEntity @@ -40,7 +40,7 @@ SELECTS: Final[list[JvcProjectorSelectDescription]] = [ async def async_setup_entry( hass: HomeAssistant, entry: JVCConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JVC Projector platform from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/jvc_projector/sensor.py b/homeassistant/components/jvc_projector/sensor.py index 5854e60c97a..7a7799bc4ee 100644 --- a/homeassistant/components/jvc_projector/sensor.py +++ b/homeassistant/components/jvc_projector/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator from .entity import JvcProjectorEntity @@ -34,7 +34,9 @@ JVC_SENSORS = ( async def async_setup_entry( - hass: HomeAssistant, entry: JVCConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: JVCConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JVC Projector platform from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py index 33acb899728..88e2e16bef2 100644 --- a/homeassistant/components/kaleidescape/media_player.py +++ b/homeassistant/components/kaleidescape/media_player.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback + from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback KALEIDESCAPE_PLAYING_STATES = [ @@ -38,7 +38,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" entities = [KaleidescapeMediaPlayer(hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id])] diff --git a/homeassistant/components/kaleidescape/remote.py b/homeassistant/components/kaleidescape/remote.py index 2d35ad2787f..ddafd52f220 100644 --- a/homeassistant/components/kaleidescape/remote.py +++ b/homeassistant/components/kaleidescape/remote.py @@ -18,11 +18,13 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback + from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" entities = [KaleidescapeRemote(hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id])] diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index 5520943e683..8bff5df2e70 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddEntitiesCallback + from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -131,7 +131,9 @@ SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" device: KaleidescapeDevice = hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id] diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py index cb7d83b9238..4d1b5da3552 100644 --- a/homeassistant/components/keenetic_ndms2/binary_sensor.py +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KeeneticRouter from .const import DOMAIN, ROUTER @@ -16,7 +16,7 @@ from .const import DOMAIN, ROUTER async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Keenetic NDMS2 component.""" router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index 0f5166e16dd..4143611d6af 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DOMAIN, ROUTER @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Keenetic NDMS2 component.""" router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] diff --git a/homeassistant/components/kegtron/sensor.py b/homeassistant/components/kegtron/sensor.py index e0638fccea0..602c61f96ff 100644 --- a/homeassistant/components/kegtron/sensor.py +++ b/homeassistant/components/kegtron/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -110,7 +110,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Kegtron BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py index ca458c5020f..57d3af98062 100644 --- a/homeassistant/components/keymitt_ble/switch.py +++ b/homeassistant/components/keymitt_ble/switch.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.typing import VolDictType @@ -29,7 +29,9 @@ CALIBRATE_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MicroBot based on a config entry.""" coordinator: MicroBotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/kitchen_sink/button.py b/homeassistant/components/kitchen_sink/button.py index 5c62c4b32d1..1ee9bd78095 100644 --- a/homeassistant/components/kitchen_sink/button.py +++ b/homeassistant/components/kitchen_sink/button.py @@ -7,7 +7,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -15,7 +15,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo button platform.""" async_add_entities( diff --git a/homeassistant/components/kitchen_sink/image.py b/homeassistant/components/kitchen_sink/image.py index 504b36464f5..130317f4bc5 100644 --- a/homeassistant/components/kitchen_sink/image.py +++ b/homeassistant/components/kitchen_sink/image.py @@ -7,7 +7,10 @@ from pathlib import Path from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -35,7 +38,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Everything but the Kitchen Sink config entry.""" await async_setup_platform(hass, {}, async_add_entities) diff --git a/homeassistant/components/kitchen_sink/lawn_mower.py b/homeassistant/components/kitchen_sink/lawn_mower.py index 51814fb262d..18a3f3dee77 100644 --- a/homeassistant/components/kitchen_sink/lawn_mower.py +++ b/homeassistant/components/kitchen_sink/lawn_mower.py @@ -9,7 +9,10 @@ from homeassistant.components.lawn_mower import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -71,7 +74,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Everything but the Kitchen Sink config entry.""" await async_setup_platform(hass, {}, async_add_entities) diff --git a/homeassistant/components/kitchen_sink/lock.py b/homeassistant/components/kitchen_sink/lock.py index 80ecc57d0d9..63566482cdf 100644 --- a/homeassistant/components/kitchen_sink/lock.py +++ b/homeassistant/components/kitchen_sink/lock.py @@ -7,7 +7,10 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature, LockState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -49,7 +52,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Everything but the Kitchen Sink config entry.""" await async_setup_platform(hass, {}, async_add_entities) diff --git a/homeassistant/components/kitchen_sink/notify.py b/homeassistant/components/kitchen_sink/notify.py index fb34a36f0b7..be5bad58109 100644 --- a/homeassistant/components/kitchen_sink/notify.py +++ b/homeassistant/components/kitchen_sink/notify.py @@ -7,7 +7,7 @@ from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -15,7 +15,7 @@ from . import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo notify entity platform.""" async_add_entities( diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index f8f82758732..19d1b31aeab 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -21,7 +21,6 @@ from .device import async_create_device async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - # pylint: disable-next=hass-argument-type async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Everything but the Kitchen Sink config entry.""" diff --git a/homeassistant/components/kitchen_sink/switch.py b/homeassistant/components/kitchen_sink/switch.py index 68a8312b496..45d3cb14eca 100644 --- a/homeassistant/components/kitchen_sink/switch.py +++ b/homeassistant/components/kitchen_sink/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .device import async_create_device @@ -17,7 +17,7 @@ from .device import async_create_device async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the demo switch platform.""" async_create_device( diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py index e94e823c692..a6b7cc69d05 100644 --- a/homeassistant/components/kitchen_sink/weather.py +++ b/homeassistant/components/kitchen_sink/weather.py @@ -26,7 +26,7 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import dt as dt_util @@ -56,7 +56,7 @@ CONDITION_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py index f00ecf8623c..b32f78b0e98 100644 --- a/homeassistant/components/kmtronic/switch.py +++ b/homeassistant/components/kmtronic/switch.py @@ -7,14 +7,16 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_REVERSE, DATA_COORDINATOR, DATA_HUB, DOMAIN, MANUFACTURER async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config entry example.""" coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] diff --git a/homeassistant/components/knocki/event.py b/homeassistant/components/knocki/event.py index 74dc5a0f64c..1b148f03f1c 100644 --- a/homeassistant/components/knocki/event.py +++ b/homeassistant/components/knocki/event.py @@ -5,7 +5,7 @@ from knocki import Event, EventType, KnockiClient, Trigger from homeassistant.components.event import EventEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import KnockiConfigEntry from .const import DOMAIN @@ -14,7 +14,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: KnockiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Knocki from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index c629860351c..c11612f79bf 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.restore_state import RestoreEntity @@ -45,7 +45,7 @@ from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the KNX binary sensor platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 5a2add5dcd7..538299a0556 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -8,7 +8,7 @@ from homeassistant import config_entries from homeassistant.components.button import ButtonEntity from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_PAYLOAD, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule @@ -19,7 +19,7 @@ from .entity import KnxYamlEntity async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the KNX binary sensor platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index e3bb63581e7..fdce5e0c470 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -34,7 +34,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule @@ -49,7 +49,7 @@ CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()} async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 2d38426a687..3c5752b990c 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -22,7 +22,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule @@ -34,7 +34,7 @@ from .schema import CoverSchema async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index 8f65ac8a952..7980e6a2bc3 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -36,7 +36,7 @@ from .entity import KnxYamlEntity async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index b75e1a14f67..7701597a8ef 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -37,7 +37,7 @@ from .entity import KnxYamlEntity async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 75d91e48048..926b6458706 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -11,7 +11,7 @@ from homeassistant import config_entries from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -30,7 +30,7 @@ DEFAULT_PERCENTAGE: Final = 50 async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up fan(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 33edc19fb1c..865cfdc6e25 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -22,7 +22,7 @@ from homeassistant.components.light import ( from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.typing import ConfigType @@ -61,7 +61,7 @@ from .storage.entity_store_schema import LightColorMode async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 245de2e937e..97980ab3d36 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -9,7 +9,7 @@ from homeassistant import config_entries from homeassistant.components.notify import NotifyEntity from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule @@ -20,7 +20,7 @@ from .entity import KnxYamlEntity async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up notify(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 27e4ff743ab..67e8778accc 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -19,7 +19,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule @@ -31,7 +31,7 @@ from .schema import NumberSchema async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index dfd226d72b1..f5361a6e7da 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -10,7 +10,7 @@ from homeassistant import config_entries from homeassistant.components.scene import Scene from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule @@ -22,7 +22,7 @@ from .schema import SceneSchema async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up scene(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index b499e3c601d..e80fa66f9d4 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -16,7 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -36,7 +36,7 @@ from .schema import SelectSchema async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index fa4911aa4b7..8e537ea234e 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum @@ -112,7 +112,7 @@ SYSTEM_ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 725468cd6a9..730c5b788ff 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.restore_state import RestoreEntity @@ -48,7 +48,7 @@ from .storage.const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch(es) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 2256afadbd9..9c2bb88f92b 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -30,7 +30,7 @@ from .entity import KnxYamlEntity async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 1e82c324502..2c74ab18af3 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -18,7 +18,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -36,7 +36,7 @@ from .entity import KnxYamlEntity async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index a1e5c0efe48..342ab445611 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -16,7 +16,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule @@ -28,7 +28,7 @@ from .schema import WeatherSchema async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch(es) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index bbddbd9f348..c4a2436548a 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -46,7 +46,10 @@ from homeassistant.helpers import ( entity_platform, ) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.network import is_internal_request from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType @@ -206,7 +209,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Kodi media player platform.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index 75c381c53f2..3f1a27302d8 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as KONNECTED_DOMAIN @@ -21,7 +21,7 @@ from .const import DOMAIN as KONNECTED_DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index 6191f98f179..cd36c217627 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW @@ -43,7 +43,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index 65b99d623f1..58311502cbe 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_ACTIVATION, @@ -33,7 +33,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches attached to a Konnected device from a config entry.""" data = hass.data[KONNECTED_DOMAIN] diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 059a09aadf2..7efb00cf8f4 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -73,7 +73,9 @@ NUMBER_SETTINGS_DATA = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Kostal Plenticore Number entities.""" plenticore = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 941b1566609..61929b9fadc 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -42,7 +42,9 @@ SELECT_SETTINGS_DATA = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kostal plenticore Select widget.""" plenticore: Plenticore = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 567ade278c3..1be7fb06e7b 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -807,7 +807,9 @@ SENSOR_PROCESS_DATA = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kostal plenticore Sensors.""" plenticore = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 86d1fe2b9be..e3d5f830c78 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -48,7 +48,9 @@ SWITCH_SETTINGS_DATA = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kostal plenticore Switch.""" plenticore = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 37fee795783..1d3f36d29e4 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -139,7 +139,7 @@ SENSOR_TYPES: tuple[KrakenSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kraken entities from a config_entry.""" diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index 552507ef50b..bcc3f32dceb 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN @@ -31,7 +31,7 @@ DISCOVERY_INTERVAL = timedelta(seconds=60) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Kuler sky light devices.""" diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 624d97d482a..df66b7ba96a 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -149,7 +149,7 @@ UNIT_OF_MEASUREMENT_MAP = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaCrosse View from a config entry.""" coordinator: DataUpdateCoordinator[list[Sensor]] = hass.data[DOMAIN][ diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index e36b53bc993..39bd5d4b954 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity @@ -71,7 +71,7 @@ SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities.""" coordinator = entry.runtime_data.config_coordinator diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index 22e92f656ff..db51d610949 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -10,7 +10,7 @@ from pylamarzocco.exceptions import RequestNotSuccessful from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator @@ -53,7 +53,7 @@ ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button entities.""" diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py index 1dcc7c324ac..4365bf56b2d 100644 --- a/homeassistant/components/lamarzocco/calendar.py +++ b/homeassistant/components/lamarzocco/calendar.py @@ -7,7 +7,7 @@ from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator @@ -32,7 +32,7 @@ DAY_OF_WEEK = [ async def async_setup_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch entities and services.""" diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 44b582fbf1a..3b3d569a6f7 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator @@ -230,7 +230,7 @@ SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities.""" coordinator = entry.runtime_data.config_coordinator diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index 7acb654f0d2..bd6ac1ee04f 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -19,7 +19,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMarzoccoConfigEntry @@ -126,7 +126,7 @@ SCALE_ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities.""" coordinator = entry.runtime_data.config_coordinator diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index a2d6143daa5..6287ea91a40 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity @@ -134,7 +134,7 @@ SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" config_coordinator = entry.runtime_data.config_coordinator diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index 54bd1ac2aed..ee03ba421d4 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator @@ -64,7 +64,7 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch entities and services.""" diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index 0833ee6e249..37960d26e95 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -15,7 +15,7 @@ from homeassistant.components.update import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMarzoccoConfigEntry @@ -55,7 +55,7 @@ ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LaMarzoccoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create update entities.""" diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index f0a452f2d02..3c7d754fa0b 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -12,7 +12,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMetricDataUpdateCoordinator @@ -58,7 +58,7 @@ BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric button based on a config entry.""" coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index ccfd48a3abf..7f356741d76 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMetricDataUpdateCoordinator @@ -58,7 +58,7 @@ NUMBERS = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric number based on a config entry.""" coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index bf9872f2791..eab7cd5997c 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -12,7 +12,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMetricDataUpdateCoordinator @@ -43,7 +43,7 @@ SELECTS = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric select based on a config entry.""" coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index f202a77b530..a5d5da3c046 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMetricDataUpdateCoordinator @@ -45,7 +45,7 @@ SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric sensor based on a config entry.""" coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index 3aabfaf17e1..85e61164639 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LaMetricDataUpdateCoordinator @@ -48,7 +48,7 @@ SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric switch based on a config entry.""" coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index dd76d3e53cc..9bb4af572fd 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -269,7 +269,9 @@ HEAT_METER_SENSOR_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" unique_id = entry.entry_id diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 48770113a80..89025583e92 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -27,7 +27,7 @@ from .coordinator import LastFMDataUpdateCoordinator, LastFMUserData async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the entries.""" diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 7d3b2bd97b6..201b4c8f037 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -122,7 +122,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" name = entry.data.get(CONF_NAME, DEFAULT_NEXT_LAUNCH_NAME) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index cee6aa6c754..82f4f7609dc 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, MODELS @@ -23,7 +23,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors from a config entry created in the integrations UI.""" diff --git a/homeassistant/components/laundrify/sensor.py b/homeassistant/components/laundrify/sensor.py index 98169f95fce..3c343861b0a 100644 --- a/homeassistant/components/laundrify/sensor.py +++ b/homeassistant/components/laundrify/sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -24,7 +24,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add power sensor for passed config_entry in HA.""" diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index d0ce4815f19..65afae56f22 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -35,7 +35,7 @@ from .helpers import InputType def add_lcn_entities( config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" @@ -54,7 +54,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" add_entities = partial( diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 1dff15c4f22..e91ae723714 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( @@ -43,7 +43,7 @@ PARALLEL_UPDATES = 0 def add_lcn_entities( config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" @@ -57,7 +57,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" add_entities = partial( diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 042461b6af2..be713871aae 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -10,7 +10,7 @@ from homeassistant.components.cover import DOMAIN as DOMAIN_COVER, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 def add_lcn_entities( config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" @@ -45,7 +45,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN cover entities from a config entry.""" add_entities = partial( diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 9ec660325c8..cba7c0888b7 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( @@ -37,7 +37,7 @@ PARALLEL_UPDATES = 0 def add_lcn_entities( config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" @@ -54,7 +54,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN light entities from a config entry.""" add_entities = partial( diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index 0f40926cf17..072d0a20757 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -10,7 +10,7 @@ from homeassistant.components.scene import DOMAIN as DOMAIN_SCENE, Scene from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SCENE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( @@ -29,7 +29,7 @@ PARALLEL_UPDATES = 0 def add_lcn_entities( config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" @@ -43,7 +43,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" add_entities = partial( diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index ada0857742c..ee87ed2a91b 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( @@ -50,7 +50,7 @@ DEVICE_CLASS_MAPPING = { def add_lcn_entities( config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" @@ -69,7 +69,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" add_entities = partial( diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index dd940bd38b3..6267a081bc9 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntit from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 def add_lcn_switch_entities( config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" @@ -53,7 +53,7 @@ def add_lcn_switch_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" add_entities = partial( diff --git a/homeassistant/components/ld2410_ble/binary_sensor.py b/homeassistant/components/ld2410_ble/binary_sensor.py index c52bc34b699..3ba43e0d6dc 100644 --- a/homeassistant/components/ld2410_ble/binary_sensor.py +++ b/homeassistant/components/ld2410_ble/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LD2410BLE, LD2410BLECoordinator @@ -31,7 +31,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform for LD2410BLE.""" data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/ld2410_ble/sensor.py b/homeassistant/components/ld2410_ble/sensor.py index 6daa1397161..db4e42580c4 100644 --- a/homeassistant/components/ld2410_ble/sensor.py +++ b/homeassistant/components/ld2410_ble/sensor.py @@ -11,7 +11,7 @@ from homeassistant.const import EntityCategory, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LD2410BLE, LD2410BLECoordinator @@ -122,7 +122,7 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform for LD2410BLE.""" data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/leaone/sensor.py b/homeassistant/components/leaone/sensor.py index 62948868870..c815a0964e0 100644 --- a/homeassistant/components/leaone/sensor.py +++ b/homeassistant/components/leaone/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfMass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -107,7 +107,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Leaone BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 3bca7269eba..14f2f228e13 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -32,7 +32,7 @@ from .models import LEDBLEData async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the light platform for LEDBLE.""" data: LEDBLEData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lektrico/binary_sensor.py b/homeassistant/components/lektrico/binary_sensor.py index d0a3e39690c..37e55ade798 100644 --- a/homeassistant/components/lektrico/binary_sensor.py +++ b/homeassistant/components/lektrico/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator from .entity import LektricoEntity @@ -101,7 +101,7 @@ BINARY_SENSORS: tuple[LektricoBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LektricoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lektrico binary sensor entities based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/lektrico/button.py b/homeassistant/components/lektrico/button.py index 62aef12ff53..e598773321d 100644 --- a/homeassistant/components/lektrico/button.py +++ b/homeassistant/components/lektrico/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator from .entity import LektricoEntity @@ -60,7 +60,7 @@ BUTTONS_FOR_LB_DEVICES: tuple[LektricoButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LektricoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lektrico charger based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/lektrico/number.py b/homeassistant/components/lektrico/number.py index 8054ba8afe5..c54ee938607 100644 --- a/homeassistant/components/lektrico/number.py +++ b/homeassistant/components/lektrico/number.py @@ -15,7 +15,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator from .entity import LektricoEntity @@ -58,7 +58,7 @@ NUMBERS: tuple[LektricoNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LektricoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lektrico number entities based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/lektrico/select.py b/homeassistant/components/lektrico/select.py index ef45d97d697..513a82365af 100644 --- a/homeassistant/components/lektrico/select.py +++ b/homeassistant/components/lektrico/select.py @@ -9,7 +9,7 @@ from lektricowifi import Device from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator from .entity import LektricoEntity @@ -46,7 +46,7 @@ SELECTS: tuple[LektricoSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LektricoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lektrico select entities based on a config entry.""" diff --git a/homeassistant/components/lektrico/sensor.py b/homeassistant/components/lektrico/sensor.py index d55d91c4cd4..927011459b0 100644 --- a/homeassistant/components/lektrico/sensor.py +++ b/homeassistant/components/lektrico/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import IntegrationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator @@ -283,7 +283,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LektricoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lektrico charger based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/lektrico/switch.py b/homeassistant/components/lektrico/switch.py index 0fdfbd2ad41..065e96f84b8 100644 --- a/homeassistant/components/lektrico/switch.py +++ b/homeassistant/components/lektrico/switch.py @@ -9,7 +9,7 @@ from lektricowifi import Device from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator from .entity import LektricoEntity @@ -59,7 +59,7 @@ SWITCHS_FOR_3_PHASE_CHARGERS: tuple[LektricoSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LektricoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lektrico switch entities based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index ab02f2860c6..41150d1b1e9 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -10,7 +10,7 @@ from letpot.models import DeviceFeature, LetPotDeviceStatus from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator from .entity import LetPotEntity, exception_handler @@ -63,7 +63,7 @@ AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: LetPotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LetPot switch entities based on a config entry and device status/features.""" coordinators = entry.runtime_data diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index cca088c8e61..bae61df6a28 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -11,7 +11,7 @@ from letpot.models import LetPotDeviceStatus from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator from .entity import LetPotEntity, exception_handler @@ -54,7 +54,7 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: LetPotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LetPot time entities based on a config entry.""" coordinators = entry.runtime_data diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index b3f8f8e0437..de652eeef08 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.trigger import PluggableAction from .const import ATTR_MANUFACTURER, DOMAIN @@ -47,7 +47,7 @@ SUPPORT_LGTV = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a LG Netcast Media Player from a config_entry.""" diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index cebe1d33728..c3ea22ee08f 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -23,7 +23,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up media_player from a config entry created in the integrations UI.""" async_add_entities( diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py index 845bf8c3079..aeade4d132a 100644 --- a/homeassistant/components/lg_thinq/binary_sensor.py +++ b/homeassistant/components/lg_thinq/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ThinqConfigEntry from .entity import ThinQEntity @@ -136,7 +136,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for binary sensor platform.""" entities: list[ThinQBinarySensorEntity] = [] diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py index 5cf9ccbd442..ff57709f9a8 100644 --- a/homeassistant/components/lg_thinq/climate.py +++ b/homeassistant/components/lg_thinq/climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.temperature import display_temp from . import ThinqConfigEntry @@ -79,7 +79,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for climate platform.""" entities: list[ThinQClimateEntity] = [] diff --git a/homeassistant/components/lg_thinq/event.py b/homeassistant/components/lg_thinq/event.py index b963cba37cc..f9baadf7a05 100644 --- a/homeassistant/components/lg_thinq/event.py +++ b/homeassistant/components/lg_thinq/event.py @@ -9,7 +9,7 @@ from thinqconnect.integration import ActiveMode, ThinQPropertyEx from homeassistant.components.event import EventEntity, EventEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator @@ -57,7 +57,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for event platform.""" entities: list[ThinQEventEntity] = [] diff --git a/homeassistant/components/lg_thinq/fan.py b/homeassistant/components/lg_thinq/fan.py index edcadf2598a..6d07c98744a 100644 --- a/homeassistant/components/lg_thinq/fan.py +++ b/homeassistant/components/lg_thinq/fan.py @@ -14,7 +14,7 @@ from homeassistant.components.fan import ( FanEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for fan platform.""" entities: list[ThinQFanEntity] = [] diff --git a/homeassistant/components/lg_thinq/number.py b/homeassistant/components/lg_thinq/number.py index 634c1a8fe84..0cbfcf9b5c8 100644 --- a/homeassistant/components/lg_thinq/number.py +++ b/homeassistant/components/lg_thinq/number.py @@ -16,7 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ThinqConfigEntry from .entity import ThinQEntity @@ -140,7 +140,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for number platform.""" entities: list[ThinQNumberEntity] = [] diff --git a/homeassistant/components/lg_thinq/select.py b/homeassistant/components/lg_thinq/select.py index e555d616ca3..929fa0b1d28 100644 --- a/homeassistant/components/lg_thinq/select.py +++ b/homeassistant/components/lg_thinq/select.py @@ -10,7 +10,7 @@ from thinqconnect.integration import ActiveMode from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ThinqConfigEntry from .coordinator import DeviceDataUpdateCoordinator @@ -142,7 +142,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for select platform.""" entities: list[ThinQSelectEntity] = [] diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 7baaab52403..bb190cccde9 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import ThinqConfigEntry @@ -492,7 +492,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for sensor platform.""" entities: list[ThinQSensorEntity] = [] diff --git a/homeassistant/components/lg_thinq/switch.py b/homeassistant/components/lg_thinq/switch.py index 6d69ce9a314..06363140193 100644 --- a/homeassistant/components/lg_thinq/switch.py +++ b/homeassistant/components/lg_thinq/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ThinqConfigEntry from .entity import ThinQEntity @@ -172,7 +172,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for switch platform.""" entities: list[ThinQSwitchEntity] = [] diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py index 6cbb731869c..6cf2a9086b1 100644 --- a/homeassistant/components/lg_thinq/vacuum.py +++ b/homeassistant/components/lg_thinq/vacuum.py @@ -15,7 +15,7 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ThinqConfigEntry from .entity import ThinQEntity @@ -73,7 +73,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an entry for vacuum platform.""" entities: list[ThinQStateVacuumEntity] = [] diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 7334241d0ed..81b2c570eab 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import BYTE_SIZES from .coordinator import LidarrConfigEntry, LidarrDataUpdateCoordinator, T @@ -114,7 +114,7 @@ SENSOR_TYPES: dict[str, LidarrSensorEntityDescription[Any]] = { async def async_setup_entry( hass: HomeAssistant, entry: LidarrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lidarr sensors based on a config entry.""" entities: list[LidarrSensor[Any]] = [] diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index 454561a6f4e..f5a974b4626 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, HEV_CYCLE_STATE from .coordinator import LIFXUpdateCoordinator @@ -26,7 +26,9 @@ HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index 694c91b4c27..25ab61aebae 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -10,7 +10,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, IDENTIFY, RESTART from .coordinator import LIFXUpdateCoordinator @@ -32,7 +32,7 @@ IDENTIFY_BUTTON_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" domain_data = hass.data[DOMAIN] diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 2a8031b3874..5641786eb61 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -22,7 +22,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType @@ -79,7 +79,7 @@ HSBK_KELVIN = 3 async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" domain_data = hass.data[DOMAIN] diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index de3a5b431a9..13b81e2a784 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -8,7 +8,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_THEME, @@ -38,7 +38,9 @@ THEME_ENTITY = SelectEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index 68f354024e4..96feba633f4 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_RSSI, DOMAIN from .coordinator import LIFXUpdateCoordinator @@ -32,7 +32,9 @@ RSSI_SENSOR = SensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX sensor from config entry.""" coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py index 1f7ae7ce114..7b0510f00d1 100644 --- a/homeassistant/components/linear_garage_door/cover.py +++ b/homeassistant/components/linear_garage_door/cover.py @@ -10,7 +10,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LinearUpdateCoordinator @@ -24,7 +24,7 @@ SCAN_INTERVAL = timedelta(seconds=10) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Linear Garage Door cover.""" coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/linear_garage_door/light.py b/homeassistant/components/linear_garage_door/light.py index 3679491712f..ac03894d446 100644 --- a/homeassistant/components/linear_garage_door/light.py +++ b/homeassistant/components/linear_garage_door/light.py @@ -7,7 +7,7 @@ from linear_garage_door import Linear from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LinearUpdateCoordinator @@ -19,7 +19,7 @@ SUPPORTED_SUBDEVICES = ["Light"] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Linear Garage Door cover.""" coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/linkplay/button.py b/homeassistant/components/linkplay/button.py index 1c93ebcdc3e..8865cf00aa5 100644 --- a/homeassistant/components/linkplay/button.py +++ b/homeassistant/components/linkplay/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LinkPlayConfigEntry from .entity import LinkPlayBaseEntity, exception_wrap @@ -50,7 +50,7 @@ BUTTON_TYPES: tuple[LinkPlayButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: LinkPlayConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the LinkPlay buttons from config entry.""" diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 456fbf23289..2986db76520 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -30,7 +30,7 @@ from homeassistant.helpers import ( entity_platform, entity_registry as er, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from . import LinkPlayConfigEntry, LinkPlayData @@ -129,7 +129,7 @@ SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema( async def async_setup_entry( hass: HomeAssistant, entry: LinkPlayConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a media player from a config entry.""" diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index f2b9af9adb4..95870927072 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_DEFAULT_TRANSITION, DOMAIN @@ -27,7 +27,7 @@ ATTR_NUMBER = "number" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index 712e223aa3e..dd96b5accb6 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -22,7 +22,7 @@ ATTR_NUMBER = "number" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index 28f751f3ec1..1b46ba360c3 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -19,7 +19,7 @@ ATTR_NUMBER = "number" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 700985d285f..ca9af22f1e9 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT @@ -63,7 +63,7 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, . async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Litter-Robot binary sensors using config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 758548b3a67..da6ac53ccec 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -11,7 +11,7 @@ from pylitterbot import FeederRobot, LitterRobot3, LitterRobot4, Robot from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT @@ -48,7 +48,7 @@ ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index f6e3781f3df..be3a9915940 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -12,7 +12,7 @@ from pylitterbot.robot.litterrobot4 import BrightnessLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator from .entity import LitterRobotEntity, _WhiskerEntityT @@ -68,7 +68,7 @@ ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Litter-Robot selects using config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 3e25a0556c6..a638f24cf2a 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfMass from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT @@ -160,7 +160,7 @@ PET_SENSORS: list[RobotSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Litter-Robot sensors using config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 4839748c068..5924f8f094a 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -11,7 +11,7 @@ from pylitterbot import FeederRobot, LitterRobot from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT @@ -45,7 +45,7 @@ ROBOT_SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Litter-Robot switches using config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index 69d81d63eae..3573418613b 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -12,7 +12,7 @@ from pylitterbot import LitterRobot3 from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry @@ -49,7 +49,7 @@ LITTER_ROBOT_3_SLEEP_START = RobotTimeEntityDescription[LitterRobot3]( async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index 53ab23e9db8..4d9dfe5074d 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -15,7 +15,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity @@ -31,7 +31,7 @@ FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Litter-Robot update platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 314fab6a621..9989c306b51 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry @@ -46,7 +46,7 @@ LITTER_BOX_ENTITY = StateVacuumEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: LitterRobotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Litter-Robot cleaner using config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/livisi/binary_sensor.py b/homeassistant/components/livisi/binary_sensor.py index d4edd59f2d7..50eb4cd28b9 100644 --- a/homeassistant/components/livisi/binary_sensor.py +++ b/homeassistant/components/livisi/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE from .coordinator import LivisiDataUpdateCoordinator @@ -21,7 +21,7 @@ from .entity import LivisiEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary_sensor device.""" coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 3ecdcb486c0..1f5e3360c7d 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -16,7 +16,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -33,7 +33,7 @@ from .entity import LivisiEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate device.""" coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index fa604c5fc87..5599a4af0d4 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES from .coordinator import LivisiDataUpdateCoordinator @@ -19,7 +19,7 @@ from .entity import LivisiEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch device.""" coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index eb7b0c20d91..df6f994a46c 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -26,7 +26,7 @@ from homeassistant.components.calendar import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import CONF_CALENDAR_NAME, DOMAIN @@ -40,7 +40,7 @@ PRODID = "-//homeassistant.io//local_calendar 1.0//EN" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the local calendar platform.""" store = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index db421bbce1d..8be0389678d 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -20,7 +20,10 @@ from homeassistant.helpers import ( entity_platform, issue_registry as ir, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify @@ -40,7 +43,7 @@ PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Camera for local file from a config entry.""" diff --git a/homeassistant/components/local_ip/sensor.py b/homeassistant/components/local_ip/sensor.py index 7f855220563..a4cb9f2d60e 100644 --- a/homeassistant/components/local_ip/sensor.py +++ b/homeassistant/components/local_ip/sensor.py @@ -5,7 +5,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SENSOR @@ -13,7 +13,7 @@ from .const import SENSOR async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" name = entry.data.get(CONF_NAME) or "Local IP" diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index c496fd6b6ba..30df24ea854 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -17,7 +17,7 @@ from homeassistant.components.todo import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util import dt as dt_util @@ -65,7 +65,7 @@ def _migrate_calendar(calendar: Calendar) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: LocalTodoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the local_todo todo platform.""" diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index 47a498331eb..f7ae9039729 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -4,13 +4,15 @@ from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN as LT_DOMAIN, TRACKER_UPDATE async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure a dispatcher connection based on a config entry.""" diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index 051a18c9a32..9cef56bcf9f 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TYPE_TO_PLATFORM from .coordinator import LookinDataUpdateCoordinator @@ -65,7 +65,7 @@ LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the climate platform for lookin from a config entry.""" lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/lookin/light.py b/homeassistant/components/lookin/light.py index 804d0ebef01..d46cb96d6c0 100644 --- a/homeassistant/components/lookin/light.py +++ b/homeassistant/components/lookin/light.py @@ -9,7 +9,7 @@ from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TYPE_TO_PLATFORM from .entity import LookinPowerPushRemoteEntity @@ -21,7 +21,7 @@ LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the light platform for lookin from a config entry.""" lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/lookin/media_player.py b/homeassistant/components/lookin/media_player.py index b3dda9c9e0c..a3568d9f155 100644 --- a/homeassistant/components/lookin/media_player.py +++ b/homeassistant/components/lookin/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TYPE_TO_PLATFORM from .coordinator import LookinDataUpdateCoordinator @@ -44,7 +44,7 @@ _FUNCTION_NAME_TO_FEATURE = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the media_player platform for lookin from a config entry.""" lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/lookin/sensor.py b/homeassistant/components/lookin/sensor.py index cae4f7782a8..89e1ed6aa69 100644 --- a/homeassistant/components/lookin/sensor.py +++ b/homeassistant/components/lookin/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import LookinDeviceCoordinatorEntity @@ -43,7 +43,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lookin sensors from the config entry.""" lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index be6b39176d6..2064537df52 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LoqedDataCoordinator from .const import DOMAIN @@ -20,7 +20,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Loqed lock platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/loqed/sensor.py b/homeassistant/components/loqed/sensor.py index 1d4595db8e9..c28b55b4f98 100644 --- a/homeassistant/components/loqed/sensor.py +++ b/homeassistant/components/loqed/sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import LoqedDataCoordinator, StatusMessage @@ -42,7 +42,9 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Loqed lock platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 8b9def63fda..2189386a4bb 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -72,7 +72,9 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Sensor.Community sensor based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 4b3d12ad743..03feabae0dc 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .entity import LupusecDevice @@ -25,7 +25,7 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an alarm control panel for a Lupusec device.""" data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index b2413e2b462..bcd21adc1aa 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .entity import LupusecBaseSensor @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a binary sensors for a Lupusec device.""" diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index 23f3c927880..a70df90f8e7 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -11,7 +11,7 @@ import lupupy.constants as CONST from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .entity import LupusecBaseSensor @@ -22,7 +22,7 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lupusec switch devices.""" diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index c33b545413d..5bed760e1ac 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, LutronData from .entity import LutronDevice @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron binary_sensor platform. diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 2f80798aee4..e8f3ad09879 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, LutronData from .entity import LutronDevice @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron cover platform. diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py index 7b1b9e65137..942e165b97f 100644 --- a/homeassistant/components/lutron/event.py +++ b/homeassistant/components/lutron/event.py @@ -8,7 +8,7 @@ from homeassistant.components.event import EventEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from . import ATTR_ACTION, ATTR_FULL_ID, ATTR_UUID, DOMAIN, LutronData @@ -33,7 +33,7 @@ LEGACY_EVENT_TYPES: dict[LutronEventType, str] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron event platform.""" entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py index 7db8b12c8d0..5928c3c2da3 100644 --- a/homeassistant/components/lutron/fan.py +++ b/homeassistant/components/lutron/fan.py @@ -10,7 +10,7 @@ from pylutron import Output from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, LutronData from .entity import LutronDevice @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron fan platform. diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 7e8829b231c..58183fb0a38 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, LutronData from .entity import LutronDevice @@ -26,7 +26,7 @@ from .entity import LutronDevice async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron light platform. diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index 9e8070713a9..4889f9056ac 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -9,7 +9,7 @@ from pylutron import Button, Keypad, Lutron from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, LutronData from .entity import LutronKeypad @@ -18,7 +18,7 @@ from .entity import LutronKeypad async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron scene platform. diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index c8b93dd7398..e1e97d1774a 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -10,7 +10,7 @@ from pylutron import Button, Keypad, Led, Lutron, Output from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, LutronData from .entity import LutronDevice, LutronKeypad @@ -19,7 +19,7 @@ from .entity import LutronDevice, LutronKeypad async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron switch platform. diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index b51756692c1..cb0f0da5227 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ATTR_SUGGESTED_AREA from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN as CASETA_DOMAIN from .const import CONFIG_URL, MANUFACTURER, UNASSIGNED_AREA @@ -21,7 +21,7 @@ from .util import area_name_from_id async def async_setup_entry( hass: HomeAssistant, config_entry: LutronCasetaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron Caseta binary_sensor platform. diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py index e56758b0af6..f2da502d346 100644 --- a/homeassistant/components/lutron_caseta/button.py +++ b/homeassistant/components/lutron_caseta/button.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .device_trigger import LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP from .entity import LutronCasetaEntity @@ -17,7 +17,7 @@ from .models import LutronCasetaConfigEntry, LutronCasetaData async def async_setup_entry( hass: HomeAssistant, config_entry: LutronCasetaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lutron pico and keypad buttons.""" data = config_entry.runtime_data diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index d8fac38ce2b..3727dbf17ba 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import LutronCasetaUpdatableEntity from .models import LutronCasetaConfigEntry @@ -114,7 +114,7 @@ PYLUTRON_TYPE_TO_CLASSES = { async def async_setup_entry( hass: HomeAssistant, config_entry: LutronCasetaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron Caseta cover platform. diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 69167929e14..1e7fe07b8ba 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -12,7 +12,7 @@ from homeassistant.components.fan import ( FanEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -28,7 +28,7 @@ ORDERED_NAMED_FAN_SPEEDS = [FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH] async def async_setup_entry( hass: HomeAssistant, config_entry: LutronCasetaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron Caseta fan platform. diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index 722c9a15d91..b920a95e435 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -22,7 +22,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DEVICE_TYPE_COLOR_TUNE, @@ -66,7 +66,7 @@ def to_hass_level(level): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron Caseta light platform. diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py index db4423495a4..671df82d8e0 100644 --- a/homeassistant/components/lutron_caseta/scene.py +++ b/homeassistant/components/lutron_caseta/scene.py @@ -8,7 +8,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as CASETA_DOMAIN from .util import serial_to_unique_id @@ -17,7 +17,7 @@ from .util import serial_to_unique_id async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron Caseta scene platform. diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index 66f23926fbf..b71ccf4bfa8 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import LutronCasetaUpdatableEntity @@ -13,7 +13,7 @@ from .entity import LutronCasetaUpdatableEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron Caseta switch platform. diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index c5d17cfb176..ffcf08b927a 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -35,7 +35,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -121,7 +121,9 @@ SCHEMA_HOLD_TIME: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric climate platform based on a config entry.""" coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 38cb895a110..065ee0fba9d 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -159,7 +159,9 @@ def get_datetime_from_future_time(time_str: str) -> datetime: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric sensor platform based on a config entry.""" coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/madvr/binary_sensor.py b/homeassistant/components/madvr/binary_sensor.py index b6820f94fea..45c915aba8c 100644 --- a/homeassistant/components/madvr/binary_sensor.py +++ b/homeassistant/components/madvr/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import MadVRConfigEntry, MadVRCoordinator from .entity import MadVREntity @@ -55,7 +55,7 @@ BINARY_SENSORS: tuple[MadvrBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: MadVRConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor entities.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/madvr/remote.py b/homeassistant/components/madvr/remote.py index 032a1d718f5..23e969e56e3 100644 --- a/homeassistant/components/madvr/remote.py +++ b/homeassistant/components/madvr/remote.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.remote import RemoteEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import MadVRConfigEntry, MadVRCoordinator from .entity import MadVREntity @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: MadVRConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the madVR remote.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/madvr/sensor.py b/homeassistant/components/madvr/sensor.py index e54e9dca476..783004f3b84 100644 --- a/homeassistant/components/madvr/sensor.py +++ b/homeassistant/components/madvr/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -253,7 +253,7 @@ SENSORS: tuple[MadvrSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: MadVRConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor entities.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py index 93ec77032ce..74537e33cae 100644 --- a/homeassistant/components/mastodon/sensor.py +++ b/homeassistant/components/mastodon/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -59,7 +59,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: MastodonConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform for entity.""" coordinator = entry.runtime_data.coordinator diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 484ed94fb90..b5665e5d47a 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -28,7 +28,7 @@ from .models import MatterDiscoverySchema async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter binary sensor from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 634406d18eb..6a0a5fc5b1d 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -26,7 +26,7 @@ from .models import MatterDiscoverySchema async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter Button platform.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 25419c34e42..df57da4ded3 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -24,7 +24,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity from .helpers import get_matter @@ -174,7 +174,7 @@ class ThermostatRunningState(IntEnum): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter climate platform from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 5b109d52189..2e2d4390b30 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -19,7 +19,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER from .entity import MatterEntity @@ -48,7 +48,7 @@ class OperationalStatus(IntEnum): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter Cover from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 3cb3fe385d4..6fa775fd1b9 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -16,7 +16,7 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity from .helpers import get_matter @@ -39,7 +39,7 @@ EVENT_TYPES_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter switches from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 8b8ebee619d..2c9e190d58a 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -16,7 +16,7 @@ from homeassistant.components.fan import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity from .helpers import get_matter @@ -45,7 +45,7 @@ PRESET_SLEEP_WIND = "sleep_wind" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter fan from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 5c20554f065..8ea804a8a7c 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -24,7 +24,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .const import LOGGER @@ -77,7 +77,7 @@ TRANSITION_BLOCKLIST = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter Light from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 8524b39d584..81de7482d46 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -15,7 +15,7 @@ from homeassistant.components.lock import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER from .entity import MatterEntity @@ -28,7 +28,7 @@ DoorLockFeature = clusters.DoorLock.Bitmaps.Feature async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter lock from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 93b6b8f75c9..44538f46856 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -32,7 +32,7 @@ from .models import MatterDiscoverySchema async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter Number Input from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index b2d1c7f8ddb..e78c34391cd 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -14,7 +14,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -47,7 +47,7 @@ type SelectCluster = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter ModeSelect from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 3503e112db5..10f8db275f5 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -40,7 +40,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from .entity import MatterEntity, MatterEntityDescription @@ -81,7 +81,7 @@ OPERATIONAL_STATE_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter sensors from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 890ca662295..af4803af9a1 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -26,7 +26,7 @@ from .models import MatterDiscoverySchema async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter switches from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py index 5ee9b2e5fa0..7c9ca991914 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import ExtraStoredData @@ -60,7 +60,7 @@ class MatterUpdateExtraStoredData(ExtraStoredData): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter lock from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index de4a885d8fb..5ea1716a37d 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity from .helpers import get_matter @@ -50,7 +50,7 @@ class ModeTag(IntEnum): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter vacuum platform from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py index 29946621853..bea11468c6b 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -14,7 +14,7 @@ from homeassistant.components.valve import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity from .helpers import get_matter @@ -28,7 +28,7 @@ ValveStateEnum = ValveConfigurationAndControl.Enums.ValveStateEnum async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter valve platform from Config Entry.""" matter = get_matter(hass) diff --git a/homeassistant/components/mealie/calendar.py b/homeassistant/components/mealie/calendar.py index 729bc16c6fd..556ddede2e2 100644 --- a/homeassistant/components/mealie/calendar.py +++ b/homeassistant/components/mealie/calendar.py @@ -8,7 +8,7 @@ from aiomealie import Mealplan, MealplanEntryType from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import MealieConfigEntry, MealieMealplanCoordinator from .entity import MealieEntity @@ -19,7 +19,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: MealieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the calendar platform for entity.""" coordinator = entry.runtime_data.mealplan_coordinator diff --git a/homeassistant/components/mealie/sensor.py b/homeassistant/components/mealie/sensor.py index e4b1655a9d1..062a2646cab 100644 --- a/homeassistant/components/mealie/sensor.py +++ b/homeassistant/components/mealie/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import MealieConfigEntry, MealieStatisticsCoordinator @@ -59,7 +59,7 @@ SENSOR_TYPES: tuple[MealieStatisticsSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: MealieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Mealie sensors based on a config entry.""" coordinator = entry.runtime_data.statistics_coordinator diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py index be04b00113e..d42c9033922 100644 --- a/homeassistant/components/mealie/todo.py +++ b/homeassistant/components/mealie/todo.py @@ -14,7 +14,7 @@ from homeassistant.components.todo import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MealieConfigEntry, MealieShoppingListCoordinator @@ -46,7 +46,7 @@ def _convert_api_item(item: ShoppingItem) -> TodoItem: async def async_setup_entry( hass: HomeAssistant, entry: MealieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the todo platform for entity.""" coordinator = entry.runtime_data.shoppinglist_coordinator diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 2a26d848ac2..00fc28b8718 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -136,7 +136,9 @@ SENSOR_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the entry.""" coordinator: DataUpdateCoordinator[dict[str, MeaterProbe]] = hass.data[DOMAIN][ diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py index b5cb29be845..f837620c829 100644 --- a/homeassistant/components/medcom_ble/sensor.py +++ b/homeassistant/components/medcom_ble/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -37,7 +37,7 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Medcom BLE radiation monitor sensors.""" diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 4defd47bc39..03bb4babf1c 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -28,7 +28,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MelCloudDevice from .const import ( @@ -76,7 +76,9 @@ ATW_ZONE_HVAC_ACTION_LOOKUP = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MelCloud device climate based on config_entry.""" mel_devices = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 84585c556ca..51a026e717a 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MelCloudDevice from .const import DOMAIN @@ -104,7 +104,9 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud device sensors based on config_entry.""" mel_devices = hass.data[DOMAIN].get(entry.entry_id) diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py index 8de1ac53311..76fbad41575 100644 --- a/homeassistant/components/melcloud/water_heater.py +++ b/homeassistant/components/melcloud/water_heater.py @@ -20,14 +20,16 @@ from homeassistant.components.water_heater import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, MelCloudDevice from .const import ATTR_STATUS async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MelCloud device climate based on config_entry.""" mel_devices = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index 15c47008346..42c22ae5a43 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -16,7 +16,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MelnorDataUpdateCoordinator @@ -68,7 +68,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index bbb3416dcc9..525a29dc6cf 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -105,7 +105,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index d7fb96739b3..cc5abe8f6f3 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MelnorDataUpdateCoordinator @@ -52,7 +52,7 @@ ZONE_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch platform.""" diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index 08de7e054de..277eb6e36eb 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -13,7 +13,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MelnorDataUpdateCoordinator @@ -42,7 +42,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index d1f0e8bc834..c4f9c8e6885 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -34,7 +34,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, sun from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( @@ -54,7 +54,7 @@ DEFAULT_NAME = "Met.no" async def async_setup_entry( hass: HomeAssistant, config_entry: MetWeatherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 404ef5d8393..72706ccb70f 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -47,7 +47,7 @@ def format_condition(condition: str | None) -> str | None: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 826716f1679..c29cc1ceda9 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -182,7 +182,9 @@ SENSOR_TYPES_PROBABILITY: tuple[MeteoFranceSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteo-France sensor platform.""" data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 8305547afd3..67a56271c2b 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -28,7 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -55,7 +55,9 @@ def format_condition(condition: str): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteo-France weather platform.""" coordinator: DataUpdateCoordinator[MeteoFranceForecast] = hass.data[DOMAIN][ diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 2194f82e43e..e51fcfd3f20 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -112,7 +112,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteoclimatic sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 75a93689efa..fa3b3c92288 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -26,7 +26,9 @@ def format_condition(condition): async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteoclimatic weather platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 61f825abdc3..5a256144d11 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -142,7 +142,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Met Office weather sensor platform.""" hass_data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 5eeddee8dd4..d3f1320c47e 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from . import get_device_info @@ -39,7 +39,9 @@ from .data import MetOfficeData async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Met Office weather sensor platform.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/microbees/binary_sensor.py b/homeassistant/components/microbees/binary_sensor.py index 551f68ba354..1dc2a8d9702 100644 --- a/homeassistant/components/microbees/binary_sensor.py +++ b/homeassistant/components/microbees/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MicroBeesUpdateCoordinator @@ -36,7 +36,9 @@ BINARYSENSOR_TYPES = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the microBees binary sensor platform.""" coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/microbees/button.py b/homeassistant/components/microbees/button.py index f449fa9afee..ca3a76753a7 100644 --- a/homeassistant/components/microbees/button.py +++ b/homeassistant/components/microbees/button.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MicroBeesUpdateCoordinator @@ -15,7 +15,9 @@ BUTTON_TRANSLATIONS = {51: "button_gate", 91: "button_panic"} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the microBees button platform.""" coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/microbees/climate.py b/homeassistant/components/microbees/climate.py index 077048ee352..554ca3b32cc 100644 --- a/homeassistant/components/microbees/climate.py +++ b/homeassistant/components/microbees/climate.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MicroBeesUpdateCoordinator @@ -26,7 +26,9 @@ THERMOVALVE_SENSOR_ID = 782 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the microBees climate platform.""" coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/microbees/cover.py b/homeassistant/components/microbees/cover.py index b6d5d366d89..fe87fcddd62 100644 --- a/homeassistant/components/microbees/cover.py +++ b/homeassistant/components/microbees/cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from .const import DOMAIN @@ -23,7 +23,9 @@ COVER_IDS = {47: "roller_shutter"} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the microBees cover platform.""" coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/microbees/light.py b/homeassistant/components/microbees/light.py index 654cdc37182..a7ff60dc64a 100644 --- a/homeassistant/components/microbees/light.py +++ b/homeassistant/components/microbees/light.py @@ -6,7 +6,7 @@ from homeassistant.components.light import ATTR_RGBW_COLOR, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MicroBeesUpdateCoordinator @@ -14,7 +14,9 @@ from .entity import MicroBeesActuatorEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config entry.""" coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/microbees/sensor.py b/homeassistant/components/microbees/sensor.py index 360422de735..e4be463ab10 100644 --- a/homeassistant/components/microbees/sensor.py +++ b/homeassistant/components/microbees/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MicroBeesUpdateCoordinator @@ -63,7 +63,9 @@ SENSOR_TYPES = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id].coordinator diff --git a/homeassistant/components/microbees/switch.py b/homeassistant/components/microbees/switch.py index 1d668d041e1..deda2d78d09 100644 --- a/homeassistant/components/microbees/switch.py +++ b/homeassistant/components/microbees/switch.py @@ -6,7 +6,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MicroBeesUpdateCoordinator @@ -17,7 +17,9 @@ SWITCH_PRODUCT_IDS = {25, 26, 27, 35, 38, 46, 63, 64, 65, 86} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id].coordinator diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index db4727ec1ec..f7bc10e31d4 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -20,7 +20,7 @@ from .coordinator import Device, MikrotikConfigEntry, MikrotikDataUpdateCoordina async def async_setup_entry( hass: HomeAssistant, config_entry: MikrotikConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Mikrotik component.""" coordinator = config_entry.runtime_data @@ -54,7 +54,7 @@ async def async_setup_entry( @callback def update_items( coordinator: MikrotikDataUpdateCoordinator, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tracked: dict[str, MikrotikDataUpdateCoordinatorTracker], ) -> None: """Update tracked device state from the hub.""" diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 3cd9247c63a..ba496923a30 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -54,7 +54,9 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mill climate.""" if entry.data.get(CONNECTION_TYPE) == LOCAL: diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py index b4ef7bdd2c2..8433a9853c6 100644 --- a/homeassistant/components/mill/number.py +++ b/homeassistant/components/mill/number.py @@ -8,7 +8,7 @@ from homeassistant.components.number import NumberDeviceClass, NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME, UnitOfPower from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CLOUD, CONNECTION_TYPE, DOMAIN from .coordinator import MillDataUpdateCoordinator @@ -16,7 +16,9 @@ from .entity import MillBaseEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mill Number.""" if entry.data.get(CONNECTION_TYPE) == CLOUD: diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 57eead9be18..3a47cb427d2 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -146,7 +146,9 @@ SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mill sensor.""" if entry.data.get(CONNECTION_TYPE) == LOCAL: diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 89252a58864..9039c3e9e24 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -25,7 +25,10 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType @@ -75,7 +78,7 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize min/max/mean config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 60f2e00da0e..d2c8aca57e4 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MinecraftServerCoordinator @@ -28,7 +28,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index fae004a015e..50571123003 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio 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 AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .api import MinecraftServerData, MinecraftServerType @@ -159,7 +159,7 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index dcb2eff2fd6..c60f1c4d760 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -27,7 +27,7 @@ from homeassistant.helpers.aiohttp_client import ( async_get_clientsession, ) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER @@ -39,7 +39,7 @@ BUFFER_SIZE = 102400 async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a MJPEG IP Camera based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/moat/sensor.py b/homeassistant/components/moat/sensor.py index 66edfbe91f2..e968577d789 100644 --- a/homeassistant/components/moat/sensor.py +++ b/homeassistant/components/moat/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -105,7 +105,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Moat BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index e19e00b1277..8f8b8d97295 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_SENSOR_ATTRIBUTES, @@ -28,7 +28,7 @@ from .entity import MobileAppEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up mobile app binary sensor from a config entry.""" entities = [] diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 7e84930e2e9..7e5a0a291b6 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -33,7 +33,9 @@ ATTR_KEYS = (ATTR_ALTITUDE, ATTR_COURSE, ATTR_SPEED, ATTR_VERTICAL_ACCURACY) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Mobile app based off an entry.""" entity = MobileAppEntity(entry) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 06ab924aba2..8200ad1fccd 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN, UnitOfTemperatur from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -36,7 +36,7 @@ from .webhook import _extract_sensor_unique_id async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up mobile app sensor from a config entry.""" entities = [] diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 3cad9062be9..954a638818d 100644 --- a/homeassistant/components/modem_callerid/button.py +++ b/homeassistant/components/modem_callerid/button.py @@ -9,13 +9,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_KEY_API, DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index 00c821f3511..de8e4b2f73c 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -9,13 +9,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CID, DATA_KEY_API, DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] diff --git a/homeassistant/components/modern_forms/binary_sensor.py b/homeassistant/components/modern_forms/binary_sensor.py index ea903c580a4..2bba85f54d7 100644 --- a/homeassistant/components/modern_forms/binary_sensor.py +++ b/homeassistant/components/modern_forms/binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import CLEAR_TIMER, DOMAIN @@ -16,7 +16,7 @@ from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Modern Forms binary sensors.""" coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 988edcb60e5..26c69b28a5c 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -35,7 +35,7 @@ from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Modern Forms platform from config entry.""" diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 2b53a414cea..6216efe3ff4 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -36,7 +36,7 @@ BRIGHTNESS_RANGE = (1, 255) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Modern Forms platform from config entry.""" diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 0f1e90cbe52..aa7d163cfdc 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -7,7 +7,7 @@ from datetime import datetime 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 homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -19,7 +19,7 @@ from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Modern Forms sensor based on a config entry.""" coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/modern_forms/switch.py b/homeassistant/components/modern_forms/switch.py index f2e8b1b705c..89a5b779d74 100644 --- a/homeassistant/components/modern_forms/switch.py +++ b/homeassistant/components/modern_forms/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import modernforms_exception_handler from .const import DOMAIN @@ -18,7 +18,7 @@ from .entity import ModernFormsDeviceEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Modern Forms switch based on a config entry.""" coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py index 1e7018ff1c7..a7479aef5e8 100644 --- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -17,7 +17,7 @@ from .coordinator import Alpha2BaseCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2 sensor entities from a config_entry.""" diff --git a/homeassistant/components/moehlenhoff_alpha2/button.py b/homeassistant/components/moehlenhoff_alpha2/button.py index c7ac574724a..57f9d0e31a2 100644 --- a/homeassistant/components/moehlenhoff_alpha2/button.py +++ b/homeassistant/components/moehlenhoff_alpha2/button.py @@ -4,7 +4,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -15,7 +15,7 @@ from .coordinator import Alpha2BaseCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2 button entities.""" diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index 7c24dad4469..85d5939049e 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2Climate entities from a config_entry.""" diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py index 5286257ff61..306e80e54d3 100644 --- a/homeassistant/components/moehlenhoff_alpha2/sensor.py +++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py @@ -4,7 +4,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -14,7 +14,7 @@ from .coordinator import Alpha2BaseCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Alpha2 sensor entities from a config_entry.""" diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 750ddce8513..451cc65fb55 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -36,7 +36,10 @@ from homeassistant.core import ( ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import TemperatureConverter @@ -105,7 +108,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mold indicator sensor entry.""" name: str = entry.options[CONF_NAME] diff --git a/homeassistant/components/monarch_money/sensor.py b/homeassistant/components/monarch_money/sensor.py index e0dff7d565c..1597d9820a1 100644 --- a/homeassistant/components/monarch_money/sensor.py +++ b/homeassistant/components/monarch_money/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import MonarchMoneyConfigEntry @@ -110,7 +110,7 @@ MONARCH_CASHFLOW_SENSORS: tuple[MonarchMoneyCashflowSensorEntityDescription, ... async def async_setup_entry( hass: HomeAssistant, config_entry: MonarchMoneyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Monarch Money sensors for config entries.""" mm_coordinator = config_entry.runtime_data diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 2dde0832440..9d678c16874 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_SOURCES, @@ -58,7 +58,7 @@ def _get_sources(config_entry): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Monoprice 6-zone amplifier platform.""" port = config_entry.data[CONF_PORT] diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py index 41b97d90452..0b6ab2b70a5 100644 --- a/homeassistant/components/monzo/sensor.py +++ b/homeassistant/components/monzo/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import MonzoCoordinator @@ -65,7 +65,7 @@ MODEL_POT = "Pot" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator: MonzoCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index 09048579859..12d0ff3ed41 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -26,7 +26,7 @@ STATE_WAXING_GIBBOUS = "waxing_gibbous" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" async_add_entities([MoonSensorEntity(entry)], True) diff --git a/homeassistant/components/mopeka/sensor.py b/homeassistant/components/mopeka/sensor.py index 0f67efaea1e..53c93f771f2 100644 --- a/homeassistant/components/mopeka/sensor.py +++ b/homeassistant/components/mopeka/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import MopekaConfigEntry @@ -115,7 +115,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: MopekaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Mopeka BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/motion_blinds/button.py b/homeassistant/components/motion_blinds/button.py index 89841bf8fd4..09f29e09c70 100644 --- a/homeassistant/components/motion_blinds/button.py +++ b/homeassistant/components/motion_blinds/button.py @@ -8,7 +8,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY from .coordinator import DataUpdateCoordinatorMotionBlinds @@ -18,7 +18,7 @@ from .entity import MotionCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Motionblinds.""" entities: list[ButtonEntity] = [] diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 1ea3a6ed9d6..dbf43e3d30f 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import ( @@ -83,7 +83,7 @@ SET_ABSOLUTE_POSITION_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Motion Blind from a config entry.""" entities: list[MotionBaseDevice] = [] diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index 6418cebda0c..60d283aa0b6 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY from .entity import MotionCoordinatorEntity @@ -26,7 +26,7 @@ ATTR_BATTERY_VOLTAGE = "battery_voltage" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Motionblinds.""" entities: list[SensorEntity] = [] diff --git a/homeassistant/components/motionblinds_ble/button.py b/homeassistant/components/motionblinds_ble/button.py index a099276cd85..12fb6c7a513 100644 --- a/homeassistant/components/motionblinds_ble/button.py +++ b/homeassistant/components/motionblinds_ble/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_CONNECT, ATTR_DISCONNECT, ATTR_FAVORITE, CONF_MAC_CODE, DOMAIN from .entity import MotionblindsBLEEntity @@ -53,7 +53,9 @@ BUTTON_TYPES: list[MotionblindsBLEButtonEntityDescription] = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button entities based on a config entry.""" diff --git a/homeassistant/components/motionblinds_ble/cover.py b/homeassistant/components/motionblinds_ble/cover.py index afeeb5b0d70..beaee8598b5 100644 --- a/homeassistant/components/motionblinds_ble/cover.py +++ b/homeassistant/components/motionblinds_ble/cover.py @@ -19,7 +19,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_BLIND_TYPE, CONF_MAC_CODE, DOMAIN, ICON_VERTICAL_BLIND from .entity import MotionblindsBLEEntity @@ -61,7 +61,9 @@ BLIND_TYPE_TO_ENTITY_DESCRIPTION: dict[str, MotionblindsBLECoverEntityDescriptio async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover entity based on a config entry.""" diff --git a/homeassistant/components/motionblinds_ble/select.py b/homeassistant/components/motionblinds_ble/select.py index c297c887910..976f51a0a0f 100644 --- a/homeassistant/components/motionblinds_ble/select.py +++ b/homeassistant/components/motionblinds_ble/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_SPEED, CONF_MAC_CODE, DOMAIN from .entity import MotionblindsBLEEntity @@ -32,7 +32,9 @@ SELECT_TYPES: dict[str, SelectEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities based on a config entry.""" diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py index 740a0509a9e..8993a3b1cd5 100644 --- a/homeassistant/components/motionblinds_ble/sensor.py +++ b/homeassistant/components/motionblinds_ble/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -92,7 +92,9 @@ SENSORS: tuple[MotionblindsBLESensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities based on a config entry.""" diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index df4c321037e..159956277a8 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import get_camera_from_cameras, is_acceptable_camera, listen_for_new_cameras @@ -93,7 +93,9 @@ SCHEMA_SERVICE_SET_TEXT = vol.Schema( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" entry_data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index e0113544848..c160b77c16a 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -12,7 +12,7 @@ from motioneye_client.const import KEY_ACTIONS from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -24,7 +24,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" entry_data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 9d704f17740..89d3b8a8727 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -19,7 +19,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import get_camera_from_cameras, listen_for_new_cameras @@ -67,7 +67,9 @@ MOTIONEYE_SWITCHES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" entry_data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index f19af67e198..104c5e65830 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MotionMountConfigEntry from .entity import MotionMountEntity @@ -16,7 +16,7 @@ from .entity import MotionMountEntity async def async_setup_entry( hass: HomeAssistant, entry: MotionMountConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" mm = entry.runtime_data diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py index 6305820174f..b764306a6a3 100644 --- a/homeassistant/components/motionmount/number.py +++ b/homeassistant/components/motionmount/number.py @@ -8,7 +8,7 @@ from homeassistant.components.number import NumberEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MotionMountConfigEntry from .const import DOMAIN @@ -18,7 +18,7 @@ from .entity import MotionMountEntity async def async_setup_entry( hass: HomeAssistant, entry: MotionMountConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" mm = entry.runtime_data diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 31c5056b91f..832a39208c6 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -9,7 +9,7 @@ import motionmount from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MotionMountConfigEntry from .const import DOMAIN, WALL_PRESET_NAME @@ -22,7 +22,7 @@ SCAN_INTERVAL = timedelta(seconds=60) async def async_setup_entry( hass: HomeAssistant, entry: MotionMountConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" mm = entry.runtime_data diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 685c3ebf932..3545581dae3 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -7,7 +7,7 @@ from motionmount import MotionMountSystemError from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MotionMountConfigEntry from .entity import MotionMountEntity @@ -24,7 +24,7 @@ ERROR_MESSAGES: Final = { async def async_setup_entry( hass: HomeAssistant, entry: MotionMountConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vogel's MotionMount from a config entry.""" mm = entry.runtime_data diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index db3901016f7..14b69e941b7 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -31,7 +31,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN, LOGGER @@ -68,7 +68,9 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up media player from config_entry.""" diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 7bdc13d0522..64b1a6b05fa 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import subscription @@ -115,7 +115,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT alarm control panel through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index d736123eae8..a1e146d4e36 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, event as evt -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -69,7 +69,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT binary sensor through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index b6056c2efd9..5b2bcc8920f 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA @@ -43,7 +43,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT button through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 88fabad0446..d3615edcbba 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription @@ -60,7 +60,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT camera through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 12619609f64..a65eb18e3f1 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -350,7 +350,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT climate through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 626e0cef64a..c93fdd9c760 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -220,7 +220,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT cover through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index d3ad57ef43d..4017245cf51 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -79,7 +79,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 5855f94dad7..aef21838d59 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object @@ -73,7 +73,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index d8e96eb2734..3fac4d4ffe0 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -28,7 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -190,7 +190,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT fan through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index bffe0ec1420..07ddcddb13a 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -183,7 +183,7 @@ TOPICS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT humidifier through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 4b7b2d783d2..a668608dd55 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType @@ -82,7 +82,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT image through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 87577c4b4d9..7727efcf04d 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -80,7 +80,7 @@ DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EX async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT lawn mower through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 328f80cb5ea..3ffad9226be 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components import light from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, VolSchemaType from ..entity import async_setup_entity_entry_helper @@ -69,7 +69,7 @@ PLATFORM_SCHEMA_MODERN = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT lights through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 895bfba3560..727e689798e 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -116,7 +116,7 @@ STATE_CONFIG_KEYS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT lock through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 7e0a7fd4dd8..0b6dbce38b4 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA @@ -39,7 +39,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT notify through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 9b47a3ad23a..5ee93cfba07 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -109,7 +109,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT number through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index c6651510a36..12f680b6e12 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import MQTT_BASE_SCHEMA @@ -43,7 +43,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT scene through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 55d56ecd774..1b3ea1a7c44 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -63,7 +63,7 @@ DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EX async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT select through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index ad84ebb09a3..3e8a4fef0fa 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -124,7 +124,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT sensor through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 5e3ca76e722..48ab4676dea 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -29,7 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.template import Template @@ -113,7 +113,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT siren through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index a305fa83485..f6996fc77ce 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType @@ -70,7 +70,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT switch through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index b4ed33a7730..d306fc0819b 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType @@ -95,7 +95,7 @@ PLATFORM_SCHEMA_MODERN = vol.All(_PLATFORM_SCHEMA_BASE, valid_text_size_configur async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT text through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 59742d24b60..c4916b5010c 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -82,7 +82,7 @@ MQTT_JSON_UPDATE_SCHEMA = vol.Schema( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT update entity through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index ae6b25eff14..f1d2eb34fe1 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType from homeassistant.util.json import json_loads_object @@ -175,7 +175,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT vacuum through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index b380199332b..53f7d06429e 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from homeassistant.util.percentage import ( @@ -136,7 +136,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT valve through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 967eceac326..31d4f0fe30e 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util.unit_conversion import TemperatureConverter @@ -166,7 +166,7 @@ DISCOVERY_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MQTT water heater device through YAML and through MQTT discovery.""" async_setup_entity_entry_helper( diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py index 2e649d9a586..ad488058025 100644 --- a/homeassistant/components/mullvad/binary_sensor.py +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -28,7 +28,7 @@ BINARY_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator = hass.data[DOMAIN] diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 4a7e20046b2..5621b5eb562 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -41,7 +41,7 @@ from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.util.dt import utc_from_timestamp @@ -137,7 +137,7 @@ def catch_musicassistant_error[_R, **P]( async def async_setup_entry( hass: HomeAssistant, entry: MusicAssistantConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Music Assistant MediaPlayer(s) from Config Entry.""" mass = entry.runtime_data.mass diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 87bf246f4e0..7a9025762ef 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import update_coordinator from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -18,7 +18,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the mütesync button.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 54f7036b79c..d42b2194315 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -71,7 +71,7 @@ SENSORS: dict[str, MySensorsBinarySensorDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index 23b7c47ebf3..d1504f3afab 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from . import setup_mysensors_platform @@ -43,7 +43,7 @@ OPERATION_LIST = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 808589b9022..14e6ff6dc15 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -31,7 +31,7 @@ class CoverState(Enum): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 5abe6a64e2d..56d8b2f5923 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -18,7 +18,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 87f60174cab..9e4054ca3d0 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import rgb_hex_to_rgb_list from . import setup_mysensors_platform @@ -27,7 +27,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" device_class_map: dict[SensorType, type[MySensorsChildEntity]] = { diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index 1a4f6fdaa90..ada801f92ab 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -25,7 +25,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index eec3c6bcd79..33f3d6afaf4 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from . import setup_mysensors_platform @@ -210,7 +210,7 @@ SENSORS: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 4eabf6374f1..52207c21f77 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType @@ -20,7 +20,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" device_class_map: dict[SensorType, type[MySensorsSwitch]] = { diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index 4edb5ccdbd8..8eff7a255e7 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo @@ -18,7 +18,7 @@ from .helpers import on_unload async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index 5dabb609437..3942f601a20 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER @@ -31,7 +31,9 @@ EFFECT_SUNRISE = "sunrise" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" info = hass.data[DOMAIN][entry.entry_id].info diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index 2c35d35dad6..bd5c9b923a2 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER @@ -55,7 +55,9 @@ SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" device: MyStromSwitch = hass.data[DOMAIN][entry.entry_id].device diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index af135027aac..f626656a4e3 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo, format_mac -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER @@ -21,7 +21,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" device = hass.data[DOMAIN][entry.entry_id].device diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py index c24bf142b43..785a7ff4532 100644 --- a/homeassistant/components/myuplink/binary_sensor.py +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import F_SERIES from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator @@ -58,7 +58,7 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink binary_sensor.""" entities: list[BinarySensorEntity] = [] diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py index 126dc49163d..33100850837 100644 --- a/homeassistant/components/myuplink/number.py +++ b/homeassistant/components/myuplink/number.py @@ -7,7 +7,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, F_SERIES from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator @@ -63,7 +63,7 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink number.""" entities: list[NumberEntity] = [] diff --git a/homeassistant/components/myuplink/select.py b/homeassistant/components/myuplink/select.py index cad84d18646..36f9be63669 100644 --- a/homeassistant/components/myuplink/select.py +++ b/homeassistant/components/myuplink/select.py @@ -9,7 +9,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator @@ -20,7 +20,7 @@ from .helpers import find_matching_platform, skip_entity async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink select.""" entities: list[SelectEntity] = [] diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 03734210e9c..3b14cdd4630 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import F_SERIES @@ -214,7 +214,7 @@ def get_description(device_point: DevicePoint) -> SensorEntityDescription | None async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink sensor.""" diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py index e175db93278..2d3706f2bdb 100644 --- a/homeassistant/components/myuplink/switch.py +++ b/homeassistant/components/myuplink/switch.py @@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, F_SERIES from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator @@ -55,7 +55,7 @@ def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up myUplink switch.""" entities: list[SwitchEntity] = [] diff --git a/homeassistant/components/myuplink/update.py b/homeassistant/components/myuplink/update.py index 8f4975fe1a5..ee259f5cbe8 100644 --- a/homeassistant/components/myuplink/update.py +++ b/homeassistant/components/myuplink/update.py @@ -6,7 +6,7 @@ from homeassistant.components.update import ( UpdateEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator from .entity import MyUplinkEntity @@ -20,7 +20,7 @@ UPDATE_DESCRIPTION = UpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: MyUplinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entity.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index 980201be28c..60145e4fe27 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -11,7 +11,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator @@ -28,7 +28,9 @@ RESTART_BUTTON: ButtonEntityDescription = ButtonEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: NAMConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NAMConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a Nettigo Air Monitor entities from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 24080d1c3c1..4478507dc59 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow @@ -356,7 +356,9 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: NAMConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NAMConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a Nettigo Air Monitor entities from a config_entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nanoleaf/button.py b/homeassistant/components/nanoleaf/button.py index eb997036b48..813d81ab571 100644 --- a/homeassistant/components/nanoleaf/button.py +++ b/homeassistant/components/nanoleaf/button.py @@ -3,7 +3,7 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NanoleafConfigEntry, NanoleafCoordinator from .entity import NanoleafEntity @@ -12,7 +12,7 @@ from .entity import NanoleafEntity async def async_setup_entry( hass: HomeAssistant, entry: NanoleafConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nanoleaf button.""" async_add_entities([NanoleafIdentifyButton(entry.runtime_data)]) diff --git a/homeassistant/components/nanoleaf/event.py b/homeassistant/components/nanoleaf/event.py index e77ee03681a..78ff889bdc5 100644 --- a/homeassistant/components/nanoleaf/event.py +++ b/homeassistant/components/nanoleaf/event.py @@ -3,7 +3,7 @@ from homeassistant.components.event import EventEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import TOUCH_MODELS from .coordinator import NanoleafConfigEntry, NanoleafCoordinator @@ -13,7 +13,7 @@ from .entity import NanoleafEntity async def async_setup_entry( hass: HomeAssistant, entry: NanoleafConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nanoleaf event.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 4d73a012765..6d42110d53e 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NanoleafConfigEntry, NanoleafCoordinator from .entity import NanoleafEntity @@ -27,7 +27,7 @@ DEFAULT_NAME = "Nanoleaf" async def async_setup_entry( hass: HomeAssistant, entry: NanoleafConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nanoleaf light.""" async_add_entities([NanoleafLight(entry.runtime_data)]) diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index c5a9e085b83..740db1ed1a1 100644 --- a/homeassistant/components/nasweb/switch.py +++ b/homeassistant/components/nasweb/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntit from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( BaseCoordinatorEntity, @@ -38,7 +38,7 @@ def _get_output(coordinator: NASwebCoordinator, index: int) -> NASwebOutput | No async def async_setup_entry( hass: HomeAssistant, config: NASwebConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switch platform.""" diff --git a/homeassistant/components/neato/button.py b/homeassistant/components/neato/button.py index 29114ce5188..8658dfd1b1b 100644 --- a/homeassistant/components/neato/button.py +++ b/homeassistant/components/neato/button.py @@ -8,14 +8,16 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NEATO_ROBOTS from .entity import NeatoEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato button from config entry.""" entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]] diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index e4d5f81f33a..42278a3a48f 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -13,7 +13,7 @@ from urllib3.response import HTTPResponse from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES from .entity import NeatoEntity @@ -26,7 +26,9 @@ ATTR_GENERATED_AT = "generated_at" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato camera with config entry.""" neato: NeatoHub = hass.data[NEATO_LOGIN] diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index c247cc48493..4be02fe1ef7 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES from .entity import NeatoEntity @@ -27,7 +27,9 @@ BATTERY = "Battery" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Neato sensor using config entry.""" neato: NeatoHub = hass.data[NEATO_LOGIN] diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index 25da1c41df1..1ae06fef44c 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES from .entity import NeatoEntity @@ -29,7 +29,9 @@ SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato switch with config entry.""" neato: NeatoHub = hass.data[NEATO_LOGIN] diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 1a9285964a2..a1e1382eb04 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTION, @@ -58,7 +58,9 @@ ATTR_ZONE = "zone" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato vacuum with config entry.""" neato: NeatoHub = hass.data[NEATO_LOGIN] diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index df02f17444f..f5985da9ff8 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -30,7 +30,7 @@ from homeassistant.components.camera import ( from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow @@ -51,7 +51,9 @@ BACKOFF_MULTIPLIER = 1.5 async def async_setup_entry( - hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NestConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cameras.""" diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 3193d592120..f5eff664f83 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -30,7 +30,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .device_info import NestDeviceInfo from .types import NestConfigEntry @@ -76,7 +76,9 @@ MIN_TEMP_RANGE = 1.66667 async def async_setup_entry( - hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NestConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the client entities.""" diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py index 1a2c0317496..9bb041fce6c 100644 --- a/homeassistant/components/nest/event.py +++ b/homeassistant/components/nest/event.py @@ -13,7 +13,7 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .device_info import NestDeviceInfo from .events import ( @@ -66,7 +66,9 @@ ENTITY_DESCRIPTIONS = [ async def async_setup_entry( - hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NestConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" async_add_entities( diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index 02a0e305813..a6fda48fe87 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .device_info import NestDeviceInfo from .types import NestConfigEntry @@ -31,7 +31,9 @@ DEVICE_TYPE_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NestConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index c478525753a..d35bfa7e8a6 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NETATMO_CREATE_WEATHER_SENSOR from .data_handler import NetatmoDevice @@ -23,7 +23,9 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Netatmo binary sensors based on a config entry.""" diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index 7b2899c84aa..e77b5188067 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -10,7 +10,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo button platform.""" diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 3bd7bcd859d..f21998bbac8 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_CAMERA_LIGHT_MODE, @@ -48,7 +48,9 @@ DEFAULT_QUALITY = "high" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo camera platform.""" diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 02c955beac3..2e3d8c6bcb8 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -30,7 +30,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import ( @@ -118,7 +118,9 @@ NA_VALVE = DeviceType.NRV async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo energy platform.""" diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index c34b3a1b47b..a599aacd719 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -16,7 +16,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo cover platform.""" diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py index 9f3fe7174ff..b0dc74c2b58 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice @@ -28,7 +28,7 @@ PRESETS = {v: k for k, v in PRESET_MAPPING.items()} async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo fan platform.""" diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index fe30dc0eaa4..ce28c455dea 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_URL_CONTROL, @@ -30,7 +30,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo camera light platform.""" diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 92568b73e80..e8637c90584 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_URL_ENERGY, @@ -26,7 +26,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo energy platform schedule selector.""" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index cc233dcc0ce..5f8084d542c 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -38,7 +38,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -385,7 +385,9 @@ BATTERY_SENSOR_DESCRIPTION = NetatmoSensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo sensor platform.""" diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py index 6ba4628a358..9ee37c11528 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo switch platform.""" diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index e5b9ec209c7..726c1b2296d 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -12,7 +12,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER @@ -38,7 +38,9 @@ BUTTONS = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index b17430d2abb..56f4ecac14f 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -7,7 +7,7 @@ import logging from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DEVICE_ICONS, DOMAIN, KEY_COORDINATOR, KEY_ROUTER @@ -18,7 +18,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index d807f7aed0a..521e18098eb 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -274,7 +274,9 @@ SENSOR_LINK_TYPES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index 85f214d784a..dd8468df099 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER @@ -99,7 +99,9 @@ ROUTER_SWITCH_TYPES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py index 1fbfee3d892..388ad8bff4f 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -12,7 +12,7 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR_FIRMWARE, KEY_ROUTER @@ -23,7 +23,9 @@ LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Netgear component.""" router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index cf7e757e8f1..890bcb37443 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NetgearLTEConfigEntry from .entity import LTEEntity @@ -39,7 +39,7 @@ BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NetgearLTEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netgear LTE binary sensor.""" async_add_entities( diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 525d7f8aea0..49301267d9d 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfInformation, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import NetgearLTEConfigEntry @@ -127,7 +127,7 @@ SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NetgearLTEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netgear LTE sensor.""" async_add_entities(NetgearLTESensor(entry, description) for description in SENSORS) diff --git a/homeassistant/components/nexia/binary_sensor.py b/homeassistant/components/nexia/binary_sensor.py index 204d84ed975..224836c81e6 100644 --- a/homeassistant/components/nexia/binary_sensor.py +++ b/homeassistant/components/nexia/binary_sensor.py @@ -2,7 +2,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import NexiaThermostatEntity from .types import NexiaConfigEntry @@ -11,7 +11,7 @@ from .types import NexiaConfigEntry async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 81e7800fd01..e9de81cca7c 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -33,7 +33,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import VolDictType @@ -116,7 +116,7 @@ NEXIA_SUPPORTED = ( async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nexia/number.py b/homeassistant/components/nexia/number.py index 46cc4d094a3..05d9e5b4614 100644 --- a/homeassistant/components/nexia/number.py +++ b/homeassistant/components/nexia/number.py @@ -7,7 +7,7 @@ from nexia.thermostat import NexiaThermostat from homeassistant.components.number import NumberEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NexiaDataUpdateCoordinator from .entity import NexiaThermostatEntity @@ -18,7 +18,7 @@ from .util import percent_conv async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nexia/scene.py b/homeassistant/components/nexia/scene.py index 60078fab822..fe75eb07e02 100644 --- a/homeassistant/components/nexia/scene.py +++ b/homeassistant/components/nexia/scene.py @@ -6,7 +6,7 @@ from nexia.automation import NexiaAutomation from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from .const import ATTR_DESCRIPTION @@ -20,7 +20,7 @@ SCENE_ACTIVATION_TIME = 5 async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up automations for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index e50bd750c2f..293a9308cb4 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity from .types import NexiaConfigEntry @@ -22,7 +22,7 @@ from .util import percent_conv async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for a Nexia device.""" diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py index 9505538e86a..1897ad67414 100644 --- a/homeassistant/components/nexia/switch.py +++ b/homeassistant/components/nexia/switch.py @@ -10,7 +10,7 @@ from nexia.zone import NexiaThermostatZone from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NexiaDataUpdateCoordinator from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity @@ -20,7 +20,7 @@ from .types import NexiaConfigEntry async def async_setup_entry( hass: HomeAssistant, config_entry: NexiaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for a Nexia device.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 554814fe2db..2e184e13fc7 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load values from configuration and initialize the platform.""" _LOGGER.debug(config.data) diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 10e1a000a68..f51796e6c7f 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity @@ -54,7 +54,7 @@ BINARY_SENSORS: Final[list[BinarySensorEntityDescription]] = [ async def async_setup_entry( hass: HomeAssistant, entry: NextcloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nextcloud binary sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index a6722821012..63b31f0edde 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utc_from_timestamp from .coordinator import NextcloudConfigEntry @@ -602,7 +602,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ async def async_setup_entry( hass: HomeAssistant, entry: NextcloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nextcloud sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py index aad6412b7b3..b991b001117 100644 --- a/homeassistant/components/nextcloud/update.py +++ b/homeassistant/components/nextcloud/update.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.update import UpdateEntity, UpdateEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NextcloudConfigEntry from .entity import NextcloudEntity @@ -13,7 +13,7 @@ from .entity import NextcloudEntity async def async_setup_entry( hass: HomeAssistant, entry: NextcloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nextcloud update entity.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index 08a1f89418f..ed244146efc 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry @@ -51,7 +51,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, entry: NextDnsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add NextDNS entities from a config_entry.""" coordinator = entry.runtime_data.connection diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index 164d725b393..b36c243a463 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -7,7 +7,7 @@ from nextdns import AnalyticsStatus from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry @@ -25,7 +25,7 @@ CLEAR_LOGS_BUTTON = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: NextDnsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add aNextDNS entities from a config_entry.""" coordinator = entry.runtime_data.status diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index ef2b5140fa1..0a4a8eaad8f 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -21,7 +21,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -286,7 +286,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NextDnsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a NextDNS entities from a config_entry.""" async_add_entities( diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 37ff22c7521..b7c77bd9dbd 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry @@ -525,7 +525,7 @@ SWITCHES = ( async def async_setup_entry( hass: HomeAssistant, entry: NextDnsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add NextDNS entities from a config_entry.""" coordinator = entry.runtime_data.settings diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 0cb16bf4485..284e4d83569 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySenso from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -18,7 +18,7 @@ from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index df8ceef6479..849912af656 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -9,7 +9,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER @@ -19,7 +19,7 @@ from .coordinator import CoilCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 94db90e7f58..1b8a0ecc0df 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -27,7 +27,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -44,7 +44,7 @@ from .coordinator import CoilCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index cb379139eed..d85e5e9b765 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -8,7 +8,7 @@ from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -18,7 +18,7 @@ from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index 3aecff94649..c92c12a882a 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -8,7 +8,7 @@ from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -18,7 +18,7 @@ from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index d34fed50977..ac4f9eba308 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -127,7 +127,7 @@ UNIT_DESCRIPTIONS = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 72b7c20c7b3..2daf3fc48ff 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import CoilCoordinator @@ -20,7 +20,7 @@ from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index f53df596d27..a72851e7eab 100644 --- a/homeassistant/components/nibe_heatpump/water_heater.py +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -17,7 +17,7 @@ from homeassistant.components.water_heater import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -32,7 +32,7 @@ from .coordinator import CoilCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py index 79afbcad532..03124971410 100644 --- a/homeassistant/components/nice_go/cover.py +++ b/homeassistant/components/nice_go/cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import NiceGOConfigEntry @@ -29,7 +29,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: NiceGOConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nice G.O. cover.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nice_go/event.py b/homeassistant/components/nice_go/event.py index a02c14f87ab..400cc3d2144 100644 --- a/homeassistant/components/nice_go/event.py +++ b/homeassistant/components/nice_go/event.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.event import EventEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: NiceGOConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nice G.O. event.""" diff --git a/homeassistant/components/nice_go/light.py b/homeassistant/components/nice_go/light.py index cd8170ae353..5b06c02f5db 100644 --- a/homeassistant/components/nice_go/light.py +++ b/homeassistant/components/nice_go/light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ColorMode, LightEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: NiceGOConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nice G.O. light.""" diff --git a/homeassistant/components/nice_go/switch.py b/homeassistant/components/nice_go/switch.py index 607b0c827d2..e81ea489d2f 100644 --- a/homeassistant/components/nice_go/switch.py +++ b/homeassistant/components/nice_go/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: NiceGOConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nice G.O. switch.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 620349ec3c3..de1dadf1143 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DATE, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, DOMAIN @@ -27,7 +27,7 @@ DEFAULT_NAME = "Blood Glucose" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Glucose Sensor.""" api = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/niko_home_control/cover.py b/homeassistant/components/niko_home_control/cover.py index b3546b517d5..2ab3438c4d9 100644 --- a/homeassistant/components/niko_home_control/cover.py +++ b/homeassistant/components/niko_home_control/cover.py @@ -8,7 +8,7 @@ from nhc.cover import NHCCover from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NikoHomeControlConfigEntry from .entity import NikoHomeControlEntity @@ -17,7 +17,7 @@ from .entity import NikoHomeControlEntity async def async_setup_entry( hass: HomeAssistant, entry: NikoHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Niko Home Control cover entry.""" controller = entry.runtime_data diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py index 7c0d11b3388..b0a2d12b004 100644 --- a/homeassistant/components/niko_home_control/light.py +++ b/homeassistant/components/niko_home_control/light.py @@ -19,7 +19,10 @@ from homeassistant.const import CONF_HOST from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import NHCController, NikoHomeControlConfigEntry @@ -80,7 +83,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: NikoHomeControlConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Niko Home Control light entry.""" controller = entry.runtime_data diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 10d3008fd82..3f7d496aca9 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -36,7 +36,7 @@ from .coordinator import NINADataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entries.""" diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index c8e7e7c25ea..afac3f06435 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -9,7 +9,7 @@ from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NmapDevice, NmapDeviceScanner, short_hostname, signal_device_update from .const import DOMAIN @@ -18,7 +18,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Nmap Tracker component.""" nmap_tracker = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 6d13777e10a..c6dea2d0843 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -23,7 +23,10 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -151,7 +154,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NMBS sensor entities based on a config entry.""" api_client = iRail() diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index a089209cde5..771da420213 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import ( @@ -46,7 +46,7 @@ MAX_TEMPERATURE = 40 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nobø Ecohub platform from UI configuration.""" diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 43f177dd7a0..c24dbe3d21d 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -10,7 +10,7 @@ from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_HARDWARE_VERSION, @@ -26,7 +26,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 1632b6ba5e7..382fd1b0bf4 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODEL, ATTR_NAME, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER @@ -22,7 +22,7 @@ from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py index 30910f8e5f6..c6993826239 100644 --- a/homeassistant/components/nordpool/sensor.py +++ b/homeassistant/components/nordpool/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify from . import NordPoolConfigEntry @@ -271,7 +271,7 @@ DAILY_AVERAGE_PRICES_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NordPoolConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Nord Pool sensor platform.""" diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 8c57310752a..5552305e867 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -107,7 +107,9 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Notion sensors based on a config entry.""" coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index fb853e65d7d..24496c8391a 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE from .coordinator import NotionDataUpdateCoordinator @@ -42,7 +42,9 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Notion sensors based on a config entry.""" coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 8248c1b9b82..376a07ddb7b 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -23,7 +23,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import event as event_helper from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, NUHEAT_API_STATE_SHIFT_DELAY @@ -55,7 +55,7 @@ SCHEDULE_MODE_TO_PRESET_MODE_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NuHeat thermostat(s).""" thermostat, coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 8269c43813e..2785c46ca17 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData from .const import DOMAIN as NUKI_DOMAIN @@ -20,7 +20,9 @@ from .entity import NukiEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki binary sensors.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index a2bf7559fc4..3cc972d3555 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -15,7 +15,7 @@ from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData from .const import ATTR_ENABLE, ATTR_UNLATCH, DOMAIN as NUKI_DOMAIN, ERROR_STATES @@ -24,7 +24,9 @@ from .helpers import CannotConnect async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock platform.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index d89202ac7d7..4f3890a10cf 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NukiEntryData from .const import DOMAIN as NUKI_DOMAIN @@ -16,7 +16,9 @@ from .entity import NukiEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock sensor.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index bb702873052..22e0496d0de 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -963,7 +963,7 @@ def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: async def async_setup_entry( hass: HomeAssistant, config_entry: NutConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NUT sensors.""" diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index d1992056d47..63579c95883 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, TimestampDataUpdateCoordinator, @@ -148,7 +148,9 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: NWSConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NWSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NWS weather platform.""" nws_data = entry.runtime_data diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index d34a5abe8af..c90c67edcb7 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -40,7 +40,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_platform, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util.json import JsonValueType from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter @@ -87,7 +87,9 @@ def convert_condition(time: str, weather: tuple[tuple[str, int | None], ...]) -> async def async_setup_entry( - hass: HomeAssistant, entry: NWSConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: NWSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NWS weather platform.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/nyt_games/sensor.py b/homeassistant/components/nyt_games/sensor.py index 4191c888ae1..5009eafd85a 100644 --- a/homeassistant/components/nyt_games/sensor.py +++ b/homeassistant/components/nyt_games/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import NYTGamesConfigEntry, NYTGamesCoordinator @@ -146,7 +146,7 @@ CONNECTIONS_SENSORS: tuple[NYTGamesConnectionsSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: NYTGamesConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NYT Games sensor entities based on a config entry.""" diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index f6a4e4cc973..2328bf453f0 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -93,7 +93,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NZBGet sensor based on a config entry.""" coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index 552a1854902..0796f628507 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_COORDINATOR, DOMAIN from .coordinator import NZBGetDataUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import NZBGetEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NZBGet sensor based on a config entry.""" coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py index d1b924b4693..9cef92d3fce 100644 --- a/homeassistant/components/obihai/button.py +++ b/homeassistant/components/obihai/button.py @@ -27,7 +27,7 @@ BUTTON_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + async_add_entities: entity_platform.AddConfigEntryEntitiesCallback, ) -> None: """Set up the Obihai sensor entries.""" diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index c162bd6c559..ec29238201a 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -9,7 +9,7 @@ from requests.exceptions import RequestException 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 homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .connectivity import ObihaiConnection from .const import DOMAIN, LOGGER, OBIHAI @@ -18,7 +18,9 @@ SCAN_INTERVAL = datetime.timedelta(seconds=5) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Obihai sensor entries.""" diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index 10a637e5a3b..a20738de150 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -9,7 +9,7 @@ from pyoctoprintapi import OctoprintPrinterInfo from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator @@ -19,7 +19,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint binary sensors.""" coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index 2a2e5015303..3a128fcd7aa 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -6,7 +6,7 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator @@ -16,7 +16,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Octoprint control buttons.""" coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index e6430c55fa2..37347539d5b 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -8,7 +8,7 @@ from homeassistant.components.mjpeg import MjpegCamera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator @@ -18,7 +18,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint camera.""" coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index fb5f292d669..71db1d804c5 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OctoprintDataUpdateCoordinator @@ -38,7 +38,7 @@ def _is_printer_printing(printer: OctoprintPrinterInfo) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint binary sensors.""" coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py index dad4416a29c..6e942215c0f 100644 --- a/homeassistant/components/ohme/button.py +++ b/homeassistant/components/ohme/button.py @@ -10,7 +10,7 @@ from ohme import ApiException, ChargerStatus, OhmeApiClient from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OhmeConfigEntry @@ -40,7 +40,7 @@ BUTTON_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: OhmeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons.""" coordinator = config_entry.runtime_data.charge_session_coordinator diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py index 875f8c93bb3..8c5be2b48be 100644 --- a/homeassistant/components/ohme/number.py +++ b/homeassistant/components/ohme/number.py @@ -9,7 +9,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OhmeConfigEntry @@ -43,7 +43,7 @@ NUMBER_DESCRIPTION = [ async def async_setup_entry( hass: HomeAssistant, config_entry: OhmeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up numbers.""" coordinators = config_entry.runtime_data diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py index 311d27f4bbb..17cc7c67e9a 100644 --- a/homeassistant/components/ohme/select.py +++ b/homeassistant/components/ohme/select.py @@ -11,7 +11,7 @@ from ohme import ApiException, ChargerMode, OhmeApiClient 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 homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OhmeConfigEntry @@ -41,7 +41,7 @@ SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: OhmeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ohme selects.""" coordinator = config_entry.runtime_data.charge_session_coordinator diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 8085f55068f..1e0572fe858 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import OhmeConfigEntry from .entity import OhmeEntity, OhmeEntityDescription @@ -91,7 +91,7 @@ SENSOR_ADVANCED_SETTINGS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: OhmeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" coordinators = config_entry.runtime_data diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py index d8b9fb52595..c4465ec7e97 100644 --- a/homeassistant/components/ohme/switch.py +++ b/homeassistant/components/ohme/switch.py @@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OhmeConfigEntry @@ -53,7 +53,7 @@ SWITCH_DEVICE_INFO = [ async def async_setup_entry( hass: HomeAssistant, config_entry: OhmeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" coordinators = config_entry.runtime_data diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py index be3da84ed72..264b2afd41a 100644 --- a/homeassistant/components/ohme/time.py +++ b/homeassistant/components/ohme/time.py @@ -9,7 +9,7 @@ from ohme import ApiException, OhmeApiClient 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 homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OhmeConfigEntry @@ -43,7 +43,7 @@ TIME_DESCRIPTION = [ async def async_setup_entry( hass: HomeAssistant, config_entry: OhmeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up time entities.""" coordinators = config_entry.runtime_data diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 8ee275865a7..90e81544f66 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, intent, llm -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_KEEP_ALIVE, @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" agent = OllamaConversationEntity(config_entry) diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index c87b589e1f6..d941eb3ae4d 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import check_guard from .const import COORDINATOR, DEFAULT_PH_OFFSET, DOMAIN, PUMP_TYPES @@ -22,7 +22,9 @@ from .entity import OmniLogicEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" diff --git a/homeassistant/components/omnilogic/switch.py b/homeassistant/components/omnilogic/switch.py index eb57d03bc34..a9f8bc77d8a 100644 --- a/homeassistant/components/omnilogic/switch.py +++ b/homeassistant/components/omnilogic/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import check_guard from .const import COORDINATOR, DOMAIN, PUMP_TYPES @@ -22,7 +22,9 @@ OMNILOGIC_SWITCH_OFF = 7 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the light platform.""" diff --git a/homeassistant/components/oncue/binary_sensor.py b/homeassistant/components/oncue/binary_sensor.py index 961b082a5c5..8dc9ba1be6f 100644 --- a/homeassistant/components/oncue/binary_sensor.py +++ b/homeassistant/components/oncue/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import OncueEntity from .types import OncueConfigEntry @@ -28,7 +28,7 @@ SENSOR_MAP = {description.key: description for description in SENSOR_TYPES} async def async_setup_entry( hass: HomeAssistant, config_entry: OncueConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/oncue/sensor.py b/homeassistant/components/oncue/sensor.py index a0f275ef692..669c34157d4 100644 --- a/homeassistant/components/oncue/sensor.py +++ b/homeassistant/components/oncue/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .entity import OncueEntity @@ -180,7 +180,7 @@ UNIT_MAPPINGS = { async def async_setup_entry( hass: HomeAssistant, config_entry: OncueConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index de755c5e8d0..ddc4a94853f 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -74,7 +74,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ondilo ICO sensors.""" pools_coordinator: OndiloIcoPoolsCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 60a1d165b15..2bb393e48a8 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription @@ -101,7 +101,7 @@ def get_sensor_types( async def async_setup_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py index 7a26ecdbb31..7f4111243aa 100644 --- a/homeassistant/components/onewire/select.py +++ b/homeassistant/components/onewire/select.py @@ -10,7 +10,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import READ_MODE_INT from .entity import OneWireEntity, OneWireEntityDescription @@ -48,7 +48,7 @@ ENTITY_DESCRIPTIONS: dict[str, tuple[OneWireEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 04141f87847..5e1c7d35bd6 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -388,7 +388,7 @@ def get_sensor_types( async def async_setup_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 7215b1ec020..d2cc3b80185 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .entity import OneWireEntity, OneWireEntityDescription @@ -161,7 +161,7 @@ def get_sensor_types( async def async_setup_entry( hass: HomeAssistant, config_entry: OneWireConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index acb57e594b8..711cede15bc 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -22,7 +22,10 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, ca from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -286,7 +289,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: OnkyoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MediaPlayer for config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 92c5ab45129..d29f732ef67 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.enum import try_parse_enum @@ -22,7 +22,7 @@ from .entity import ONVIFBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a ONVIF binary sensor.""" device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/onvif/button.py b/homeassistant/components/onvif/button.py index 644a7c942f7..8e92cb07a8c 100644 --- a/homeassistant/components/onvif/button.py +++ b/homeassistant/components/onvif/button.py @@ -4,7 +4,7 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .device import ONVIFDevice @@ -14,7 +14,7 @@ from .entity import ONVIFBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ONVIF button based on a config entry.""" device = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 8c0fd027b95..da99e170ff6 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -22,7 +22,7 @@ from homeassistant.const import HTTP_BASIC_AUTHENTICATION from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ABSOLUTE_MOVE, @@ -57,7 +57,7 @@ from .models import Profile async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ONVIF camera video stream.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 46db26361bc..a0162a05f76 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.enum import try_parse_enum @@ -21,7 +21,7 @@ from .entity import ONVIFBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a ONVIF binary sensor.""" device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index ff62e469af0..d8e1020c6a3 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .device import ONVIFDevice @@ -66,7 +66,7 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a ONVIF switch platform.""" device = hass.data[DOMAIN][config_entry.unique_id] diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index 51ee91de083..9782051ab22 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -20,7 +20,7 @@ from homeassistant.components.weather import ( from homeassistant.const import UnitOfPrecipitationDepth, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -31,7 +31,7 @@ from .coordinator import OpenMeteoConfigEntry async def async_setup_entry( hass: HomeAssistant, entry: OpenMeteoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Open-Meteo weather entity based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 4dee1d4b167..fddabb740ac 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -25,7 +25,7 @@ from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, device_registry as dr, intent, llm -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry from .const import ( @@ -51,7 +51,7 @@ MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( hass: HomeAssistant, config_entry: OpenAIConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" agent = OpenAIConversationEntity(config_entry) diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 55ca7bd2fb9..756823ff0ec 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_QUOTE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -19,7 +19,7 @@ ATTRIBUTION = "Data provided by openexchangerates.org" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Open Exchange Rates sensor.""" quote: str = config_entry.data.get(CONF_QUOTE, "EUR") diff --git a/homeassistant/components/opengarage/binary_sensor.py b/homeassistant/components/opengarage/binary_sensor.py index 55cacfb5f90..33420ab3fd5 100644 --- a/homeassistant/components/opengarage/binary_sensor.py +++ b/homeassistant/components/opengarage/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OpenGarageDataUpdateCoordinator @@ -29,7 +29,9 @@ SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage binary sensors.""" open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py index 9f93e0fa716..64a4f2f20e7 100644 --- a/homeassistant/components/opengarage/button.py +++ b/homeassistant/components/opengarage/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OpenGarageDataUpdateCoordinator @@ -43,7 +43,7 @@ BUTTONS: tuple[OpenGarageButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage button entities.""" coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 9623050c090..859e3382772 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -13,7 +13,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OpenGarageDataUpdateCoordinator @@ -25,7 +25,9 @@ STATES_MAP = {0: CoverState.CLOSED, 1: CoverState.OPEN} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage covers.""" async_add_entities( diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py index 003e0e0fa5a..14d14dd5d23 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import OpenGarageDataUpdateCoordinator @@ -59,7 +59,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage sensors.""" open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 8c903c90bbb..9f8840b8487 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_PIN_INDEX, DOMAIN, SERVICE_INVOKE_PIN @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Openhome config entry.""" diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index bbe4fdac3b3..cc210866e64 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Reolink component.""" diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 9d317ae3e0d..0ab5b49f086 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -16,7 +16,7 @@ from .coordinator import OpenSkyDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the entries.""" diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 5d542bedc07..8e73392da05 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BOILER_DEVICE_DESCRIPTION, @@ -393,7 +393,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[OpenThermBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway binary sensors.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/opentherm_gw/button.py b/homeassistant/components/opentherm_gw/button.py index 00b91ad33e0..046b44bfa8c 100644 --- a/homeassistant/components/opentherm_gw/button.py +++ b/homeassistant/components/opentherm_gw/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenThermGatewayHub from .const import ( @@ -53,7 +53,7 @@ BUTTON_DESCRIPTIONS: tuple[OpenThermButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway buttons.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index e8aa99f7325..c69151c293a 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenThermGatewayHub from .const import ( @@ -50,7 +50,7 @@ class OpenThermClimateEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an OpenTherm Gateway climate entity.""" ents = [] diff --git a/homeassistant/components/opentherm_gw/select.py b/homeassistant/components/opentherm_gw/select.py index cee1632dc48..da3fa1e80ec 100644 --- a/homeassistant/components/opentherm_gw/select.py +++ b/homeassistant/components/opentherm_gw/select.py @@ -20,7 +20,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenThermGatewayHub from .const import ( @@ -234,7 +234,7 @@ SELECT_DESCRIPTIONS: tuple[OpenThermSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway select entities.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index 5ccb4166665..f9ac1b272be 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BOILER_DEVICE_DESCRIPTION, @@ -875,7 +875,7 @@ SENSOR_DESCRIPTIONS: tuple[OpenThermSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway sensors.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/opentherm_gw/switch.py b/homeassistant/components/opentherm_gw/switch.py index 41ffa03a932..873675f0211 100644 --- a/homeassistant/components/opentherm_gw/switch.py +++ b/homeassistant/components/opentherm_gw/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenThermGatewayHub from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, GATEWAY_DEVICE_DESCRIPTION @@ -48,7 +48,7 @@ SWITCH_DESCRIPTIONS: tuple[OpenThermSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenTherm Gateway switches.""" gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 018d91710df..f45404ce38e 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime, utcnow from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW @@ -25,7 +25,9 @@ BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: # Once we've successfully authenticated, we re-enable client request retries: """Set up an OpenUV sensor based on a config entry.""" diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 742017be639..5b681655e2b 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UV_INDEX, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime from .const import ( @@ -166,7 +166,9 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a OpenUV sensor based on a config entry.""" coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 46789f4b3d2..0afab69b638 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -156,7 +156,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: OpenweathermapConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OpenWeatherMap sensor entities based on a config entry.""" domain_data = config_entry.runtime_data diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 3a134a0ee26..43e9c0a868a 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenweathermapConfigEntry from .const import ( @@ -48,7 +48,7 @@ from .coordinator import WeatherUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, config_entry: OpenweathermapConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OpenWeatherMap weather entity based on a config entry.""" domain_data = config_entry.runtime_data diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 1b3aa0fd710..61b0e0567b3 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -184,7 +184,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: OpowerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Opower sensor.""" diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 9994bfc6443..3b345f4b36a 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import OralBConfigEntry @@ -108,7 +108,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: OralBConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OralB BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/osoenergy/binary_sensor.py b/homeassistant/components/osoenergy/binary_sensor.py index 0cf0ac74d36..a2ba61ccbe4 100644 --- a/homeassistant/components/osoenergy/binary_sensor.py +++ b/homeassistant/components/osoenergy/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import OSOEnergyEntity @@ -45,7 +45,9 @@ SENSOR_TYPES: dict[str, OSOEnergyBinarySensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy binary sensor.""" osoenergy: OSOEnergy = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/osoenergy/sensor.py b/homeassistant/components/osoenergy/sensor.py index 40ec33e3e02..18859627952 100644 --- a/homeassistant/components/osoenergy/sensor.py +++ b/homeassistant/components/osoenergy/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -138,7 +138,9 @@ SENSOR_TYPES: dict[str, OSOEnergySensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy sensor.""" osoenergy = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index b3281193da3..07820ee97d5 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType @@ -49,7 +49,9 @@ SERVICE_TURN_ON = "turn_on" async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy heater based on a config entry.""" osoenergy = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py index 255bc0ded34..af508d2e915 100644 --- a/homeassistant/components/otp/sensor.py +++ b/homeassistant/components/otp/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -20,7 +20,9 @@ TIME_STEP = 30 # Default time step assumed by Google Authenticator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OTP sensor.""" diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index 5b8d19e5aa1..f257ef481c7 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -11,7 +11,7 @@ from homeassistant.components.todo import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -19,7 +19,9 @@ from .coordinator import OurGroceriesDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OurGroceries todo platform config entry.""" coordinator: OurGroceriesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index 90c135291c3..1a5490dd329 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -19,7 +19,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .coordinator import OverkizDataUpdateCoordinator @@ -209,7 +209,7 @@ SUPPORTED_DEVICES = {description.key: description for description in ALARM_DESCR async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz alarm control panel from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 3a75cd77c2f..09319d59932 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES @@ -143,7 +143,7 @@ SUPPORTED_STATES = { async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz binary sensors from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index 92711ac8ca8..f4e051ef9ca 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -14,7 +14,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES @@ -100,7 +100,7 @@ SUPPORTED_COMMANDS = { async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz button from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 3276a1979cc..058c3aefdb7 100644 --- a/homeassistant/components/overkiz/climate/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -10,7 +10,7 @@ from pyoverkiz.enums.ui import UIWidget from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .. import OverkizDataConfigEntry from .atlantic_electrical_heater import AtlanticElectricalHeater @@ -82,7 +82,7 @@ WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz climate from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/cover/__init__.py b/homeassistant/components/overkiz/cover/__init__.py index 38c02eba1bb..dd3216f9c10 100644 --- a/homeassistant/components/overkiz/cover/__init__.py +++ b/homeassistant/components/overkiz/cover/__init__.py @@ -4,7 +4,7 @@ from pyoverkiz.enums import OverkizCommand, UIClass from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .. import OverkizDataConfigEntry from .awning import Awning @@ -15,7 +15,7 @@ from .vertical_cover import LowSpeedCover, VerticalCover async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz covers from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/light.py b/homeassistant/components/overkiz/light.py index 933d4cf695b..acd63140196 100644 --- a/homeassistant/components/overkiz/light.py +++ b/homeassistant/components/overkiz/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .coordinator import OverkizDataUpdateCoordinator @@ -24,7 +24,7 @@ from .entity import OverkizEntity async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz lights from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/lock.py b/homeassistant/components/overkiz/lock.py index 1c073d2f9aa..16ec32b0667 100644 --- a/homeassistant/components/overkiz/lock.py +++ b/homeassistant/components/overkiz/lock.py @@ -9,7 +9,7 @@ from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.lock import LockEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .entity import OverkizEntity @@ -18,7 +18,7 @@ from .entity import OverkizEntity async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz locks from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 0e03e822424..83c0e7cf7a8 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -16,7 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES @@ -191,7 +191,7 @@ SUPPORTED_STATES = {description.key: description for description in NUMBER_DESCR async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz number from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/scene.py b/homeassistant/components/overkiz/scene.py index 4533ed3245c..bd362b4b372 100644 --- a/homeassistant/components/overkiz/scene.py +++ b/homeassistant/components/overkiz/scene.py @@ -9,7 +9,7 @@ from pyoverkiz.models import Scenario from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry @@ -17,7 +17,7 @@ from . import OverkizDataConfigEntry async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz scenes from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index ac467eaaa7a..e23dafdaab8 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -10,7 +10,7 @@ from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .const import IGNORED_OVERKIZ_DEVICES @@ -129,7 +129,7 @@ SUPPORTED_STATES = {description.key: description for description in SELECT_DESCR async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz select from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 81a9ab41d2d..9214398a37b 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import OverkizDataConfigEntry @@ -483,7 +483,7 @@ SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCR async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz sensors from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/siren.py b/homeassistant/components/overkiz/siren.py index f7246e50ec0..af761611444 100644 --- a/homeassistant/components/overkiz/siren.py +++ b/homeassistant/components/overkiz/siren.py @@ -12,7 +12,7 @@ from homeassistant.components.siren import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .entity import OverkizEntity @@ -21,7 +21,7 @@ from .entity import OverkizEntity async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz sirens from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index c921dbab776..d14b2792947 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry from .entity import OverkizDescriptiveEntity @@ -110,7 +110,7 @@ SUPPORTED_DEVICES = { async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz switch from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py index 1dd1d596a33..9895ea84c2c 100644 --- a/homeassistant/components/overkiz/water_heater/__init__.py +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -6,7 +6,7 @@ from pyoverkiz.enums.ui import UIWidget from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .. import OverkizDataConfigEntry from ..entity import OverkizEntity @@ -21,7 +21,7 @@ from .hitachi_dhw import HitachiDHW async def async_setup_entry( hass: HomeAssistant, entry: OverkizDataConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Overkiz DHW from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py index 589a80c5404..1ffb1e71771 100644 --- a/homeassistant/components/overseerr/event.py +++ b/homeassistant/components/overseerr/event.py @@ -8,7 +8,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, EVENT_KEY from .coordinator import OverseerrConfigEntry, OverseerrCoordinator @@ -44,7 +44,7 @@ EVENTS: tuple[OverseerrEventEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: OverseerrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Overseerr sensor entities based on a config entry.""" diff --git a/homeassistant/components/overseerr/sensor.py b/homeassistant/components/overseerr/sensor.py index 2daaa3de0cb..510e6f52c59 100644 --- a/homeassistant/components/overseerr/sensor.py +++ b/homeassistant/components/overseerr/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import REQUESTS from .coordinator import OverseerrConfigEntry, OverseerrCoordinator @@ -76,7 +76,7 @@ SENSORS: tuple[OverseerrSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: OverseerrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Overseerr sensor entities based on a config entry.""" diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 8cada86da34..1dc12c7f008 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -111,7 +111,9 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OVO Energy sensor based on a config entry.""" coordinator: DataUpdateCoordinator[OVODailyUsage] = hass.data[DOMAIN][ diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 6a6f0f078b1..7ccbbb69aa1 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -16,14 +16,16 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN as OT_DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OwnTracks based off an entry.""" # Restore previously loaded devices diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index 84e331a4099..15a8f510fd7 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -237,7 +237,7 @@ SENSORS_WATERMETER: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: P1MonitorConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up P1 Monitor Sensors based on a config entry.""" entities: list[P1MonitorSensorEntity] = [] diff --git a/homeassistant/components/palazzetti/button.py b/homeassistant/components/palazzetti/button.py index 32a60e195e9..319a1174542 100644 --- a/homeassistant/components/palazzetti/button.py +++ b/homeassistant/components/palazzetti/button.py @@ -7,7 +7,7 @@ from pypalazzetti.exceptions import CommunicationError from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import PalazzettiEntity async def async_setup_entry( hass: HomeAssistant, config_entry: PalazzettiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Palazzetti button platform.""" diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py index 2c7053073ea..5a4097e083a 100644 --- a/homeassistant/components/palazzetti/climate.py +++ b/homeassistant/components/palazzetti/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator @@ -23,7 +23,7 @@ from .entity import PalazzettiEntity async def async_setup_entry( hass: HomeAssistant, entry: PalazzettiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Palazzetti climates based on a config entry.""" async_add_entities([PalazzettiClimateEntity(entry.runtime_data)]) diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py index bba729c523c..63c1ed16f0c 100644 --- a/homeassistant/components/palazzetti/number.py +++ b/homeassistant/components/palazzetti/number.py @@ -8,7 +8,7 @@ from pypalazzetti.fan import FanType from homeassistant.components.number import NumberDeviceClass, NumberEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import PalazzettiEntity async def async_setup_entry( hass: HomeAssistant, config_entry: PalazzettiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Palazzetti number platform.""" diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py index fdad817da4d..57d5ca861a2 100644 --- a/homeassistant/components/palazzetti/sensor.py +++ b/homeassistant/components/palazzetti/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfLength, UnitOfMass, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import STATUS_TO_HA @@ -59,7 +59,7 @@ PROPERTY_SENSOR_DESCRIPTIONS: list[PropertySensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: PalazzettiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Palazzetti sensor entities based on a config entry.""" diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 8738b897d29..a78920f33a5 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_DEVICE_INFO, @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Panasonic Viera TV from a config entry.""" diff --git a/homeassistant/components/panasonic_viera/remote.py b/homeassistant/components/panasonic_viera/remote.py index ad40a97f700..5fa4be9ca2b 100644 --- a/homeassistant/components/panasonic_viera/remote.py +++ b/homeassistant/components/panasonic_viera/remote.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Remote from .const import ( @@ -28,7 +28,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Panasonic Viera TV Remote from a config entry.""" diff --git a/homeassistant/components/peblar/binary_sensor.py b/homeassistant/components/peblar/binary_sensor.py index e8e5095f050..8834a2ba2a0 100644 --- a/homeassistant/components/peblar/binary_sensor.py +++ b/homeassistant/components/peblar/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PeblarConfigEntry, PeblarData, PeblarDataUpdateCoordinator from .entity import PeblarEntity @@ -50,7 +50,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar binary sensor based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/button.py b/homeassistant/components/peblar/button.py index 22150c82649..8c60c8d84d3 100644 --- a/homeassistant/components/peblar/button.py +++ b/homeassistant/components/peblar/button.py @@ -15,7 +15,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator from .entity import PeblarEntity @@ -52,7 +52,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar buttons based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py index 0e929a63523..bff1bb26db4 100644 --- a/homeassistant/components/peblar/number.py +++ b/homeassistant/components/peblar/number.py @@ -14,7 +14,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PeblarConfigEntry, PeblarDataUpdateCoordinator from .entity import PeblarEntity @@ -26,7 +26,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar number based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/select.py b/homeassistant/components/peblar/select.py index a2a0997a797..17503951ccd 100644 --- a/homeassistant/components/peblar/select.py +++ b/homeassistant/components/peblar/select.py @@ -11,7 +11,7 @@ from peblar import Peblar, PeblarUserConfiguration, SmartChargingMode from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator from .entity import PeblarEntity @@ -49,7 +49,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar select based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/sensor.py b/homeassistant/components/peblar/sensor.py index e655253d75c..81476eef9aa 100644 --- a/homeassistant/components/peblar/sensor.py +++ b/homeassistant/components/peblar/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from .const import ( @@ -231,7 +231,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar sensors based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/switch.py b/homeassistant/components/peblar/switch.py index 74a42ddc47d..f2e1ae13ae2 100644 --- a/homeassistant/components/peblar/switch.py +++ b/homeassistant/components/peblar/switch.py @@ -11,7 +11,7 @@ from peblar import PeblarEVInterface from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ( PeblarConfigEntry, @@ -71,7 +71,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar switch based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py index 58c2fbdc899..88966916069 100644 --- a/homeassistant/components/peblar/update.py +++ b/homeassistant/components/peblar/update.py @@ -11,7 +11,7 @@ from homeassistant.components.update import ( UpdateEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ( PeblarConfigEntry, @@ -53,7 +53,7 @@ DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Peblar update based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py index a55f0fcc731..a4d59a8c9a2 100644 --- a/homeassistant/components/peco/binary_sensor.py +++ b/homeassistant/components/peco/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -24,7 +24,7 @@ PARALLEL_UPDATES: Final = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor for PECO.""" if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]: diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index d08947eb0ec..eafa36c98e9 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -76,7 +76,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" county: str = config_entry.data[CONF_COUNTY] diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 181c0f5dc6d..fd90683a9b2 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity @@ -92,7 +92,7 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PegelOnlineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the PEGELONLINE sensor.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py index 4b768cf5af5..c2d51067e19 100644 --- a/homeassistant/components/permobil/binary_sensor.py +++ b/homeassistant/components/permobil/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MyPermobilCoordinator @@ -42,7 +42,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[PermobilBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create and setup the binary sensor.""" diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index 54d3a61c519..5f8cb88290a 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -32,7 +32,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN, KM, MILES from .coordinator import MyPermobilCoordinator @@ -175,7 +175,7 @@ DISTANCE_UNITS: dict[Any, UnitOfLength] = { async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create sensors from a config entry created in the integrations UI.""" diff --git a/homeassistant/components/pglab/switch.py b/homeassistant/components/pglab/switch.py index 790ac7e7814..554b5cf80ca 100644 --- a/homeassistant/components/pglab/switch.py +++ b/homeassistant/components/pglab/switch.py @@ -10,7 +10,7 @@ from pypglab.relay import Relay as PyPGLabRelay from homeassistant.components.switch import SwitchEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PGLABConfigEntry from .discovery import PGLabDiscovery @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: PGLABConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for device.""" diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index eef91513efe..3667d37dc48 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity @@ -41,7 +41,7 @@ DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index 5c4f629aea4..bf15292335e 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator @@ -34,7 +34,7 @@ EFFECT_EXPERT_STYLES = {"FOLLOW_AUDIO", "FOLLOW_COLOR", "Lounge light"} async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index a1ed3e4c168..a433a63f31f 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -18,7 +18,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.trigger import PluggableAction from . import LOGGER as _LOGGER @@ -49,7 +49,7 @@ def _inverted(data): async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/philips_js/remote.py b/homeassistant/components/philips_js/remote.py index a573a2946fe..b026b33a857 100644 --- a/homeassistant/components/philips_js/remote.py +++ b/homeassistant/components/philips_js/remote.py @@ -13,7 +13,7 @@ from homeassistant.components.remote import ( RemoteEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.trigger import PluggableAction from . import LOGGER @@ -25,7 +25,7 @@ from .helpers import async_get_turn_on_trigger async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/philips_js/switch.py b/homeassistant/components/philips_js/switch.py index fd7add5122d..45963432665 100644 --- a/homeassistant/components/philips_js/switch.py +++ b/homeassistant/components/philips_js/switch.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator from .entity import PhilipsJsEntity @@ -18,7 +18,7 @@ HUE_POWER_ON = "On" async def async_setup_entry( hass: HomeAssistant, config_entry: PhilipsTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the configuration entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 5e3ce560ab4..1d12307b6e5 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleConfigEntry @@ -41,7 +41,7 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PiHoleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole binary sensor.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 4cf5133e700..54a9cb23d02 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -7,7 +7,7 @@ from hole import Hole from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -47,7 +47,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PiHoleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole sensor.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py index 805ba479a9e..84ffe7e51a4 100644 --- a/homeassistant/components/pi_hole/switch.py +++ b/homeassistant/components/pi_hole/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PiHoleConfigEntry from .const import SERVICE_DISABLE, SERVICE_DISABLE_ATTR_DURATION @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: PiHoleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole switch.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index 510f5d1dc19..56e92b47289 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -10,7 +10,7 @@ from hole import Hole from homeassistant.components.update import UpdateEntity, UpdateEntityDescription from homeassistant.const import CONF_NAME, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleConfigEntry @@ -65,7 +65,7 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PiHoleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pi-hole update entities.""" name = entry.data[CONF_NAME] diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 866bd6b56c1..dcfd9086491 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -203,7 +203,7 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Picnic sensor entries.""" picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py index 7fa2bbccd3e..383c236de3c 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COORDINATOR, DOMAIN @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Picnic shopping cart todo platform config entry.""" picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 060d2532309..35bf2707694 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_IMPORTED_BY from .coordinator import PingConfigEntry, PingUpdateCoordinator @@ -15,7 +15,9 @@ from .entity import PingEntity async def async_setup_entry( - hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: PingConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Ping config entry.""" async_add_entities([PingBinarySensor(entry, entry.runtime_data)]) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 43969aaac03..9d093da262d 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import ( ScannerEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -19,7 +19,9 @@ from .coordinator import PingConfigEntry, PingUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: PingConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Ping config entry.""" async_add_entities([PingDeviceTracker(entry, entry.runtime_data)]) diff --git a/homeassistant/components/ping/sensor.py b/homeassistant/components/ping/sensor.py index afd6f53db7c..82d88064e02 100644 --- a/homeassistant/components/ping/sensor.py +++ b/homeassistant/components/ping/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PingConfigEntry, PingResult, PingUpdateCoordinator from .entity import PingEntity @@ -75,7 +75,9 @@ SENSORS: tuple[PingSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: PingConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ping sensors from config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py index 42019bbec9b..b71673aa1fd 100644 --- a/homeassistant/components/plaato/binary_sensor.py +++ b/homeassistant/components/plaato/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_USE_WEBHOOK, COORDINATOR, DOMAIN from .entity import PlaatoEntity @@ -19,7 +19,7 @@ from .entity import PlaatoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plaato from a config entry.""" diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index b11bac40144..7a98c8a1ced 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -12,7 +12,10 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import ATTR_TEMP, SENSOR_UPDATE @@ -38,7 +41,9 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plaato from a config entry.""" entry_data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 8bb34be38ce..5ed34eac6b2 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -8,7 +8,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PlexServer from .const import CONF_SERVER_IDENTIFIER, DOMAIN, PLEX_UPDATE_PLATFORMS_SIGNAL @@ -18,7 +18,7 @@ from .helpers import get_plex_server async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plex button from config entry.""" server_id: str = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 1dd79ad27a5..4a1654959f6 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -27,7 +27,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.network import is_internal_request from .const import ( @@ -68,7 +68,7 @@ def needs_session[_PlexMediaPlayerT: PlexMediaPlayer, **_P, _R]( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plex media_player from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index eb27f465a7e..66e513dd83a 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_SERVER_IDENTIFIER, @@ -52,7 +52,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plex sensor from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/plex/update.py b/homeassistant/components/plex/update.py index 7acf4551f33..9b7645cd078 100644 --- a/homeassistant/components/plex/update.py +++ b/homeassistant/components/plex/update.py @@ -11,7 +11,7 @@ from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_SERVER_IDENTIFIER from .helpers import get_plex_server @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plex update entities from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index e8e658da5bb..f2c2fd6ed68 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity @@ -85,7 +85,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile binary_sensors from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py index aa541378a36..c0896b602f0 100644 --- a/homeassistant/components/plugwise/button.py +++ b/homeassistant/components/plugwise/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import REBOOT from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator @@ -18,7 +18,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Plugwise buttons from a ConfigEntry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index a7e17f6b688..c7fac07f1cb 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -16,7 +16,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MASTER_THERMOSTATS from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator @@ -29,7 +29,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile Thermostats from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 57e3ba77972..1dbb0506748 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import NumberType from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator @@ -57,7 +57,7 @@ NUMBER_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plugwise number platform.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index 9c43b71f5f4..6ca1d4ce7a2 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SelectOptionsType, SelectType from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator @@ -55,7 +55,7 @@ SELECT_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile selector from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 33419abb4dc..7bd93e2ff84 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity @@ -405,7 +405,7 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile sensors from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 9a36d0d708c..8179fb546b4 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity @@ -57,7 +57,7 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PlugwiseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smile switches from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index 08a3d0ab0b9..78743c12808 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .const import DOMAIN @@ -25,7 +25,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Plum Lightpad dimmer lights and glow rings.""" diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 4e4e4238176..0f501d2ee09 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MinutPointClient from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK @@ -33,7 +33,7 @@ EVENT_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's alarm_control_panel based on a config entry.""" diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index 546c7d9cb0f..c9338cb63f2 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK from .entity import MinutPointEntity @@ -43,7 +43,7 @@ DEVICES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's binary sensors based on a config entry.""" diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index d864c8bb18c..c959d09d606 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfSoundPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import parse_datetime from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW @@ -48,7 +48,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Point's sensors based on a config entry.""" diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index dbff3d4cef4..b93f017501d 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PoolSenseConfigEntry from .entity import PoolSenseEntity @@ -30,7 +30,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: PoolSenseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index 11d94167b6d..b0ac4404237 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import PoolSenseConfigEntry @@ -65,7 +65,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: PoolSenseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py index d293c5c7a53..ab60c99a58b 100644 --- a/homeassistant/components/powerfox/sensor.py +++ b/homeassistant/components/powerfox/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator from .entity import PowerfoxEntity @@ -130,7 +130,7 @@ SENSORS_HEAT: tuple[PowerfoxSensorEntityDescription[HeatMeter], ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PowerfoxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Powerfox sensors based on a config entry.""" entities: list[SensorEntity] = [] diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py index c50876e22fb..100e31b1c21 100644 --- a/homeassistant/components/powerwall/binary_sensor.py +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import PowerWallEntity from .models import PowerwallConfigEntry @@ -23,7 +23,7 @@ CONNECTED_GRID_STATUSES = { async def async_setup_entry( hass: HomeAssistant, entry: PowerwallConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the powerwall sensors.""" powerwall_data = entry.runtime_data diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 28506e2a60c..b4988133727 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import POWERWALL_COORDINATOR from .entity import BatteryEntity, PowerWallEntity @@ -213,7 +213,7 @@ BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: PowerwallConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the powerwall sensors.""" powerwall_data = entry.runtime_data diff --git a/homeassistant/components/powerwall/switch.py b/homeassistant/components/powerwall/switch.py index 214ca01fb63..a874161de5b 100644 --- a/homeassistant/components/powerwall/switch.py +++ b/homeassistant/components/powerwall/switch.py @@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import PowerWallEntity from .models import PowerwallConfigEntry, PowerwallRuntimeData @@ -22,7 +22,7 @@ OFF_GRID_STATUSES = { async def async_setup_entry( hass: HomeAssistant, entry: PowerwallConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Powerwall switch platform from Powerwall resources.""" async_add_entities([PowerwallOffGridEnabledEntity(entry.runtime_data)]) diff --git a/homeassistant/components/private_ble_device/device_tracker.py b/homeassistant/components/private_ble_device/device_tracker.py index fbaf0d44751..eaccbd6c785 100644 --- a/homeassistant/components/private_ble_device/device_tracker.py +++ b/homeassistant/components/private_ble_device/device_tracker.py @@ -11,7 +11,7 @@ from homeassistant.components.device_tracker.config_entry import BaseTrackerEnti from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BasePrivateDeviceEntity @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Device Tracker entities for a config entry.""" async_add_entities([BasePrivateDeviceTracker(config_entry)]) diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index e2c4fb0c7da..d8c09500332 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BasePrivateDeviceEntity @@ -92,7 +92,9 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for Private BLE component.""" async_add_entities( diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index a89b8b3c3f1..40296dcac90 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -9,7 +9,7 @@ from ProgettiHWSW.input import Input from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(DOMAIN) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensors from a config entry.""" board_api = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 983a2383e99..256d90ae5b7 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -10,7 +10,7 @@ from ProgettiHWSW.relay import Relay from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(DOMAIN) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switches from a config entry.""" board_api = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 1c58b64cf55..1f0f89c5f04 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN @@ -30,7 +30,9 @@ STATE_MAPPING = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Prosegur alarm control panel platform.""" async_add_entities( diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 2df6ff62038..3e1c91713e1 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -24,7 +24,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Prosegur camera platform.""" diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index 55d4ca02b9b..72203a2dff4 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -82,7 +82,7 @@ def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo: async def async_setup_entry( hass: HomeAssistant, entry: ProximityConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the proximity sensors.""" diff --git a/homeassistant/components/prusalink/binary_sensor.py b/homeassistant/components/prusalink/binary_sensor.py index d40ac8a4cfa..56be36c3e9d 100644 --- a/homeassistant/components/prusalink/binary_sensor.py +++ b/homeassistant/components/prusalink/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import PrusaLinkUpdateCoordinator @@ -57,7 +57,7 @@ BINARY_SENSORS: dict[str, tuple[PrusaLinkBinarySensorEntityDescription, ...]] = async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink sensor based on a config entry.""" coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index 06d356b2ca6..59a63d874ee 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import PrusaLinkUpdateCoordinator @@ -72,7 +72,7 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink buttons based on a config entry.""" coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index eee655447cc..6aac03ca179 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -7,7 +7,7 @@ from pyprusalink.types import PrinterState from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import JobUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import PrusaLinkEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink camera.""" coordinator: JobUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["job"] diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 0c746adbe2e..b9588f72a3c 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from homeassistant.util.variance import ignore_variance @@ -205,7 +205,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink sensor based on a config entry.""" coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 8db24beae20..4de7cbeb463 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -27,7 +27,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.json import JsonObjectType from . import format_unique_id, load_games, save_games @@ -48,7 +48,7 @@ DEFAULT_RETRIES = 2 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PS4 from a config entry.""" config = config_entry diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 9dd234ac2f6..ad57206adeb 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_HOST, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -64,7 +64,7 @@ SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PureEnergieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Pure Energie Sensors based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index 9fb0249a360..bed1d878557 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_SENSOR_INDICES, DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator @@ -166,7 +166,7 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PurpleAir sensors based on a config entry.""" coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 4989fc91d5e..2dbaa8fc713 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .api import PushBulletNotificationProvider from .const import DATA_UPDATED, DOMAIN @@ -68,7 +68,9 @@ SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pushbullet sensors from config entry.""" diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index ef2bb3eb660..b4ed3f93945 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_SYSTEM_ID, DOMAIN @@ -98,7 +98,7 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a PVOutput sensors based on a config entry.""" coordinator: PVOutputDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 9d9fe5b9661..1b92cfc533d 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO, UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_change from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -148,7 +148,9 @@ _PRICE_SENSOR_ATTRIBUTES_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the electricity price sensor from config_entry.""" coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py index f849200a70e..9fcba7e723a 100644 --- a/homeassistant/components/pyload/button.py +++ b/homeassistant/components/pyload/button.py @@ -12,7 +12,7 @@ from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import PyLoadConfigEntry @@ -63,7 +63,7 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PyLoadConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons from a config entry.""" diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index b36dbb806be..edf7c6a756c 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import UNIT_DOWNLOADS @@ -85,7 +85,7 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PyLoadConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the pyLoad sensors.""" diff --git a/homeassistant/components/pyload/switch.py b/homeassistant/components/pyload/switch.py index 1187e545f25..d4416666d93 100644 --- a/homeassistant/components/pyload/switch.py +++ b/homeassistant/components/pyload/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import PyLoadConfigEntry, PyLoadData @@ -65,7 +65,7 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: PyLoadConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the pyLoad sensors.""" diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 67eb856bb83..9f4610cff64 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -129,7 +129,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" diff --git a/homeassistant/components/qbittorrent/switch.py b/homeassistant/components/qbittorrent/switch.py index f12118e5233..dd61f130ca1 100644 --- a/homeassistant/components/qbittorrent/switch.py +++ b/homeassistant/components/qbittorrent/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -43,7 +43,7 @@ SWITCH_TYPES: tuple[QBittorrentSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up qBittorrent switch entries.""" diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index 2413b8f152f..8a932e1e414 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -8,7 +8,7 @@ from qbusmqttapi.state import QbusMqttOnOffState, StateType from homeassistant.components.mqtt import ReceiveMessage from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import QbusConfigEntry from .entity import QbusEntity @@ -17,7 +17,9 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, entry: QbusConfigEntry, add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: QbusConfigEntry, + add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch entities.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/qingping/binary_sensor.py b/homeassistant/components/qingping/binary_sensor.py index 5f1367fbce8..3431204595a 100644 --- a/homeassistant/components/qingping/binary_sensor.py +++ b/homeassistant/components/qingping/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import QingpingConfigEntry @@ -74,7 +74,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: QingpingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Qingping BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/qingping/sensor.py b/homeassistant/components/qingping/sensor.py index 3d5f30c61fc..ee2a63b169a 100644 --- a/homeassistant/components/qingping/sensor.py +++ b/homeassistant/components/qingping/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import QingpingConfigEntry @@ -142,7 +142,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: QingpingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Qingping BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 383a4e5f572..381455cb7e1 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -248,7 +248,7 @@ SENSOR_KEYS: list[str] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" coordinator = QnapCoordinator(hass, config_entry) diff --git a/homeassistant/components/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py index a9c025b86ce..c1f77d068df 100644 --- a/homeassistant/components/qnap_qsw/binary_sensor.py +++ b/homeassistant/components/qnap_qsw/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED from .const import ATTR_MESSAGE, DOMAIN, QSW_COORD_DATA @@ -78,7 +78,9 @@ PORT_BINARY_SENSOR_TYPES: Final[tuple[QswBinarySensorEntityDescription, ...]] = async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW binary sensors from a config_entry.""" coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py index 091c6786a92..02cf96766f2 100644 --- a/homeassistant/components/qnap_qsw/button.py +++ b/homeassistant/components/qnap_qsw/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, QSW_COORD_DATA, QSW_REBOOT from .coordinator import QswDataCoordinator @@ -41,7 +41,9 @@ BUTTON_TYPES: Final[tuple[QswButtonDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW buttons from a config_entry.""" coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index e7f2c18638f..af02c121656 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -44,7 +44,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util import dt as dt_util @@ -286,7 +286,9 @@ PORT_SENSOR_TYPES: Final[tuple[QswSensorEntityDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW sensors from a config_entry.""" coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py index ac789235271..c5cef729849 100644 --- a/homeassistant/components/qnap_qsw/update.py +++ b/homeassistant/components/qnap_qsw/update.py @@ -20,7 +20,7 @@ from homeassistant.components.update import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, QSW_COORD_FW, QSW_UPDATE from .coordinator import QswFirmwareCoordinator @@ -36,7 +36,9 @@ UPDATE_TYPES: Final[tuple[UpdateEntityDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW updates from a config_entry.""" coordinator: QswFirmwareCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py index cfbee0be67c..4c13f3a8b02 100644 --- a/homeassistant/components/rabbitair/fan.py +++ b/homeassistant/components/rabbitair/fan.py @@ -9,7 +9,7 @@ from rabbitair import Mode, Model, Speed from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -39,7 +39,9 @@ PRESET_MODES = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator: RabbitAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 189a08e998d..3bf0f716c6d 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN as DOMAIN_RACHIO, @@ -46,7 +46,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio binary sensors.""" entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) diff --git a/homeassistant/components/rachio/calendar.py b/homeassistant/components/rachio/calendar.py index 5c7e13c748a..91ad29fac9f 100644 --- a/homeassistant/components/rachio/calendar.py +++ b/homeassistant/components/rachio/calendar.py @@ -12,7 +12,7 @@ from homeassistant.components.calendar import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for Rachio smart hose timer calendar.""" person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 92e7c0ea2ba..25cdeac62f7 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp @@ -102,7 +102,7 @@ START_MULTIPLE_ZONES_SCHEMA = vol.Schema( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Rachio switches.""" zone_entities = [] diff --git a/homeassistant/components/radarr/binary_sensor.py b/homeassistant/components/radarr/binary_sensor.py index 62f78cc9d6f..f09e6015b53 100644 --- a/homeassistant/components/radarr/binary_sensor.py +++ b/homeassistant/components/radarr/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import HEALTH_ISSUES from .coordinator import RadarrConfigEntry @@ -28,7 +28,7 @@ BINARY_SENSOR_TYPE = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RadarrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Radarr sensors based on a config entry.""" coordinator = entry.runtime_data.health diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py index 2976c7b6fea..00df27f21bd 100644 --- a/homeassistant/components/radarr/calendar.py +++ b/homeassistant/components/radarr/calendar.py @@ -7,7 +7,7 @@ from datetime import datetime from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import CalendarUpdateCoordinator, RadarrConfigEntry, RadarrEvent from .entity import RadarrEntity @@ -21,7 +21,7 @@ CALENDAR_TYPE = EntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RadarrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Radarr calendar entity.""" coordinator = entry.runtime_data.calendar diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index e37fd51a494..fa0cb95d549 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RadarrConfigEntry, RadarrDataUpdateCoordinator, T from .entity import RadarrEntity @@ -116,7 +116,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: RadarrConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Radarr sensors based on a config entry.""" entities: list[RadarrSensor[Any]] = [] diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index af52c5fcea3..09ac5b42b60 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -20,7 +20,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .coordinator import RadioThermUpdateCoordinator @@ -93,7 +93,7 @@ def round_temp(temperature): async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate for a radiotherm device.""" coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/radiotherm/switch.py b/homeassistant/components/radiotherm/switch.py index e7b463e3def..2952e1e5817 100644 --- a/homeassistant/components/radiotherm/switch.py +++ b/homeassistant/components/radiotherm/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RadioThermUpdateCoordinator @@ -19,7 +19,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for a radiotherm device.""" coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 5722b8852dd..0b27c7e33c4 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import RainbirdUpdateCoordinator @@ -27,7 +27,7 @@ RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird binary_sensor.""" coordinator = config_entry.runtime_data.coordinator diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index 160fe70c61e..c48ca438146 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -9,7 +9,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation calendar.""" data = config_entry.runtime_data diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index d8081a796b9..7f1dfe74752 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -10,7 +10,7 @@ from homeassistant.components.number import NumberEntity from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import RainbirdUpdateCoordinator @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird number platform.""" async_add_entities( diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 4725a33bc9a..9fab1af0a23 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +25,7 @@ RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird sensor.""" async_add_entities( diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index f622a1b9b2c..f188350138e 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -32,7 +32,7 @@ SERVICE_SCHEMA_IRRIGATION: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: RainbirdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Rain Bird irrigation switches.""" coordinator = config_entry.runtime_data.coordinator diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 8c4c5927998..58427b0e5ba 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -45,7 +45,9 @@ SENSORS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py index 1025e92ef86..3d358322b70 100644 --- a/homeassistant/components/rainforest_raven/sensor.py +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -80,7 +80,7 @@ DIAGNOSTICS = ( async def async_setup_entry( hass: HomeAssistant, entry: RAVEnConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 4ba9b58d596..610505e2b7f 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RainMachineConfigEntry from .const import DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_CURRENT @@ -94,7 +94,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine binary sensors based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index 2f68c6a8a9c..e4ed00930dd 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -17,7 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RainMachineConfigEntry from .const import DATA_PROVISION_SETTINGS @@ -53,7 +53,7 @@ BUTTON_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine buttons based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 1d9225a5bb2..5b23a5d79ef 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem from . import RainMachineConfigEntry, RainMachineData @@ -83,7 +83,7 @@ SELECT_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine selects based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 64f9ecf3990..4677a6d8bca 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utc_from_timestamp, utcnow from . import RainMachineConfigEntry, RainMachineData @@ -153,7 +153,7 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine sensors based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 2a065f18976..9b62b15d196 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -17,7 +17,7 @@ from homeassistant.const import ATTR_ID, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import RainMachineConfigEntry, RainMachineData, async_update_programs_and_zones @@ -174,7 +174,7 @@ RESTRICTIONS_SWITCH_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RainMachine switches based on a config entry.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py index 39156b05cd4..312937184e4 100644 --- a/homeassistant/components/rainmachine/update.py +++ b/homeassistant/components/rainmachine/update.py @@ -16,7 +16,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RainMachineConfigEntry from .const import DATA_MACHINE_FIRMWARE_UPDATE_STATUS @@ -60,7 +60,7 @@ UPDATE_DESCRIPTION = RainMachineUpdateEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RainMachineConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Rainmachine update based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index fadc966bc3d..1af85b43486 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -17,7 +17,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType DEFAULT_NAME = "Random binary sensor" @@ -44,7 +47,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" async_add_entities( diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 590b391c3a0..6ea296c791e 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -22,7 +22,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DEFAULT_MAX, DEFAULT_MIN @@ -57,7 +60,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" diff --git a/homeassistant/components/rapt_ble/sensor.py b/homeassistant/components/rapt_ble/sensor.py index fd88cbcb54c..01aeedbd344 100644 --- a/homeassistant/components/rapt_ble/sensor.py +++ b/homeassistant/components/rapt_ble/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -99,7 +99,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the RAPT Pill BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 5360ce4a7fe..58e1c2e8237 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -49,7 +49,7 @@ BINARY_SENSORS: tuple[RDWBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW binary sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index 2c9c9addcfb..4133082bcf4 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -51,7 +51,7 @@ SENSORS: tuple[RDWSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up RDW sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/recollect_waste/calendar.py b/homeassistant/components/recollect_waste/calendar.py index 3a76451358e..8145a93a2b7 100644 --- a/homeassistant/components/recollect_waste/calendar.py +++ b/homeassistant/components/recollect_waste/calendar.py @@ -9,7 +9,7 @@ from aiorecollect.client import PickupEvent from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -35,7 +35,9 @@ def async_get_calendar_event_from_pickup_event( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" coordinator: DataUpdateCoordinator[list[PickupEvent]] = hass.data[DOMAIN][ diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 36658fb5008..69b1772b9fa 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER @@ -39,7 +39,9 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ReCollect Waste sensors based on a config entry.""" coordinator: DataUpdateCoordinator[list[PickupEvent]] = hass.data[DOMAIN][ diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py index 7065470657f..82637aae538 100644 --- a/homeassistant/components/refoss/sensor.py +++ b/homeassistant/components/refoss/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .bridge import RefossDataUpdateCoordinator @@ -117,7 +117,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py index aed132ecc3a..1d465f7f319 100644 --- a/homeassistant/components/refoss/switch.py +++ b/homeassistant/components/refoss/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .bridge import RefossDataUpdateCoordinator from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN @@ -20,7 +20,7 @@ from .entity import RefossEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Refoss device from a config entry.""" diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index a8fdf324f1c..0aebd3bd835 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry @@ -37,7 +37,7 @@ class RenaultBinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultBinarySensor] = [ diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 6a9f5e05a38..82b811821ea 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RenaultConfigEntry from .entity import RenaultEntity @@ -29,7 +29,7 @@ class RenaultButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultButtonEntity] = [ diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 08a2a698802..c55ddeb2190 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -11,7 +11,7 @@ from homeassistant.components.device_tracker import ( TrackerEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription @@ -30,7 +30,7 @@ class RenaultTrackerEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultDeviceTracker] = [ diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index cab1d1f4d8a..cddf83bb860 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -9,7 +9,7 @@ from renault_api.kamereon.models import KamereonVehicleBatteryStatusData from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry @@ -32,7 +32,7 @@ class RenaultSelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultSelectEntity] = [ diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 7854d70b1c4..7c513c1b9de 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import as_utc, parse_datetime @@ -60,7 +60,7 @@ class RenaultSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultSensor[Any]] = [ diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index 46f832ed15c..60b4f54b85c 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -24,7 +24,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RensonCoordinator @@ -86,7 +86,7 @@ BINARY_SENSORS: tuple[RensonBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Call the Renson integration to setup.""" diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index 02278a0d6f6..830e5a03a4a 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -15,7 +15,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RensonCoordinator, RensonData from .const import DOMAIN @@ -54,7 +54,7 @@ ENTITY_DESCRIPTIONS: tuple[RensonButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson button platform.""" diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 00edd4547cb..474ab640943 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -19,7 +19,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -85,7 +85,7 @@ SPEED_RANGE: tuple[float, float] = (1, 4) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson fan platform.""" diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py index fb8ab8fc552..67fde1c56dc 100644 --- a/homeassistant/components/renson/number.py +++ b/homeassistant/components/renson/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RensonCoordinator @@ -40,7 +40,7 @@ RENSON_NUMBER_DESCRIPTION = NumberEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson number platform.""" diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 1df62e12312..ce7e71b1c0b 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -43,7 +43,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RensonData from .const import DOMAIN @@ -272,7 +272,7 @@ class RensonSensor(RensonEntity, SensorEntity): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson sensor platform.""" diff --git a/homeassistant/components/renson/switch.py b/homeassistant/components/renson/switch.py index 2cd44d20a6a..3b73bb3dffe 100644 --- a/homeassistant/components/renson/switch.py +++ b/homeassistant/components/renson/switch.py @@ -11,7 +11,7 @@ from renson_endura_delta.renson import Level, RensonVentilation from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RensonCoordinator from .const import DOMAIN @@ -68,7 +68,7 @@ class RensonBreezeSwitch(RensonEntity, SwitchEntity): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Call the Renson integration to setup.""" diff --git a/homeassistant/components/renson/time.py b/homeassistant/components/renson/time.py index feb47fadf99..0a07fd2ec4f 100644 --- a/homeassistant/components/renson/time.py +++ b/homeassistant/components/renson/time.py @@ -13,7 +13,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RensonData from .const import DOMAIN @@ -50,7 +50,7 @@ ENTITY_DESCRIPTIONS: tuple[RensonTimeEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Renson time platform.""" diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 2191dedc9cf..4e90bfc9eef 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription from .util import ReolinkConfigEntry, ReolinkData @@ -125,7 +125,7 @@ BINARY_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index c1a2aed4119..a67b30a394c 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -19,7 +19,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -151,7 +151,7 @@ HOST_BUTTON_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink button entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index a597be3ec7a..329ef9028de 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -13,7 +13,7 @@ from homeassistant.components.camera import ( CameraEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error @@ -89,7 +89,7 @@ CAMERA_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index bbb9592dd76..d48790264d1 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ( ReolinkChannelCoordinatorEntity, @@ -92,7 +92,7 @@ HOST_LIGHT_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink light entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index d8fabfaa3b8..48382df4cbc 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ( ReolinkChannelCoordinatorEntity, @@ -538,7 +538,7 @@ CHIME_NUMBER_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink number entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index df8c0269957..c0b20da0238 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -23,7 +23,7 @@ from reolink_aio.api import ( from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfFrequency from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ( ReolinkChannelCoordinatorEntity, @@ -295,7 +295,7 @@ CHIME_SELECT_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink select entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 36900da99ca..ecad555b481 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .entity import ( @@ -150,7 +150,7 @@ HDD_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index 74bb227d078..f5d2de977ae 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -13,7 +13,7 @@ from homeassistant.components.siren import ( SirenEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error @@ -40,7 +40,7 @@ SIREN_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink siren entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index a0b8824782a..0f106c0f2cc 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import ( @@ -330,7 +330,7 @@ DEPRECATED_NVR_SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Reolink switch entities.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 5a8c7d7dc08..0744d66fb5b 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -16,7 +16,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -75,7 +75,7 @@ HOST_UPDATE_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Reolink component.""" reolink_data: ReolinkData = config_entry.runtime_data diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 316cf44ef0d..a86ad5557b4 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON, STATE_ON from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event as evt from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeviceTuple, async_setup_platform_entry, get_pt2262_cmd from .const import ( @@ -91,7 +91,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index 473a0d94056..07443afb38b 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import CoverEntity, CoverEntityFeature, Cove from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeviceTuple, async_setup_platform_entry from .const import ( @@ -34,7 +34,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/event.py b/homeassistant/components/rfxtrx/event.py index 212d93b5019..40d02953aeb 100644 --- a/homeassistant/components/rfxtrx/event.py +++ b/homeassistant/components/rfxtrx/event.py @@ -11,7 +11,7 @@ from homeassistant.components.event import EventEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from . import DeviceTuple, async_setup_platform_entry @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 0e2f7bef65a..90c0d2eeed7 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeviceTuple, async_setup_platform_entry from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST @@ -32,7 +32,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 13f3c012af8..4b256279445 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import DeviceTuple, async_setup_platform_entry, get_rfx_object @@ -241,7 +241,7 @@ SENSOR_TYPES_DICT = {desc.key: desc for desc in SENSOR_TYPES} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py index 1635f1f55a9..1164dafbfce 100644 --- a/homeassistant/components/rfxtrx/siren.py +++ b/homeassistant/components/rfxtrx/siren.py @@ -11,7 +11,7 @@ from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFe from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from . import DEFAULT_OFF_DELAY, DeviceTuple, async_setup_platform_entry @@ -47,7 +47,7 @@ def get_first_key(data: dict[int, str], entry: str) -> int: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index cd17e71f4f0..b3eb63fb2b4 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON, STATE_ON from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DeviceTuple, async_setup_platform_entry, get_pt2262_cmd from .const import ( @@ -41,7 +41,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up config entry.""" diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py index ecca0366754..bb7982a5391 100644 --- a/homeassistant/components/ridwell/calendar.py +++ b/homeassistant/components/ridwell/calendar.py @@ -9,7 +9,7 @@ from aioridwell.model import RidwellAccount, RidwellPickupEvent from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RidwellDataUpdateCoordinator @@ -36,7 +36,9 @@ def async_get_calendar_event_from_pickup_event( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell calendars based on a config entry.""" coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 7fc7fdb5348..30f97ecaea8 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, SENSOR_TYPE_NEXT_PICKUP from .coordinator import RidwellDataUpdateCoordinator @@ -34,7 +34,9 @@ SENSOR_DESCRIPTION = SensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell sensors based on a config entry.""" coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index 04e3e4c5ff9..e3be9ea5368 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RidwellDataUpdateCoordinator @@ -24,7 +24,9 @@ SWITCH_DESCRIPTION = SwitchEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ridwell sensors based on a config entry.""" coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index da0e0cc1d9b..49051ee5e11 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_at from . import RingConfigEntry @@ -67,7 +67,7 @@ BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ring binary sensors from a config entry.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py index 30600237847..09e6c0e413a 100644 --- a/homeassistant/components/ring/button.py +++ b/homeassistant/components/ring/button.py @@ -6,7 +6,7 @@ from ring_doorbell import RingOther from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RingConfigEntry from .coordinator import RingDataCoordinator @@ -24,7 +24,7 @@ BUTTON_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the buttons for the Ring devices.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index e0ae2b52fa0..156d82665d2 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -27,7 +27,7 @@ from homeassistant.components.camera import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import RingConfigEntry @@ -76,7 +76,7 @@ CAMERA_DESCRIPTIONS: tuple[RingCameraEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py index 4d7a6277579..db99a10de74 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -12,7 +12,7 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RingConfigEntry from .coordinator import RingListenCoordinator @@ -57,7 +57,7 @@ EVENT_DESCRIPTIONS: tuple[RingEventEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up events for a Ring device.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 62c5217a89b..34915dd5133 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -9,7 +9,7 @@ from ring_doorbell import RingStickUpCam from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import RingConfigEntry @@ -40,7 +40,7 @@ class OnOffState(StrEnum): async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the lights for the Ring devices.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/number.py b/homeassistant/components/ring/number.py index b920ff7edc7..68b41451bd0 100644 --- a/homeassistant/components/ring/number.py +++ b/homeassistant/components/ring/number.py @@ -13,7 +13,7 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import RingConfigEntry @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a numbers for a Ring device.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index a2f72b94336..5744ed9a4d8 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import RingConfigEntry @@ -48,7 +48,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a sensor for a Ring device.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 05fa07c39eb..7f096c0e643 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -22,7 +22,7 @@ from homeassistant.components.siren import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RingConfigEntry from .coordinator import RingDataCoordinator @@ -85,7 +85,7 @@ SIRENS: tuple[RingSirenEntityDescription[Any], ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the sirens for the Ring devices.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index cab5654fc5a..02d98388edc 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -11,7 +11,7 @@ from ring_doorbell.const import DOORBELL_EXISTING_TYPE from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import RingConfigEntry @@ -86,7 +86,7 @@ SWITCHES: Sequence[RingSwitchEntityDescription[Any]] = ( async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the switches for the Ring devices.""" ring_data = entry.runtime_data diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index b1eae8fd917..2472baa932e 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LocalData, is_local from .const import ( @@ -50,7 +50,7 @@ STATES_TO_SUPPORTED_FEATURES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco alarm control panel.""" options = {**DEFAULT_OPTIONS, **config_entry.options} diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index a7ca0129b06..ff61985fef3 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LocalData, is_local from .const import DATA_COORDINATOR, DOMAIN, SYSTEM_UPDATE_SIGNAL @@ -73,7 +73,7 @@ SYSTEM_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco alarm control panel.""" if is_local(config_entry): diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index c1495512e62..93683f1aa50 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -46,7 +46,7 @@ EVENT_ATTRIBUTES = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" if is_local(config_entry): diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py index 8bad2c6c15e..547dedd3933 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import LocalData, is_local from .const import DATA_COORDINATOR, DOMAIN @@ -21,7 +21,7 @@ from .entity import RiscoCloudZoneEntity, RiscoLocalZoneEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco switch.""" if is_local(config_entry): diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 63666fc1aca..97e9c8418d1 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -44,7 +44,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser binary sensors.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 0ac9c30f285..98e833ff9bd 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -11,7 +11,7 @@ from pyrituals import Diffuser from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -41,7 +41,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser numbers.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 27aff70649b..c239627e9c6 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfArea from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -44,7 +44,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser select entities.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 46faa8d73e9..3921fd0b6c2 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -60,7 +60,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser sensors.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index b5828f5ca07..c5331b49078 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -11,7 +11,7 @@ from pyrituals import Diffuser from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RitualsDataUpdateCoordinator @@ -42,7 +42,7 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the diffuser switch.""" coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index c734eaf5ce8..db557f055dc 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -69,7 +69,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roborock vacuum binary sensors.""" async_add_entities( diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 038f224f726..33e9502aca1 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -9,7 +9,7 @@ from roborock.roborock_typing import RoborockCommand from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 @@ -62,7 +62,7 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock button platform.""" async_add_entities( diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index ff1c94957e0..6d9e87b0556 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import ( @@ -34,7 +34,7 @@ from .entity import RoborockCoordinatedEntityV1 async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock image platform.""" diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 97aa8c2ffd4..a710eeefb90 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator @@ -50,7 +50,7 @@ NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock number platform.""" possible_entities: list[ diff --git a/homeassistant/components/roborock/scene.py b/homeassistant/components/roborock/scene.py index c07014431cd..ff418a2810c 100644 --- a/homeassistant/components/roborock/scene.py +++ b/homeassistant/components/roborock/scene.py @@ -8,7 +8,7 @@ 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 AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import RoborockEntity async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up scene platform.""" scene_lists = await asyncio.gather( diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 826af3e24e8..6133eed0652 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -11,7 +11,7 @@ from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import MAP_SLEEP from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator @@ -64,7 +64,7 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock select platform.""" diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 0d376debcbf..f95dc5fa98f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -28,7 +28,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import ( @@ -295,7 +295,7 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roborock vacuum sensors.""" coordinators = config_entry.runtime_data diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index ebf8225b4f5..0171d59abfd 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator @@ -99,7 +99,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock switch platform.""" possible_entities: list[ diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 76f20bc6607..6aa70e300e5 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -16,7 +16,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator @@ -114,7 +114,7 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock time platform.""" possible_entities: list[ diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index e604ab6a209..59abc888673 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -16,7 +16,7 @@ from homeassistant.components.vacuum import ( from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -58,7 +58,7 @@ STATE_CODE_TO_STATE = { async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roborock sensor.""" async_add_entities( diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index 1afc580f2fe..31250898055 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RokuConfigEntry from .entity import RokuEntity @@ -59,7 +59,7 @@ BINARY_SENSORS: tuple[RokuBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RokuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Roku binary sensors based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index fb4f8b1c2e8..d0e1e3a53c0 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -26,7 +26,7 @@ from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .browse_media import async_browse_media @@ -82,7 +82,9 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( - hass: HomeAssistant, entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: RokuConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Roku config entry.""" async_add_entities( diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index fd76e2e8dcf..cc3689c9df3 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RokuConfigEntry from .entity import RokuEntity @@ -19,7 +19,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: RokuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Roku remote based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index c99b9892b47..062e1258ea2 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -10,7 +10,7 @@ from rokuecp.models import Device as RokuDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RokuConfigEntry from .entity import RokuEntity @@ -109,7 +109,7 @@ CHANNEL_ENTITY = RokuSelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: RokuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roku select based on a config entry.""" device: RokuDevice = entry.runtime_data.data diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index 96295984f76..a61a9be6a73 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -10,7 +10,7 @@ from rokuecp.models import Device as RokuDevice from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RokuConfigEntry from .entity import RokuEntity @@ -45,7 +45,7 @@ SENSORS: tuple[RokuSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: RokuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roku sensor based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/romy/binary_sensor.py b/homeassistant/components/romy/binary_sensor.py index d8f6216007f..599c0fe023e 100644 --- a/homeassistant/components/romy/binary_sensor.py +++ b/homeassistant/components/romy/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RomyVacuumCoordinator @@ -39,7 +39,7 @@ BINARY_SENSORS: list[BinarySensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py index 341125b86ba..85bf0df8f64 100644 --- a/homeassistant/components/romy/sensor.py +++ b/homeassistant/components/romy/sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import RomyVacuumCoordinator @@ -77,7 +77,7 @@ SENSORS: list[SensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py index 49129daabbd..0e9dd13ffe1 100644 --- a/homeassistant/components/romy/vacuum.py +++ b/homeassistant/components/romy/vacuum.py @@ -13,7 +13,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, LOGGER from .coordinator import RomyVacuumCoordinator @@ -51,7 +51,7 @@ SUPPORT_ROMY_ROBOT = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up ROMY vacuum cleaner.""" diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index baf66375036..d50535c885a 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -3,7 +3,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import roomba_reported_state from .const import DOMAIN @@ -14,7 +14,7 @@ from .models import RoombaData async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index d358dcb428c..3a98bedcd94 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -125,7 +125,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 92063f74afa..10606814a35 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -14,7 +14,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM @@ -89,7 +89,7 @@ SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index 7bc6ea27dd9..2f2967c5789 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roon Event from Config Entry.""" roon_server = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 3b1735cd2fc..0460e2cfc6e 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -25,7 +25,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import convert from homeassistant.util.dt import utcnow @@ -52,7 +52,7 @@ REPEAT_MODE_MAPPING_TO_ROON = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roon MediaPlayer from Config Entry.""" roon_server = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 589183eb7a8..59f9f28f8f5 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -43,7 +43,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Rova entry.""" coordinator: RovaCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/rpi_power/binary_sensor.py b/homeassistant/components/rpi_power/binary_sensor.py index 00d7ec0e3f4..1424148f554 100644 --- a/homeassistant/components/rpi_power/binary_sensor.py +++ b/homeassistant/components/rpi_power/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,7 @@ DESCRIPTION_UNDER_VOLTAGE = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up rpi_power binary sensor.""" under_voltage = await hass.async_add_executor_job(new_under_voltage) diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 8a5e8b79294..890148ec25c 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -8,7 +8,7 @@ from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -25,7 +25,9 @@ _LOGGER = logging.getLogger(__package__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Ruckus component.""" coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] @@ -69,7 +71,7 @@ def restore_entities( registry: er.EntityRegistry, coordinator: RuckusDataUpdateCoordinator, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], ) -> None: """Restore clients that are not a part of active clients list.""" diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 346f4903f6a..b40b82862f9 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -20,7 +20,7 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RussoundConfigEntry from .entity import RussoundBaseEntity, command @@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: RussoundConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Russound RIO platform.""" client = entry.runtime_data diff --git a/homeassistant/components/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py index ef287753ed4..57248d547ba 100644 --- a/homeassistant/components/ruuvitag_ble/sensor.py +++ b/homeassistant/components/ruuvitag_ble/sensor.py @@ -32,7 +32,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -126,7 +126,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ruuvitag BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 8bb0af6e9ff..250e942fb4f 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -48,7 +48,7 @@ SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" coordinator: RymProDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/sabnzbd/binary_sensor.py b/homeassistant/components/sabnzbd/binary_sensor.py index 1d65bf01211..59ef17237e2 100644 --- a/homeassistant/components/sabnzbd/binary_sensor.py +++ b/homeassistant/components/sabnzbd/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SabnzbdConfigEntry from .entity import SabnzbdEntity @@ -40,7 +40,7 @@ BINARY_SENSORS: tuple[SabnzbdBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SabnzbdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Sabnzbd sensor entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/sabnzbd/button.py b/homeassistant/components/sabnzbd/button.py index 1ff26b41655..25c11f6b2ec 100644 --- a/homeassistant/components/sabnzbd/button.py +++ b/homeassistant/components/sabnzbd/button.py @@ -9,7 +9,7 @@ from pysabnzbd import SabnzbdApiException from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator @@ -40,7 +40,7 @@ BUTTON_DESCRIPTIONS: tuple[SabnzbdButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SabnzbdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py index 53c8d462f11..63b2206ac70 100644 --- a/homeassistant/components/sabnzbd/number.py +++ b/homeassistant/components/sabnzbd/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator @@ -48,7 +48,7 @@ NUMBER_DESCRIPTIONS: tuple[SabnzbdNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SabnzbdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SABnzbd number entity.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 662ae739d15..5e871b4bf40 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import SabnzbdConfigEntry @@ -115,7 +115,7 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SabnzbdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Sabnzbd sensor entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 9db9916c24a..4e6ecfd3593 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -31,7 +31,7 @@ from homeassistant.components.media_player import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.async_ import create_eager_task from .bridge import SamsungTVWSBridge @@ -63,7 +63,7 @@ APP_LIST_DELAY = 3 async def async_setup_entry( hass: HomeAssistant, entry: SamsungTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Samsung TV from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index 3d2529153be..d6fef262d91 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER from .coordinator import SamsungTVConfigEntry @@ -17,7 +17,7 @@ from .entity import SamsungTVEntity async def async_setup_entry( hass: HomeAssistant, entry: SamsungTVConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Samsung TV from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/sanix/sensor.py b/homeassistant/components/sanix/sensor.py index 39a1c593433..d2a1aecb099 100644 --- a/homeassistant/components/sanix/sensor.py +++ b/homeassistant/components/sanix/sensor.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER @@ -82,7 +82,9 @@ SENSOR_TYPES: tuple[SanixSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sanix Sensor entities based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py index 280853237d4..62e69b5cb4a 100644 --- a/homeassistant/components/schlage/binary_sensor.py +++ b/homeassistant/components/schlage/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -39,7 +39,7 @@ _DESCRIPTIONS: tuple[SchlageBinarySensorEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SchlageConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary_sensors based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index 697c2e8399f..83abf9214e3 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -15,7 +15,7 @@ from .entity import SchlageEntity async def async_setup_entry( hass: HomeAssistant, config_entry: SchlageConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Schlage WiFi locks based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py index f93eee78d34..4648686aaac 100644 --- a/homeassistant/components/schlage/select.py +++ b/homeassistant/components/schlage/select.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -32,7 +32,7 @@ _DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SchlageConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up selects based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py index f7fb7c63b22..494efc7585a 100644 --- a/homeassistant/components/schlage/sensor.py +++ b/homeassistant/components/schlage/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -29,7 +29,7 @@ _SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: SchlageConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py index 56ff0ebe360..c40d0c41e88 100644 --- a/homeassistant/components/schlage/switch.py +++ b/homeassistant/components/schlage/switch.py @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -56,7 +56,7 @@ SWITCHES: tuple[SchlageSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SchlageConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches based on a config entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 5ee837f32d1..b8ad9cb8a56 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -21,7 +21,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, @@ -92,7 +95,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: ScrapeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Scrape sensor entry.""" entities: list = [] diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 4a178c60d81..a846a9fa4e3 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( @@ -195,7 +195,7 @@ SUPPORTED_SCG_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index e44d9b18ae1..03aebadbba6 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -21,7 +21,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .entity import ScreenLogicPushEntity, ScreenLogicPushEntityDescription @@ -42,7 +42,7 @@ SUPPORTED_PRESETS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 412b2df5f81..b0bd154b66d 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( LightEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LIGHT_CIRCUIT_FUNCTIONS from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription @@ -22,7 +22,7 @@ from .types import ScreenLogicConfigEntry async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" entities: list[ScreenLogicLight] = [] diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index 3634147e509..ea9bf8ac95d 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -17,7 +17,7 @@ from homeassistant.components.number import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( @@ -104,7 +104,7 @@ SUPPORTED_SCG_NUMBERS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" entities: list[ScreenLogicNumber] = [] diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 7a5e910923c..95a7e3a5c75 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( @@ -272,7 +272,7 @@ SUPPORTED_SCG_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 1d36ee00b94..dfbb1c1781d 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -8,7 +8,7 @@ from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LIGHT_CIRCUIT_FUNCTIONS from .entity import ( @@ -29,7 +29,7 @@ class ScreenLogicCircuitSwitchDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ScreenLogicConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" entities: list[ScreenLogicSwitchingEntity] = [] diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index 96744db1d02..bdc24883c90 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from .const import DOMAIN, TYPE_ASTRONOMICAL @@ -37,7 +37,7 @@ HEMISPHERE_SEASON_SWAP = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config entry.""" hemisphere = EQUATOR diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index d06b3a62937..3bb8a32b8e4 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SenseConfigEntry from .const import DOMAIN @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: SenseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sense binary sensor.""" sense_monitor_id = config_entry.runtime_data.data.sense_monitor_id diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 2f5c82675d5..8cb4bdd3e56 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SenseConfigEntry from .const import ( @@ -66,7 +66,7 @@ TREND_SENSOR_VARIANTS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: SenseConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sense sensor.""" data = config_entry.runtime_data.data diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index a66ab46c882..0d6c47ce46c 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry from .const import LOGGER @@ -118,7 +118,7 @@ DESCRIPTION_BY_MODELS = {"pure": PURE_SENSOR_TYPES} async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo binary sensor platform.""" diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index df8d4625840..ed0688d6f2c 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator @@ -35,7 +35,7 @@ DEVICE_BUTTON_TYPES = SensiboButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo button platform.""" diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 5d1c6ff9e79..2190d121248 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -25,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter from . import SensiboConfigEntry @@ -138,7 +138,7 @@ def _find_valid_target_temp(target: float, valid_targets: list[int]) -> int: async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sensibo climate entry.""" diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index aa46c7f8c1e..9d077b308a0 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator @@ -65,7 +65,7 @@ DEVICE_NUMBER_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo number platform.""" diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 51521b59f03..73c0734ef73 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -17,7 +17,7 @@ from homeassistant.components.select import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -67,7 +67,7 @@ DEVICE_SELECT_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo select platform.""" diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index b242f38febe..4174d4b859b 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import SensiboConfigEntry @@ -240,7 +240,7 @@ DESCRIPTION_BY_MODELS = { async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo sensor platform.""" diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 0bc2c55a706..8c140074e57 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry from .const import DOMAIN @@ -78,7 +78,7 @@ DESCRIPTION_BY_MODELS = {"pure": PURE_SWITCH_TYPES} async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo Switch platform.""" diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 0b02264b3e0..2103bbbf64a 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -14,7 +14,7 @@ from homeassistant.components.update import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator @@ -45,7 +45,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SensiboConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensibo Update platform.""" diff --git a/homeassistant/components/sensirion_ble/sensor.py b/homeassistant/components/sensirion_ble/sensor.py index a7254fd3609..16f7571f392 100644 --- a/homeassistant/components/sensirion_ble/sensor.py +++ b/homeassistant/components/sensirion_ble/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -106,7 +106,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sensirion BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/sensorpro/sensor.py b/homeassistant/components/sensorpro/sensor.py index b972aac04fb..997fa0db995 100644 --- a/homeassistant/components/sensorpro/sensor.py +++ b/homeassistant/components/sensorpro/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -111,7 +111,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SensorPro BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py index 6eea5c10f78..730277350b5 100644 --- a/homeassistant/components/sensorpush/sensor.py +++ b/homeassistant/components/sensorpush/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from . import SensorPushConfigEntry @@ -97,7 +97,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: SensorPushConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SensorPush BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/sensoterra/sensor.py b/homeassistant/components/sensoterra/sensor.py index a32fe3d98c9..56f47ade212 100644 --- a/homeassistant/components/sensoterra/sensor.py +++ b/homeassistant/components/sensoterra/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -84,7 +84,7 @@ SENSORS: dict[ProbeSensorType, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: SensoterraConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_devices: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sensoterra sensor.""" diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index d5749a3f040..48eeee54974 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SENZDataUpdateCoordinator @@ -26,7 +26,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SENZ climate entities from a config entry.""" coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index dade9efb67c..b0f9d6cd2bd 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -28,7 +28,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a 17Track sensor entry.""" diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 4ef5e87761d..de40291b0b6 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -62,7 +62,9 @@ WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index bddb1e8f926..9798602ef6b 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -21,7 +21,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .models import DomainData @@ -65,7 +65,9 @@ BUTTON_TYPES: tuple[SFRBoxButtonEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the buttons.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index ee3285a8f38..8f50b6acd90 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -217,7 +217,9 @@ def _get_temperature(value: float | None) -> float | None: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 332d95b0a3e..daea195a770 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER, SERVICE_CLEAN_ROOM, SHARK @@ -50,7 +50,7 @@ ATTR_ROOMS = "rooms" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Shark IQ vacuum cleaner.""" coordinator: SharkIqUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index fb253c682d8..ed2ac68d264 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import CONF_SLEEP_PERIOD @@ -290,7 +290,7 @@ RPC_SENSORS: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index f1e2f8ef885..1f3c555a64b 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -18,7 +18,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -106,7 +106,7 @@ def async_migrate_unique_ids( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" entry_data = config_entry.runtime_data diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index f1491acdd81..a3ec9be7cb0 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -27,7 +27,7 @@ from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -56,7 +56,7 @@ from .utils import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -75,7 +75,7 @@ async def async_setup_entry( @callback def async_setup_climate_entities( - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: ShellyBlockCoordinator, ) -> None: """Set up online climate devices.""" @@ -102,7 +102,7 @@ def async_setup_climate_entities( def async_restore_climate_entities( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: ShellyBlockCoordinator, ) -> None: """Restore sleeping climate devices.""" @@ -124,7 +124,7 @@ def async_restore_climate_entities( def async_setup_rpc_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 09e8279bf9b..e9eb5acf161 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ShellyBlockEntity, ShellyRpcEntity @@ -25,7 +25,7 @@ from .utils import get_device_entry_gen, get_rpc_key_ids async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up covers for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -38,7 +38,7 @@ async def async_setup_entry( def async_setup_block_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover for device.""" coordinator = config_entry.runtime_data.block @@ -55,7 +55,7 @@ def async_setup_block_entry( def async_setup_rpc_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 78093bec8aa..bfd705f447a 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -18,7 +18,7 @@ from homeassistant.components.event import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -83,7 +83,7 @@ SCRIPT_EVENT: Final = ShellyRpcEventDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" entities: list[ShellyBlockEvent | ShellyRpcEvent] = [] diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 5d7bad810b4..ce31533b557 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -21,7 +21,7 @@ from homeassistant.components.light import ( brightness_supported, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BLOCK_MAX_TRANSITION_TIME_MS, @@ -53,7 +53,7 @@ from .utils import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lights for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -66,7 +66,7 @@ async def async_setup_entry( def async_setup_block_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for block device.""" coordinator = config_entry.runtime_data.block @@ -96,7 +96,7 @@ def async_setup_block_entry( def async_setup_rpc_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 1fc47b23bdb..59716f39c7f 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -22,7 +22,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP @@ -238,7 +238,7 @@ RPC_NUMBERS: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up numbers for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index 0caf4661240..1fb3dfb3447 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -13,7 +13,7 @@ from homeassistant.components.select import ( SelectEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( @@ -45,7 +45,7 @@ RPC_SELECT_ENTITIES: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up selectors for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index c492fc1de9e..183a1aa06a1 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType @@ -1324,7 +1324,7 @@ RPC_SENSORS: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 8a33dae0938..9b34b2e079b 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import RestoreEntity @@ -77,7 +77,7 @@ RPC_SCRIPT_SWITCH = RpcSwitchDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: @@ -90,7 +90,7 @@ async def async_setup_entry( def async_setup_block_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for block device.""" coordinator = config_entry.runtime_data.block @@ -142,7 +142,7 @@ def async_setup_block_entry( def async_setup_rpc_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index 66e2ee4c715..f64d1252b7e 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -13,7 +13,7 @@ from homeassistant.components.text import ( TextEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ShellyConfigEntry from .entity import ( @@ -45,7 +45,7 @@ RPC_TEXT_ENTITIES: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for device.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index f22547acf50..b1aa84b2640 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -22,7 +22,7 @@ from homeassistant.components.update import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import CONF_SLEEP_PERIOD, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS @@ -104,7 +104,7 @@ RPC_UPDATES: Final = { async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Shelly component.""" if get_device_entry_gen(config_entry) in RPC_GENERATIONS: diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py index ea6feaabe69..1829f663b22 100644 --- a/homeassistant/components/shelly/valve.py +++ b/homeassistant/components/shelly/valve.py @@ -15,7 +15,7 @@ from homeassistant.components.valve import ( ValveEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry from .entity import ( @@ -42,7 +42,7 @@ GAS_VALVE = BlockValveDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up valves for device.""" if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS: @@ -53,7 +53,7 @@ async def async_setup_entry( def async_setup_block_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up valve for device.""" coordinator = config_entry.runtime_data.block diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index 82b6cbfc7f5..2952c283082 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -11,7 +11,7 @@ from homeassistant.components.todo import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NoMatchingShoppingListItem, ShoppingData from .const import DOMAIN @@ -20,7 +20,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the shopping_list todo platform.""" shopping_data = hass.data[DOMAIN] diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 7ea878f538d..bb6a0669a99 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -16,7 +16,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ZONES, KEY_ALARM, PREVIOUS_STATE from .entity import SIABaseEntity, SIAEntityDescription @@ -69,7 +69,7 @@ ENTITY_DESCRIPTION_ALARM = SIAAlarmControlPanelEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SIA alarm_control_panel(s) from a config entry.""" async_add_entities( diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index 4c8e4ca6130..e1b40dc2e55 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_ACCOUNT, @@ -105,7 +105,7 @@ def generate_binary_sensors(entry: ConfigEntry) -> Iterable[SIABinarySensor]: async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SIA binary sensors from a config entry.""" async_add_entities(generate_binary_sensors(entry)) diff --git a/homeassistant/components/simplefin/binary_sensor.py b/homeassistant/components/simplefin/binary_sensor.py index 66d920fb309..af97fe9a394 100644 --- a/homeassistant/components/simplefin/binary_sensor.py +++ b/homeassistant/components/simplefin/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SimpleFinConfigEntry from .entity import SimpleFinEntity @@ -39,7 +39,7 @@ SIMPLEFIN_BINARY_SENSORS: tuple[SimpleFinBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SimpleFinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpleFIN sensors for config entries.""" diff --git a/homeassistant/components/simplefin/sensor.py b/homeassistant/components/simplefin/sensor.py index 51a96bae2be..183a198040b 100644 --- a/homeassistant/components/simplefin/sensor.py +++ b/homeassistant/components/simplefin/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import SimpleFinConfigEntry @@ -55,7 +55,7 @@ SIMPLEFIN_SENSORS: tuple[SimpleFinSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SimpleFinConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpleFIN sensors for config entries.""" diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 18f2d8ddcd5..c5a1b2bc708 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -31,7 +31,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import ( @@ -103,7 +103,9 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a SimpliSafe alarm control panel based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 0310e958e6e..e1f69ed8113 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import DOMAIN, LOGGER @@ -55,7 +55,9 @@ TRIGGERED_SENSOR_TYPES = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe binary sensors based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index f0272d09f61..129209354c3 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import DOMAIN @@ -46,7 +46,9 @@ BUTTON_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe buttons based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index c610223bff1..9e29bb2051b 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -13,7 +13,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import DOMAIN, LOGGER @@ -31,7 +31,9 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = (EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe locks based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index a5f46e87a7c..b82162f0fe7 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SimpliSafe from .const import DOMAIN, LOGGER @@ -22,7 +22,9 @@ from .entity import SimpliSafeEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SimpliSafe freeze sensors based on a config entry.""" simplisafe = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sky_remote/remote.py b/homeassistant/components/sky_remote/remote.py index 05a464f73a6..1ecd6c3716e 100644 --- a/homeassistant/components/sky_remote/remote.py +++ b/homeassistant/components/sky_remote/remote.py @@ -10,7 +10,7 @@ from homeassistant.components.remote import RemoteEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SkyRemoteConfigEntry from .const import DOMAIN @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config: SkyRemoteConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sky remote platform.""" async_add_entities( diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index 3c2d90b2630..cc42da48b26 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .coordinator import SkybellDataUpdateCoordinator @@ -31,7 +31,9 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell binary sensor.""" async_add_entities( diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 683b840debe..4ee873f8350 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SkybellDataUpdateCoordinator @@ -30,7 +30,9 @@ CAMERA_TYPES: tuple[CameraEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell camera.""" entities = [] diff --git a/homeassistant/components/skybell/light.py b/homeassistant/components/skybell/light.py index cba9e70c848..3f924f68da8 100644 --- a/homeassistant/components/skybell/light.py +++ b/homeassistant/components/skybell/light.py @@ -15,14 +15,16 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import SkybellEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell switch.""" async_add_entities( diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 5f0df77ecfa..a67fdae3b35 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .entity import DOMAIN, SkybellEntity @@ -88,7 +88,9 @@ SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Skybell sensor.""" async_add_entities( diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index fa4f723573f..858363043ca 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -7,7 +7,7 @@ from typing import Any, cast from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import SkybellEntity @@ -29,7 +29,9 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SkyBell switch.""" async_add_entities( diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index ca8c9830818..042ab00916e 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA @@ -21,7 +21,7 @@ from .entity import SlackEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Slack select.""" async_add_entities( diff --git a/homeassistant/components/sleepiq/binary_sensor.py b/homeassistant/components/sleepiq/binary_sensor.py index cb56a516b9b..99fff9c49b0 100644 --- a/homeassistant/components/sleepiq/binary_sensor.py +++ b/homeassistant/components/sleepiq/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ICON_EMPTY, ICON_OCCUPIED, IS_IN_BED from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import SleepIQSleeperEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed binary sensors.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/button.py b/homeassistant/components/sleepiq/button.py index 94b010066c9..74b1bc0789f 100644 --- a/homeassistant/components/sleepiq/button.py +++ b/homeassistant/components/sleepiq/button.py @@ -11,7 +11,7 @@ from asyncsleepiq import SleepIQBed from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SleepIQData @@ -44,7 +44,7 @@ ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sleep number buttons.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/light.py b/homeassistant/components/sleepiq/light.py index 781bd8e600a..542c212df27 100644 --- a/homeassistant/components/sleepiq/light.py +++ b/homeassistant/components/sleepiq/light.py @@ -8,7 +8,7 @@ from asyncsleepiq import SleepIQBed, SleepIQLight from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed lights.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 905ceab18bd..53d6c366e46 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -17,7 +17,7 @@ from asyncsleepiq import ( from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTUATOR, @@ -138,7 +138,7 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed sensors.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 0a09aa4d657..7d059ba6b59 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -13,7 +13,7 @@ from asyncsleepiq import ( from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator @@ -23,7 +23,7 @@ from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ foundation preset select entities.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/sensor.py b/homeassistant/components/sleepiq/sensor.py index 413e8e4d856..ca4fbc186ed 100644 --- a/homeassistant/components/sleepiq/sensor.py +++ b/homeassistant/components/sleepiq/sensor.py @@ -7,7 +7,7 @@ from asyncsleepiq import SleepIQBed, SleepIQSleeper from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, PRESSURE, SLEEP_NUMBER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator @@ -19,7 +19,7 @@ SENSORS = [PRESSURE, SLEEP_NUMBER] async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SleepIQ bed sensors.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/sleepiq/switch.py b/homeassistant/components/sleepiq/switch.py index 9fc8ca9d20e..8363782c064 100644 --- a/homeassistant/components/sleepiq/switch.py +++ b/homeassistant/components/sleepiq/switch.py @@ -9,7 +9,7 @@ from asyncsleepiq import SleepIQBed from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SleepIQData, SleepIQPauseUpdateCoordinator @@ -19,7 +19,7 @@ from .entity import SleepIQBedEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sleep number switches.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py index 12474969ca6..3d5de33303d 100644 --- a/homeassistant/components/slide_local/button.py +++ b/homeassistant/components/slide_local/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SlideConfigEntry, SlideCoordinator @@ -25,7 +25,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: SlideConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button for Slide platform.""" diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py index 0e5e647dea8..6bb3f338cb8 100644 --- a/homeassistant/components/slide_local/cover.py +++ b/homeassistant/components/slide_local/cover.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET from .coordinator import SlideConfigEntry, SlideCoordinator @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SlideConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover(s) for Slide platform.""" diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py index 8de608b7fc0..e83924c87ee 100644 --- a/homeassistant/components/slide_local/switch.py +++ b/homeassistant/components/slide_local/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SlideConfigEntry, SlideCoordinator @@ -27,7 +27,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: SlideConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch for Slide platform.""" diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 42c50d21e75..417444961fe 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -22,7 +22,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow from .const import DEFAULT_NAME, DOMAIN, PLAYER_EVENT @@ -39,7 +39,7 @@ STATE_MAPPING = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SlimProto MediaPlayer(s) from Config Entry.""" slimserver: SlimServer = hass.data[DOMAIN] diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 863f15a9a17..ffef026aaed 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -838,7 +838,7 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SMA sensors.""" sma_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py index 86bc225dba1..06dcaa62853 100644 --- a/homeassistant/components/smappee/binary_sensor.py +++ b/homeassistant/components/smappee/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SmappeeConfigEntry from .const import DOMAIN @@ -37,7 +37,7 @@ ICON_MAPPING = { async def async_setup_entry( hass: HomeAssistant, config_entry: SmappeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smappee binary sensor.""" smappee_base = config_entry.runtime_data diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 2f9d6443568..759dfb34013 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SmappeeConfigEntry from .const import DOMAIN @@ -189,7 +189,7 @@ VOLTAGE_SENSORS: tuple[SmappeeVoltageSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SmappeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smappee sensor.""" smappee_base = config_entry.runtime_data diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index bccf816c823..cf2ddea5938 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SmappeeConfigEntry from .const import DOMAIN @@ -16,7 +16,7 @@ SWITCH_PREFIX = "Switch" async def async_setup_entry( hass: HomeAssistant, config_entry: SmappeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smappee Comfort Plugs.""" smappee_base = config_entry.runtime_data diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 80fc79671b5..c6e18bf43c1 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, UnitOfEnergy from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -30,7 +30,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smart Meter Texas sensors.""" coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 611473b011d..6b511c86677 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity @@ -48,7 +48,7 @@ ATTRIB_TO_ENTTIY_CATEGORY = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add binary sensors for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index d9535272295..238f8015620 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity @@ -100,7 +100,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add climate entities for a config entry.""" ac_capabilities = [ diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 55e86bd582e..daf9b0f38f8 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity @@ -36,7 +36,7 @@ VALUE_TO_STATE = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add covers for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 61e30589273..1f26a805dcb 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -11,7 +11,7 @@ from pysmartthings import Capability from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -27,7 +27,7 @@ SPEED_RANGE = (1, 3) # off is not included async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add fans for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index eb7c9af246b..2ee369176cb 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity @@ -29,7 +29,7 @@ from .entity import SmartThingsEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add lights for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index a0ae9e50443..468b7c2083a 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -10,7 +10,7 @@ from pysmartthings import Attribute, Capability from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity @@ -29,7 +29,7 @@ ST_LOCK_ATTR_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add locks for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py index 9756cef9f04..aa6655b0134 100644 --- a/homeassistant/components/smartthings/scene.py +++ b/homeassistant/components/smartthings/scene.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_BROKERS, DOMAIN @@ -13,7 +13,7 @@ from .const import DATA_BROKERS, DOMAIN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add switches for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 8bd0421d2bc..c0b079da070 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DATA_BROKERS, DOMAIN @@ -563,7 +563,7 @@ POWER_CONSUMPTION_REPORT_NAMES = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensors for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 5cfe4576d6a..7a88ca0c422 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -10,7 +10,7 @@ from pysmartthings import Capability from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity @@ -19,7 +19,7 @@ from .entity import SmartThingsEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add switches for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index f665f5e61b3..2e8792140b0 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import ATTR_ERRORS, ATTR_REMINDERS, DOMAIN, SMARTTUB_CONTROLLER @@ -43,7 +43,9 @@ SNOOZE_REMINDER_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities for the binary sensors in the tub.""" diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index 7f3163834e0..f5759f32fa3 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -17,7 +17,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SMARTTUB_CONTROLLER @@ -42,7 +42,9 @@ HVAC_ACTIONS = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate entity for the thermostat in the tub.""" diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py index 532234f4059..dda936aa56a 100644 --- a/homeassistant/components/smarttub/light.py +++ b/homeassistant/components/smarttub/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_LIGHTS, @@ -28,7 +28,9 @@ from .helpers import get_spa_name async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entities for any lights in the tub.""" diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py index 585e8859432..b2bb1170d09 100644 --- a/homeassistant/components/smarttub/sensor.py +++ b/homeassistant/components/smarttub/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import DOMAIN, SMARTTUB_CONTROLLER @@ -43,7 +43,9 @@ SET_SECONDARY_FILTRATION_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities for the sensors in the tub.""" diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py index 6e1cf9bef2a..2dedad8e18a 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -8,7 +8,7 @@ from smarttub import SpaPump from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API_TIMEOUT, ATTR_PUMPS, DOMAIN, SMARTTUB_CONTROLLER from .entity import SmartTubEntity @@ -16,7 +16,9 @@ from .helpers import get_spa_name async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch entities for the pumps on the tub.""" diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index 213cb00d47c..82236a154f0 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity @@ -53,7 +53,7 @@ ENTITIES: tuple[SmartyBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Binary Sensor Platform.""" diff --git a/homeassistant/components/smarty/button.py b/homeassistant/components/smarty/button.py index b8e31cf6fc8..78638561088 100644 --- a/homeassistant/components/smarty/button.py +++ b/homeassistant/components/smarty/button.py @@ -11,7 +11,7 @@ from pysmarty2 import Smarty from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity @@ -38,7 +38,7 @@ ENTITIES: tuple[SmartyButtonDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Button Platform.""" diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index 2804f14ee15..07dec85ae47 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -29,7 +29,7 @@ SPEED_RANGE = (1, 3) # off is not included async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Fan Platform.""" diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 48b169c104e..fe35f741380 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import REVOLUTIONS_PER_MINUTE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .coordinator import SmartyConfigEntry, SmartyCoordinator @@ -85,7 +85,7 @@ ENTITIES: tuple[SmartySensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Sensor Platform.""" diff --git a/homeassistant/components/smarty/switch.py b/homeassistant/components/smarty/switch.py index bf5fe80db44..5781bb11680 100644 --- a/homeassistant/components/smarty/switch.py +++ b/homeassistant/components/smarty/switch.py @@ -11,7 +11,7 @@ from pysmarty2 import Smarty from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmartyConfigEntry, SmartyCoordinator from .entity import SmartyEntity @@ -42,7 +42,7 @@ ENTITIES: tuple[SmartySwitchDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SmartyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Smarty Switch Platform.""" diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 1707afa2fca..a263eeb6174 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -56,7 +56,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, sun from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.util import Throttle @@ -97,7 +97,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from map location.""" location = config_entry.data diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index de13e648961..ce3457ae81b 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SCAN_INTERNET_INTERVAL from .coordinator import SmConfigEntry, SmDataUpdateCoordinator @@ -56,7 +56,7 @@ SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: SmConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SMLIGHT sensor based on a config entry.""" coordinator = entry.runtime_data.data diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index 20ad507fa78..5caf43b7cba 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -17,7 +17,7 @@ from homeassistant.components.button import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SmConfigEntry, SmDataUpdateCoordinator @@ -65,7 +65,7 @@ ROUTER = SmButtonDescription( async def async_setup_entry( hass: HomeAssistant, entry: SmConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SMLIGHT buttons based on a config entry.""" coordinator = entry.runtime_data.data diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 3b7683f61fe..57a08d177d4 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -123,7 +123,7 @@ UPTIME: list[SmSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: SmConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SMLIGHT sensor based on a config entry.""" coordinator = entry.runtime_data.data diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index ce473da358e..09d2714956c 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SmConfigEntry, SmDataUpdateCoordinator from .entity import SmEntity @@ -67,7 +67,7 @@ SWITCHES: list[SmSwitchEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, entry: SmConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize switches for SLZB-06 device.""" coordinator = entry.runtime_data.data diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 662195bdfc0..10d142e6221 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -20,7 +20,7 @@ from homeassistant.components.update import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import get_radio from .const import LOGGER @@ -62,7 +62,9 @@ ZB_UPDATE_ENTITY = SmUpdateEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: SmConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SMLIGHT update entities.""" coordinator = entry.runtime_data.firmware diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index 821200f68b1..46ee754a1f1 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, GATEWAY, NETWORK_COORDINATOR, SIGNAL_COORDINATOR, SMS_GATEWAY @@ -77,7 +77,7 @@ NETWORK_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all device sensors.""" sms_data = hass.data[DOMAIN][SMS_GATEWAY] diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 0ec27c1ad9c..5f011ca41ee 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -25,7 +25,7 @@ from homeassistant.helpers import ( entity_platform, entity_registry as er, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_LATENCY, @@ -73,7 +73,7 @@ def register_services() -> None: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the snapcast config entry.""" diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index bfe773b4780..ce804450cab 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -38,7 +38,9 @@ from .models import SnoozConfigurationData async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Snooz device from a config entry.""" diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 004335b644b..acb86f875c9 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -201,7 +201,7 @@ SENSOR_TYPES = [ async def async_setup_entry( hass: HomeAssistant, entry: SolarEdgeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add an solarEdge entry.""" # Add the needed sensors to hass diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index 8fd6e3c0194..c4bb119c006 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import SolarlogConfigEntry @@ -276,7 +276,7 @@ INVERTER_SENSOR_TYPES: tuple[SolarLogInverterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SolarlogConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add solarlog entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index 6ca0bac0c38..1cdec0389fe 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SolaxConfigEntry @@ -89,7 +89,7 @@ SENSOR_DESCRIPTIONS: dict[tuple[Units, bool], SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: SolaxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Entry setup.""" api = entry.runtime_data.api diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index e64fee00f16..15aa21b1f48 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API, DEVICES, DOMAIN from .entity import SomaEntity @@ -24,7 +24,7 @@ from .utils import is_api_response_success async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Soma cover platform.""" diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 806886009f3..839f28e9a65 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle from .const import API, DEVICES, DOMAIN @@ -18,7 +18,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Soma sensor platform.""" diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index 8c64e58362b..5b888ea4b96 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -7,7 +7,7 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverS from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -29,7 +29,7 @@ MYLINK_COVER_TYPE_TO_DEVICE_CLASS = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover and configure Somfy covers.""" reversed_target_ids = config_entry.options.get(CONF_REVERSED_TARGET_IDS, {}) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index fa7d0aa7756..6a0293e455c 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -149,7 +149,7 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonarr sensors based on a config entry.""" coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = hass.data[DOMAIN][ diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index b4063b09691..3fc75d712a7 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -34,7 +34,10 @@ from homeassistant.helpers import ( entity_platform, ) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY, SET_SOUND_SETTING @@ -63,7 +66,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up songpal media player.""" name = config_entry.data[CONF_NAME] diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 2c1e8af9961..322beaed092 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SONOS_CREATE_BATTERY, SONOS_CREATE_MIC_SENSOR from .entity import SonosEntity @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 8d0917c5dba..0c66484202f 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -46,7 +46,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, cal from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from . import UnjoinData, media_browser @@ -108,7 +108,7 @@ ATTR_QUEUE_POSITION = "queue_position" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index 272218cc01e..c23ba51a877 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SONOS_CREATE_LEVELS from .entity import SonosEntity @@ -70,7 +70,7 @@ LEVEL_FROM_NUMBER = {"balance": _balance_from_number} async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sonos number platform from a config entry.""" diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index a089c09b33c..d888ee669bb 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( SONOS_CREATE_AUDIO_FORMAT_SENSOR, @@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 4bf5487b1a6..ce4774a4138 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -15,7 +15,7 @@ from homeassistant.const import ATTR_TIME, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_change from .const import ( @@ -74,7 +74,7 @@ WEEKEND_DAYS = (0, 6) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 5edd42b931a..c540b8dfd64 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -27,7 +27,7 @@ from homeassistant.helpers.device_registry import ( DeviceInfo, format_mac, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -47,7 +47,7 @@ ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Bose SoundTouch media player based on a config entry.""" device = hass.data[DOMAIN][entry.entry_id].device diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 4363be5cf93..c2b7a6de28c 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfDataRate, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -69,7 +69,7 @@ SENSOR_TYPES: tuple[SpeedtestSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SpeedTestConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Speedtestdotnet sensors.""" speedtest_coordinator = config_entry.runtime_data diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 20a634efb42..d6265cbc39d 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -31,7 +31,7 @@ from homeassistant.components.media_player import ( RepeatMode, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .browse_media import async_browse_media_internal @@ -70,7 +70,7 @@ AFTER_REQUEST_SLEEP = 1 async def async_setup_entry( hass: HomeAssistant, entry: SpotifyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Spotify based on a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 312b0cd345e..a7b488dd521 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -36,7 +36,10 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.template import Template from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, @@ -101,7 +104,9 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SQL sensor from config entry.""" @@ -178,7 +183,7 @@ async def async_setup_sensor( unique_id: str | None, db_url: str, yaml: bool, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, ) -> None: """Set up the SQL sensor.""" try: diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index ec0bac0fe43..daae8703597 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SqueezeboxConfigEntry from .const import STATUS_SENSOR_NEEDSRESTART, STATUS_SENSOR_RESCAN @@ -35,7 +35,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: SqueezeboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Platform setup using common elements.""" diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 19cd1e36910..1b810019373 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -40,7 +40,7 @@ from homeassistant.helpers.device_registry import ( format_mac, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.start import async_at_start from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow @@ -113,7 +113,7 @@ async def start_server_discovery(hass: HomeAssistant) -> None: async def async_setup_entry( hass: HomeAssistant, entry: SqueezeboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Squeezebox media_player platform from a server config entry.""" diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index 0ca33179f9f..c0a7a37d539 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import SqueezeboxConfigEntry @@ -73,7 +73,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: SqueezeboxConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Platform setup using common elements.""" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index a9f5c25d6a5..89274390411 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -20,7 +20,9 @@ from .const import DEVICE_CONFIG_URL, DEVICE_MANUFACTURER, DEVICE_MODEL, DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SRP Energy Usage sensor.""" coordinator: SRPEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index ac1ad4f2b6e..a570b26a0d1 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .account import StarlineAccount, StarlineDevice from .const import DOMAIN @@ -70,7 +70,9 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine sensors.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py index 6fb307cda74..fa46d2a3773 100644 --- a/homeassistant/components/starline/button.py +++ b/homeassistant/components/starline/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .account import StarlineAccount, StarlineDevice from .const import DOMAIN @@ -34,7 +34,9 @@ BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine button.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py index 610317b72c3..0c8418d28fc 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -3,7 +3,7 @@ from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .account import StarlineAccount, StarlineDevice @@ -12,7 +12,9 @@ from .entity import StarlineEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up StarLine entry.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 19aad1a19b2..43886d63962 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .account import StarlineAccount, StarlineDevice from .const import DOMAIN @@ -15,7 +15,9 @@ from .entity import StarlineEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine lock.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index f9bd304c1e1..16988f1a9dc 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level from .account import StarlineAccount, StarlineDevice @@ -87,7 +87,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine sensors.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index eb71f0b73b5..79d4fa86ddf 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .account import StarlineAccount, StarlineDevice from .const import DOMAIN @@ -34,7 +34,9 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the StarLine switch.""" account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index b03648e81c5..f5eaf2baba0 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import StarlinkData @@ -21,7 +21,9 @@ from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index f8f18763d30..dc23e31d8d2 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -13,7 +13,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import StarlinkUpdateCoordinator @@ -21,7 +21,9 @@ from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index 5174be19760..53e7ab1cee0 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_ALTITUDE, DOMAIN from .coordinator import StarlinkData @@ -18,7 +18,9 @@ from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 5481e310fbd..dadbf8a061a 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import now @@ -34,7 +34,9 @@ from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all sensors for this entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index 3534748127e..51603850690 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import StarlinkData, StarlinkUpdateCoordinator @@ -21,7 +21,9 @@ from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py index 7395ec101ba..3540123e1eb 100644 --- a/homeassistant/components/starlink/time.py +++ b/homeassistant/components/starlink/time.py @@ -11,7 +11,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import StarlinkData, StarlinkUpdateCoordinator @@ -19,7 +19,9 @@ from .entity import StarlinkEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all time entities for this entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 5252c23fd3d..a5c5f10ecd0 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -47,7 +47,10 @@ from homeassistant.core import ( ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_state_change_event, @@ -617,7 +620,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Statistics sensor entry.""" sampling_size = entry.options.get(CONF_SAMPLES_MAX_BUFFER_SIZE) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 625a8b95979..c1e20933185 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -8,7 +8,7 @@ from typing import cast from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp @@ -29,7 +29,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: SteamConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Steam platform.""" async_add_entities( diff --git a/homeassistant/components/steamist/sensor.py b/homeassistant/components/steamist/sensor.py index 7c24d015513..94e3ff86ee1 100644 --- a/homeassistant/components/steamist/sensor.py +++ b/homeassistant/components/steamist/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SteamistDataUpdateCoordinator @@ -58,7 +58,7 @@ SENSORS: tuple[SteamistSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" coordinator: SteamistDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/steamist/switch.py b/homeassistant/components/steamist/switch.py index 91806f4fa0c..17e1d6d47ac 100644 --- a/homeassistant/components/steamist/switch.py +++ b/homeassistant/components/steamist/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SteamistDataUpdateCoordinator @@ -22,7 +22,7 @@ ACTIVE_SWITCH = SwitchEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" coordinator: SteamistDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 2660ff2ddb2..91224b711be 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfSpeed from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -59,7 +59,7 @@ STOOKWIJZER_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: StookwijzerConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Stookwijzer sensor from a config entry.""" async_add_entities( diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index 5a0073c25d3..e3e966edde0 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import StreamlabsCoordinator from .const import DOMAIN @@ -15,7 +15,7 @@ from .entity import StreamlabsWaterEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Streamlabs water binary sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 412b2187495..dea3f081326 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import StreamlabsCoordinator @@ -60,7 +60,7 @@ SENSORS: tuple[StreamlabsWaterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Streamlabs water sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index d406234c36e..6bcca848ef2 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -9,7 +9,7 @@ from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -29,7 +29,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru device tracker by config_entry.""" entry: dict = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index e21102f0b0c..07caa0d6367 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, get_device_info from .const import ( @@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru locks by config_entry.""" entry = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index ba9b7d46b06..aa4c4ee16be 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfPressure, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -141,7 +141,7 @@ EV_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Subaru sensors by config_entry.""" entry = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 1152ebd551b..a162cc6168d 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COUNTER_ID, DOMAIN @@ -53,7 +53,7 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SuezWaterConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Suez Water sensor from a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index e7e621d06cd..a042adb9b83 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import DEGREE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED @@ -106,7 +106,9 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: SunConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SunConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sun sensor platform.""" diff --git a/homeassistant/components/sunweg/sensor/__init__.py b/homeassistant/components/sunweg/sensor/__init__.py index e582b5135d3..f71d992bea9 100644 --- a/homeassistant/components/sunweg/sensor/__init__.py +++ b/homeassistant/components/sunweg/sensor/__init__.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .. import SunWEGData from ..const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType @@ -49,7 +49,7 @@ def get_device_list( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the SunWEG sensor.""" name = config_entry.data[CONF_NAME] diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index 3acd768cb30..416d56d1bdd 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SurePetcareDataCoordinator @@ -23,7 +23,9 @@ from .entity import SurePetcareEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare Flaps binary sensors based on a config entry.""" diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py index f960400bcbc..09fadf8be60 100644 --- a/homeassistant/components/surepetcare/lock.py +++ b/homeassistant/components/surepetcare/lock.py @@ -10,7 +10,7 @@ from surepy.enums import EntityType, LockState as SurepyLockState from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SurePetcareDataCoordinator @@ -18,7 +18,9 @@ from .entity import SurePetcareEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare locks on a config entry.""" diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index b4e7c6203a3..b012878caf7 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VOLTAGE, PERCENTAGE, EntityCategory, UnitOfVolume from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW from .coordinator import SurePetcareDataCoordinator @@ -20,7 +20,9 @@ from .entity import SurePetcareEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sure PetCare Flaps sensors.""" diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index c8075a6746c..6475fe802c2 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -90,7 +90,7 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: SwissPublicTransportConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor from a config entry created in the integrations UI.""" unique_id = config_entry.unique_id diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index 7c6a7ff38ad..8fd9c799bcb 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_INVERT from .entity import BaseInvertableEntity @@ -29,7 +29,7 @@ from .entity import BaseInvertableEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Cover Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index 858379e71df..846e9ae7e80 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BaseToggleEntity @@ -21,7 +21,7 @@ from .entity import BaseToggleEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Fan Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/light.py b/homeassistant/components/switch_as_x/light.py index 59b816f7935..c043a354869 100644 --- a/homeassistant/components/switch_as_x/light.py +++ b/homeassistant/components/switch_as_x/light.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BaseToggleEntity @@ -19,7 +19,7 @@ from .entity import BaseToggleEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Light Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index 2095b06bd84..946429e0395 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_INVERT from .entity import BaseInvertableEntity @@ -25,7 +25,7 @@ from .entity import BaseInvertableEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Lock Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/siren.py b/homeassistant/components/switch_as_x/siren.py index 7d9a41d9cd9..b96c7c6e0ea 100644 --- a/homeassistant/components/switch_as_x/siren.py +++ b/homeassistant/components/switch_as_x/siren.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import BaseToggleEntity @@ -19,7 +19,7 @@ from .entity import BaseToggleEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Siren Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switch_as_x/valve.py b/homeassistant/components/switch_as_x/valve.py index 8626ca3cfb4..2b5f252ac2d 100644 --- a/homeassistant/components/switch_as_x/valve.py +++ b/homeassistant/components/switch_as_x/valve.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_INVERT from .entity import BaseInvertableEntity @@ -29,7 +29,7 @@ from .entity import BaseInvertableEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Valve Switch config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/switchbee/button.py b/homeassistant/components/switchbee/button.py index 78b5c0e6888..1ac81ec4e0d 100644 --- a/homeassistant/components/switchbee/button.py +++ b/homeassistant/components/switchbee/button.py @@ -7,7 +7,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -15,7 +15,9 @@ from .entity import SwitchBeeEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbee button.""" coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index d946ed1761b..7837798b0cb 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -27,7 +27,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -74,7 +74,9 @@ SUPPORTED_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee climate.""" coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbee/cover.py b/homeassistant/components/switchbee/cover.py index 02f3d7167e3..247063ab18a 100644 --- a/homeassistant/components/switchbee/cover.py +++ b/homeassistant/components/switchbee/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -25,7 +25,9 @@ from .entity import SwitchBeeDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee switch.""" coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbee/light.py b/homeassistant/components/switchbee/light.py index 0daa6e204aa..228667540df 100644 --- a/homeassistant/components/switchbee/light.py +++ b/homeassistant/components/switchbee/light.py @@ -11,7 +11,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -35,7 +35,9 @@ def _switchbee_brightness_to_hass(value: int) -> int: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBee light.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py index c502e6f22f5..41538f6fd71 100644 --- a/homeassistant/components/switchbee/switch.py +++ b/homeassistant/components/switchbee/switch.py @@ -17,7 +17,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SwitchBeeCoordinator @@ -25,7 +25,9 @@ from .entity import SwitchBeeDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbee switch.""" coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index 144872ff315..6d1490c895b 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity @@ -75,7 +75,7 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot curtain based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index d2fd073cdcb..3ef0f5625c2 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator @@ -31,7 +31,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot curtain based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py index 40f96577842..34a24948df1 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -12,7 +12,7 @@ from homeassistant.components.humidifier import ( HumidifierEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry from .entity import SwitchbotSwitchedEntity @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot based on a config entry.""" async_add_entities([SwitchBotHumidifier(entry.runtime_data)]) diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index 927ad5120c7..0a2c342ecf0 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switchbot light.""" async_add_entities([SwitchbotLightEntity(entry.runtime_data)]) diff --git a/homeassistant/components/switchbot/lock.py b/homeassistant/components/switchbot/lock.py index a3bee5661b2..6bad154813a 100644 --- a/homeassistant/components/switchbot/lock.py +++ b/homeassistant/components/switchbot/lock.py @@ -7,7 +7,7 @@ from switchbot.const import LockStatus from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import SwitchbotEntity async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot lock based on a config entry.""" force_nightlatch = entry.options.get(CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH) diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 9787521a5e9..025c40bff9e 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity @@ -102,7 +102,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 427496ef20c..fd1e8bb6393 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -9,7 +9,7 @@ import switchbot from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: SwitchbotConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot based on a config entry.""" async_add_entities([SwitchBotSwitch(entry.runtime_data)]) diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py index a6eb1a134a5..aae2758f3ca 100644 --- a/homeassistant/components/switchbot_cloud/button.py +++ b/homeassistant/components/switchbot_cloud/button.py @@ -7,7 +7,7 @@ from switchbot_api import BotCommands from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import DOMAIN @@ -17,7 +17,7 @@ from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 9e996649e8c..27698420ae9 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import DOMAIN @@ -42,7 +42,7 @@ _DEFAULT_SWITCHBOT_FAN_MODE = _SWITCHBOT_FAN_MODES[FanState.FAN_AUTO] async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index 52f48c66d38..74a9e9d8b1e 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -7,7 +7,7 @@ from switchbot_api import LockCommands from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import DOMAIN @@ -17,7 +17,7 @@ from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 1f755c141a2..28384ffd4d5 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import DOMAIN @@ -139,7 +139,7 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index 22d033625f9..ebe20620d3e 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -7,7 +7,7 @@ from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotA from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import DOMAIN @@ -18,7 +18,7 @@ from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py index 84db7cfdbb8..9a9ad49626f 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -11,7 +11,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData from .const import ( @@ -28,7 +28,7 @@ from .entity import SwitchBotCloudEntity async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index d2686e2e550..30597ed0738 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -20,7 +20,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitcherConfigEntry from .const import SIGNAL_DEVICE_ADD @@ -81,7 +81,7 @@ THERMOSTAT_BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: SwitcherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher button from config entry.""" diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 2fc4a331676..c8bf33eca09 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -29,7 +29,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitcherConfigEntry from .const import SIGNAL_DEVICE_ADD @@ -62,7 +62,7 @@ HA_TO_DEVICE_FAN = {value: key for key, value in DEVICE_FAN_TO_HA.items()} async def async_setup_entry( hass: HomeAssistant, config_entry: SwitcherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher climate from config entry.""" diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 513b786a033..5d8e4a4b0ac 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator @@ -28,7 +28,7 @@ API_STOP = "stop_shutter" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher cover from config entry.""" diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index 75156044efa..b9dc78f5bdf 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SIGNAL_DEVICE_ADD from .coordinator import SwitcherDataUpdateCoordinator @@ -22,7 +22,7 @@ API_SET_LIGHT = "set_light" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher light from a config entry.""" diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 0ed60e5a721..029d517bb09 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent, UnitOfPower from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import SIGNAL_DEVICE_ADD @@ -61,7 +61,7 @@ THERMOSTAT_SENSORS = TEMPERATURE_SENSORS async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher sensor from config entry.""" diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 7d3d71a0615..30b0b4161b1 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import ( @@ -49,7 +49,7 @@ SERVICE_TURN_ON_WITH_TIMER_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switcher switch from config entry.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index fc1f9ae8aea..697ea8aea6e 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from .const import ( @@ -25,7 +25,7 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Syncthing sensors.""" syncthing = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py index 2b110c2af1d..e6d26d22433 100644 --- a/homeassistant/components/syncthru/binary_sensor.py +++ b/homeassistant/components/syncthru/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -35,7 +35,7 @@ SYNCTHRU_STATE_PROBLEM = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index df2ffd99803..c2063bf6c0a 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -43,7 +43,7 @@ SYNCTHRU_STATE_HUMAN = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up from config entry.""" diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index b9c7ff483ea..2f7d041cb10 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN @@ -63,7 +63,9 @@ STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ... async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS binary sensor.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index fccd0860036..6512c370334 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN @@ -53,7 +53,7 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index cbf17ec05b4..acbcccb8894 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import ( @@ -46,7 +46,9 @@ class SynologyDSMCameraEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS cameras.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index b29a33f7253..2987de7a7c7 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -286,7 +286,9 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS Sensor.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index facce824bda..c4f1572ceea 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN @@ -40,7 +40,9 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS switch.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index ed60191f296..71eed2d7f1f 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -12,7 +12,7 @@ from homeassistant.components.update import UpdateEntity, UpdateEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SynologyDSMCentralUpdateCoordinator @@ -38,7 +38,9 @@ UPDATE_ENTITIES: Final = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Synology DSM update entities.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 019b1df4639..0140499a75a 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator @@ -65,7 +65,9 @@ BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, .. async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge binary sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index aeff3b22fb2..6d3bbd21a05 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -18,7 +18,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator @@ -66,7 +66,7 @@ MEDIA_PLAYER_DESCRIPTION: Final[MediaPlayerEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge media players based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 94c73a2ac05..c7cae2f347b 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util import dt as dt_util @@ -359,7 +359,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/system_bridge/update.py b/homeassistant/components/system_bridge/update.py index b0d341cee3b..12060c28669 100644 --- a/homeassistant/components/system_bridge/update.py +++ b/homeassistant/components/system_bridge/update.py @@ -6,7 +6,7 @@ from homeassistant.components.update import UpdateEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import SystemBridgeDataUpdateCoordinator @@ -16,7 +16,7 @@ from .entity import SystemBridgeEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Bridge update based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py index aecd30765ff..3968e94ec03 100644 --- a/homeassistant/components/systemmonitor/binary_sensor.py +++ b/homeassistant/components/systemmonitor/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -91,7 +91,7 @@ SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: SystemMonitorConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Monitor binary sensors based on a config entry.""" coordinator = entry.runtime_data.coordinator diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 048d7cefd6c..e70bccf0833 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -397,7 +397,7 @@ IF_ADDRS_FAMILY = {"ipv4_address": socket.AF_INET, "ipv6_address": socket.AF_INE async def async_setup_entry( hass: HomeAssistant, entry: SystemMonitorConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up System Monitor sensors based on a config entry.""" entities: list[SystemMonitorSensor] = [] diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index c969ea34f42..8cec32e20f0 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TadoConfigEntry @@ -115,7 +115,9 @@ ZONE_SENSORS = { async def async_setup_entry( - hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado sensor platform.""" diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index db7b1823bd9..e6aa921d428 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import TadoConfigEntry @@ -100,7 +100,9 @@ CLIMATE_TEMP_OFFSET_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado climate platform.""" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index a9be560f434..34aca2dd833 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -11,7 +11,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: TadoConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado device scannery entity.""" _LOGGER.debug("Setting up Tado device scanner entity") @@ -57,7 +57,7 @@ async def async_setup_entry( def add_tracked_entities( hass: HomeAssistant, coordinator: TadoMobileDeviceUpdateCoordinator, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], ) -> None: """Add new tracker entities from Tado.""" diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 037b33574e7..d0d54e79670 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TadoConfigEntry @@ -191,7 +191,9 @@ ZONE_SENSORS = { async def async_setup_entry( - hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado sensor platform.""" diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 02fbb3f5e23..3d8825b264f 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -12,7 +12,7 @@ from homeassistant.components.water_heater import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import TadoConfigEntry @@ -61,7 +61,9 @@ WATER_HEATER_TIMER_SCHEMA: VolDictType = { async def async_setup_entry( - hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tado water heater platform.""" diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 981f871de09..6569b40ada2 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import TailscaleEntity @@ -84,7 +84,7 @@ BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tailscale binary sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py index fa4c966a7d7..cf944aa73ef 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import TailscaleEntity @@ -55,7 +55,7 @@ SENSORS: tuple[TailscaleSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tailscale sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index d2f8e1e2ced..4d927b0769e 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity @@ -41,7 +41,7 @@ DESCRIPTIONS: tuple[TailwindDoorBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TailwindConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tailwind binary sensor based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py index edff3434866..380eb7ccd7e 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -16,7 +16,7 @@ from homeassistant.components.button import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TailwindConfigEntry @@ -43,7 +43,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: TailwindConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tailwind button based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 8ea1c7d4f6d..84f38c7d579 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -20,7 +20,7 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, LOGGER from .coordinator import TailwindConfigEntry @@ -30,7 +30,7 @@ from .entity import TailwindDoorEntity async def async_setup_entry( hass: HomeAssistant, entry: TailwindConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tailwind cover based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index b67df9a6a25..ca6b610c351 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TailwindConfigEntry @@ -47,7 +47,7 @@ DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, entry: TailwindConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tailwind number based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/tami4/button.py b/homeassistant/components/tami4/button.py index 11377a2dcfb..a1b8db79674 100644 --- a/homeassistant/components/tami4/button.py +++ b/homeassistant/components/tami4/button.py @@ -11,7 +11,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API, DOMAIN from .entity import Tami4EdgeBaseEntity @@ -41,7 +41,9 @@ BOIL_WATER_BUTTON = Tami4EdgeButtonEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Tami4Edge.""" diff --git a/homeassistant/components/tami4/sensor.py b/homeassistant/components/tami4/sensor.py index 888acda9372..2bfd3079c19 100644 --- a/homeassistant/components/tami4/sensor.py +++ b/homeassistant/components/tami4/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import API, COORDINATOR, DOMAIN @@ -52,7 +52,9 @@ ENTITY_DESCRIPTIONS = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Tami4Edge.""" data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 774262a8854..a38266e57e8 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: TankerkoenigConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the tankerkoenig binary sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 5970f3d3b24..b1646489d96 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -9,7 +9,7 @@ from aiotankerkoenig import GasType, Station from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_BRAND, @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: TankerkoenigConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the tankerkoenig sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 22cdf1a5ff0..3b2e640b807 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import event as evt from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -26,7 +26,7 @@ from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota binary sensor dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/cover.py b/homeassistant/components/tasmota/cover.py index 2cb3cfeea25..1d7aa8316b6 100644 --- a/homeassistant/components/tasmota/cover.py +++ b/homeassistant/components/tasmota/cover.py @@ -18,7 +18,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -28,7 +28,7 @@ from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota cover dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index e927bd6ad72..c89b36577be 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -16,7 +16,7 @@ from homeassistant.components.fan import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -36,7 +36,7 @@ ORDERED_NAMED_FAN_SPEEDS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota fan dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py index a06e77eceb1..ed66fa128dc 100644 --- a/homeassistant/components/tasmota/light.py +++ b/homeassistant/components/tasmota/light.py @@ -31,7 +31,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .const import DATA_REMOVE_DISCOVER_COMPONENT @@ -45,7 +45,7 @@ TASMOTA_BRIGHTNESS_MAX = 100 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota light dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 8cc538e706a..ec20e1c0348 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -40,7 +40,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -243,7 +243,7 @@ SENSOR_UNIT_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota sensor dynamically through discovery.""" diff --git a/homeassistant/components/tasmota/switch.py b/homeassistant/components/tasmota/switch.py index b5c19fc2431..03e594b125c 100644 --- a/homeassistant/components/tasmota/switch.py +++ b/homeassistant/components/tasmota/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW @@ -21,7 +21,7 @@ from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaOnOffEnt async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tasmota switch dynamically through discovery.""" diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index ee186a29225..c8d35623c21 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -23,7 +23,10 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from .const import ATTR_TOP_USER, DOMAIN @@ -212,7 +215,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: TautulliConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tautulli sensor.""" data = entry.runtime_data diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py index 4c0e1111e9a..ac52a19884e 100644 --- a/homeassistant/components/technove/binary_sensor.py +++ b/homeassistant/components/technove/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator from .entity import TechnoVEEntity @@ -65,7 +65,7 @@ BINARY_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: TechnoVEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform.""" async_add_entities( diff --git a/homeassistant/components/technove/number.py b/homeassistant/components/technove/number.py index 529ce407c79..11d8f281276 100644 --- a/homeassistant/components/technove/number.py +++ b/homeassistant/components/technove/number.py @@ -17,7 +17,7 @@ from homeassistant.components.number import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator @@ -65,7 +65,7 @@ NUMBERS = [ async def async_setup_entry( hass: HomeAssistant, entry: TechnoVEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TechnoVE number entity based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py index ad80f5f419e..398c1911cd4 100644 --- a/homeassistant/components/technove/sensor.py +++ b/homeassistant/components/technove/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfEnergy, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator @@ -121,7 +121,7 @@ SENSORS: tuple[TechnoVESensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TechnoVEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" async_add_entities( diff --git a/homeassistant/components/technove/switch.py b/homeassistant/components/technove/switch.py index 943cd62f86e..19688075b35 100644 --- a/homeassistant/components/technove/switch.py +++ b/homeassistant/components/technove/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator @@ -79,7 +79,7 @@ SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, entry: TechnoVEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TechnoVE switch based on a config entry.""" diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 4f167619f04..a01b889ef8f 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TedeeConfigEntry from .entity import TedeeDescriptionEntity @@ -64,7 +64,7 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TedeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 482cd039a98..da6db242db3 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -7,7 +7,7 @@ from aiotedee import TedeeClientException, TedeeLock, TedeeLockState from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TedeeApiCoordinator, TedeeConfigEntry @@ -19,7 +19,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: TedeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tedee lock entity.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index 828793b4458..a697d36be50 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TedeeConfigEntry from .entity import TedeeDescriptionEntity @@ -53,7 +53,7 @@ ENTITIES: tuple[TedeeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TedeeConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tedee sensor entity.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/tellduslive/binary_sensor.py b/homeassistant/components/tellduslive/binary_sensor.py index 33f936beb54..65301708646 100644 --- a/homeassistant/components/tellduslive/binary_sensor.py +++ b/homeassistant/components/tellduslive/binary_sensor.py @@ -5,7 +5,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TELLDUS_DISCOVERY_NEW from .entity import TelldusLiveEntity @@ -14,7 +14,7 @@ from .entity import TelldusLiveEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index d55a72cd633..2554acc428c 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -7,7 +7,7 @@ from homeassistant.components.cover import CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TelldusLiveClient from .const import DOMAIN, TELLDUS_DISCOVERY_NEW @@ -17,7 +17,7 @@ from .entity import TelldusLiveEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 005bf97d8c0..9f291bb845a 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -8,7 +8,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TELLDUS_DISCOVERY_NEW from .entity import TelldusLiveEntity @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 9bd2b1fe599..782f240cc41 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TELLDUS_DISCOVERY_NEW from .entity import TelldusLiveEntity @@ -121,7 +121,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index bd770ab08f5..3ca2ba066ab 100644 --- a/homeassistant/components/tellduslive/switch.py +++ b/homeassistant/components/tellduslive/switch.py @@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TELLDUS_DISCOVERY_NEW from .entity import TelldusLiveEntity @@ -16,7 +16,7 @@ from .entity import TelldusLiveEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tellduslive sensors dynamically.""" diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index a67e2969f9a..0a468994295 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -31,7 +31,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -146,7 +149,7 @@ async def _async_create_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 3c6e4899502..7ef64e8077b 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -43,7 +43,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_call_later, async_track_point_in_utc_time from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -150,7 +153,7 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( @callback def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, hass: HomeAssistant, definitions: list[dict], unique_id_prefix: str | None, @@ -209,7 +212,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 67ce7e7a16b..f43fc242bba 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -19,7 +19,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -93,7 +96,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index ba85418c339..5afbca55cbb 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -96,7 +99,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 90dd555ca42..661dbb45dc1 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -27,7 +27,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -121,7 +124,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index bd37ca1015c..a42ee3d0612 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -24,7 +24,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -115,7 +118,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index ee24407699d..ca3736ebf76 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -44,7 +44,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -178,7 +181,7 @@ _LOGGER = logging.getLogger(__name__) @callback def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, hass: HomeAssistant, definitions: list[dict], unique_id_prefix: str | None, @@ -237,7 +240,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index bddb51e5e67..756866cfd44 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -28,7 +28,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -104,7 +107,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" _options = dict(config_entry.options) diff --git a/homeassistant/components/tesla_fleet/binary_sensor.py b/homeassistant/components/tesla_fleet/binary_sensor.py index b92ef9233d1..886fe304c91 100644 --- a/homeassistant/components/tesla_fleet/binary_sensor.py +++ b/homeassistant/components/tesla_fleet/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TeslaFleetConfigEntry @@ -179,7 +179,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet binary sensor platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py index aea0f91a97c..2ddce2d517b 100644 --- a/homeassistant/components/tesla_fleet/button.py +++ b/homeassistant/components/tesla_fleet/button.py @@ -10,7 +10,7 @@ from tesla_fleet_api.const import Scope from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .entity import TeslaFleetVehicleEntity @@ -61,7 +61,7 @@ DESCRIPTIONS: tuple[TeslaFleetButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet Button platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py index 06e9c9d7c64..f752509ee17 100644 --- a/homeassistant/components/tesla_fleet/climate.py +++ b/homeassistant/components/tesla_fleet/climate.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .const import DOMAIN, TeslaFleetClimateSide @@ -38,7 +38,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet Climate platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py index f270734424f..701b107f9f9 100644 --- a/homeassistant/components/tesla_fleet/cover.py +++ b/homeassistant/components/tesla_fleet/cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .entity import TeslaFleetVehicleEntity @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet cover platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index d6dcef895a6..19bf353c62d 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -6,7 +6,7 @@ from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .entity import TeslaFleetVehicleEntity @@ -14,7 +14,9 @@ from .models import TeslaFleetVehicleData async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet device tracker platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/lock.py b/homeassistant/components/tesla_fleet/lock.py index 32998d409be..cdb1d4b066b 100644 --- a/homeassistant/components/tesla_fleet/lock.py +++ b/homeassistant/components/tesla_fleet/lock.py @@ -9,7 +9,7 @@ from tesla_fleet_api.const import Scope from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .const import DOMAIN @@ -25,7 +25,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet lock platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py index 455c990077d..89f0768f082 100644 --- a/homeassistant/components/tesla_fleet/media_player.py +++ b/homeassistant/components/tesla_fleet/media_player.py @@ -11,7 +11,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .entity import TeslaFleetVehicleEntity @@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet Media platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py index b806b4dbc77..a1123ab9553 100644 --- a/homeassistant/components/tesla_fleet/number.py +++ b/homeassistant/components/tesla_fleet/number.py @@ -18,7 +18,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from . import TeslaFleetConfigEntry @@ -95,7 +95,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TeslaFleetNumberBatteryEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet number platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/select.py b/homeassistant/components/tesla_fleet/select.py index 515a0e7c2e7..1c495657bc1 100644 --- a/homeassistant/components/tesla_fleet/select.py +++ b/homeassistant/components/tesla_fleet/select.py @@ -10,7 +10,7 @@ from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslaFleetConfigEntry from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity @@ -78,7 +78,7 @@ SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet select platform from a config entry.""" diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index c1d38bf85c5..64ecc35469b 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance @@ -446,7 +446,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tesla Fleet sensor platform from a config entry.""" async_add_entities( diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py index 054ea84cbe1..614af8772cc 100644 --- a/homeassistant/components/tesla_fleet/switch.py +++ b/homeassistant/components/tesla_fleet/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TeslaFleetConfigEntry @@ -94,7 +94,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslaFleetConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the TeslaFleet Switch platform from a config entry.""" diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index f7ef385b8ed..6d60162412e 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WallConnectorData from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS @@ -48,7 +48,7 @@ WALL_CONNECTOR_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index a50c81c912e..c6c63a93edb 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WallConnectorData from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS @@ -187,7 +187,7 @@ WALL_CONNECTOR_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 0b6823f8b61..9d14df4501b 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType @@ -377,7 +377,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry binary sensor platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index ceeda265795..4ca2fd9b166 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -10,7 +10,7 @@ from tesla_fleet_api.const import Scope from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry from .entity import TeslemetryVehicleEntity @@ -61,7 +61,7 @@ DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry Button platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 95b769a1c2d..86811131ab6 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry from .const import DOMAIN, TeslemetryClimateSide @@ -38,7 +38,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry Climate platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 4cc15b6feb8..de91f43f084 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry @@ -36,7 +36,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry cover platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 42c8fea8d09..6a758e68497 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker.config_entry import ( ) from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry @@ -68,7 +68,7 @@ DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry device tracker platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index 18b88273bec..68505a12a13 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -10,7 +10,7 @@ from tesla_fleet_api.const import Scope from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry @@ -31,7 +31,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry lock platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index e0e144ffe3a..1bfc9bf66dc 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -11,7 +11,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry from .entity import TeslemetryVehicleEntity @@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry Media platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index c44028f2da7..10c15a68b09 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from . import TeslemetryConfigEntry @@ -133,7 +133,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry number platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index d2e90a4f5c9..0d268e302de 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -13,7 +13,7 @@ from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry @@ -170,7 +170,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry select platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index dd83ad04ed6..70315e92da0 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance @@ -529,7 +529,7 @@ ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry sensor platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index f810dee8554..83441e6c4f6 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry @@ -94,7 +94,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry Switch platform from a config entry.""" diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 670cd0e0eda..f560f25a8ff 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -8,7 +8,7 @@ from tesla_fleet_api.const import Scope from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry from .entity import TeslemetryVehicleEntity @@ -27,7 +27,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TeslemetryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Teslemetry update platform from a config entry.""" diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index fd6565b62b7..515339c3da8 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieState @@ -177,7 +177,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie binary sensor platform from a config entry.""" async_add_entities( diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index bef9c2585f6..a370f504323 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -16,7 +16,7 @@ from tessie_api import ( from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .entity import TessieEntity @@ -50,7 +50,7 @@ DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Button platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py index 1d26926aeaa..a8aa18132ee 100644 --- a/homeassistant/components/tessie/climate.py +++ b/homeassistant/components/tessie/climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieClimateKeeper @@ -32,7 +32,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Climate platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py index e739f8c074d..bfd7b1b816c 100644 --- a/homeassistant/components/tessie/cover.py +++ b/homeassistant/components/tessie/cover.py @@ -22,7 +22,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieCoverStates @@ -35,7 +35,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py index df74cd2a7a7..fe81ed67337 100644 --- a/homeassistant/components/tessie/device_tracker.py +++ b/homeassistant/components/tessie/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TessieConfigEntry @@ -17,7 +17,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie device tracker platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index 76d58a9070c..66cb813b995 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -9,7 +9,7 @@ from tessie_api import lock, open_unlock_charge_port, unlock from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import DOMAIN, TessieChargeCableLockStates @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py index 7dfe568926b..139ee07ca5b 100644 --- a/homeassistant/components/tessie/media_player.py +++ b/homeassistant/components/tessie/media_player.py @@ -8,7 +8,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .entity import TessieEntity @@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Media platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py index 74249d392a7..1e857345278 100644 --- a/homeassistant/components/tessie/number.py +++ b/homeassistant/components/tessie/number.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfSpeed, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from . import TessieConfigEntry @@ -111,7 +111,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TessieNumberBatteryEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py index 4dfe7088439..471372a68bd 100644 --- a/homeassistant/components/tessie/select.py +++ b/homeassistant/components/tessie/select.py @@ -9,7 +9,7 @@ from tessie_api import set_seat_cool, set_seat_heat from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieSeatCoolerOptions, TessieSeatHeaterOptions @@ -38,7 +38,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie select platform from a config entry.""" diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 323fa76ef1f..4f62e1b1855 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance @@ -375,7 +375,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie sensor platform from a config entry.""" diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index dba00a85bb2..41134b38fda 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -26,7 +26,7 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TessieConfigEntry @@ -81,7 +81,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Switch platform from a config entry.""" diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index f6198fa6c03..e9af673b1f4 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -8,7 +8,7 @@ from tessie_api import schedule_software_update from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TessieConfigEntry from .const import TessieUpdateStatus @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TessieConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tessie Update platform from a config entry.""" data = entry.runtime_data diff --git a/homeassistant/components/thermobeacon/sensor.py b/homeassistant/components/thermobeacon/sensor.py index 53e86f37f11..916ec91359a 100644 --- a/homeassistant/components/thermobeacon/sensor.py +++ b/homeassistant/components/thermobeacon/sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -113,7 +113,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ThermoBeacon BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py index 4aca6101685..4c9c6a4e42a 100644 --- a/homeassistant/components/thermopro/sensor.py +++ b/homeassistant/components/thermopro/sensor.py @@ -30,7 +30,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -111,7 +111,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ThermoPro BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 25dd2f1e1eb..ba512d07f18 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -7,7 +7,7 @@ from ttn_client import TTNSensorValue from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import CONF_APP_ID, DOMAIN @@ -17,7 +17,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for TTN.""" diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 3d52d2225be..3227f030812 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -33,7 +33,10 @@ from homeassistant.core import ( 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 -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -90,7 +93,7 @@ PLATFORM_SCHEMA = vol.All( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize threshold config entry.""" registry = er.async_get(hass) diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index fdeeeba68ef..df6541591e0 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -12,13 +12,15 @@ from homeassistant.components.notify import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN as TIBBER_DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tibber notification entity.""" async_add_entities([TibberNotificationEntity(entry.entry_id)]) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index c14a62bb608..9f87b8a8490 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -33,7 +33,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -261,7 +261,9 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tibber sensor.""" @@ -531,7 +533,7 @@ class TibberRtEntityCreator: def __init__( self, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tibber_home: tibber.TibberHome, entity_registry: er.EntityRegistry, ) -> None: diff --git a/homeassistant/components/tile/binary_sensor.py b/homeassistant/components/tile/binary_sensor.py index 1719c793c0e..6abc80732a6 100644 --- a/homeassistant/components/tile/binary_sensor.py +++ b/homeassistant/components/tile/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TileConfigEntry, TileCoordinator from .entity import TileEntity @@ -35,7 +35,9 @@ ENTITIES: tuple[TileBinarySensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TileConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tile binary sensors.""" diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 6a0aae1bdf9..66a3b8b0e27 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import as_utc from .coordinator import TileConfigEntry, TileCoordinator @@ -26,7 +26,9 @@ ATTR_VOIP_STATE = "voip_state" async def async_setup_entry( - hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TileConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tile device trackers.""" diff --git a/homeassistant/components/tilt_ble/sensor.py b/homeassistant/components/tilt_ble/sensor.py index e8e1f902cd9..411484cf2fe 100644 --- a/homeassistant/components/tilt_ble/sensor.py +++ b/homeassistant/components/tilt_ble/sensor.py @@ -20,7 +20,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN @@ -86,7 +86,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tilt Hydrometer BLE sensors.""" coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 1e86a1ba6c6..f05244e7680 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -18,7 +18,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -56,7 +59,9 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Time & Date sensor.""" diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index 3ac90b5578c..1ab34861a6e 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -24,7 +24,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, event -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -59,7 +62,7 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Times of the Day config entry.""" if hass.config.time_zone is None: diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 8c61394d300..2e2873353c6 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -24,7 +24,10 @@ from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -113,7 +116,9 @@ SCAN_INTERVAL = timedelta(minutes=1) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Todoist calendar platform config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 490e4ad9f1a..202c51fb4c0 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -14,7 +14,7 @@ from homeassistant.components.todo import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -23,7 +23,9 @@ from .coordinator import TodoistCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Todoist todo platform config entry.""" coordinator: TodoistCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/binary_sensor.py b/homeassistant/components/tolo/binary_sensor.py index 845f8ed22e3..cb3ba46b604 100644 --- a/homeassistant/components/tolo/binary_sensor.py +++ b/homeassistant/components/tolo/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/button.py b/homeassistant/components/tolo/button.py index b7c4362ca7b..9e4c8c84be9 100644 --- a/homeassistant/components/tolo/button.py +++ b/homeassistant/components/tolo/button.py @@ -6,7 +6,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -16,7 +16,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index 5e6428525c1..0df8635fca9 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -23,7 +23,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -33,7 +33,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py index 9e48778b507..7bddf775143 100644 --- a/homeassistant/components/tolo/fan.py +++ b/homeassistant/components/tolo/fan.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up fan controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/light.py b/homeassistant/components/tolo/light.py index eeb37305fe8..9ccd4a8e407 100644 --- a/homeassistant/components/tolo/light.py +++ b/homeassistant/components/tolo/light.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index 73505c5b251..902fb749d23 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -18,7 +18,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -68,7 +68,7 @@ NUMBERS = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/select.py b/homeassistant/components/tolo/select.py index fee1ac1774e..b08f37e40ae 100644 --- a/homeassistant/components/tolo/select.py +++ b/homeassistant/components/tolo/select.py @@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, AromaTherapySlot, LampMode from .coordinator import ToloSaunaUpdateCoordinator @@ -54,7 +54,7 @@ SELECTS = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py index 0e94ec0ae1e..e97211c8e40 100644 --- a/homeassistant/components/tolo/sensor.py +++ b/homeassistant/components/tolo/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -89,7 +89,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up (non-binary, general) sensors for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tolo/switch.py b/homeassistant/components/tolo/switch.py index d39dd17f0f3..ce863053e26 100644 --- a/homeassistant/components/tolo/switch.py +++ b/homeassistant/components/tolo/switch.py @@ -11,7 +11,7 @@ from tololib import ToloClient, ToloStatus from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToloSaunaUpdateCoordinator @@ -45,7 +45,7 @@ SWITCHES = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch controls for TOLO Sauna.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 7ff17961b58..08e1991d831 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -34,7 +34,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -328,7 +328,7 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 92b09500e7b..0a070a1b33b 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util @@ -66,7 +66,7 @@ from .entity import TomorrowioEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index 11b13a32ee5..eff8aed0a20 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator @@ -25,7 +25,9 @@ from .entity import ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Toon binary sensor based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 0c2e5b9b232..5538a0abd91 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -24,7 +24,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ToonDataUpdateCoordinator from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN @@ -33,7 +33,9 @@ from .helpers import toon_exception_handler async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Toon binary sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 09f36c88079..e5b155b409b 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CURRENCY_EUR, DOMAIN, VOLUME_CM3, VOLUME_LMIN from .coordinator import ToonDataUpdateCoordinator @@ -36,7 +36,9 @@ from .entity import ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Toon sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index deb2a12f2d0..d59a542d4d8 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -15,7 +15,7 @@ from toonapi import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator @@ -24,7 +24,9 @@ from .helpers import toon_exception_handler async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Toon switches based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 7121e5bf806..9ed29ea01c8 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CODE_REQUIRED, DOMAIN from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator @@ -28,7 +28,7 @@ SERVICE_ALARM_ARM_HOME_INSTANT = "arm_home_instant" async def async_setup_entry( hass: HomeAssistant, entry: TotalConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TotalConnect alarm panels based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 5a67385cd20..2f3802dc9a6 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity @@ -120,7 +120,7 @@ LOCATION_BINARY_SENSORS: tuple[TotalConnectAlarmBinarySensorEntityDescription, . async def async_setup_entry( hass: HomeAssistant, entry: TotalConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TotalConnect device sensors based on a config entry.""" sensors: list = [] diff --git a/homeassistant/components/totalconnect/button.py b/homeassistant/components/totalconnect/button.py index 7cdad00534d..eb85dcce1bf 100644 --- a/homeassistant/components/totalconnect/button.py +++ b/homeassistant/components/totalconnect/button.py @@ -9,7 +9,7 @@ from total_connect_client.zone import TotalConnectZone from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity @@ -39,7 +39,7 @@ PANEL_BUTTONS: tuple[TotalConnectButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TotalConnectConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up TotalConnect buttons based on a config entry.""" buttons: list = [] diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py index e7bb33311d0..7c5ea4ea9ca 100644 --- a/homeassistant/components/touchline_sl/climate.py +++ b/homeassistant/components/touchline_sl/climate.py @@ -10,7 +10,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator from .entity import TouchlineSLZoneEntity @@ -19,7 +19,7 @@ from .entity import TouchlineSLZoneEntity async def async_setup_entry( hass: HomeAssistant, entry: TouchlineSLConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Touchline devices.""" coordinators = entry.runtime_data diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py index 6986765b110..38935595fe2 100644 --- a/homeassistant/components/tplink/binary_sensor.py +++ b/homeassistant/components/tplink/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription @@ -73,7 +73,7 @@ BINARYSENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in BINARY_SENSOR_DESCRI async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py index 4279a233d21..145adb79185 100644 --- a/homeassistant/components/tplink/button.py +++ b/homeassistant/components/tplink/button.py @@ -15,7 +15,7 @@ from homeassistant.components.button import ( ) from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .deprecate import DeprecatedInfo @@ -95,7 +95,7 @@ BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up buttons.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py index b0f1f1a62c1..7b59678da8e 100644 --- a/homeassistant/components/tplink/camera.py +++ b/homeassistant/components/tplink/camera.py @@ -19,7 +19,7 @@ from homeassistant.components.camera import ( from homeassistant.config_entries import ConfigFlowContext from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .const import CONF_CAMERA_CREDENTIALS @@ -59,7 +59,7 @@ CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up camera entities.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index 7204c2a7665..66037d7476e 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -22,7 +22,7 @@ from homeassistant.components.climate import ( from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry, legacy_device_id from .const import DOMAIN, UNIT_MAPPING @@ -71,7 +71,7 @@ CLIMATE_DESCRIPTIONS: tuple[TPLinkClimateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate entities.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py index 1c31d84b778..88396742b36 100644 --- a/homeassistant/components/tplink/fan.py +++ b/homeassistant/components/tplink/fan.py @@ -15,7 +15,7 @@ from homeassistant.components.fan import ( FanEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -59,7 +59,7 @@ FAN_DESCRIPTIONS: tuple[TPLinkFanEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up fans.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 718b5ed7120..b3cee1d3baf 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -29,7 +29,7 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from . import TPLinkConfigEntry, legacy_device_id @@ -196,7 +196,7 @@ LIGHT_EFFECT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lights.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index a9d002c0083..252c4888d26 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .entity import ( @@ -81,7 +81,7 @@ NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py index 8e9dee7b964..72042f571e6 100644 --- a/homeassistant/components/tplink/select.py +++ b/homeassistant/components/tplink/select.py @@ -13,7 +13,7 @@ from homeassistant.components.select import ( SelectEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .entity import ( @@ -53,7 +53,7 @@ SELECT_DESCRIPTIONS_MAP = {desc.key: desc for desc in SELECT_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 9b21ba775a9..cc35b1fd142 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .const import UNIT_MAPPING @@ -271,7 +271,7 @@ SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py index 027fa2dd58f..65cb722052f 100644 --- a/homeassistant/components/tplink/siren.py +++ b/homeassistant/components/tplink/siren.py @@ -21,7 +21,7 @@ from homeassistant.components.siren import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry, legacy_device_id from .const import DOMAIN @@ -61,7 +61,7 @@ SIREN_DESCRIPTIONS: tuple[TPLinkSirenEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up siren entities.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index f08753def26..3cb20d63cd7 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .entity import ( @@ -85,7 +85,7 @@ SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py index c62cd1d27c8..e948e778be4 100644 --- a/homeassistant/components/tplink/vacuum.py +++ b/homeassistant/components/tplink/vacuum.py @@ -16,7 +16,7 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry from .coordinator import TPLinkDataUpdateCoordinator @@ -63,7 +63,7 @@ VACUUM_DESCRIPTIONS: tuple[TPLinkVacuumEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TPLinkConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up vacuum entities.""" data = config_entry.runtime_data diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py index 73d5f54b8b3..fb179634fd1 100644 --- a/homeassistant/components/tplink_omada/binary_sensor.py +++ b/homeassistant/components/tplink_omada/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OmadaConfigEntry from .controller import OmadaGatewayCoordinator @@ -28,7 +28,7 @@ from .entity import OmadaDeviceEntity async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/tplink_omada/device_tracker.py b/homeassistant/components/tplink_omada/device_tracker.py index fe78adf8847..ce1c8ba40e1 100644 --- a/homeassistant/components/tplink_omada/device_tracker.py +++ b/homeassistant/components/tplink_omada/device_tracker.py @@ -6,7 +6,7 @@ from tplink_omada_client.clients import OmadaWirelessClient from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import OmadaConfigEntry @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device trackers and scanners.""" diff --git a/homeassistant/components/tplink_omada/sensor.py b/homeassistant/components/tplink_omada/sensor.py index 272334d1b52..b41f3da2f33 100644 --- a/homeassistant/components/tplink_omada/sensor.py +++ b/homeassistant/components/tplink_omada/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import OmadaConfigEntry @@ -57,7 +57,7 @@ def _map_device_status(device: OmadaListDevice) -> str | None: async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index f99d8aaedde..37c73a9e11f 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -22,7 +22,7 @@ from tplink_omada_client.omadasiteclient import GatewayPortSettings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OmadaConfigEntry from .controller import OmadaGatewayCoordinator, OmadaSwitchPortCoordinator @@ -37,7 +37,7 @@ TCoordinator = TypeVar("TCoordinator", bound="OmadaCoordinator[Any]") async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 8b7fcfba394..8a8531c10b6 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -16,7 +16,7 @@ from homeassistant.components.update import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OmadaConfigEntry from .coordinator import POLL_DEVICES, OmadaCoordinator, OmadaDevicesCoordinator @@ -93,7 +93,7 @@ class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): # async def async_setup_entry( hass: HomeAssistant, config_entry: OmadaConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" controller = config_entry.runtime_data diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 0fa7fc344ea..43210ee92ea 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from . import DOMAIN, TRACKER_UPDATE @@ -69,7 +69,9 @@ EVENTS = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure a dispatcher connection based on a config entry.""" diff --git a/homeassistant/components/traccar_server/binary_sensor.py b/homeassistant/components/traccar_server/binary_sensor.py index 58c46502b53..6d81ba84ed4 100644 --- a/homeassistant/components/traccar_server/binary_sensor.py +++ b/homeassistant/components/traccar_server/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TraccarServerCoordinator @@ -55,7 +55,7 @@ TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities.""" coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py index 9e5a3c0ee9f..7f2a6dd7c40 100644 --- a/homeassistant/components/traccar_server/device_tracker.py +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_CATEGORY, ATTR_TRACCAR_ID, ATTR_TRACKER, DOMAIN from .coordinator import TraccarServerCoordinator @@ -17,7 +17,7 @@ from .entity import TraccarServerEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker entities.""" coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/traccar_server/sensor.py b/homeassistant/components/traccar_server/sensor.py index bb3c4ed4401..9aee6f28489 100644 --- a/homeassistant/components/traccar_server/sensor.py +++ b/homeassistant/components/traccar_server/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfSpeed from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -83,7 +83,7 @@ TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS: tuple[ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor entities.""" coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index 80219154d81..2978d369344 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry from .const import TRACKER_HARDWARE_STATUS_UPDATED @@ -58,7 +58,7 @@ SENSOR_TYPE = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: TractiveConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tractive device trackers.""" client = entry.runtime_data.client diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index f31afaf92f6..73be7216a2f 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry from .const import ( @@ -21,7 +21,7 @@ from .entity import TractiveEntity async def async_setup_entry( hass: HomeAssistant, entry: TractiveConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tractive device trackers.""" client = entry.runtime_data.client diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index a3c1893267c..18d7e4c23ab 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import Trackables, TractiveClient, TractiveConfigEntry @@ -182,7 +182,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TractiveConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tractive device trackers.""" client = entry.runtime_data.client diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 3bf6887e99c..da2c8e35ff7 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -11,7 +11,7 @@ from aiotractive.exceptions import TractiveError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry from .const import ( @@ -57,7 +57,7 @@ SWITCH_TYPES: tuple[TractiveSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TractiveConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tractive switches.""" client = entry.runtime_data.client diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index 92d10320327..b1fb9b153ad 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -10,7 +10,7 @@ from pytradfri.command import Command from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator @@ -20,7 +20,7 @@ from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri covers based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index 3f45ee3e1eb..e8fb7c050ed 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -10,7 +10,7 @@ from pytradfri.command import Command from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator @@ -33,7 +33,7 @@ def _from_fan_speed(fan_speed: int) -> int: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index e464d1a8142..b945c7f2bec 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -19,7 +19,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API @@ -30,7 +30,7 @@ from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri lights based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 4e560f0e7b5..b4a7c335481 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_GATEWAY_ID, @@ -128,7 +128,7 @@ def _migrate_old_unique_ids(hass: HomeAssistant, old_unique_id: str, key: str) - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Tradfri config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 088b775b9fd..a2a1a5b4623 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -10,7 +10,7 @@ from pytradfri.command import Command from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator @@ -20,7 +20,7 @@ from .entity import TradfriBaseEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Load Tradfri switches based on a config entry.""" gateway_id = config_entry.data[CONF_GATEWAY_ID] diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py index b367fa0fb45..92112b41466 100644 --- a/homeassistant/components/trafikverket_camera/binary_sensor.py +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TVCameraConfigEntry from .coordinator import CameraData @@ -36,7 +36,7 @@ BINARY_SENSOR_TYPE = TVCameraSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: TVCameraConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Trafikverket Camera binary sensor platform.""" diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index ece02cacf70..b4eddb0890f 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.camera import Camera from homeassistant.const import ATTR_LOCATION from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TVCameraConfigEntry from .const import ATTR_DESCRIPTION, ATTR_TYPE @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: TVCameraConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Trafikverket Camera.""" diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py index cb5c458f742..726fcb6f901 100644 --- a/homeassistant/components/trafikverket_camera/sensor.py +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TVCameraConfigEntry @@ -74,7 +74,7 @@ SENSOR_TYPES: tuple[TVCameraSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TVCameraConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Trafikverket Camera sensor platform.""" diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index 44176ab82b7..b908bc5f550 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import as_utc @@ -92,7 +92,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TVFerryConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Trafikverket sensor entry.""" diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index a4de8c1ef26..150b5ee7abb 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -110,7 +110,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TVTrainConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Trafikverket sensor entry.""" diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index bc17c82748a..cb923037a24 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -204,7 +204,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TVWeatherConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Trafikverket sensor entry.""" diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index bae9e7f3cc7..a0babe7464a 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -129,7 +129,7 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TransmissionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Transmission sensors.""" diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index d06932ff862..9ca8a197344 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -44,7 +44,7 @@ SWITCH_TYPES: tuple[TransmissionSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: TransmissionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Transmission switch.""" diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index e5ff5c64a8b..4261f96bbe6 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -35,7 +35,10 @@ from homeassistant.core import Event, EventStateChangedData, HomeAssistant, call from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.restore_state import RestoreEntity @@ -130,7 +133,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up trend sensor from config entry.""" diff --git a/homeassistant/components/triggercmd/switch.py b/homeassistant/components/triggercmd/switch.py index 94566fe301d..e04cf5ee7e8 100644 --- a/homeassistant/components/triggercmd/switch.py +++ b/homeassistant/components/triggercmd/switch.py @@ -9,7 +9,7 @@ from triggercmd import client, ha from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TriggercmdConfigEntry from .const import DOMAIN @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: TriggercmdConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add switch for passed config_entry in HA.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 56bccc73581..96f7d3a1e1c 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -53,7 +53,9 @@ ALARM: dict[str, tuple[AlarmControlPanelEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya alarm dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 12661a26fd1..1487a80248c 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -341,7 +341,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya binary sensor dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index f77fed776b0..8e538b07309 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -8,7 +8,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -58,7 +58,9 @@ BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya buttons dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 9e66531dd51..b07b9e9959e 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -8,7 +8,7 @@ from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -24,7 +24,9 @@ CAMERAS: tuple[str, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya cameras dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 1780256a740..deccb08c5aa 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -21,7 +21,7 @@ from homeassistant.components.climate import ( from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -84,7 +84,9 @@ CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya climate dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 9c3269c27f2..315075e7f37 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -142,7 +142,9 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya cover dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index ffab9efdde8..3b951e75da1 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -14,7 +14,7 @@ from homeassistant.components.fan import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -34,7 +34,9 @@ TUYA_SUPPORT_TYPE = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya fan dynamically through tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index cb872d67719..6c47148eeda 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -14,7 +14,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -55,7 +55,9 @@ HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya (de)humidifier dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index d7dffc16b58..7f4a964f47e 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import TuyaConfigEntry @@ -421,7 +421,9 @@ class ColorData: async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya light dynamically through tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 8d5b5dbfa19..4e98cf34d4d 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -12,7 +12,7 @@ from homeassistant.components.number import ( from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType @@ -307,7 +307,9 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya number dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index dbc849356b2..4ad027d39ee 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -9,14 +9,16 @@ from tuya_sharing import Manager, SharingScene from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import DOMAIN async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya scenes.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 831d3cb3e0c..766cdd295f1 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -8,7 +8,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -328,7 +328,9 @@ SELECTS["pc"] = SELECTS["kg"] async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya select dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 756564c6a03..cb7602e24fe 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import TuyaConfigEntry @@ -1222,7 +1222,9 @@ SENSORS["pc"] = SENSORS["kg"] async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya sensor dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 6f7dfe4c96c..310385df93d 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -14,7 +14,7 @@ from homeassistant.components.siren import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -56,7 +56,9 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya siren dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 2b5e6fec4a6..d0192b41ee6 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -14,7 +14,7 @@ from homeassistant.components.switch import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode @@ -728,7 +728,9 @@ SWITCHES["cz"] = SWITCHES["pc"] async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index bab9ac309ec..e36a682fa4e 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -13,7 +13,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType @@ -48,7 +48,9 @@ TUYA_STATUS_TO_HA = { async def async_setup_entry( - hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TuyaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya vacuum dynamically through Tuya discovery.""" hass_data = entry.runtime_data diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index 606fb4913d1..19e3f4f3337 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import WASTE_TYPE_TO_DESCRIPTION @@ -18,7 +18,7 @@ from .entity import TwenteMilieuEntity async def async_setup_entry( hass: HomeAssistant, entry: TwenteMilieuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Twente Milieu calendar based on a config entry.""" async_add_entities([TwenteMilieuCalendar(entry)]) diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 4605ede1f87..81751d10a81 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import TwenteMilieuConfigEntry @@ -65,7 +65,7 @@ SENSORS: tuple[TwenteMilieuSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: TwenteMilieuConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Twente Milieu sensor based on a config entry.""" async_add_entities( diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 5ce731d158f..c270421d8cd 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEV_LED_PROFILE, DEV_PROFILE_RGB, DEV_PROFILE_RGBW from .coordinator import TwinklyConfigEntry, TwinklyCoordinator @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: TwinklyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Setups an entity from a config entry (UI config flow).""" entity = TwinklyLight(config_entry.runtime_data) diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py index a97424b4b8b..86d9732b8cc 100644 --- a/homeassistant/components/twinkly/select.py +++ b/homeassistant/components/twinkly/select.py @@ -8,7 +8,7 @@ from ttls.client import TWINKLY_MODES from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import TwinklyConfigEntry, TwinklyCoordinator from .entity import TwinklyEntity @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: TwinklyConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a mode select from a config entry.""" entity = TwinklyModeSelect(config_entry.runtime_data) diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index b407eae0319..deec319e5cf 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -32,7 +32,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: TwitchConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize entries.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/ukraine_alarm/binary_sensor.py b/homeassistant/components/ukraine_alarm/binary_sensor.py index 30cb8e0f553..9009031ea14 100644 --- a/homeassistant/components/ukraine_alarm/binary_sensor.py +++ b/homeassistant/components/ukraine_alarm/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -64,7 +64,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ukraine Alarm binary sensor entities based on a config entry.""" name = config_entry.data[CONF_NAME] diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 25c6816d794..3e5ef62f49e 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -31,7 +31,7 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry from .entity import ( @@ -135,7 +135,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up button platform for UniFi Network integration.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index da5ca74fc37..a26232664a8 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -26,7 +26,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import UnifiConfigEntry @@ -222,7 +222,7 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for UniFi Network integration.""" async_update_unique_id(hass, config_entry) diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index f1ada9a01e0..f3045d5fc1c 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -16,7 +16,7 @@ from aiounifi.models.wlan import Wlan from homeassistant.components.image import ImageEntity, ImageEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import UnifiConfigEntry @@ -67,7 +67,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiImageEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up image platform for UniFi Network integration.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index fd78c606043..47a2c2ba62e 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -46,7 +46,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util, slugify @@ -644,7 +644,7 @@ ENTITY_DESCRIPTIONS += make_wan_latency_sensors() + make_device_temperatur_senso async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for UniFi Network integration.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 91e4a0222f6..de0e8d3f412 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -46,7 +46,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN @@ -352,7 +352,7 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for UniFi Network integration.""" async_update_unique_id(hass, config_entry) diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 65202045a05..589b2ff1215 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -19,7 +19,7 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry from .entity import ( @@ -68,7 +68,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiUpdateEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for UniFi Network integration.""" config_entry.runtime_data.entity_loader.register_platform( diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index a88d4b65678..0d904d3c3ba 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( @@ -769,7 +769,7 @@ def _async_nvr_entities( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index b24c90be3ec..7b766299946 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -19,7 +19,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEVICES_THAT_ADOPT, DOMAIN from .data import ProtectDeviceType, UFPConfigEntry @@ -120,7 +120,7 @@ def _async_remove_adopt_button( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover devices on a UniFi Protect NVR.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 0b1c03b8dd6..3947324fd73 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -16,7 +16,7 @@ from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity from .const import ( @@ -138,7 +138,7 @@ def _async_camera_entities( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover cameras on a UniFi Protect NVR.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 78fdf7746de..cb9090dd530 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -10,7 +10,7 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Bootstrap from .const import ( @@ -218,7 +218,7 @@ def _async_event_entities( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up event entities for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index fcdfe5e85b8..873f715de58 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -9,7 +9,7 @@ from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lights for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 3e9372db0e5..79ed47a6c3b 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -14,7 +14,7 @@ from uiprotect.data import ( from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up locks on a UniFi Protect NVR.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 5f9991b257b..a1e60931026 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -21,7 +21,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ProtectDeviceEntity @@ -36,7 +36,7 @@ _SPEAKER_DESCRIPTION = MediaPlayerEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Discover cameras with speakers on a UniFi Protect NVR.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 767128337ba..5dbf9f2b00e 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -17,7 +17,7 @@ from uiprotect.data import ( from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( @@ -227,7 +227,7 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 00c277c957e..054c9430387 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -29,7 +29,7 @@ from uiprotect.data import ( from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import TYPE_EMPTY_VALUE from .data import ProtectData, ProtectDeviceType, UFPConfigEntry @@ -334,7 +334,9 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { async def async_setup_entry( - hass: HomeAssistant, entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: UFPConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 09187e023a1..a719f36c2b3 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -38,7 +38,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( @@ -640,7 +640,7 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index fa960261cf2..fce92912a52 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -18,7 +18,7 @@ from uiprotect.data import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .data import ProtectData, ProtectDeviceType, UFPConfigEntry @@ -568,7 +568,7 @@ class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch): async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 0c7e1322f23..1c468d44cc6 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -15,7 +15,7 @@ from uiprotect.data import ( from homeassistant.components.text import TextEntity, TextEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry from .entity import ( @@ -63,7 +63,7 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = { async def async_setup_entry( hass: HomeAssistant, entry: UFPConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors for UniFi Protect integration.""" data = entry.runtime_data diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py index 07bd50b7d9f..0838ec3ef01 100644 --- a/homeassistant/components/upb/light.py +++ b/homeassistant/components/upb/light.py @@ -13,7 +13,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA from .entity import UpbAttachedEntity @@ -26,7 +26,7 @@ SERVICE_LIGHT_BLINK = "light_blink" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPB light based on a config entry.""" diff --git a/homeassistant/components/upb/scene.py b/homeassistant/components/upb/scene.py index 5a5e17b3e4c..45a1d664b15 100644 --- a/homeassistant/components/upb/scene.py +++ b/homeassistant/components/upb/scene.py @@ -6,7 +6,7 @@ from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA from .entity import UpbEntity @@ -21,7 +21,7 @@ SERVICE_LINK_BLINK = "link_blink" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPB link based on a config entry.""" upb = hass.data[DOMAIN][config_entry.entry_id]["upb"] diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py index bca313d306f..923d8f2d896 100644 --- a/homeassistant/components/upcloud/binary_sensor.py +++ b/homeassistant/components/upcloud/binary_sensor.py @@ -5,7 +5,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity @@ -14,7 +14,7 @@ from .entity import UpCloudServerEntity async def async_setup_entry( hass: HomeAssistant, config_entry: UpCloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UpCloud server binary sensor.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py index 97c08b19188..de180907919 100644 --- a/homeassistant/components/upcloud/switch.py +++ b/homeassistant/components/upcloud/switch.py @@ -6,7 +6,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import UpCloudConfigEntry from .entity import UpCloudServerEntity @@ -17,7 +17,7 @@ SIGNAL_UPDATE_UPCLOUD = "upcloud_update" async def async_setup_entry( hass: HomeAssistant, config_entry: UpCloudConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UpCloud server switch.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 1576cccac6a..0c7b7aa5dc2 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import LOGGER, WAN_STATUS from .coordinator import UpnpConfigEntry, UpnpDataUpdateCoordinator @@ -38,7 +38,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UpnpConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index c0e77315f77..c7e343d36b5 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BYTES_RECEIVED, @@ -153,7 +153,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: UpnpConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UPnP/IGD sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 25917d09096..488682a79c6 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -15,7 +15,7 @@ from .const import DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" async_add_entities([UptimeSensor(entry)]) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 0c1bd972387..73f9400c013 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import UptimeRobotDataUpdateCoordinator @@ -19,7 +19,7 @@ from .entity import UptimeRobotEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot binary_sensors.""" coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index c5ff8abf5d9..724c3075a3b 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import UptimeRobotDataUpdateCoordinator @@ -28,7 +28,7 @@ SENSORS_INFO = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot sensors.""" coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index aa7d07e10fd..31401ac7eb4 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import API_ATTR_OK, DOMAIN, LOGGER from .coordinator import UptimeRobotDataUpdateCoordinator @@ -21,7 +21,9 @@ from .entity import UptimeRobotEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the UptimeRobot switches.""" coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 5815ce7ec95..0c818525c8d 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -10,7 +10,10 @@ from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -22,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Utility Meter config entry.""" name = config_entry.title diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index cd65c42b22a..425dfa2c3fd 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -41,7 +41,10 @@ from homeassistant.core import ( from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.event import ( async_track_point_in_time, async_track_state_change_event, @@ -116,7 +119,7 @@ def validate_is_number(value): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize Utility Meter config entry.""" entry_id = config_entry.entry_id diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py index 18724a4eada..85f03d6b4fb 100644 --- a/homeassistant/components/v2c/binary_sensor.py +++ b/homeassistant/components/v2c/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity @@ -50,7 +50,7 @@ TRYDAN_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: V2CConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up V2C binary sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index 0d6401d194f..e52242f0ce0 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfElectricCurrent from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity @@ -71,7 +71,7 @@ TRYDAN_NUMBER_SETTINGS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: V2CConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up V2C Trydan number platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 5b02928385b..cfccaacda18 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import V2CConfigEntry, V2CUpdateCoordinator @@ -142,7 +142,7 @@ TRYDAN_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: V2CConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up V2C sensor platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index d6ba6a3b13e..20bc3419757 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -18,7 +18,7 @@ from pytrydan.models.trydan import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import V2CConfigEntry, V2CUpdateCoordinator from .entity import V2CBaseEntity @@ -79,7 +79,7 @@ TRYDAN_SWITCHES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: V2CConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up V2C switch platform.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 4a0efc7b101..a205dd2039e 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator @@ -62,7 +62,7 @@ BINARY_SENSOR_ENTITIES: tuple[ValloxBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" diff --git a/homeassistant/components/vallox/date.py b/homeassistant/components/vallox/date.py index 33c3ebb253c..da2906c02c2 100644 --- a/homeassistant/components/vallox/date.py +++ b/homeassistant/components/vallox/date.py @@ -10,7 +10,7 @@ from homeassistant.components.date import DateEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator @@ -51,7 +51,7 @@ class ValloxFilterChangeDateEntity(ValloxEntity, DateEntity): async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vallox filter change date entity.""" diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 3a21ef060a7..8519b4cb913 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -57,7 +57,9 @@ def _convert_to_int(value: StateType) -> int | None: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fan device.""" data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py index 96bc07b5a93..ce3b9c72a6d 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator @@ -102,7 +102,9 @@ NUMBER_ENTITIES: tuple[ValloxNumberEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" data = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 7165947861a..e9194a8254c 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -278,7 +278,9 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors.""" name = hass.data[DOMAIN][entry.entry_id]["name"] diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py index 20b270f8f18..9386f914f58 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator @@ -82,7 +82,7 @@ SWITCH_ENTITIES: tuple[ValloxSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switches.""" diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index 88dc994efe8..2ddf6605c19 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -4,7 +4,7 @@ from velbusaio.channels import Button as VelbusButton from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity @@ -15,7 +15,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py index fc943159123..8f736dcd35b 100644 --- a/homeassistant/components/velbus/button.py +++ b/homeassistant/components/velbus/button.py @@ -10,7 +10,7 @@ from velbusaio.channels import ( from homeassistant.components.button import ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index b2f3077ecee..e31d9a97416 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -14,7 +14,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .const import DOMAIN, PRESET_MODES @@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 2ddea37f2d6..995b7e9d59c 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index c134095c2ff..5037e2b1ced 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -23,7 +23,7 @@ from homeassistant.components.light import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py index 6c2dfe0a3b1..1d52b8d4afc 100644 --- a/homeassistant/components/velbus/select.py +++ b/homeassistant/components/velbus/select.py @@ -5,7 +5,7 @@ from velbusaio.channels import SelectedProgram from homeassistant.components.select import SelectEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -16,7 +16,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus select based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 77833da3ee1..96ef91e8174 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index 8256e716d4f..40dc3c09f73 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -6,7 +6,7 @@ from velbusaio.channels import Relay as VelbusRelay from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VelbusConfigEntry from .entity import VelbusEntity, api_call @@ -17,7 +17,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: VelbusConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 90745f601b4..d6bf8905d91 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -16,7 +16,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import VeluxEntity @@ -25,7 +25,9 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover(s) for Velux platform.""" module = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index 14f12a01060..b991239b7a4 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -9,7 +9,7 @@ from pyvlx import Intensity, LighteningDevice from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import VeluxEntity @@ -18,7 +18,9 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light(s) for Velux platform.""" module = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index 54888413613..636ab82e819 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -15,7 +15,9 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( - hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the scenes for Velux platform.""" module = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/venstar/binary_sensor.py b/homeassistant/components/venstar/binary_sensor.py index 315df09b625..672db463791 100644 --- a/homeassistant/components/venstar/binary_sensor.py +++ b/homeassistant/components/venstar/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import VenstarEntity @@ -15,7 +15,7 @@ from .entity import VenstarEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Vensar device binary_sensors based on a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index 50f6508e7ed..ade86e8dd71 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -33,7 +33,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -66,7 +69,7 @@ PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Venstar thermostat.""" venstar_data_coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index 94180f6ad79..14e7103a83f 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import VenstarDataUpdateCoordinator @@ -81,7 +81,7 @@ class VenstarSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Venstar device sensors based on a config entry.""" coordinator: VenstarDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py index 3438ee81d4a..00780fec8ce 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySenso from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -17,7 +17,7 @@ from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index eb2a5206f30..084725f484e 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -17,7 +17,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -30,7 +30,7 @@ SUPPORT_HVAC = [HVACMode.COOL, HVACMode.HEAT, HVACMode.HEAT_COOL, HVACMode.OFF] async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py index b5b57f43c0c..8256804b8a3 100644 --- a/homeassistant/components/vera/cover.py +++ b/homeassistant/components/vera/cover.py @@ -10,7 +10,7 @@ from homeassistant.components.cover import ATTR_POSITION, ENTITY_ID_FORMAT, Cove from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -19,7 +19,7 @@ from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index 9b8ae42f620..f573fcd94ea 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .common import ControllerData, get_controller_data @@ -26,7 +26,7 @@ from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py index 18f0b9de3e2..3f76f3a6106 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -22,7 +22,7 @@ ATTR_LOW_BATTERY = "low_battery" async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py index 22061f98929..0e504b12303 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -9,7 +9,7 @@ import pyvera as veraApi from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify from .common import ControllerData, get_controller_data @@ -19,7 +19,7 @@ from .const import VERA_ID_FORMAT async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index 95f1fa0bd89..d778b4c2e5d 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -32,7 +32,7 @@ SCAN_INTERVAL = timedelta(seconds=5) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py index ad7fbe68458..67be4a7849a 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import ControllerData, get_controller_data from .entity import VeraEntity @@ -19,7 +19,7 @@ from .entity import VeraEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" controller_data = get_controller_data(hass, entry) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 2b9ae7b60b6..7ead1f014c8 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -13,7 +13,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ALARM_STATE_TO_HA, CONF_GIID, DOMAIN, LOGGER @@ -23,7 +23,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure alarm control panel from a config entry.""" async_add_entities([VerisureAlarm(coordinator=hass.data[DOMAIN][entry.entry_id])]) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 94a44550d47..4d9221c3ca9 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -22,7 +22,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure binary sensors based on a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 7f49f917d83..1f5d48ea197 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -13,7 +13,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +25,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure sensors based on a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 16c69ecf2e2..76aeedd05fa 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -13,7 +13,7 @@ from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -33,7 +33,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure alarm control panel from a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 77a576caad8..6ed4784bffb 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_GIID, DEVICE_TYPE_NAME, DOMAIN @@ -22,7 +22,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure sensors based on a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index 838e0222087..0deb1da5e95 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_GIID, DOMAIN @@ -19,7 +19,7 @@ from .coordinator import VerisureDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Verisure alarm control panel from a config entry.""" coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/version/binary_sensor.py b/homeassistant/components/version/binary_sensor.py index 6fafd219417..900daa7aba1 100644 --- a/homeassistant/components/version/binary_sensor.py +++ b/homeassistant/components/version/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import CONF_NAME, EntityCategory, __version__ as HA_VERSION from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_SOURCE, DEFAULT_NAME from .coordinator import VersionConfigEntry @@ -23,7 +23,7 @@ HA_VERSION_OBJECT = AwesomeVersion(HA_VERSION) async def async_setup_entry( hass: HomeAssistant, config_entry: VersionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up version binary_sensors.""" coordinator = config_entry.runtime_data diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py index d44625d38f8..7e173b46d36 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import CONF_SOURCE, DEFAULT_NAME @@ -18,7 +18,7 @@ from .entity import VersionEntity async def async_setup_entry( hass: HomeAssistant, entry: VersionConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up version sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py index dd1b6398c06..620222e4d2f 100644 --- a/homeassistant/components/vesync/binary_sensor.py +++ b/homeassistant/components/vesync/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import rgetattr from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY @@ -52,7 +52,7 @@ SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary_sensor platform.""" diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 21a92a22db2..daf734d50a8 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -12,7 +12,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -72,7 +72,7 @@ SPEED_RANGE = { # off is not included async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the VeSync fan platform.""" diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 5afe7360673..9a98a39aa8c 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import is_humidifier from .const import ( @@ -50,7 +50,7 @@ VS_TO_HA_MODE_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the VeSync humidifier platform.""" @@ -71,7 +71,7 @@ async def async_setup_entry( @callback def _setup_entities( devices: list[VeSyncBaseDevice], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: VeSyncDataCoordinator, ): """Add humidifier entities.""" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 40f68986145..887400b2cf0 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -14,7 +14,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY @@ -29,7 +29,7 @@ MIN_MIREDS = 153 # 1,000,000 divided by 6500 Kelvin = 153 Mireds async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lights.""" diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py index 3c43cce28cf..707dd6ab30e 100644 --- a/homeassistant/components/vesync/number.py +++ b/homeassistant/components/vesync/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import is_humidifier from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY @@ -51,7 +51,7 @@ NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities.""" @@ -72,7 +72,7 @@ async def async_setup_entry( @callback def _setup_entities( devices: list[VeSyncBaseDevice], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: VeSyncDataCoordinator, ): """Add number entities.""" diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index bf52050d745..3bc6608989a 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .common import is_humidifier @@ -194,7 +194,7 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" @@ -215,7 +215,7 @@ async def async_setup_entry( @callback def _setup_entities( devices: list[VeSyncBaseDevice], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: VeSyncDataCoordinator, ): """Check if device is online and add entity.""" diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 3d2dc8a8e96..3e8deedb4ad 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -15,7 +15,7 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import is_outlet, is_wall_switch from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY @@ -51,7 +51,7 @@ SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch platform.""" diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 9d216404156..902dfd18d30 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin @@ -157,7 +157,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the ViCare binary sensor devices.""" async_add_entities( diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 65182990bfb..9c30a9e68ee 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -18,7 +18,7 @@ import requests from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixinWithSet @@ -66,7 +66,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the ViCare button entities.""" async_add_entities( diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index f62fdc363a6..9fba83c5700 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import ViCareEntity @@ -98,7 +98,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index c5e24f46c33..26136260a4b 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -18,7 +18,7 @@ from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -111,7 +111,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ViCare fan platform.""" async_add_entities( diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 534c0752cc1..04c4088bd3e 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -27,7 +27,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ViCareEntity from .types import ( @@ -374,7 +374,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the ViCare number devices.""" async_add_entities( diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 56a95d5f513..cc79812b504 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -37,7 +37,7 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( VICARE_CUBIC_METER, @@ -1041,7 +1041,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" async_add_entities( diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 114ff620c3f..f92c9e3e1af 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -22,7 +22,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ViCareEntity from .types import ViCareConfigEntry, ViCareDevice @@ -80,7 +80,7 @@ def _build_entities( async def async_setup_entry( hass: HomeAssistant, config_entry: ViCareConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ViCare water heater platform.""" async_add_entities( diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index 77a7df7a0a8..fa2d5cae196 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_API_DATA_FIELD_BOOT_TIME, @@ -51,7 +51,7 @@ SENSOR_TYPES: tuple[VilfoSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Vilfo Router entities from a config_entry.""" vilfo = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 5711d8fbac9..d44db5e45ee 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -32,7 +32,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_ADDITIONAL_CONFIGS, @@ -63,7 +63,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Vizio media player entry.""" host = config_entry.data[CONF_HOST] diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 9597c706570..6ae9fbb9f5a 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import VlcConfigEntry @@ -39,7 +39,9 @@ def _get_str(data: dict, key: str) -> str | None: async def async_setup_entry( - hass: HomeAssistant, entry: VlcConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: VlcConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the vlc platform.""" # CONF_NAME is only present in imported YAML. diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index efea011a541..9812cef48d6 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -14,7 +14,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN @@ -67,7 +67,9 @@ BUTTON_TYPES: Final = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station buttons") diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index 4af0b85e003..ece4bd05a02 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -6,7 +6,7 @@ from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN @@ -14,7 +14,9 @@ from .coordinator import VodafoneStationDeviceInfo, VodafoneStationRouter async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Vodafone Station component.""" @@ -40,7 +42,7 @@ async def async_setup_entry( @callback def async_add_new_tracked_entities( coordinator: VodafoneStationRouter, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, tracked: set[str], ) -> None: """Add new tracker entities from the router.""" diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 307fcaf0ea8..d29fb7f21e9 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN, LINE_TYPES @@ -165,7 +165,9 @@ SENSOR_TYPES: Final = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station sensors") diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 1877b8c655c..1026df8d0d9 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -28,7 +28,7 @@ from homeassistant.components.assist_satellite import ( from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CHANNELS, CONF_SIP_PORT, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH from .devices import VoIPDevice @@ -64,7 +64,7 @@ _TONE_FILENAMES: dict[Tones, str] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP Assist satellite entity.""" domain_data: DomainData = hass.data[DOMAIN] diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py index f38b228c46c..34dac4b6068 100644 --- a/homeassistant/components/voip/binary_sensor.py +++ b/homeassistant/components/voip/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .devices import VoIPDevice @@ -24,7 +24,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP binary sensor entities.""" domain_data: DomainData = hass.data[DOMAIN] diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py index f145f866ae3..bfce112d0c5 100644 --- a/homeassistant/components/voip/select.py +++ b/homeassistant/components/voip/select.py @@ -10,7 +10,7 @@ from homeassistant.components.assist_pipeline.select import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .devices import VoIPDevice @@ -23,7 +23,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" domain_data: DomainData = hass.data[DOMAIN] diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py index f8484241fc5..7690b8f125c 100644 --- a/homeassistant/components/voip/switch.py +++ b/homeassistant/components/voip/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import restore_state -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .devices import VoIPDevice @@ -22,7 +22,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" domain_data: DomainData = hass.data[DOMAIN] diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 5ba67d7974f..514f1ad9221 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle from .browse_media import browse_node, browse_top_level @@ -33,7 +33,7 @@ PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Volumio media player platform.""" diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py index e6104f8d87c..2ba8d19e3db 100644 --- a/homeassistant/components/volvooncall/binary_sensor.py +++ b/homeassistant/components/volvooncall/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -24,7 +24,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure binary_sensors from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py index 96fe5a644bb..018acb02d49 100644 --- a/homeassistant/components/volvooncall/device_tracker.py +++ b/homeassistant/components/volvooncall/device_tracker.py @@ -8,7 +8,7 @@ from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure device_trackers from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py index cff5df35750..75b54e9dbbc 100644 --- a/homeassistant/components/volvooncall/lock.py +++ b/homeassistant/components/volvooncall/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -20,7 +20,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure locks from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py index 9916d37197b..feb7248ccaf 100644 --- a/homeassistant/components/volvooncall/sensor.py +++ b/homeassistant/components/volvooncall/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -18,7 +18,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure sensors from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py index 7e60f47fb44..ff321577348 100644 --- a/homeassistant/components/volvooncall/switch.py +++ b/homeassistant/components/volvooncall/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, VOLVO_DISCOVERY_NEW from .coordinator import VolvoUpdateCoordinator @@ -20,7 +20,7 @@ from .entity import VolvoEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Configure binary_sensors from a config entry created in the integrations UI.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index a89b6b4a116..c2ef8b70d46 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN from .fetch_data import get_lessons, get_student_info @@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the calendar platform for entity.""" client = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wake_on_lan/button.py b/homeassistant/components/wake_on_lan/button.py index 4d6b19bdd8e..e9cf69b1fe7 100644 --- a/homeassistant/components/wake_on_lan/button.py +++ b/homeassistant/components/wake_on_lan/button.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback _LOGGER = logging.getLogger(__name__) @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Wake on LAN button entry.""" broadcast_address: str | None = entry.options.get(CONF_BROADCAST_ADDRESS) diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 4853a9104f2..ef35734ed7e 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -8,7 +8,7 @@ from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CHARGER_DATA_KEY, @@ -28,7 +28,9 @@ LOCK_TYPES: dict[str, LockEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox lock entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 24cdd16f99d..462266636d7 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -13,7 +13,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( BIDIRECTIONAL_MODEL_PREFIXES, @@ -82,7 +82,9 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox number entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 18d8afb5612..78b26520bec 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -157,7 +157,9 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 06c2674579d..30275951ab2 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CHARGER_DATA_KEY, @@ -29,7 +29,9 @@ SWITCH_TYPES: dict[str, SwitchEntityDescription] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 4c921c68336..59daf60392e 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -138,7 +138,9 @@ SENSORS: list[WAQISensorEntityDescription] = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WAQI sensor.""" coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py index 44630d2f587..5aced8b7488 100644 --- a/homeassistant/components/watergate/sensor.py +++ b/homeassistant/components/watergate/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -182,7 +182,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, config_entry: WatergateConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Watergate Platform.""" diff --git a/homeassistant/components/watergate/valve.py b/homeassistant/components/watergate/valve.py index ce914ebbb55..cb6bfa7bd59 100644 --- a/homeassistant/components/watergate/valve.py +++ b/homeassistant/components/watergate/valve.py @@ -8,7 +8,7 @@ from homeassistant.components.valve import ( ValveState, ) from homeassistant.core import callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import WatergateConfigEntry, WatergateDataCoordinator from .entity import WatergateEntity @@ -20,7 +20,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: WatergateConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Watergate Platform.""" diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index c6cc81580d7..d3aa9d8f895 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -52,7 +52,9 @@ REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WattTime sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index a216a02f61e..1f21cc2ea78 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.location import find_coordinates @@ -57,7 +57,7 @@ SECONDS_BETWEEN_API_CALLS = 0.5 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Waze travel time sensor entry.""" destination = config_entry.data[CONF_DESTINATION] diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index cacede55c42..683413236c1 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -40,7 +40,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.unit_system import METRIC_SYSTEM @@ -285,7 +285,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeatherFlow sensors using config entry.""" diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index aeab955878f..d2c62b5f281 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN @@ -172,7 +172,7 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeatherFlow sensors based on a config entry.""" diff --git a/homeassistant/components/weatherflow_cloud/weather.py b/homeassistant/components/weatherflow_cloud/weather.py index c475f2974a9..3cb1f477095 100644 --- a/homeassistant/components/weatherflow_cloud/weather.py +++ b/homeassistant/components/weatherflow_cloud/weather.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, STATE_MAP from .coordinator import WeatherFlowCloudDataUpdateCoordinator @@ -27,7 +27,7 @@ from .entity import WeatherFlowCloudEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/weatherkit/sensor.py b/homeassistant/components/weatherkit/sensor.py index d9c17bb855a..b3639fa5356 100644 --- a/homeassistant/components/weatherkit/sensor.py +++ b/homeassistant/components/weatherkit/sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolumetricFlux from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,7 +36,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add sensor entities from a config_entry.""" coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index 98816d520ba..b57e488d06a 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_CURRENT_WEATHER, @@ -45,7 +45,7 @@ from .entity import WeatherKitEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ diff --git a/homeassistant/components/webmin/sensor.py b/homeassistant/components/webmin/sensor.py index 785140393a2..a21c73bed13 100644 --- a/homeassistant/components/webmin/sensor.py +++ b/homeassistant/components/webmin/sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE, UnitOfInformation from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import WebminConfigEntry @@ -200,7 +200,7 @@ def generate_filesystem_sensor_description( async def async_setup_entry( hass: HomeAssistant, entry: WebminConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Webmin sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 5c47a5e775f..33c09aa8708 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -28,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.trigger import PluggableAction from homeassistant.helpers.typing import VolDictType @@ -102,7 +102,7 @@ SERVICES = ( async def async_setup_entry( hass: HomeAssistant, entry: WebOsTvConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the LG webOS TV platform.""" platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py index 0ffa876ad0f..6a4a03a1e48 100644 --- a/homeassistant/components/weheat/binary_sensor.py +++ b/homeassistant/components/weheat/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator @@ -64,7 +64,7 @@ BINARY_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: WeheatConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors for weheat heat pump.""" entities = [ diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 5d948c6d565..615bfd30d18 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( @@ -200,7 +200,7 @@ DHW_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, entry: WeheatConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensors for weheat heat pump.""" entities = [ diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index f2bcb04d96f..4ed361b18ba 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -5,7 +5,7 @@ from pywemo import Insight, Maker, StandbyState from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import async_wemo_dispatcher_connect from .coordinator import DeviceCoordinator @@ -15,7 +15,7 @@ from .entity import WemoBinaryStateEntity, WemoEntity async def async_setup_entry( hass: HomeAssistant, _config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo binary sensors.""" diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 42dae679aa5..edfdfc1c78c 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -13,7 +13,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.util.percentage import ( percentage_to_ranged_value, @@ -48,7 +48,7 @@ SET_HUMIDITY_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, _config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo binary sensors.""" diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 619e0952457..838073be84a 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from . import async_wemo_dispatcher_connect @@ -35,7 +35,7 @@ WEMO_OFF = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo lights.""" @@ -53,7 +53,7 @@ async def async_setup_entry( def async_setup_bridge( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, coordinator: DeviceCoordinator, ) -> None: """Set up a WeMo link.""" diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 90e3546eaf7..76a0265d7da 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import async_wemo_dispatcher_connect @@ -59,7 +59,7 @@ ATTRIBUTE_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, _config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo sensors.""" diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 3f7bb08b704..7b87b3147d0 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import async_wemo_dispatcher_connect from .coordinator import DeviceCoordinator @@ -36,7 +36,7 @@ MAKER_SWITCH_TOGGLE = "toggle" async def async_setup_entry( hass: HomeAssistant, _config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WeMo switches.""" diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 943c5d1c956..6baf738e54e 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry from .const import DOMAIN @@ -70,7 +70,7 @@ SUPPORTED_TARGET_TEMPERATURE_STEP = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: WhirlpoolConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" whirlpool_data = config_entry.runtime_data diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 9180164c272..f4811feb2c9 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -132,7 +132,7 @@ SENSOR_TIMER: tuple[SensorEntityDescription] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: WhirlpoolConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config flow entry for Whrilpool Laundry.""" entities: list = [] diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index fe193b16eea..8098e052575 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -127,7 +127,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from config_entry.""" coordinator: DataUpdateCoordinator[Domain | None] = hass.data[DOMAIN][ diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index b7431b2555c..93fdb7cce1c 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -4,7 +4,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CREATE_ENTITY_SIGNAL from .entity import WiffiEntity @@ -13,7 +13,7 @@ from .entity import WiffiEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform for a new integration. diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index 699a760685a..9afcc719c9b 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE, LIGHT_LUX, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CREATE_ENTITY_SIGNAL from .entity import WiffiEntity @@ -41,7 +41,7 @@ UOM_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform for a new integration. diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py index 8a5cb45d909..2e9b92e7a21 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -18,7 +18,7 @@ from pywilight.const import ( from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WiLightDevice @@ -26,7 +26,9 @@ from .parent_device import WiLightParent async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight covers from a config entry.""" parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index a14198e3b5d..6a22da5879e 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -19,7 +19,7 @@ from pywilight.wilight_device import PyWiLightDevice from homeassistant.components.fan import DIRECTION_FORWARD, FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -33,7 +33,9 @@ ORDERED_NAMED_FAN_SPEEDS = [WL_SPEED_LOW, WL_SPEED_MEDIUM, WL_SPEED_HIGH] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight lights from a config entry.""" parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py index fbe2499798d..7df0eb1a4c6 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WiLightDevice @@ -41,7 +41,9 @@ def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> list[LightE async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight lights from a config entry.""" parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wilight/switch.py b/homeassistant/components/wilight/switch.py index f2a1ce8b0c5..148ea65dd94 100644 --- a/homeassistant/components/wilight/switch.py +++ b/homeassistant/components/wilight/switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WiLightDevice @@ -75,7 +75,9 @@ def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> tuple[Any]: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WiLight switches from a config entry.""" parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 856aeeffc5c..457bbe59bcc 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WithingsConfigEntry from .const import DOMAIN @@ -22,7 +22,7 @@ from .entity import WithingsEntity async def async_setup_entry( hass: HomeAssistant, entry: WithingsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" coordinator = entry.runtime_data.bed_presence_coordinator diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index ac867fbfdca..8dcad9d73ba 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -11,7 +11,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DOMAIN, WithingsConfigEntry from .coordinator import WithingsWorkoutDataUpdateCoordinator @@ -21,7 +21,7 @@ from .entity import WithingsEntity async def async_setup_entry( hass: HomeAssistant, entry: WithingsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the calendar platform for entity.""" ent_reg = er.async_get(hass) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 1005b5995a5..96cb433deba 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util @@ -683,7 +683,7 @@ def get_current_goals(goals: Goals) -> set[str]: async def async_setup_entry( hass: HomeAssistant, entry: WithingsConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor config entry.""" ent_reg = er.async_get(hass) diff --git a/homeassistant/components/wiz/binary_sensor.py b/homeassistant/components/wiz/binary_sensor.py index 3411ee200b9..385e6827d77 100644 --- a/homeassistant/components/wiz/binary_sensor.py +++ b/homeassistant/components/wiz/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .const import DOMAIN, SIGNAL_WIZ_PIR @@ -27,7 +27,7 @@ OCCUPANCY_UNIQUE_ID = "{}_occupancy" async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WiZ binary sensor platform.""" mac = entry.runtime_data.bulb.mac diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index 9ef4cd57b3d..e38d518f6bc 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( filter_supported_color_modes, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .entity import WizToggleEntity @@ -57,7 +57,7 @@ def _async_pilot_builder(**kwargs: Any) -> PilotBuilder: async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WiZ Platform from config_flow.""" if entry.runtime_data.bulb.bulbtype.bulb_type != BulbClass.SOCKET: diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index 0591e854d7d..0c8ee3f2bf4 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -15,7 +15,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .entity import WizEntity @@ -68,7 +68,7 @@ NUMBERS: tuple[WizNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the wiz speed number.""" async_add_entities( diff --git a/homeassistant/components/wiz/sensor.py b/homeassistant/components/wiz/sensor.py index eb77686a5cf..217dae9e8fb 100644 --- a/homeassistant/components/wiz/sensor.py +++ b/homeassistant/components/wiz/sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .entity import WizEntity @@ -45,7 +45,7 @@ POWER_SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the wiz sensor.""" entities = [ diff --git a/homeassistant/components/wiz/switch.py b/homeassistant/components/wiz/switch.py index 4c089d2d6d2..a57834bc18d 100644 --- a/homeassistant/components/wiz/switch.py +++ b/homeassistant/components/wiz/switch.py @@ -9,7 +9,7 @@ from pywizlight.bulblibrary import BulbClass from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WizConfigEntry from .entity import WizToggleEntity @@ -19,7 +19,7 @@ from .models import WizData async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WiZ switch platform.""" if entry.runtime_data.bulb.bulbtype.bulb_type == BulbClass.SOCKET: diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py index 74799b4dcc4..119b2dc9b9f 100644 --- a/homeassistant/components/wled/button.py +++ b/homeassistant/components/wled/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator @@ -16,7 +16,7 @@ from .helpers import wled_exception_handler async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED button based on a config entry.""" async_add_entities([WLEDRestartButton(entry.runtime_data)]) diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index b4edf10dc58..5e2ff117580 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .const import ( @@ -39,7 +39,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED light based on a config entry.""" coordinator = entry.runtime_data @@ -284,7 +284,7 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): def async_update_segments( coordinator: WLEDDataUpdateCoordinator, current_ids: set[int], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" segment_ids = { diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 225d783bfdb..e4ff184fd4b 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -11,7 +11,7 @@ from wled import Segment from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .const import ATTR_INTENSITY, ATTR_SPEED @@ -25,7 +25,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED number based on a config entry.""" coordinator = entry.runtime_data @@ -130,7 +130,7 @@ class WLEDNumber(WLEDEntity, NumberEntity): def async_update_segments( coordinator: WLEDDataUpdateCoordinator, current_ids: set[int], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" segment_ids = { diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index a645b04573c..e340c323151 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -9,7 +9,7 @@ from wled import LiveDataOverride from homeassistant.components.select import SelectEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED select based on a config entry.""" coordinator = entry.runtime_data @@ -191,7 +191,7 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity): def async_update_segments( coordinator: WLEDDataUpdateCoordinator, current_ids: set[int], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" segment_ids = { diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 4f97c367612..06f96782019 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfInformation, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow @@ -128,7 +128,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED sensor based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 643834dcdec..8ed6ed56114 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLEDConfigEntry from .const import ATTR_DURATION, ATTR_TARGET_BRIGHTNESS, ATTR_UDP_PORT @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED switch based on a config entry.""" coordinator = entry.runtime_data @@ -195,7 +195,7 @@ class WLEDReverseSwitch(WLEDEntity, SwitchEntity): def async_update_segments( coordinator: WLEDDataUpdateCoordinator, current_ids: set[int], - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Update segments.""" segment_ids = { diff --git a/homeassistant/components/wled/update.py b/homeassistant/components/wled/update.py index 384b394ac50..ccf72425b77 100644 --- a/homeassistant/components/wled/update.py +++ b/homeassistant/components/wled/update.py @@ -10,7 +10,7 @@ from homeassistant.components.update import ( UpdateEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WLED_KEY, WLEDConfigEntry from .coordinator import WLEDDataUpdateCoordinator, WLEDReleasesDataUpdateCoordinator @@ -21,7 +21,7 @@ from .helpers import wled_exception_handler async def async_setup_entry( hass: HomeAssistant, entry: WLEDConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up WLED update based on a config entry.""" async_add_entities([WLEDUpdateEntity(entry.runtime_data, hass.data[WLED_KEY])]) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index a36b34642b7..715add3023f 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -12,7 +12,7 @@ from wmspro.const import ( from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WebControlProConfigEntry from .entity import WebControlProGenericEntity @@ -24,7 +24,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: WebControlProConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WMS based covers from a config entry.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index 9242982bcf9..d181beb1eaa 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -9,7 +9,7 @@ from wmspro.const import WMS_WebControl_pro_API_actionDescription from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import brightness_to_value, value_to_brightness from . import WebControlProConfigEntry @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: WebControlProConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WMS based lights from a config entry.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/wmspro/scene.py b/homeassistant/components/wmspro/scene.py index de18106b7f0..7edd7a2b186 100644 --- a/homeassistant/components/wmspro/scene.py +++ b/homeassistant/components/wmspro/scene.py @@ -9,7 +9,7 @@ from wmspro.scene import Scene as WMS_Scene from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WebControlProConfigEntry from .const import ATTRIBUTION, DOMAIN, MANUFACTURER @@ -18,7 +18,7 @@ from .const import ATTRIBUTION, DOMAIN, MANUFACTURER async def async_setup_entry( hass: HomeAssistant, config_entry: WebControlProConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WMS based scenes from a config entry.""" hub = config_entry.runtime_data diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 1f6e6c42464..cf6d712dd0d 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES @@ -26,7 +26,7 @@ from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STA async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Wolf Platform.""" diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 3aad6d805d0..6b878db8159 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -26,7 +26,7 @@ from homeassistant.core import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) from homeassistant.helpers.event import async_track_point_in_utc_time @@ -113,7 +113,9 @@ def _get_obj_holidays( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Workday sensor.""" add_holidays: list[str] = entry.options[CONF_ADD_HOLIDAYS] diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index 88e5a317cdd..9b52993919c 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_TIME_ZONE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from .const import CONF_TIME_FORMAT, DOMAIN @@ -18,7 +18,7 @@ from .const import CONF_TIME_FORMAT, DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the World clock sensor entry.""" time_zone = await dt_util.async_get_time_zone(entry.options[CONF_TIME_ZONE]) diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index a2cd7ba471b..fb8ba5ae996 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -10,7 +10,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MAX_VOL @@ -23,7 +23,7 @@ PARALLEL_UPDATES = 1 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WS66i 6-zone amplifier platform from a config entry.""" ws66i_data: Ws66iData = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 615084bcbf3..d43af2d21b9 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -33,7 +33,7 @@ from homeassistant.components.assist_satellite import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .data import WyomingService @@ -62,7 +62,7 @@ _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming Assist satellite entity.""" domain_data: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py index 24ee073ec4d..a3652e7f70f 100644 --- a/homeassistant/components/wyoming/binary_sensor.py +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WyomingSatelliteEntity @@ -22,7 +22,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index 988d47925ac..5760d04bfc2 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -12,7 +12,7 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import ulid as ulid_util from .const import DOMAIN @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming conversation.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/number.py b/homeassistant/components/wyoming/number.py index d9a58cc3333..96ec5877545 100644 --- a/homeassistant/components/wyoming/number.py +++ b/homeassistant/components/wyoming/number.py @@ -8,7 +8,7 @@ from homeassistant.components.number import NumberEntityDescription, RestoreNumb from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WyomingSatelliteEntity @@ -24,7 +24,7 @@ _MAX_VOLUME_MULTIPLIER: Final = 10.0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming number entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py index bbcaab81710..2af0438e35f 100644 --- a/homeassistant/components/wyoming/select.py +++ b/homeassistant/components/wyoming/select.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import restore_state -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .devices import SatelliteDevice @@ -36,7 +36,7 @@ _DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming select entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index a28e5fdb527..2851004a854 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -10,7 +10,7 @@ from wyoming.client import AsyncTcpClient from homeassistant.components import stt from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH from .data import WyomingService @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py index 308429331c3..9eb91d5ef39 100644 --- a/homeassistant/components/wyoming/switch.py +++ b/homeassistant/components/wyoming/switch.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import restore_state -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .entity import WyomingSatelliteEntity @@ -21,7 +21,7 @@ if TYPE_CHECKING: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 65ce4d942f1..79e431fee98 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -12,7 +12,7 @@ from wyoming.tts import Synthesize, SynthesizeVoice from homeassistant.components import tts from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming speech-to-text.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 64dfd60c068..2a21b7303e5 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -11,7 +11,7 @@ from wyoming.wake import Detect, Detection from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .data import WyomingService, load_wyoming_info @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Wyoming wake-word-detection.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index af95834425a..5339c4d7a8e 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import XboxUpdateCoordinator @@ -18,7 +18,9 @@ PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox Live friends.""" coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 7298c7e2da3..6464b2417cc 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -24,7 +24,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .browse_media import build_item_response @@ -56,7 +56,9 @@ XBOX_STATE_MAP: dict[PlaybackState | PowerState, MediaPlayerState | None] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox media_player from a config entry.""" client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"] diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py index 1b4ffdf35cc..4e5893ddb13 100644 --- a/homeassistant/components/xbox/remote.py +++ b/homeassistant/components/xbox/remote.py @@ -24,7 +24,7 @@ from homeassistant.components.remote import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -32,7 +32,9 @@ from .coordinator import ConsoleData, XboxUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox media_player from a config entry.""" client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"] diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index f269e0a5bb9..da53557a2d3 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import XboxUpdateCoordinator @@ -20,7 +20,7 @@ SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xbox Live friends.""" coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id][ diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index ad91dda2173..47cc823ad7f 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity @@ -32,7 +32,7 @@ ATTR_DENSITY = "Density" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities: list[XiaomiBinarySensor] = [] diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index e073ef6b683..82d5129ac5e 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, GATEWAYS_KEY from .entity import XiaomiDevice @@ -19,7 +19,7 @@ DATA_KEY_PROTO_V2 = "curtain_status" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index 11ce7a0107b..ef1f06695f9 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -13,7 +13,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .const import DOMAIN, GATEWAYS_KEY @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index 5e538f25699..b3f4e9f4caf 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from .const import DOMAIN, GATEWAYS_KEY @@ -24,7 +24,7 @@ UNLOCK_MAINTAIN_TIME = 5 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 49358276a48..59ccee5a1a8 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS from .entity import XiaomiDevice @@ -85,7 +85,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities: list[XiaomiSensor | XiaomiBatterySensor] = [] diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index f66cf8c7603..7d3abf47bd1 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, GATEWAYS_KEY from .entity import XiaomiDevice @@ -29,7 +29,7 @@ IN_USE = "inuse" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" entities = [] diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index b853f83b967..8956e207253 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorEntity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .coordinator import XiaomiPassiveBluetoothDataProcessor @@ -135,7 +135,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: XiaomiBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/xiaomi_ble/event.py b/homeassistant/components/xiaomi_ble/event.py index 7265bcd112c..c5f6e01e575 100644 --- a/homeassistant/components/xiaomi_ble/event.py +++ b/homeassistant/components/xiaomi_ble/event.py @@ -12,7 +12,7 @@ from homeassistant.components.event import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import format_discovered_event_class, format_event_dispatcher_name from .const import ( @@ -183,7 +183,7 @@ class XiaomiEventEntity(EventEntity): async def async_setup_entry( hass: HomeAssistant, entry: XiaomiBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Xiaomi event.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index ba8f64383ee..01f15ff09b8 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .coordinator import XiaomiPassiveBluetoothDataProcessor @@ -208,7 +208,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, entry: XiaomiBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi BLE sensors.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 199d9161353..1ce37c661a2 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -9,7 +9,7 @@ from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_FLOW_TYPE, @@ -242,7 +242,7 @@ DEVICE_MAP: dict[str, dict[str, Callable]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi Air Quality from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 9c06198bc7e..ecab5228f6e 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_GATEWAY, DOMAIN @@ -29,7 +29,7 @@ XIAOMI_STATE_ARMING_VALUE = "oning" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi Gateway Alarm from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index a5ab7e56e6b..213886691f0 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import VacuumCoordinatorDataAttributes from .const import ( @@ -171,7 +171,7 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi sensor from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 9a64941f398..a5d1b4b69c6 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -14,7 +14,7 @@ from homeassistant.components.button import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( DOMAIN, @@ -124,7 +124,7 @@ MODEL_TO_BUTTON_MAP: dict[str, tuple[str, ...]] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the button from a config entry.""" model = config_entry.data[CONF_MODEL] diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 12ed9f7195b..31d5dd9de2c 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -34,7 +34,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -205,7 +205,7 @@ FAN_DIRECTIONS_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Fan from a config entry.""" entities: list[FanEntity] = [] diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 4701345756a..f19fbec5e78 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -23,7 +23,7 @@ from homeassistant.components.humidifier import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import percentage_to_ranged_value from .const import ( @@ -71,7 +71,7 @@ AVAILABLE_MODES_OTHER = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Humidifier from a config entry.""" if config_entry.data[CONF_FLOW_TYPE] != CONF_DEVICE: diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index c1f778928d9..81f68306cbc 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -44,7 +44,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util, dt as dt_util from .const import ( @@ -132,7 +132,7 @@ SERVICE_TO_METHOD = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi light from a config entry.""" entities: list[LightEntity] = [] diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index a3c501aad3f..f30d4728275 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -289,7 +289,7 @@ FAVORITE_LEVEL_VALUES = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Selectors from a config entry.""" entities = [] diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 6729ce2e0f4..94a93fc1fae 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -32,7 +32,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_FLOW_TYPE, @@ -205,7 +205,7 @@ SELECTOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Selectors from a config entry.""" if config_entry.data[CONF_FLOW_TYPE] != CONF_DEVICE: diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index aafcba97487..6f623c46af8 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import VacuumCoordinatorDataAttributes @@ -755,7 +755,7 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi sensor from a config entry.""" entities: list[SensorEntity] = [] diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index b4c4300dbe8..e4b94aebc20 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_FLOW_TYPE, @@ -341,7 +341,7 @@ SWITCH_TYPES = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch from a config entry.""" model = config_entry.data[CONF_MODEL] diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 532eb9581cd..1cbc79b89f3 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import as_utc @@ -79,7 +79,7 @@ STATE_CODE_TO_STATE = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Xiaomi vacuum cleaner robot from a config entry.""" entities = [] diff --git a/homeassistant/components/yale/binary_sensor.py b/homeassistant/components/yale/binary_sensor.py index dbb00ad7d42..bb9acb16644 100644 --- a/homeassistant/components/yale/binary_sensor.py +++ b/homeassistant/components/yale/binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from . import YaleConfigEntry, YaleData @@ -92,7 +92,7 @@ SENSOR_TYPES_DOORBELL: tuple[YaleDoorbellBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale binary sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/button.py b/homeassistant/components/yale/button.py index b04ad638f0c..005d477e4ca 100644 --- a/homeassistant/components/yale/button.py +++ b/homeassistant/components/yale/button.py @@ -2,7 +2,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .entity import YaleEntity @@ -11,7 +11,7 @@ from .entity import YaleEntity async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yale lock wake buttons.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/camera.py b/homeassistant/components/yale/camera.py index 217e8f5f6fd..acabba23b59 100644 --- a/homeassistant/components/yale/camera.py +++ b/homeassistant/components/yale/camera.py @@ -12,7 +12,7 @@ from yalexs.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry, YaleData from .const import DEFAULT_NAME, DEFAULT_TIMEOUT @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yale cameras.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/event.py b/homeassistant/components/yale/event.py index 935ba7376f8..0ea7694be6d 100644 --- a/homeassistant/components/yale/event.py +++ b/homeassistant/components/yale/event.py @@ -16,7 +16,7 @@ from homeassistant.components.event import ( EventEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry, YaleData from .entity import YaleDescriptionEntity @@ -59,7 +59,7 @@ TYPES_DOORBELL: tuple[YaleEventEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the yale event platform.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index 7fdad118cde..079c1dcd3dd 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -14,7 +14,7 @@ from yalexs.util import get_latest_activity, update_lock_detail_from_activity from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -29,7 +29,7 @@ LOCK_JAMMED_ERR = 531 async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yale locks.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale/sensor.py b/homeassistant/components/yale/sensor.py index bb3d4317277..91ecbea704d 100644 --- a/homeassistant/components/yale/sensor.py +++ b/homeassistant/components/yale/sensor.py @@ -25,7 +25,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .const import ( @@ -82,7 +82,7 @@ SENSOR_TYPE_KEYPAD_BATTERY = YaleSensorEntityDescription[KeypadDetail]( async def async_setup_entry( hass: HomeAssistant, config_entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale sensors.""" data = config_entry.runtime_data diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 8244d96064a..b443ba016d6 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -17,7 +17,7 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .const import DOMAIN, STATE_MAP, YALE_ALL_ERRORS @@ -26,7 +26,9 @@ from .entity import YaleAlarmEntity async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the alarm entry.""" diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index fa9584505e2..20fe3648eed 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .coordinator import YaleDataUpdateCoordinator @@ -44,7 +44,9 @@ SENSOR_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale binary sensor entry.""" diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py index 0e53c814fd4..0875ab4514d 100644 --- a/homeassistant/components/yale_smart_alarm/button.py +++ b/homeassistant/components/yale_smart_alarm/button.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .const import DOMAIN, YALE_ALL_ERRORS @@ -25,7 +25,7 @@ BUTTON_TYPES = ( async def async_setup_entry( hass: HomeAssistant, entry: YaleConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the button from a config entry.""" diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 7a93baf0827..f4fae531b67 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity, LockState from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .const import ( @@ -30,7 +30,9 @@ LOCK_STATE_MAP = { async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale lock entry.""" diff --git a/homeassistant/components/yale_smart_alarm/select.py b/homeassistant/components/yale_smart_alarm/select.py index 55b56dd8e54..0b443e762e6 100644 --- a/homeassistant/components/yale_smart_alarm/select.py +++ b/homeassistant/components/yale_smart_alarm/select.py @@ -6,7 +6,7 @@ from yalesmartalarmclient import YaleLock, YaleLockVolume from homeassistant.components.select import SelectEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .coordinator import YaleDataUpdateCoordinator @@ -16,7 +16,9 @@ VOLUME_OPTIONS = {value.name.lower(): str(value.value) for value in YaleLockVolu async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale select entry.""" diff --git a/homeassistant/components/yale_smart_alarm/sensor.py b/homeassistant/components/yale_smart_alarm/sensor.py index 50343f2e41f..14301d0c6b5 100644 --- a/homeassistant/components/yale_smart_alarm/sensor.py +++ b/homeassistant/components/yale_smart_alarm/sensor.py @@ -7,7 +7,7 @@ from typing import cast from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import YaleConfigEntry @@ -15,7 +15,9 @@ from .entity import YaleEntity async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale sensor entry.""" diff --git a/homeassistant/components/yale_smart_alarm/switch.py b/homeassistant/components/yale_smart_alarm/switch.py index e8c0817c2de..e4523a66802 100644 --- a/homeassistant/components/yale_smart_alarm/switch.py +++ b/homeassistant/components/yale_smart_alarm/switch.py @@ -8,7 +8,7 @@ from yalesmartalarmclient import YaleLock from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YaleConfigEntry from .coordinator import YaleDataUpdateCoordinator @@ -16,7 +16,9 @@ from .entity import YaleLockEntity async def async_setup_entry( - hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: YaleConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Yale switch entry.""" diff --git a/homeassistant/components/yalexs_ble/binary_sensor.py b/homeassistant/components/yalexs_ble/binary_sensor.py index 7cd142bb9ba..dc924486df2 100644 --- a/homeassistant/components/yalexs_ble/binary_sensor.py +++ b/homeassistant/components/yalexs_ble/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity @@ -18,7 +18,7 @@ from .entity import YALEXSBLEEntity async def async_setup_entry( hass: HomeAssistant, entry: YALEXSBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YALE XS binary sensors.""" data = entry.runtime_data diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index 6eb32e3f78a..78b92ab9eb1 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -8,7 +8,7 @@ from yalexs_ble import ConnectionInfo, LockInfo, LockState, LockStatus from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity @@ -17,7 +17,7 @@ from .entity import YALEXSBLEEntity async def async_setup_entry( hass: HomeAssistant, entry: YALEXSBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up locks.""" async_add_entities([YaleXSBLELock(entry.runtime_data)]) diff --git a/homeassistant/components/yalexs_ble/sensor.py b/homeassistant/components/yalexs_ble/sensor.py index 90f61219e0b..bc9312effe3 100644 --- a/homeassistant/components/yalexs_ble/sensor.py +++ b/homeassistant/components/yalexs_ble/sensor.py @@ -20,7 +20,7 @@ from homeassistant.const import ( UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity @@ -75,7 +75,7 @@ SENSORS: tuple[YaleXSBLESensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, entry: YALEXSBLEConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YALE XS Bluetooth sensors.""" data = entry.runtime_data diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index cff14f2b67d..7bf139e9c3b 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import uuid as uuid_util from .const import ( @@ -55,7 +55,7 @@ MUSIC_PLAYER_BASE_SUPPORT = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast sensor based on a config entry.""" coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py index 02dd6720d91..0de14ef142d 100644 --- a/homeassistant/components/yamaha_musiccast/number.py +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -7,7 +7,7 @@ from aiomusiccast.capabilities import NumberSetter from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MusicCastDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import MusicCastCapabilityEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast number entities based on a config entry.""" coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py index 3a4649b9ae5..133cb4c4d7b 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -7,7 +7,7 @@ from aiomusiccast.capabilities import OptionSetter from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, TRANSLATION_KEY_MAPPING from .coordinator import MusicCastDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import MusicCastCapabilityEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast select entities based on a config entry.""" coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/yamaha_musiccast/switch.py b/homeassistant/components/yamaha_musiccast/switch.py index 49d031a02b5..148f09930f3 100644 --- a/homeassistant/components/yamaha_musiccast/switch.py +++ b/homeassistant/components/yamaha_musiccast/switch.py @@ -7,7 +7,7 @@ from aiomusiccast.capabilities import BinarySetter from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import MusicCastDataUpdateCoordinator @@ -17,7 +17,7 @@ from .entity import MusicCastCapabilityEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MusicCast sensor based on a config entry.""" coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index 910bacc1c2e..6531a48dc82 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -26,7 +26,7 @@ SERVICE_SCHEMA_START_IRRIGATION: VolDictType = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry for a Yardian irrigation switches.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 9993272d510..69427c65fd5 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN from .entity import YeelightEntity @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE] diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 92ee3976f7f..a2f705298c9 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -34,7 +34,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import VolDictType from homeassistant.util import color as color_util @@ -278,7 +278,7 @@ def _async_cmd[_YeelightBaseLightT: YeelightBaseLight, **_P, _R]( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Yeelight from a config entry.""" custom_effects = _parse_custom_effects(hass.data[DOMAIN][DATA_CUSTOM_EFFECTS]) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index fa4c2202b03..30c04d3a424 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -101,7 +101,7 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py index ff3bbf0d93b..65253094fa9 100644 --- a/homeassistant/components/yolink/climate.py +++ b/homeassistant/components/yolink/climate.py @@ -22,7 +22,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -47,7 +47,7 @@ YOLINK_ACTION_2_HA = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Thermostat from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/cover.py b/homeassistant/components/yolink/cover.py index b2454bd0d4a..b1cfc3681cc 100644 --- a/homeassistant/components/yolink/cover.py +++ b/homeassistant/components/yolink/cover.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -24,7 +24,7 @@ from .entity import YoLinkEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink garage door from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/light.py b/homeassistant/components/yolink/light.py index e07d17f7d74..54470673fa5 100644 --- a/homeassistant/components/yolink/light.py +++ b/homeassistant/components/yolink/light.py @@ -10,7 +10,7 @@ from yolink.const import ATTR_DEVICE_DIMMER from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -20,7 +20,7 @@ from .entity import YoLinkEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Dimmer from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py index d675fd8cf06..135d0e26d04 100644 --- a/homeassistant/components/yolink/lock.py +++ b/homeassistant/components/yolink/lock.py @@ -10,7 +10,7 @@ from yolink.const import ATTR_DEVICE_LOCK, ATTR_DEVICE_LOCK_V2 from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -20,7 +20,7 @@ from .entity import YoLinkEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink lock from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/number.py b/homeassistant/components/yolink/number.py index 7b7b582382b..c643a20d0ea 100644 --- a/homeassistant/components/yolink/number.py +++ b/homeassistant/components/yolink/number.py @@ -17,7 +17,7 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -66,7 +66,7 @@ DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device number type config option entity from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 8f263cdae07..511b7718e26 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -47,7 +47,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import percentage from .const import ( @@ -280,7 +280,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 9e02f50bb70..d13e2dc6573 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -17,7 +17,7 @@ from homeassistant.components.siren import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import YoLinkCoordinator @@ -46,7 +46,7 @@ DEVICE_TYPE = [ATTR_DEVICE_SIREN] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink siren from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index c999f04d90d..f2b3c83711c 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -23,7 +23,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEV_MODEL_MULTI_OUTLET_YS6801, DOMAIN from .coordinator import YoLinkCoordinator @@ -116,7 +116,7 @@ DEVICE_TYPE = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink switch from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index d8c199697c3..26ce72a53d1 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -17,7 +17,7 @@ from homeassistant.components.valve import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DEV_MODEL_WATER_METER_YS5007, DOMAIN from .coordinator import YoLinkCoordinator @@ -50,7 +50,7 @@ DEVICE_TYPE = [ATTR_DEVICE_WATER_METER_CONTROLLER] async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up YoLink valve from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index db8244c0b06..be17bed4352 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import DOMAIN @@ -302,7 +302,9 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the integration.""" coordinator: YouLessCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 8832382508c..128c23f7082 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import YouTubeDataUpdateCoordinator @@ -72,7 +72,9 @@ SENSOR_TYPES = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the YouTube sensor.""" coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 7c7f5fd6c16..5846092e555 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -163,7 +163,9 @@ API_FIELDS: list[str] = [desc.para_name for desc in SENSOR_TYPES] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ZAMG sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 286a6460f19..ac376577ade 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, CONF_STATION_ID, DOMAIN, MANUFACTURER_URL @@ -20,7 +20,9 @@ from .coordinator import ZamgDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ZAMG weather platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 36a964a46ab..19175ae3084 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import color as color_util @@ -51,7 +51,7 @@ async def discover_entities(hass: HomeAssistant) -> list[ZerprocLight]: async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Zerproc light devices.""" warned = False diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py index 5023e274267..330e5bb72d8 100644 --- a/homeassistant/components/zeversolar/sensor.py +++ b/homeassistant/components/zeversolar/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import ZeversolarCoordinator @@ -52,7 +52,9 @@ SENSOR_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zeversolar sensor.""" coordinator: ZeversolarCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 734683e5497..ff61ce07d23 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -46,7 +46,7 @@ ZHA_STATE_TO_ALARM_STATE_MAP = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation alarm control panel from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index f45ebf0c5a5..f8146026384 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -26,7 +26,7 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation binary sensor from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index ecd5cd51f61..dd90bcd29b1 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation button from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index af9f56cd7dc..a3f60420a38 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -30,7 +30,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PRECISION_TENTHS, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -66,7 +66,7 @@ ZHA_TO_HA_HVAC_ACTION = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 0d6be2dbb35..d058f37ff6b 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation cover from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 7bdfc54c986..c86bb3352b5 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -23,7 +23,7 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation device tracker from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 73b23e97387..81206f8819e 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -27,7 +27,7 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation fan from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2f5d9e9e4c9..a2fb61dc019 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -28,7 +28,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .entity import ZHAEntity @@ -59,7 +59,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation light from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index ebac03eb7b8..dc27ec7a6fa 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, + AddConfigEntryEntitiesCallback, async_get_current_platform, ) @@ -33,7 +33,7 @@ SERVICE_CLEAR_LOCK_USER_CODE = "clear_lock_user_code" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Door Lock from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 263f5262994..567e2a5b37a 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UndefinedType from .entity import ZHAEntity @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Analog Output from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index fdb47b550fe..4a38738b7dd 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 0506496f447..a8383857e57 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .entity import ZHAEntity @@ -80,7 +80,7 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index 9d876d9ca4d..0c8b447cb37 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -26,7 +26,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -41,7 +41,7 @@ from .helpers import ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index cb0268f98e0..dc150e2407d 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import ZHAEntity from .helpers import ( @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation switch from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 2f540da5ea7..062581fd259 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -19,7 +19,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -52,7 +52,7 @@ OTA_MESSAGE_RELIABILITY = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation update from config entry.""" zha_data = get_zha_data(hass) diff --git a/homeassistant/components/zodiac/sensor.py b/homeassistant/components/zodiac/sensor.py index d7cac07a322..41f200366ae 100644 --- a/homeassistant/components/zodiac/sensor.py +++ b/homeassistant/components/zodiac/sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import as_local, utcnow from .const import ( @@ -150,7 +150,7 @@ ZODIAC_BY_DATE = ( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the entries.""" diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 0f1495fc6e6..d07846c8dcc 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -261,7 +261,7 @@ def is_valid_notification_binary_sensor( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave binary sensor from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 7fd42700a05..f3a1d5af04d 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo @@ -24,7 +24,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave button from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 580694cae11..b27dbdad1a0 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -35,7 +35,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter from .const import DATA_CLIENT, DOMAIN @@ -97,7 +97,7 @@ ATTR_FAN_STATE = "fan_state" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave climate from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 218c5cc82fe..dc44f46a3ce 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -37,7 +37,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( COVER_POSITION_PROPERTY_KEYS, @@ -55,7 +55,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Cover from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py index 8dae66c26ac..66959aa9b75 100644 --- a/homeassistant/components/zwave_js/event.py +++ b/homeassistant/components/zwave_js/event.py @@ -10,7 +10,7 @@ from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_VALUE, DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Event entity from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index d83132e4b95..ae36e0afb42 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -46,7 +46,7 @@ ATTR_FAN_STATE = "fan_state" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Fan from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index e883858036b..2b85bd4449f 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -26,7 +26,7 @@ from homeassistant.components.humidifier import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -70,7 +70,7 @@ DEHUMIDIFIER_ENTITY_DESCRIPTION = ZwaveHumidifierEntityDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave humidifier from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 0a2ca95a2b0..a610bbcb91e 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -41,7 +41,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .const import DATA_CLIENT, DOMAIN @@ -67,7 +67,7 @@ MAX_MIREDS = 370 # 2700K as a safe default async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Light from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index c14517f4b03..f609084955c 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ATTR_AUTO_RELOCK_TIME, @@ -62,7 +62,7 @@ UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave lock from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 54162488d89..2e2d93bbdbe 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ATTR_RESERVED_VALUES, DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Number entity from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 49ad1868005..8a6ccc57c17 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -27,7 +27,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Select entity from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index b259711d21b..4db14d003b1 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -48,7 +48,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from .binary_sensor import is_valid_notification_binary_sensor @@ -552,7 +552,7 @@ def get_entity_description( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 3a09049def3..f0526171a70 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -18,7 +18,7 @@ from homeassistant.components.siren import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Siren entity from Config Entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index ef769209b31..2ff80d8505e 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index d060abe007d..985c4a86813 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -32,7 +32,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import ExtraStoredData @@ -77,7 +77,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave update entity from config entry.""" client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] diff --git a/homeassistant/components/zwave_me/binary_sensor.py b/homeassistant/components/zwave_me/binary_sensor.py index d121c17770b..8563ef76ce1 100644 --- a/homeassistant/components/zwave_me/binary_sensor.py +++ b/homeassistant/components/zwave_me/binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ZWaveMeController from .const import DOMAIN, ZWaveMePlatform @@ -33,7 +33,7 @@ DEVICE_NAME = ZWaveMePlatform.BINARY_SENSOR async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform.""" diff --git a/homeassistant/components/zwave_me/button.py b/homeassistant/components/zwave_me/button.py index 50ddf01aeab..27d95a14199 100644 --- a/homeassistant/components/zwave_me/button.py +++ b/homeassistant/components/zwave_me/button.py @@ -4,7 +4,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -15,7 +15,7 @@ DEVICE_NAME = ZWaveMePlatform.BUTTON async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py index b8eed88b505..d54cc6a9310 100644 --- a/homeassistant/components/zwave_me/climate.py +++ b/homeassistant/components/zwave_me/climate.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -28,7 +28,7 @@ DEVICE_NAME = ZWaveMePlatform.CLIMATE async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the climate platform.""" diff --git a/homeassistant/components/zwave_me/cover.py b/homeassistant/components/zwave_me/cover.py index c9359402c01..3ae8ec894e1 100644 --- a/homeassistant/components/zwave_me/cover.py +++ b/homeassistant/components/zwave_me/cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -23,7 +23,7 @@ DEVICE_NAME = ZWaveMePlatform.COVER async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the cover platform.""" diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py index bd0feba0dfb..6ab1df618cb 100644 --- a/homeassistant/components/zwave_me/fan.py +++ b/homeassistant/components/zwave_me/fan.py @@ -8,7 +8,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -19,7 +19,7 @@ DEVICE_NAME = ZWaveMePlatform.FAN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the fan platform.""" diff --git a/homeassistant/components/zwave_me/light.py b/homeassistant/components/zwave_me/light.py index ef3eca5d389..f8ed397ea25 100644 --- a/homeassistant/components/zwave_me/light.py +++ b/homeassistant/components/zwave_me/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ZWaveMeController from .const import DOMAIN, ZWaveMePlatform @@ -25,7 +25,7 @@ from .entity import ZWaveMeEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the rgb platform.""" diff --git a/homeassistant/components/zwave_me/lock.py b/homeassistant/components/zwave_me/lock.py index 0bcc8f092ae..cdc8b6471c1 100644 --- a/homeassistant/components/zwave_me/lock.py +++ b/homeassistant/components/zwave_me/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -21,7 +21,7 @@ DEVICE_NAME = ZWaveMePlatform.LOCK async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the lock platform.""" diff --git a/homeassistant/components/zwave_me/number.py b/homeassistant/components/zwave_me/number.py index 9a98a4f8d00..2d6b88840f4 100644 --- a/homeassistant/components/zwave_me/number.py +++ b/homeassistant/components/zwave_me/number.py @@ -4,7 +4,7 @@ from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -15,7 +15,7 @@ DEVICE_NAME = ZWaveMePlatform.NUMBER async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" diff --git a/homeassistant/components/zwave_me/sensor.py b/homeassistant/components/zwave_me/sensor.py index be0b0bae284..fa9ccdfee99 100644 --- a/homeassistant/components/zwave_me/sensor.py +++ b/homeassistant/components/zwave_me/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ZWaveMeController from .const import DOMAIN, ZWaveMePlatform @@ -118,7 +118,7 @@ DEVICE_NAME = ZWaveMePlatform.SENSOR async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" diff --git a/homeassistant/components/zwave_me/siren.py b/homeassistant/components/zwave_me/siren.py index 443b2cc7b37..7bfbf2b2cd4 100644 --- a/homeassistant/components/zwave_me/siren.py +++ b/homeassistant/components/zwave_me/siren.py @@ -6,7 +6,7 @@ from homeassistant.components.siren import SirenEntity, SirenEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -17,7 +17,7 @@ DEVICE_NAME = ZWaveMePlatform.SIREN async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the siren platform.""" diff --git a/homeassistant/components/zwave_me/switch.py b/homeassistant/components/zwave_me/switch.py index 05cf06484e9..26d832ca022 100644 --- a/homeassistant/components/zwave_me/switch.py +++ b/homeassistant/components/zwave_me/switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, ZWaveMePlatform from .entity import ZWaveMeEntity @@ -30,7 +30,7 @@ SWITCH_MAP: dict[str, SwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch platform.""" diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f76e0b43c10..e2b6de6e6a3 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -252,7 +252,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { arg_types={ 0: "HomeAssistant", 1: "ConfigEntry", - 2: "AddEntitiesCallback", + 2: "AddConfigEntryEntitiesCallback", }, return_type=None, ), diff --git a/script/scaffold/templates/config_flow_helper/integration/sensor.py b/script/scaffold/templates/config_flow_helper/integration/sensor.py index 741b2e85eb2..9c00dd568eb 100644 --- a/script/scaffold/templates/config_flow_helper/integration/sensor.py +++ b/script/scaffold/templates/config_flow_helper/integration/sensor.py @@ -7,13 +7,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize NEW_NAME config entry.""" registry = er.async_get(hass) diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 6c53e9832d9..efa3ca9523a 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1370,7 +1370,7 @@ def test_valid_generic( async def async_setup_entry( #@ hass: HomeAssistant, entry: {entry_annotation}, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: pass """, @@ -1402,7 +1402,7 @@ def test_invalid_generic( async def async_setup_entry( #@ hass: HomeAssistant, entry: {entry_annotation}, #@ - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: pass """, From e1ad3f05e682955cd91583572f3cabfdae80fa3a Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Wed, 5 Feb 2025 07:09:59 -0500 Subject: [PATCH 0400/1941] Bump lacrosse-view to 1.1.1 (#137282) --- homeassistant/components/lacrosse_view/manifest.json | 2 +- homeassistant/components/lacrosse_view/sensor.py | 8 +++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index 86b2f61a872..38e64274deb 100644 --- a/homeassistant/components/lacrosse_view/manifest.json +++ b/homeassistant/components/lacrosse_view/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lacrosse_view", "iot_class": "cloud_polling", "loggers": ["lacrosse_view"], - "requirements": ["lacrosse-view==1.0.4"] + "requirements": ["lacrosse-view==1.1.1"] } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 64fd8259966..5c56a0328a2 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -45,7 +45,7 @@ class LaCrosseSensorEntityDescription(SensorEntityDescription): def get_value(sensor: Sensor, field: str) -> float | int | str | None: """Get the value of a sensor field.""" - field_data = sensor.data.get(field) + field_data = sensor.data.get(field) if sensor.data is not None else None if field_data is None: return None value = field_data["spot"]["value"] @@ -178,7 +178,7 @@ async def async_setup_entry( continue # if the API returns a different unit of measurement from the description, update it - if sensor.data.get(field) is not None: + if sensor.data is not None and sensor.data.get(field) is not None: native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get( sensor.data[field].get("unit") ) @@ -240,7 +240,9 @@ class LaCrosseViewSensor( @property def available(self) -> bool: """Return True if entity is available.""" + data = self.coordinator.data[self.index].data return ( super().available - and self.entity_description.key in self.coordinator.data[self.index].data + and data is not None + and self.entity_description.key in data ) diff --git a/requirements_all.txt b/requirements_all.txt index 1b77d0d896c..0f44da6d6f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1281,7 +1281,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.4 +lacrosse-view==1.1.1 # homeassistant.components.eufy lakeside==0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9758594549e..2dbd991472c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1083,7 +1083,7 @@ konnected==1.2.0 krakenex==2.2.2 # homeassistant.components.lacrosse_view -lacrosse-view==1.0.4 +lacrosse-view==1.1.1 # homeassistant.components.laundrify laundrify-aio==1.2.2 From 6bc61117712e3341b36aab36540a4c5fcfe1b545 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 10 Feb 2025 14:36:20 -0600 Subject: [PATCH 0401/1941] Add Wyoming satellite announce (#138221) * Add Wyoming satellite announce * Initialize when necessary --- .../components/wyoming/assist_satellite.py | 92 +++++++++++++++- .../components/wyoming/manifest.json | 3 +- tests/components/wyoming/test_satellite.py | 103 +++++++++++++++++- 3 files changed, 187 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index d43af2d21b9..5440b2bebeb 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -24,18 +24,20 @@ from wyoming.tts import Synthesize, SynthesizeVoice from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection -from homeassistant.components import assist_pipeline, intent, tts +from homeassistant.components import assist_pipeline, ffmpeg, intent, tts from homeassistant.components.assist_pipeline import PipelineEvent from homeassistant.components.assist_satellite import ( + AssistSatelliteAnnouncement, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, + AssistSatelliteEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_WIDTH from .data import WyomingService from .devices import SatelliteDevice from .entity import WyomingSatelliteEntity @@ -49,6 +51,8 @@ _RESTART_SECONDS: Final = 3 _PING_TIMEOUT: Final = 5 _PING_SEND_DELAY: Final = 2 _PIPELINE_FINISH_TIMEOUT: Final = 1 +_TTS_SAMPLE_RATE: Final = 22050 +_ANNOUNCE_CHUNK_BYTES: Final = 2048 # 1024 samples # Wyoming stage -> Assist stage _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { @@ -83,6 +87,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): entity_description = AssistSatelliteEntityDescription(key="assist_satellite") _attr_translation_key = "assist_satellite" _attr_name = None + _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE def __init__( self, @@ -116,6 +121,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self.device.set_pipeline_listener(self._pipeline_changed) self.device.set_audio_settings_listener(self._audio_settings_changed) + # For announcements + self._ffmpeg_manager: ffmpeg.FFmpegManager | None = None + self._played_event_received: asyncio.Event | None = None + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -131,9 +140,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): """Options passed for text-to-speech.""" return { tts.ATTR_PREFERRED_FORMAT: "wav", - tts.ATTR_PREFERRED_SAMPLE_RATE: 16000, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 1, - tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + tts.ATTR_PREFERRED_SAMPLE_RATE: _TTS_SAMPLE_RATE, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: SAMPLE_CHANNELS, + tts.ATTR_PREFERRED_SAMPLE_BYTES: SAMPLE_WIDTH, } async def async_added_to_hass(self) -> None: @@ -244,6 +253,76 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): ) ) + async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: + """Announce media on the satellite. + + Should block until the announcement is done playing. + """ + assert self._client is not None + + if self._ffmpeg_manager is None: + self._ffmpeg_manager = ffmpeg.get_ffmpeg_manager(self.hass) + + if self._played_event_received is None: + self._played_event_received = asyncio.Event() + + self._played_event_received.clear() + await self._client.write_event( + AudioStart( + rate=_TTS_SAMPLE_RATE, + width=SAMPLE_WIDTH, + channels=SAMPLE_CHANNELS, + timestamp=0, + ).event() + ) + + timestamp = 0 + try: + # Use ffmpeg to convert to raw PCM audio with the appropriate format + proc = await asyncio.create_subprocess_exec( + self._ffmpeg_manager.binary, + "-i", + announcement.media_id, + "-f", + "s16le", + "-ac", + str(SAMPLE_CHANNELS), + "-ar", + str(_TTS_SAMPLE_RATE), + "-nostats", + "pipe:", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + close_fds=False, # use posix_spawn in CPython < 3.13 + ) + assert proc.stdout is not None + while True: + chunk_bytes = await proc.stdout.read(_ANNOUNCE_CHUNK_BYTES) + if not chunk_bytes: + break + + chunk = AudioChunk( + rate=_TTS_SAMPLE_RATE, + width=SAMPLE_WIDTH, + channels=SAMPLE_CHANNELS, + audio=chunk_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + + timestamp += chunk.milliseconds + finally: + await self._client.write_event(AudioStop().event()) + if timestamp > 0: + # Wait the length of the audio or until we receive a played event + audio_seconds = timestamp / 1000 + try: + async with asyncio.timeout(audio_seconds + 0.5): + await self._played_event_received.wait() + except TimeoutError: + # Older satellite clients will wait longer than necessary + _LOGGER.debug("Did not receive played event for announcement") + # ------------------------------------------------------------------------- def start_satellite(self) -> None: @@ -511,6 +590,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): elif Played.is_type(client_event.type): # TTS response has finished playing on satellite self.tts_response_finished() + + if self._played_event_received is not None: + self._played_event_received.set() else: _LOGGER.debug("Unexpected event from satellite: %s", client_event) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index b837d2a9e76..d75b70dffa8 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -7,7 +7,8 @@ "assist_satellite", "assist_pipeline", "intent", - "conversation" + "conversation", + "ffmpeg" ], "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index f293f976242..0e4bb3da78c 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable import io +import tempfile from typing import Any from unittest.mock import patch import wave @@ -17,17 +18,18 @@ from wyoming.info import Info from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite +from wyoming.snd import Played from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated from wyoming.tts import Synthesize from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection -from homeassistant.components import assist_pipeline, wyoming +from homeassistant.components import assist_pipeline, assist_satellite, wyoming from homeassistant.components.wyoming.assist_satellite import WyomingAssistSatellite from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import intent as intent_helper +from homeassistant.helpers import entity_registry as er, intent as intent_helper from homeassistant.setup import async_setup_component from . import SATELLITE_INFO, WAKE_WORD_INFO, MockAsyncTcpClient @@ -65,7 +67,7 @@ def get_test_wav() -> bytes: wav_file.setnchannels(1) # Single frame - wav_file.writeframes(b"123") + wav_file.writeframes(b"1234") return wav_io.getvalue() @@ -73,10 +75,15 @@ def get_test_wav() -> bytes: class SatelliteAsyncTcpClient(MockAsyncTcpClient): """Satellite AsyncTcpClient.""" - def __init__(self, responses: list[Event]) -> None: + def __init__( + self, responses: list[Event], block_until_inject: bool = False + ) -> None: """Initialize client.""" super().__init__(responses) + self.block_until_inject = block_until_inject + self._responses_ready = asyncio.Event() + self.connect_event = asyncio.Event() self.run_satellite_event = asyncio.Event() self.detect_event = asyncio.Event() @@ -188,6 +195,9 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): async def read_event(self) -> Event | None: """Receive.""" + if self.block_until_inject and (not self.responses): + await self._responses_ready.wait() + event = await super().read_event() # Keep sending audio chunks instead of None @@ -196,6 +206,7 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): def inject_event(self, event: Event) -> None: """Put an event in as the next response.""" self.responses = [event, *self.responses] + self._responses_ready.set() async def test_satellite_pipeline(hass: HomeAssistant) -> None: @@ -416,7 +427,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.tts_audio_chunk.rate == 22050 assert mock_client.tts_audio_chunk.width == 2 assert mock_client.tts_audio_chunk.channels == 1 - assert mock_client.tts_audio_chunk.audio == b"123" + assert mock_client.tts_audio_chunk.audio == b"1234" # Pipeline finished pipeline_event_callback( @@ -1283,3 +1294,85 @@ async def test_timers(hass: HomeAssistant) -> None: timer_finished = mock_client.timer_finished assert timer_finished is not None assert timer_finished.id == timer_started.id + + +async def test_announce( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test announce on satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + def async_process_play_media_url(hass: HomeAssistant, media_id: str) -> str: + # Don't create a URL + return media_id + + with ( + tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as temp_wav_file, + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(responses=[], block_until_inject=True), + ) as mock_client, + patch( + "homeassistant.components.assist_satellite.entity.async_process_play_media_url", + new=async_process_play_media_url, + ), + ): + # Use test WAV data for media + with wave.open(temp_wav_file.name, "wb") as wav_file: + wav_file.setframerate(22050) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(22050 * 2)) # 1 sec + + temp_wav_file.seek(0) + + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][entry.entry_id].device + assert device is not None + + satellite_entry = next( + ( + maybe_entry + for maybe_entry in er.async_entries_for_device( + entity_registry, device.device_id + ) + if maybe_entry.domain == assist_satellite.DOMAIN + ), + None, + ) + assert satellite_entry is not None + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + announce_task = hass.async_create_background_task( + hass.services.async_call( + assist_satellite.DOMAIN, + "announce", + { + "entity_id": satellite_entry.entity_id, + "media_id": temp_wav_file.name, + }, + blocking=True, + ), + "wyoming_satellite_announce", + ) + + # Wait for audio to come from ffmpeg + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Stop announcement from blocking + mock_client.inject_event(Played().event()) + await announce_task + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From b8ec8ab3ccd9a31f6f8e8ca9caa701ba243907e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Feb 2025 16:25:39 -0600 Subject: [PATCH 0402/1941] Bump aiodiscover to 2.6.0 (#138239) --- 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 45af4f1b5dd..45aa5a29171 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "aiodhcpwatcher==1.1.0", - "aiodiscover==2.2.2", + "aiodiscover==2.6.0", "cached-ipaddress==0.8.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cdafeb97040..4f52f49ce09 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.0 -aiodiscover==2.2.2 +aiodiscover==2.6.0 aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index af1d0853ce8..4154a7b72fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -219,7 +219,7 @@ aiocomelit==0.10.1 aiodhcpwatcher==1.1.0 # homeassistant.components.dhcp -aiodiscover==2.2.2 +aiodiscover==2.6.0 # homeassistant.components.dnsip aiodns==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f57f38a8be..42a4e501840 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -207,7 +207,7 @@ aiocomelit==0.10.1 aiodhcpwatcher==1.1.0 # homeassistant.components.dhcp -aiodiscover==2.2.2 +aiodiscover==2.6.0 # homeassistant.components.dnsip aiodns==3.2.0 From ba583cc669f28ece133f12f33286e40695a655b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 01:21:43 +0100 Subject: [PATCH 0403/1941] Add test for trying to add an entity to an unknown config subentry (#138211) --- tests/helpers/test_entity_platform.py | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index ee9f9f09110..41b7271150a 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2402,3 +2402,41 @@ async def test_device_type_error_checking( assert len(device_registry.devices) == 0 assert len(entity_registry.entities) == number_of_entities assert len(hass.states.async_all()) == number_of_entities + + +async def test_add_entity_unknown_subentry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test adding an entity to an unknown subentry.""" + + async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setup entry method.""" + async_add_entities( + [MockEntity(name="test", unique_id="unique")], + config_subentry_id="unknown-subentry", + ) + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert not await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + full_name = f"{config_entry.domain}.{entity_platform.domain}" + assert full_name not in hass.config.components + assert len(hass.states.async_entity_ids()) == 0 + assert len(entity_registry.entities) == 0 + + assert ( + "Can't add entities to unknown subentry unknown-subentry " + "of config entry super-mock-id" + ) in caplog.text From 97a8f24f8e401486f078f4d5f6d31ecd917fda6c Mon Sep 17 00:00:00 2001 From: Jamin Date: Mon, 10 Feb 2025 18:24:59 -0600 Subject: [PATCH 0404/1941] Allow specifying SIP username for outgoing calls (#137059) * Allow specifying SIP username for outgoing calls Allow configuring a SIP username to be sent in outgoing call requests to identify the home assistant source endpoint. * Remove advanced options section * Add test for removing user * Allow unsetting SIP user Make previous SIP user value a suggested value rather than default to allow unsetting by submitting an empty value in the form. * Remove unnecessary checks Remove user check from main flow and remove none or empty check. --- .../components/voip/assist_satellite.py | 15 +++++++++-- homeassistant/components/voip/config_flow.py | 22 ++++++++++++--- homeassistant/components/voip/const.py | 1 + homeassistant/components/voip/strings.json | 3 ++- tests/components/voip/test_config_flow.py | 27 +++++++++++++++++++ 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 1026df8d0d9..a0aeaaf38d3 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -30,7 +30,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CHANNELS, CONF_SIP_PORT, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH +from .const import ( + CHANNELS, + CONF_SIP_PORT, + CONF_SIP_USER, + DOMAIN, + RATE, + RTP_AUDIO_SETTINGS, + WIDTH, +) from .devices import VoIPDevice from .entity import VoIPEntity @@ -199,7 +207,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol # HA SIP server source_ip = await async_get_source_ip(self.hass) sip_port = self.config_entry.options.get(CONF_SIP_PORT, SIP_PORT) - source_endpoint = get_sip_endpoint(host=source_ip, port=sip_port) + sip_user = self.config_entry.options.get(CONF_SIP_USER) + source_endpoint = get_sip_endpoint( + host=source_ip, port=sip_port, username=sip_user + ) try: # VoIP ID is SIP header diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py index 63dcb8f86ee..7ae603f0f6a 100644 --- a/homeassistant/components/voip/config_flow.py +++ b/homeassistant/components/voip/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from .const import CONF_SIP_PORT, DOMAIN +from .const import CONF_SIP_PORT, CONF_SIP_USER, DOMAIN class VoIPConfigFlow(ConfigFlow, domain=DOMAIN): @@ -58,7 +58,15 @@ class VoipOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - return self.async_create_entry(title="", data=user_input) + if CONF_SIP_USER in user_input and not user_input[CONF_SIP_USER]: + del user_input[CONF_SIP_USER] + self.hass.config_entries.async_update_entry( + self.config_entry, options=user_input + ) + return self.async_create_entry( + title="", + data=user_input, + ) return self.async_show_form( step_id="init", @@ -70,7 +78,15 @@ class VoipOptionsFlowHandler(OptionsFlow): CONF_SIP_PORT, SIP_PORT, ), - ): cv.port + ): cv.port, + vol.Optional( + CONF_SIP_USER, + description={ + "suggested_value": self.config_entry.options.get( + CONF_SIP_USER, None + ) + }, + ): vol.Any(None, cv.string), } ), ) diff --git a/homeassistant/components/voip/const.py b/homeassistant/components/voip/const.py index b4ee5d8ce7a..9a4403f9df2 100644 --- a/homeassistant/components/voip/const.py +++ b/homeassistant/components/voip/const.py @@ -13,3 +13,4 @@ RTP_AUDIO_SETTINGS = { } CONF_SIP_PORT = "sip_port" +CONF_SIP_USER = "sip_user" diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index c25c22f3f80..96c902bf39a 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -53,7 +53,8 @@ "step": { "init": { "data": { - "sip_port": "SIP port" + "sip_port": "SIP port", + "sip_user": "SIP user" } } } diff --git a/tests/components/voip/test_config_flow.py b/tests/components/voip/test_config_flow.py index 1b7aaad7c03..05f14afa4e7 100644 --- a/tests/components/voip/test_config_flow.py +++ b/tests/components/voip/test_config_flow.py @@ -80,3 +80,30 @@ async def test_options_flow(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options == {"sip_port": 5061} + + # Manual with user + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"sip_port": 5061, "sip_user": "HA"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == {"sip_port": 5061, "sip_user": "HA"} + + # Manual remove user + result = await hass.config_entries.options.async_init( + config_entry.entry_id, + ) + + assert config_entry.options == {"sip_port": 5061, "sip_user": "HA"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"sip_port": 5060, "sip_user": ""}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == {"sip_port": 5060} From 6102c2b451ef175cffcac684161d0b3c3cc44a10 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 11 Feb 2025 02:03:31 +0000 Subject: [PATCH 0405/1941] Bump pyipma to 3.0.9 (#138238) --- homeassistant/components/ipma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 1abd7807213..971525e013f 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ipma", "iot_class": "cloud_polling", "loggers": ["geopy", "pyipma"], - "requirements": ["pyipma==3.0.8"] + "requirements": ["pyipma==3.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4154a7b72fb..5ca792b1705 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2014,7 +2014,7 @@ pyinsteon==1.6.3 pyintesishome==1.8.0 # homeassistant.components.ipma -pyipma==3.0.8 +pyipma==3.0.9 # homeassistant.components.ipp pyipp==0.17.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42a4e501840..c0bfebe5673 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1640,7 +1640,7 @@ pyicloud==1.0.0 pyinsteon==1.6.3 # homeassistant.components.ipma -pyipma==3.0.8 +pyipma==3.0.9 # homeassistant.components.ipp pyipp==0.17.0 From 35416189f2944f58b5b91d9cf8ac90a491a10890 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Feb 2025 21:25:34 -0500 Subject: [PATCH 0406/1941] Remove some unused tests from Google Generative AI (#138249) * Remove some unused tests from Google Generative AI * Remove unused snapshots --- .../snapshots/test_conversation.ambr | 442 ------------------ .../test_conversation.py | 214 +-------- 2 files changed, 3 insertions(+), 653 deletions(-) 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 c89981e67bb..1fe02ac2536 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -1,446 +1,4 @@ # serializer version: 1 -# name: test_chat_history[models/gemini-1.0-pro-False] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.0-pro', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': None, - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'role': 'user', - }), - dict({ - 'parts': 'Ok', - 'role': 'model', - }), - dict({ - 'parts': '1st user request', - 'role': 'user', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - '1st user request', - ), - dict({ - }), - ), - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.0-pro', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': None, - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'role': 'user', - }), - dict({ - 'parts': 'Ok', - 'role': 'model', - }), - dict({ - 'parts': '1st user request', - 'role': 'user', - }), - dict({ - 'parts': '1st model response', - 'role': 'model', - }), - dict({ - 'parts': '2nd user request', - 'role': 'user', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - '2nd user request', - ), - dict({ - }), - ), - ]) -# --- -# name: test_chat_history[models/gemini-1.5-pro-True] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-pro', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': '1st user request', - 'role': 'user', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - '1st user request', - ), - dict({ - }), - ), - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-1.5-pro', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': '1st user request', - 'role': 'user', - }), - dict({ - 'parts': '1st model response', - 'role': 'model', - }), - dict({ - 'parts': '2nd user request', - 'role': 'user', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - '2nd user request', - ), - dict({ - }), - ), - ]) -# --- -# name: test_default_prompt[config_entry_options0-0-None] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-2.0-flash', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': 'hello', - 'role': 'user', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - 'hello', - ), - dict({ - }), - ), - ]) -# --- -# name: test_default_prompt[config_entry_options0-0-conversation.google_generative_ai_conversation] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-2.0-flash', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': 'hello', - 'role': 'user', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - 'hello', - ), - dict({ - }), - ), - ]) -# --- -# name: test_default_prompt[config_entry_options1-1-None] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-2.0-flash', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': 'hello', - 'role': 'user', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - 'hello', - ), - dict({ - }), - ), - ]) -# --- -# name: test_default_prompt[config_entry_options1-1-conversation.google_generative_ai_conversation] - list([ - tuple( - '', - tuple( - ), - dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-2.0-flash', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - - ''', - 'tools': None, - }), - ), - tuple( - '().start_chat', - tuple( - ), - dict({ - 'history': list([ - dict({ - 'parts': 'hello', - 'role': 'user', - }), - ]), - }), - ), - tuple( - '().start_chat().send_message_async', - tuple( - 'hello', - ), - dict({ - }), - ), - ]) -# --- # name: test_function_call list([ tuple( diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 72a5390f4b1..9b255666a67 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -13,20 +13,16 @@ import voluptuous as vol from homeassistant.components import conversation from homeassistant.components.conversation import trace -from homeassistant.components.google_generative_ai_conversation.const import ( - CONF_CHAT_MODEL, -) from homeassistant.components.google_generative_ai_conversation.conversation import ( _escape_decode, _format_schema, ) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API +from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm from tests.common import MockConfigEntry -from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) @@ -43,143 +39,6 @@ def mock_ulid_tools(): yield -@pytest.mark.parametrize( - "agent_id", [None, "conversation.google_generative_ai_conversation"] -) -@pytest.mark.parametrize( - ("config_entry_options", "expected_features"), - [ - ({}, 0), - ( - {CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, - conversation.ConversationEntityFeature.CONTROL, - ), - ], -) -@pytest.mark.usefixtures("mock_init_component") -async def test_default_prompt( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - snapshot: SnapshotAssertion, - agent_id: str | None, - config_entry_options: {}, - expected_features: conversation.ConversationEntityFeature, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test that the default prompt works.""" - entry = MockConfigEntry(title=None) - entry.add_to_hass(hass) - - if agent_id is None: - agent_id = mock_config_entry.entry_id - - hass.config_entries.async_update_entry( - mock_config_entry, - options={**mock_config_entry.options, **config_entry_options}, - ) - - with ( - patch("google.generativeai.GenerativeModel") as mock_model, - patch( - "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools", - return_value=[], - ) as mock_get_tools, - patch( - "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_api_prompt", - return_value="", - ), - ): - mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() - mock_part.function_call = None - mock_part.text = "Hi there!\n" - chat_response.parts = [mock_part] - result = await conversation.async_converse( - hass, - "hello", - None, - Context(), - agent_id=agent_id, - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot - assert mock_get_tools.called == (CONF_LLM_HASS_API in config_entry_options) - - state = hass.states.get("conversation.google_generative_ai_conversation") - assert state.attributes[ATTR_SUPPORTED_FEATURES] == expected_features - - -@pytest.mark.parametrize( - ("model_name", "supports_system_instruction"), - [("models/gemini-1.5-pro", True), ("models/gemini-1.0-pro", False)], -) -@pytest.mark.usefixtures("mock_init_component") -async def test_chat_history( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - model_name: str, - supports_system_instruction: bool, - snapshot: SnapshotAssertion, -) -> None: - """Test that the agent keeps track of the chat history.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_CHAT_MODEL: model_name} - ) - with patch("google.generativeai.GenerativeModel") as mock_model: - mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() - mock_part.function_call = None - mock_part.text = "1st model response" - chat_response.parts = [mock_part] - if supports_system_instruction: - mock_chat.history = [] - else: - mock_chat.history = [ - {"role": "user", "parts": "prompt"}, - {"role": "model", "parts": "Ok"}, - ] - mock_chat.history += [ - {"role": "user", "parts": "1st user request"}, - {"role": "model", "parts": "1st model response"}, - ] - result = await conversation.async_converse( - hass, - "1st user request", - None, - Context(), - agent_id=mock_config_entry.entry_id, - ) - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert ( - result.response.as_dict()["speech"]["plain"]["speech"] - == "1st model response" - ) - mock_part.text = "2nd model response" - chat_response.parts = [mock_part] - result = await conversation.async_converse( - hass, - "2nd user request", - result.conversation_id, - Context(), - agent_id=mock_config_entry.entry_id, - ) - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert ( - result.response.as_dict()["speech"]["plain"]["speech"] - == "2nd model response" - ) - - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot - - @patch( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" ) @@ -539,10 +398,10 @@ async def test_empty_response( @pytest.mark.usefixtures("mock_init_component") -async def test_invalid_llm_api( +async def test_converse_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: - """Test handling of invalid llm api.""" + """Test handling ChatLog raising ConverseError.""" hass.config_entries.async_update_entry( mock_config_entry, options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"}, @@ -563,73 +422,6 @@ async def test_invalid_llm_api( ) -async def test_template_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template error handling works.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", - }, - ) - with patch("google.generativeai.GenerativeModel"): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - 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 - - -async def test_template_variables( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template variables work.""" - context = Context(user_id="12345") - mock_user = MagicMock() - mock_user.id = "12345" - mock_user.name = "Test User" - - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": ( - "The user name is {{ user_name }}. " - "The user id is {{ llm_context.context.user_id }}." - ), - }, - ) - with ( - patch("google.generativeai.GenerativeModel") as mock_model, - patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() - mock_part.text = "Model response" - mock_part.function_call = None - chat_response.parts = [mock_part] - result = await conversation.async_converse( - hass, "hello", None, context, agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( - result - ) - assert ( - "The user name is Test User." - in mock_model.mock_calls[0][2]["system_instruction"] - ) - assert "The user id is 12345." in mock_model.mock_calls[0][2]["system_instruction"] - - @pytest.mark.usefixtures("mock_init_component") async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry From ec0cef0611902ceb9de037503c557912c3b31f82 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 09:47:09 +0100 Subject: [PATCH 0407/1941] Unify error reporting in onboarding backup API (#138200) --- homeassistant/components/onboarding/views.py | 4 +-- tests/components/onboarding/test_views.py | 26 +++++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 1e29860e3c5..2d4b44a73cd 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -357,7 +357,7 @@ def with_backup_manager[_ViewT: BackupOnboardingView, **_P]( manager = async_get_backup_manager(request.app[KEY_HASS]) except HomeAssistantError: return self.json( - {"error": "backup_disabled"}, + {"code": "backup_disabled"}, status_code=HTTPStatus.INTERNAL_SERVER_ERROR, ) @@ -420,7 +420,7 @@ class RestoreBackupView(BackupOnboardingView): ) except IncorrectPasswordError: return self.json( - {"message": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST + {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST ) return web.Response(status=HTTPStatus.OK) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 98f6426609e..003a137738d 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -777,7 +777,7 @@ async def test_onboarding_backup_view_without_backup( resp = await client.request(method, f"/api/onboarding/{view}", **kwargs) assert resp.status == 500 - assert await resp.json() == {"error": "backup_disabled"} + assert await resp.json() == {"code": "backup_disabled"} async def test_onboarding_backup_info( @@ -920,14 +920,16 @@ async def test_onboarding_backup_restore( @pytest.mark.parametrize( - ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + ("params", "restore_error", "expected_status", "expected_json", "restore_calls"), [ # Missing agent_id ( {"backup_id": "abc123"}, None, 400, - "Message format incorrect: required key not provided @ data['agent_id']", + { + "message": "Message format incorrect: required key not provided @ data['agent_id']" + }, 0, ), # Missing backup_id @@ -935,7 +937,9 @@ async def test_onboarding_backup_restore( {"agent_id": "backup.local"}, None, 400, - "Message format incorrect: required key not provided @ data['backup_id']", + { + "message": "Message format incorrect: required key not provided @ data['backup_id']" + }, 0, ), # Invalid restore_database @@ -947,7 +951,9 @@ async def test_onboarding_backup_restore( }, None, 400, - "Message format incorrect: expected bool for dictionary value @ data['restore_database']", + { + "message": "Message format incorrect: expected bool for dictionary value @ data['restore_database']" + }, 0, ), # Invalid folder @@ -959,7 +965,9 @@ async def test_onboarding_backup_restore( }, None, 400, - "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]", + { + "message": "Message format incorrect: expected Folder or one of 'share', 'addons/local', 'ssl', 'media' @ data['restore_folders'][0]" + }, 0, ), # Wrong password @@ -967,7 +975,7 @@ async def test_onboarding_backup_restore( {"backup_id": "abc123", "agent_id": "backup.local"}, backup.IncorrectPasswordError, 400, - "incorrect_password", + {"code": "incorrect_password"}, 1, ), ], @@ -979,7 +987,7 @@ async def test_onboarding_backup_restore_error( params: dict[str, Any], restore_error: Exception | None, expected_status: int, - expected_message: str, + expected_json: str, restore_calls: int, ) -> None: """Test returning installation type during onboarding.""" @@ -998,7 +1006,7 @@ async def test_onboarding_backup_restore_error( resp = await client.post("/api/onboarding/backup/restore", json=params) assert resp.status == expected_status - assert await resp.json() == {"message": expected_message} + assert await resp.json() == expected_json assert len(mock_restore.mock_calls) == restore_calls From a4decabf3ba4a9a02170dee38c4b34edc925d12d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 09:54:49 +0100 Subject: [PATCH 0408/1941] Remove question marks and "true/false" from action fields in zwave_js (#138263) - change three field names from a question to just a name - remove "true" / "false" to reflect that these are toggles in the UI --- homeassistant/components/zwave_js/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index e2d7720189d..e845cc28707 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -344,8 +344,8 @@ "name": "[%key:component::zwave_js::services::set_value::fields::area_id::name%]" }, "broadcast": { - "description": "Whether command should be broadcast to all devices on the network.", - "name": "Broadcast?" + "description": "Whether the command should be broadcast to all devices on the network.", + "name": "Broadcast" }, "command_class": { "description": "[%key:component::zwave_js::services::set_value::fields::command_class::description%]", @@ -434,8 +434,8 @@ "name": "Entities" }, "refresh_all_values": { - "description": "Whether to refresh all values (true) or just the primary value (false).", - "name": "Refresh all values?" + "description": "Whether to refresh all values or just the primary value.", + "name": "Refresh all values" } }, "name": "Refresh values" @@ -592,8 +592,8 @@ "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]" }, "wait_for_result": { - "description": "Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the action can take a while if setting a value on an asleep battery device.", - "name": "Wait for result?" + "description": "Whether to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If enabled, the action can take a while if setting a value on an asleep battery device.", + "name": "Wait for result" } }, "name": "Set a value (advanced)" From 24293c5bfefed900b65c00790c27f5061aff710f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 11:35:13 +0100 Subject: [PATCH 0409/1941] Remove "true" / "false" from field descriptions in osoenergy (#138267) Make two fields descriptions UI-friendly as there is now a toggle to turn the options on or off. --- homeassistant/components/osoenergy/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json index ca23265048f..7e10168d941 100644 --- a/homeassistant/components/osoenergy/strings.json +++ b/homeassistant/components/osoenergy/strings.json @@ -215,7 +215,7 @@ "fields": { "until_temp_limit": { "name": "Until temperature limit", - "description": "Choose if heating should be off until min temperature (True) is reached or for one hour (False)" + "description": "Whether heating should be off until the minimum temperature is reached instead of for one hour." } } }, @@ -225,7 +225,7 @@ "fields": { "until_temp_limit": { "name": "Until temperature limit", - "description": "Choose if heating should be on until max temperature (True) is reached or for one hour (False)" + "description": "Whether heating should be on until the maximum temperature is reached instead of for one hour." } } } From d46e29d7a9ff5bfeaddf81a6e99a464477eeb575 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 11:36:18 +0100 Subject: [PATCH 0410/1941] Make field descriptions in knx actions UI-friendly (#138268) - drop `true` / `false` to match the toggle in the UI - replace `address` key with its friendly name in the UI --- homeassistant/components/knx/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index dadc8e84796..10730d87ed1 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -397,7 +397,7 @@ }, "response": { "name": "Send as Response", - "description": "If set to `True`, the telegram will be sent as a `GroupValueResponse` instead of a `GroupValueWrite`." + "description": "Whether the telegram should be sent as a `GroupValueResponse` instead of a `GroupValueWrite`." } } }, @@ -425,7 +425,7 @@ }, "remove": { "name": "Remove event registration", - "description": "If `True` the group address(es) will be removed." + "description": "Whether the group address(es) will be removed." } } }, @@ -455,7 +455,7 @@ }, "remove": { "name": "Remove exposure", - "description": "If `True` the exposure will be removed. Only `address` is required for removal." + "description": "Whether the exposure should be removed. Only the 'Address' field is required for removal." } } }, From e71f8c444b42a2727b0d3bd8f84ca94c4fb64879 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 11 Feb 2025 11:41:11 +0100 Subject: [PATCH 0411/1941] Add user profile info to Habitica sensor and device URL (#137152) Add user profile attributes to Habitica sensor and device URL --- homeassistant/components/habitica/entity.py | 8 +++++++- homeassistant/components/habitica/sensor.py | 20 +++++++++++++++++++ .../components/habitica/strings.json | 16 ++++++++++++++- tests/components/habitica/fixtures/user.json | 17 +++++++++++++--- .../habitica/snapshots/test_sensor.ambr | 5 +++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index 932fec69f83..692ea5e5ac1 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +from yarl import URL + from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -36,6 +38,10 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]): manufacturer=MANUFACTURER, model=NAME, name=coordinator.config_entry.data[CONF_NAME], - configuration_url=coordinator.config_entry.data[CONF_URL], + configuration_url=( + URL(coordinator.config_entry.data[CONF_URL]) + / "profile" + / coordinator.config_entry.unique_id + ), identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, ) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index e89bd0e7006..e715dd6d07b 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -35,6 +35,7 @@ from homeassistant.helpers.issue_registry import ( async_delete_issue, ) from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util from .const import ASSETS_URL, DOMAIN from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator @@ -105,6 +106,20 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( key=HabiticaSensorEntity.DISPLAY_NAME, translation_key=HabiticaSensorEntity.DISPLAY_NAME, value_fn=lambda user, _: user.profile.name, + attributes_fn=lambda user, _: { + "blurb": user.profile.blurb, + "joined": ( + dt_util.as_local(joined).date() + if (joined := user.auth.timestamps.created) + else None + ), + "last_login": ( + dt_util.as_local(last).date() + if (last := user.auth.timestamps.loggedin) + else None + ), + "total_logins": user.loginIncentives, + }, ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.HEALTH, @@ -393,6 +408,11 @@ class HabiticaSensor(HabiticaBase, SensorEntity): ): return SVG_CLASS[_class] + if self.entity_description.key is HabiticaSensorEntity.DISPLAY_NAME and ( + img_url := self.coordinator.data.user.profile.imageUrl + ): + return img_url + if entity_picture := self.entity_description.entity_picture: return ( entity_picture diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 4d353cec40e..a5f64dca7c2 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -199,7 +199,21 @@ }, "sensor": { "display_name": { - "name": "Display name" + "name": "Display name", + "state_attributes": { + "blurb": { + "name": "About" + }, + "joined": { + "name": "Joined" + }, + "last_login": { + "name": "Last login" + }, + "total_logins": { + "name": "Total logins" + } + } }, "health": { "name": "Health", diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 991f2db0ba8..58eca2837b6 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -2,8 +2,18 @@ "success": true, "data": { "api_user": "test-api-user", - "profile": { "name": "test-user" }, - "auth": { "local": { "username": "test-username" } }, + "profile": { + "name": "test-user", + "blurb": "My mind is a swirling miasma of scintillating thoughts and turgid ideas.", + "imageUrl": "https://pbs.twimg.com/profile_images/378800000771780608/a32e71fe6a64eba6773c20d289eddc8e.png" + }, + "auth": { + "local": { "username": "test-username" }, + "timestamps": { + "created": "2013-12-02T22:23:29.249Z", + "loggedin": "2025-02-02T03:14:33.864Z" + } + }, "stats": { "buffs": { "str": 26, @@ -162,6 +172,7 @@ "createdAt": "2025-02-08T22:06:08.894Z", "updatedAt": "2025-02-08T22:06:17.195Z" } - ] + ], + "loginIncentives": 241 } } diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 110bde5e60d..881326f76d8 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -154,7 +154,12 @@ # name: test_sensors[sensor.test_user_display_name-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'blurb': 'My mind is a swirling miasma of scintillating thoughts and turgid ideas.', + 'entity_picture': 'https://pbs.twimg.com/profile_images/378800000771780608/a32e71fe6a64eba6773c20d289eddc8e.png', 'friendly_name': 'test-user Display name', + 'joined': datetime.date(2013, 12, 2), + 'last_login': datetime.date(2025, 2, 1), + 'total_logins': 241, }), 'context': , 'entity_id': 'sensor.test_user_display_name', From 0d605f9f74d4121cee1e4d43d1d6644231d371a6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 11 Feb 2025 11:42:21 +0100 Subject: [PATCH 0412/1941] Improve device naming for ViCare integration (#138240) Update entity.py --- homeassistant/components/vicare/entity.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 11955a94b94..7b73d2e5ba3 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -28,6 +28,7 @@ class ViCareEntity(Entity): """Initialize the entity.""" gateway_serial = device_config.getConfig().serial device_id = device_config.getId() + model = device_config.getModel().replace("_", " ") identifier = ( f"{gateway_serial}_{device_serial.replace('zigbee-', 'zigbee_')}" @@ -45,8 +46,8 @@ class ViCareEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, serial_number=device_serial, - name=device_config.getModel(), + name=model, manufacturer="Viessmann", - model=device_config.getModel(), + model=model, configuration_url="https://developer.viessmann.com/", ) From 3578f4ebbfa7ed59e567994c7f70fee4dc0cc1c1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Feb 2025 02:51:30 -0800 Subject: [PATCH 0413/1941] Refresh nest access token before before building subscriber Credentials (#138259) --- homeassistant/components/nest/api.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 727b126dda4..d55826f7ed0 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -50,13 +50,14 @@ class AsyncConfigEntryAuth(AbstractAuth): return cast(str, self._oauth_session.token["access_token"]) async def async_get_creds(self) -> Credentials: - """Return an OAuth credential for Pub/Sub Subscriber.""" - # We don't have a way for Home Assistant to refresh creds on behalf - # of the google pub/sub subscriber. Instead, build a full - # Credentials object with enough information for the subscriber to - # handle this on its own. We purposely don't refresh the token here - # even when it is expired to fully hand off this responsibility and - # know it is working at startup (then if not, fail loudly). + """Return an OAuth credential for Pub/Sub Subscriber. + + The subscriber will call this when connecting to the stream to refresh + the token. We construct a credentials object using the underlying + OAuth2Session since the subscriber may expect the expiry fields to + be present. + """ + await self.async_get_access_token() token = self._oauth_session.token creds = Credentials( # type: ignore[no-untyped-call] token=token["access_token"], From 89f157592da37c28c42c2b7f642331da4cc3b5e8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 12:15:11 +0100 Subject: [PATCH 0414/1941] Simplify the description of insteon.load_all_link_database action (#138275) This also replaces "true" with "enabled" to better it match the toggle in the UI. --- homeassistant/components/insteon/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 4a8aadb70db..538107dd816 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -144,7 +144,7 @@ }, "reload": { "name": "[%key:common::action::reload%]", - "description": "Reloads all records. If true the current records are cleared from memory (does not effect the device) and the records are reloaded. If false the existing records are left in place and only missing records are added. Default is false." + "description": "If enabled, all current records are cleared from memory (does not effect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added." } } }, From 428cc1a951e01c5f25ca2e7f04cb8ead36a57f7f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 13:17:39 +0100 Subject: [PATCH 0415/1941] Update signature of platforms' async_setup_entry in tests (#138271) --- tests/common.py | 7 +++++-- tests/components/alarm_control_panel/conftest.py | 4 ++-- tests/components/assist_pipeline/conftest.py | 8 ++++---- tests/components/assist_pipeline/test_select.py | 4 ++-- tests/components/binary_sensor/test_init.py | 6 +++--- tests/components/button/conftest.py | 4 ++-- tests/components/button/test_init.py | 4 ++-- tests/components/calendar/conftest.py | 4 ++-- tests/components/climate/test_init.py | 4 ++-- tests/components/climate/test_intent.py | 4 ++-- .../device_tracker/test_config_entry.py | 4 ++-- tests/components/event/test_init.py | 4 ++-- tests/components/image/conftest.py | 7 +++++-- tests/components/lawn_mower/test_init.py | 4 ++-- tests/components/lock/conftest.py | 4 ++-- tests/components/number/test_init.py | 4 ++-- tests/components/sensor/test_init.py | 4 ++-- tests/components/stt/test_init.py | 4 ++-- tests/components/template/test_sensor.py | 4 ++-- tests/components/todo/__init__.py | 4 ++-- tests/components/tts/common.py | 4 ++-- tests/components/update/test_init.py | 4 ++-- tests/components/vacuum/conftest.py | 4 ++-- tests/components/valve/test_init.py | 4 ++-- tests/components/wake_word/test_init.py | 4 ++-- tests/components/water_heater/test_init.py | 4 ++-- tests/components/weather/__init__.py | 4 ++-- tests/helpers/test_entity.py | 8 ++++---- tests/test_config_entries.py | 16 ++++++++-------- 29 files changed, 75 insertions(+), 69 deletions(-) diff --git a/tests/common.py b/tests/common.py index b88f261e83c..65e84bc6f00 100644 --- a/tests/common.py +++ b/tests/common.py @@ -86,7 +86,10 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, ulid as ulid_util @@ -1813,7 +1816,7 @@ def setup_test_component_platform( async def _async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a test component platform.""" async_add_entities(entities) diff --git a/tests/components/alarm_control_panel/conftest.py b/tests/components/alarm_control_panel/conftest.py index ddf67b27860..541644def38 100644 --- a/tests/components/alarm_control_panel/conftest.py +++ b/tests/components/alarm_control_panel/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel.const import CodeFormat from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, frame -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import MockAlarm @@ -194,7 +194,7 @@ async def setup_alarm_control_panel_platform_test_entity( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test alarm control panel platform via config entry.""" async_add_entities([entity]) diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 0f6872edbfe..02ec7c04607 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -25,7 +25,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from tests.common import ( @@ -225,7 +225,7 @@ async def init_supporting_components( async def async_setup_entry_stt_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test stt platform via config entry.""" async_add_entities([mock_stt_provider_entity]) @@ -233,7 +233,7 @@ async def init_supporting_components( async def async_setup_entry_wake_word_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test wake word platform via config entry.""" async_add_entities( @@ -325,7 +325,7 @@ async def assist_device( async def async_setup_entry_select_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test select platform via config entry.""" entities = [ diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index 5ce3b1020d0..fec34cb2496 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import MockConfigEntry, MockPlatform, mock_platform @@ -31,7 +31,7 @@ class SelectPlatform(MockPlatform): self, hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up fake select platform.""" pipeline_entity = AssistPipelineSelect(hass, "test-domain", "test-prefix") diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 26b8d919d72..de2b2565fe1 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components import binary_sensor from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import MockBinarySensor @@ -102,7 +102,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) @@ -172,7 +172,7 @@ async def test_entity_category_config_raises_error( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2]) diff --git a/tests/components/button/conftest.py b/tests/components/button/conftest.py index 75d5509efc9..0784aa09963 100644 --- a/tests/components/button/conftest.py +++ b/tests/components/button/conftest.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.button import DOMAIN, ButtonEntity from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import TEST_DOMAIN @@ -31,7 +31,7 @@ async def setup_platform(hass: HomeAssistant) -> None: async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up test button platform.""" diff --git a/tests/components/button/test_init.py b/tests/components/button/test_init.py index 7df5308e096..783fd786a50 100644 --- a/tests/components/button/test_init.py +++ b/tests/components/button/test_init.py @@ -22,7 +22,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -175,7 +175,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test button platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/calendar/conftest.py b/tests/components/calendar/conftest.py index 3e18f595764..5bf061591ee 100644 --- a/tests/components/calendar/conftest.py +++ b/tests/components/calendar/conftest.py @@ -12,7 +12,7 @@ from homeassistant.components.calendar import DOMAIN, CalendarEntity, CalendarEv from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from tests.common import ( @@ -145,7 +145,7 @@ def mock_setup_integration( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" new_entities = create_test_entities() diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 45570c63008..8900a9faefa 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -42,7 +42,7 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTempera from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -582,7 +582,7 @@ async def test_issue_aux_property_deprecated( async def async_setup_entry_climate_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test weather platform via config entry.""" async_add_entities([climate_entity]) diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 00ab2f8d278..65d607e618b 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -24,7 +24,7 @@ from homeassistant.helpers import ( floor_registry as fr, intent, ) -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from tests.common import ( @@ -90,7 +90,7 @@ async def create_mock_platform( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" async_add_entities(entities) diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index bc721803450..fa1e65ded51 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -35,7 +35,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -114,7 +114,7 @@ async def create_mock_platform( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" async_add_entities(entities) diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index c6828c2c290..bc43a234ffc 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -17,7 +17,7 @@ from homeassistant.components.event import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -297,7 +297,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/image/conftest.py b/tests/components/image/conftest.py index 06ef7db9f49..6879bc793bb 100644 --- a/tests/components/image/conftest.py +++ b/tests/components/image/conftest.py @@ -7,7 +7,10 @@ import pytest from homeassistant.components import image from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -123,7 +126,7 @@ class MockImageConfigEntry: self, hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test image platform via config entry.""" async_add_entities([self._entities]) diff --git a/tests/components/lawn_mower/test_init.py b/tests/components/lawn_mower/test_init.py index 0735d4541ff..be588b86e80 100644 --- a/tests/components/lawn_mower/test_init.py +++ b/tests/components/lawn_mower/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.lawn_mower import ( from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -97,7 +97,7 @@ async def test_lawn_mower_setup(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test platform via config entry.""" async_add_entities([entity1]) diff --git a/tests/components/lock/conftest.py b/tests/components/lock/conftest.py index fd569b162bc..254a59cae0d 100644 --- a/tests/components/lock/conftest.py +++ b/tests/components/lock/conftest.py @@ -14,7 +14,7 @@ from homeassistant.components.lock import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -120,7 +120,7 @@ async def setup_lock_platform_test_entity( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test lock platform via config entry.""" async_add_entities([entity]) diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 31d99dc55d7..7b19879d873 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -38,7 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM @@ -974,7 +974,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test number platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 604cd91770e..b162200f95e 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -2584,7 +2584,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 3d5daab2bec..e36ece52f57 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -16,7 +16,7 @@ from homeassistant.components.stt import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from .common import ( @@ -145,7 +145,7 @@ async def mock_config_entry_setup( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test stt platform via config entry.""" async_add_entities([mock_provider_entity]) diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 3bf91549114..6f0e6be8a2a 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( from homeassistant.core import Context, CoreState, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ATTR_COMPONENT, async_setup_component @@ -393,7 +393,7 @@ async def test_creating_sensor_loads_group(hass: HomeAssistant) -> None: async def async_setup_template( hass: HomeAssistant, config: ConfigType, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> bool: order.append("sensor.template") diff --git a/tests/components/todo/__init__.py b/tests/components/todo/__init__.py index 0138e561fad..53772ab144e 100644 --- a/tests/components/todo/__init__.py +++ b/tests/components/todo/__init__.py @@ -3,7 +3,7 @@ from homeassistant.components.todo import DOMAIN, TodoItem, TodoListEntity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import MockConfigEntry, MockPlatform, mock_platform @@ -44,7 +44,7 @@ async def create_mock_platform( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" async_add_entities(entities) diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index b1eae12d694..921cab4cba2 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -24,7 +24,7 @@ from homeassistant.components.tts import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component @@ -249,7 +249,7 @@ async def mock_config_entry_setup( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test tts platform via config entry.""" async_add_entities([tts_entity]) diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index d4916de8039..f3eb3f9344c 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -43,7 +43,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component @@ -857,7 +857,7 @@ async def test_name(hass: HomeAssistant) -> None: async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test update platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) diff --git a/tests/components/vacuum/conftest.py b/tests/components/vacuum/conftest.py index 6e6639431d0..2c700daece0 100644 --- a/tests/components/vacuum/conftest.py +++ b/tests/components/vacuum/conftest.py @@ -9,7 +9,7 @@ from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntit from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, frame -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MockVacuum @@ -87,7 +87,7 @@ async def setup_vacuum_platform_test_entity( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test vacuum platform via config entry.""" async_add_entities([entity]) diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py index d8eb38a3b9b..a26f88f6982 100644 --- a/tests/components/valve/test_init.py +++ b/tests/components/valve/test_init.py @@ -22,7 +22,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -174,7 +174,7 @@ def mock_config_entry(hass: HomeAssistant) -> tuple[MockConfigEntry, list[ValveE async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test platform via config entry.""" async_add_entities(entities) diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index cdaf7e0e3f0..e6e8ff72a6d 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component from .common import mock_wake_word_entity_platform @@ -143,7 +143,7 @@ async def mock_config_entry_setup( async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test stt platform via config entry.""" async_add_entities([mock_provider_entity]) diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index 67f0c1de36e..191acdf24f9 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -23,7 +23,7 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -145,7 +145,7 @@ async def test_operation_mode_validation( async def async_setup_entry_water_heater_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test water_heater platform via config entry.""" async_add_entities([water_heater_entity]) diff --git a/tests/components/weather/__init__.py b/tests/components/weather/__init__.py index 2dbffbbd617..301e055129d 100644 --- a/tests/components/weather/__init__.py +++ b/tests/components/weather/__init__.py @@ -21,7 +21,7 @@ from homeassistant.components.weather import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from tests.common import ( MockConfigEntry, @@ -90,7 +90,7 @@ async def create_entity( async def async_setup_entry_weather_platform( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up test weather platform via config entry.""" async_add_entities([weather_entity]) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 5e8c9fc88f7..6cf0e7c54d2 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -35,7 +35,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import ( @@ -986,7 +986,7 @@ async def _test_friendly_name( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([ent]) @@ -1314,7 +1314,7 @@ async def test_entity_name_translation_placeholder_errors( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities([ent]) @@ -1542,7 +1542,7 @@ async def test_friendly_name_updated( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setup entry method.""" async_add_entities( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 420da8cdb59..105b5273918 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -39,7 +39,7 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import entity_registry as er, frame, issue_registry as ir from homeassistant.helpers.discovery_flow import DiscoveryKey -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.hassio import HassioServiceInfo @@ -469,7 +469,7 @@ async def test_remove_entry( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" async_add_entities([entity]) @@ -6625,7 +6625,7 @@ async def test_raise_wrong_exception_in_forwarded_platform( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" raise exc @@ -6699,7 +6699,7 @@ async def test_config_entry_unloaded_during_platform_setups( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await asyncio.sleep(0) @@ -6771,7 +6771,7 @@ async def test_non_awaited_async_forward_entry_setups( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await forward_event.wait() @@ -6843,7 +6843,7 @@ async def test_non_awaited_async_forward_entry_setup( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await forward_event.wait() @@ -6918,7 +6918,7 @@ async def test_config_entry_unloaded_during_platform_setup( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await asyncio.sleep(0) @@ -6993,7 +6993,7 @@ async def test_config_entry_late_platform_setup( async def mock_setup_entry_platform( hass: HomeAssistant, entry: config_entries.ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Mock setting up platform.""" await asyncio.sleep(0) From 77486b9306f46956ce9b21e2252815b55a7d3185 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 13:17:54 +0100 Subject: [PATCH 0416/1941] Improve config_entries tests (#138274) * Improve config_entries tests * Drop unnecessary use of OrderedDict --- .../components/config/test_config_entries.py | 67 ++++++++----------- tests/test_config_entries.py | 19 +++--- 2 files changed, 39 insertions(+), 47 deletions(-) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 24b775ccd90..9b5ff3c9f3e 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1,6 +1,5 @@ """Test config entries API.""" -from collections import OrderedDict from collections.abc import Generator from http import HTTPStatus from typing import Any @@ -411,9 +410,10 @@ async def test_initialize_flow(hass: HomeAssistant, client: TestClient) -> None: class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("username")] = str - schema[vol.Required("password")] = str + schema = { + vol.Required("username"): str, + vol.Required("password"): str, + } return self.async_show_form( step_id="user", @@ -493,13 +493,14 @@ async def test_initialize_flow_unauth( class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("username")] = str - schema[vol.Required("password")] = str + schema = { + vol.Required("username"): str, + vol.Required("password"): str, + } return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema(schema), description_placeholders={"url": "https://example.com"}, errors={"username": "Should be unique."}, ) @@ -540,7 +541,7 @@ async def test_abort(hass: HomeAssistant, client: TestClient) -> None: } -@pytest.mark.usefixtures("enable_custom_integrations", "freezer") +@pytest.mark.usefixtures("freezer") async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: """Test a flow that creates an account.""" mock_platform(hass, "test.config_flow", None) @@ -604,7 +605,7 @@ async def test_create_account(hass: HomeAssistant, client: TestClient) -> None: } -@pytest.mark.usefixtures("enable_custom_integrations", "freezer") +@pytest.mark.usefixtures("freezer") async def test_two_step_flow(hass: HomeAssistant, client: TestClient) -> None: """Test we can finish a two step flow.""" mock_integration( @@ -835,9 +836,10 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("username")] = str - schema[vol.Required("password")] = str + schema = { + vol.Required("username"): str, + vol.Required("password"): str, + } return self.async_show_form( step_id="user", @@ -873,9 +875,10 @@ async def test_get_progress_flow_unauth( class TestFlow(core_ce.ConfigFlow): async def async_step_user(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("username")] = str - schema[vol.Required("password")] = str + schema = { + vol.Required("username"): str, + vol.Required("password"): str, + } return self.async_show_form( step_id="user", @@ -907,11 +910,9 @@ async def test_options_flow(hass: HomeAssistant, client: TestClient) -> None: def async_get_options_flow(config_entry): class OptionsFlowHandler(data_entry_flow.FlowHandler): async def async_step_init(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("enabled")] = bool return self.async_show_form( step_id="user", - data_schema=vol.Schema(schema), + data_schema=vol.Schema({vol.Required("enabled"): bool}), description_placeholders={"enabled": "Set to true to be true"}, ) @@ -972,11 +973,9 @@ async def test_options_flow_unauth( def async_get_options_flow(config_entry): class OptionsFlowHandler(data_entry_flow.FlowHandler): async def async_step_init(self, user_input=None): - schema = OrderedDict() - schema[vol.Required("enabled")] = bool return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema({vol.Required("enabled"): bool}), description_placeholders={"enabled": "Set to true to be true"}, ) @@ -1150,11 +1149,9 @@ async def test_subentry_flow(hass: HomeAssistant, client) -> None: raise NotImplementedError async def async_step_user(self, user_input=None): - schema = {} - schema[vol.Required("enabled")] = bool return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema({vol.Required("enabled"): bool}), description_placeholders={"enabled": "Set to true to be true"}, ) @@ -1206,11 +1203,9 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: raise NotImplementedError async def async_step_reconfigure(self, user_input=None): - schema = {} - schema[vol.Required("enabled")] = bool return self.async_show_form( step_id="reconfigure", - data_schema=schema, + data_schema=vol.Schema({vol.Required("enabled"): bool}), description_placeholders={"enabled": "Set to true to be true"}, ) @@ -1277,11 +1272,9 @@ async def test_subentry_flow_unauth( class TestFlow(core_ce.ConfigFlow): class SubentryFlowHandler(core_ce.ConfigSubentryFlow): async def async_step_init(self, user_input=None): - schema = {} - schema[vol.Required("enabled")] = bool return self.async_show_form( step_id="user", - data_schema=schema, + data_schema=vol.Schema({vol.Required("enabled"): bool}), description_placeholders={"enabled": "Set to true to be true"}, ) @@ -2792,7 +2785,7 @@ async def test_flow_with_multiple_schema_errors_base( "ignore_translations", ["component.test.config.abort.reconfigure_successful"], ) -@pytest.mark.usefixtures("enable_custom_integrations", "freezer") +@pytest.mark.usefixtures("freezer") async def test_supports_reconfigure( hass: HomeAssistant, client: TestClient, @@ -2868,7 +2861,6 @@ async def test_supports_reconfigure( } -@pytest.mark.usefixtures("enable_custom_integrations") async def test_does_not_support_reconfigure( hass: HomeAssistant, client: TestClient ) -> None: @@ -2894,11 +2886,10 @@ async def test_does_not_support_reconfigure( ) assert resp.status == HTTPStatus.BAD_REQUEST - response = await resp.text() - assert ( - response - == '{"message":"Handler ConfigEntriesFlowManager doesn\'t support step reconfigure"}' - ) + response = await resp.json() + assert response == { + "message": "Handler ConfigEntriesFlowManager doesn't support step reconfigure" + } async def test_list_subentries( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 105b5273918..a5cf3ad3a1a 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -512,6 +512,7 @@ async def test_remove_entry( assert len(entity_registry.entities) == 1 entity_entry = list(entity_registry.entities.values())[0] assert entity_entry.config_entry_id == entry.entry_id + assert entity_entry.config_subentry_id is None # Remove entry result = await manager.async_remove("test2") @@ -1271,7 +1272,7 @@ async def test_discovery_notification( notifications = async_get_persistent_notifications(hass) assert "config_entry_discovery" not in notifications - # Start first discovery flow to assert that reconfigure notification fires + # Start first discovery flow to assert that discovery notification fires flow1 = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY} ) @@ -1994,7 +1995,7 @@ async def test_entry_subentry( class TestFlow(config_entries.ConfigFlow): """Test flow.""" - class SubentryFlowHandler(data_entry_flow.FlowHandler): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): """Test subentry flow handler.""" @classmethod @@ -2050,7 +2051,7 @@ async def test_entry_subentry_non_string( class TestFlow(config_entries.ConfigFlow): """Test flow.""" - class SubentryFlowHandler(data_entry_flow.FlowHandler): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): """Test subentry flow handler.""" @classmethod @@ -2092,7 +2093,7 @@ async def test_entry_subentry_no_context( class TestFlow(config_entries.ConfigFlow): """Test flow.""" - class SubentryFlowHandler(data_entry_flow.FlowHandler): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): """Test subentry flow handler.""" @classmethod @@ -2139,7 +2140,7 @@ async def test_entry_subentry_duplicate( class TestFlow(config_entries.ConfigFlow): """Test flow.""" - class SubentryFlowHandler(data_entry_flow.FlowHandler): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): """Test subentry flow handler.""" @classmethod @@ -2180,7 +2181,7 @@ async def test_entry_subentry_abort( class TestFlow(config_entries.ConfigFlow): """Test flow.""" - class SubentryFlowHandler(data_entry_flow.FlowHandler): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): """Test subentry flow handler.""" @classmethod @@ -2227,7 +2228,7 @@ async def test_entry_subentry_deleted_config_entry( class TestFlow(config_entries.ConfigFlow): """Test flow.""" - class SubentryFlowHandler(data_entry_flow.FlowHandler): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): """Test subentry flow handler.""" @classmethod @@ -2270,7 +2271,7 @@ async def test_entry_subentry_unsupported_subentry_type( class TestFlow(config_entries.ConfigFlow): """Test flow.""" - class SubentryFlowHandler(data_entry_flow.FlowHandler): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): """Test subentry flow handler.""" @classmethod @@ -7412,7 +7413,7 @@ async def test_get_reauth_entry( async def test_get_reconfigure_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: - """Test _get_context_entry behavior.""" + """Test _get_reconfigure_entry behavior.""" entry = MockConfigEntry( title="test_title", domain="test", From 31ea2e1714d8bed10b372728de9537bf56edf6b0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 13:48:13 +0100 Subject: [PATCH 0417/1941] Improve error reporting in onboarding backup API (#138203) --- homeassistant/components/onboarding/views.py | 5 ++ tests/components/onboarding/test_views.py | 52 ++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 2d4b44a73cd..cb0dc4fdfa7 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -422,6 +422,11 @@ class RestoreBackupView(BackupOnboardingView): return self.json( {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST ) + except HomeAssistantError as err: + return self.json( + {"code": "restore_failed", "message": str(err)}, + status_code=HTTPStatus.BAD_REQUEST, + ) return web.Response(status=HTTPStatus.OK) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 003a137738d..99623cb6efe 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -14,6 +14,7 @@ from syrupy import SnapshotAssertion from homeassistant.components import backup, onboarding 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.setup import async_setup_component @@ -978,6 +979,14 @@ async def test_onboarding_backup_restore( {"code": "incorrect_password"}, 1, ), + # Home Assistant error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + HomeAssistantError("Boom!"), + 400, + {"code": "restore_failed", "message": "Boom!"}, + 1, + ), ], ) async def test_onboarding_backup_restore_error( @@ -1010,6 +1019,49 @@ async def test_onboarding_backup_restore_error( assert len(mock_restore.mock_calls) == restore_calls +@pytest.mark.parametrize( + ("params", "restore_error", "expected_status", "expected_message", "restore_calls"), + [ + # Unexpected error + ( + {"backup_id": "abc123", "agent_id": "backup.local"}, + Exception("Boom!"), + 500, + "500 Internal Server Error", + 1, + ), + ], +) +async def test_onboarding_backup_restore_unexpected_error( + hass: HomeAssistant, + hass_storage: dict[str, Any], + hass_client: ClientSessionGenerator, + params: dict[str, Any], + restore_error: Exception | None, + expected_status: int, + expected_message: str, + restore_calls: int, +) -> None: + """Test returning installation type during onboarding.""" + mock_storage(hass_storage, {"done": []}) + + assert await async_setup_component(hass, "onboarding", {}) + assert await async_setup_component(hass, "backup", {}) + await hass.async_block_till_done() + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_restore_backup", + side_effect=restore_error, + ) as mock_restore: + resp = await client.post("/api/onboarding/backup/restore", json=params) + + assert resp.status == expected_status + assert (await resp.content.read()).decode().startswith(expected_message) + assert len(mock_restore.mock_calls) == restore_calls + + async def test_onboarding_backup_upload( hass: HomeAssistant, hass_storage: dict[str, Any], From 2784a28ef7fb3f52e7fd39957673dbcbb18fc567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Buli=C5=84ski?= Date: Tue, 11 Feb 2025 14:39:42 +0100 Subject: [PATCH 0418/1941] Flexit BACnet: Cooker hood mode support (#138229) --- .../components/flexit_bacnet/icons.json | 6 +++ .../components/flexit_bacnet/strings.json | 3 ++ .../components/flexit_bacnet/switch.py | 7 +++ .../flexit_bacnet/snapshots/test_switch.ambr | 48 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/homeassistant/components/flexit_bacnet/icons.json b/homeassistant/components/flexit_bacnet/icons.json index a0c5ccd5a6e..d03cffab9ad 100644 --- a/homeassistant/components/flexit_bacnet/icons.json +++ b/homeassistant/components/flexit_bacnet/icons.json @@ -47,6 +47,12 @@ "state": { "off": "mdi:fireplace-off" } + }, + "cooker_hood_mode": { + "default": "mdi:kettle-steam", + "state": { + "off": "mdi:kettle" + } } } } diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index 8888b02a3ef..f7c54c88050 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -110,6 +110,9 @@ }, "fireplace_mode": { "name": "Fireplace mode" + }, + "cooker_hood_mode": { + "name": "Cooker hood mode" } } } diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index 1ceb6aefcdd..af6066410a1 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -47,6 +47,13 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( turn_on_fn=lambda data: data.trigger_fireplace_mode(), turn_off_fn=lambda data: data.trigger_fireplace_mode(), ), + FlexitSwitchEntityDescription( + key="cooker_hood_mode", + translation_key="cooker_hood_mode", + is_on_fn=lambda data: data.cooker_hood_status, + turn_on_fn=lambda data: data.activate_cooker_hood(), + turn_off_fn=lambda data: data.deactivate_cooker_hood(), + ), ) diff --git a/tests/components/flexit_bacnet/snapshots/test_switch.ambr b/tests/components/flexit_bacnet/snapshots/test_switch.ambr index 3d931dd7753..0e27c2e938a 100644 --- a/tests/components/flexit_bacnet/snapshots/test_switch.ambr +++ b/tests/components/flexit_bacnet/snapshots/test_switch.ambr @@ -1,4 +1,52 @@ # serializer version: 1 +# name: test_switches[switch.device_name_cooker_hood_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': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device_name_cooker_hood_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooker hood mode', + 'platform': 'flexit_bacnet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooker_hood_mode', + 'unique_id': '0000-0001-cooker_hood_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.device_name_cooker_hood_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Device Name Cooker hood mode', + }), + 'context': , + 'entity_id': 'switch.device_name_cooker_hood_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch.device_name_electric_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 10180cd464593a62d996de1205dce6bf3a318d9c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 14:53:07 +0100 Subject: [PATCH 0419/1941] Fix BackupManager.async_delete_backup (#138286) --- homeassistant/components/backup/manager.py | 4 +- tests/components/backup/test_websocket.py | 233 ++++++++------------- 2 files changed, 93 insertions(+), 144 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index e175ff9c03d..81826ffcb24 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -688,8 +688,8 @@ class BackupManager: delete_backup_results = await asyncio.gather( *( - agent.async_delete_backup(backup_id) - for agent in self.backup_agents.values() + self.backup_agents[agent_id].async_delete_backup(backup_id) + for agent_id in agent_ids ), return_exceptions=True, ) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 966cfbbef78..773256bdd0b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -6,7 +6,6 @@ from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch from freezegun.api import FrozenDateTimeFactory import pytest -from pytest_unordered import unordered from syrupy import SnapshotAssertion from homeassistant.components.backup import ( @@ -100,15 +99,6 @@ def mock_delay_save() -> Generator[None]: yield -@pytest.fixture(name="delete_backup") -def mock_delete_backup() -> Generator[AsyncMock]: - """Mock manager delete backup.""" - with patch( - "homeassistant.components.backup.BackupManager.async_delete_backup" - ) as mock_delete_backup: - yield mock_delete_backup - - @pytest.fixture(name="get_backups") def mock_get_backups() -> Generator[AsyncMock]: """Mock manager get backups.""" @@ -911,7 +901,7 @@ async def test_agents_info( assert await client.receive_json() == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "storage_data", [ @@ -1161,7 +1151,7 @@ async def test_config_info( assert await client.receive_json() == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "commands", [ @@ -1326,7 +1316,7 @@ async def test_config_update( assert hass_storage[DOMAIN] == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "command", [ @@ -1783,14 +1773,13 @@ async def test_config_schedule_logic( "command", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", + "agent_delete_backup_side_effects", "last_backup_time", "next_time", "backup_time", "backup_calls", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ ( @@ -1833,8 +1822,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, # we get backups even if backup retention copies is None - 0, - [], + {}, ), ( { @@ -1876,8 +1864,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ( { @@ -1907,8 +1894,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ( { @@ -1971,13 +1957,10 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ) - ], + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1")], + }, ), ( { @@ -2039,13 +2022,10 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ) - ], + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1")], + }, ), ( { @@ -2093,11 +2073,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ( { @@ -2132,15 +2108,14 @@ async def test_config_schedule_logic( spec=ManagerBackup, ), }, - {"test-agent": BackupAgentError("Boom!")}, + {"test.test-agent": BackupAgentError("Boom!")}, {}, "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( { @@ -2176,14 +2151,13 @@ async def test_config_schedule_logic( ), }, {}, - {"test-agent": BackupAgentError("Boom!")}, + {"test.test-agent": BackupAgentError("Boom!")}, "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( { @@ -2246,21 +2220,18 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 3, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call( - "backup-2", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call( - "backup-3", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - ], + { + "test.test-agent": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + "test.test-agent2": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + }, ), ( { @@ -2322,18 +2293,14 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 3, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call( - "backup-2", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call("backup-3", agent_ids=["test.test-agent"]), - ], + { + "test.test-agent": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, ), ( { @@ -2363,8 +2330,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ], ) @@ -2375,19 +2341,17 @@ async def test_config_retention_copies_logic( freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], create_backup: AsyncMock, - delete_backup: AsyncMock, get_backups: AsyncMock, command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], + agent_delete_backup_side_effects: dict[str, Exception], last_backup_time: str, next_time: str, backup_time: str, backup_calls: int, get_backups_calls: int, - delete_calls: int, - delete_args_list: Any, + delete_calls: dict[str, Any], ) -> None: """Test config backup retention copies logic.""" created_backup: MagicMock = create_backup.return_value[1].result().backup @@ -2425,13 +2389,18 @@ async def test_config_retention_copies_logic( "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + await setup_backup_integration(hass, remote_agents=["test-agent", "test-agent2"]) await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + for agent_id, agent in manager.backup_agents.items(): + agent.async_delete_backup = AsyncMock( + side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True + ) + await client.send_json_auto_id(command) result = await client.receive_json() @@ -2442,8 +2411,10 @@ async def test_config_retention_copies_logic( await hass.async_block_till_done() assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in manager.backup_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() assert ( @@ -2474,11 +2445,9 @@ async def test_config_retention_copies_logic( "config_command", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", "backup_calls", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ ( @@ -2515,11 +2484,9 @@ async def test_config_retention_copies_logic( ), }, {}, - {}, 1, 1, # we get backups even if backup retention copies is None - 0, - [], + {}, ), ( { @@ -2555,11 +2522,9 @@ async def test_config_retention_copies_logic( ), }, {}, + 1, + 1, {}, - 1, - 1, - 0, - [], ), ( { @@ -2601,11 +2566,9 @@ async def test_config_retention_copies_logic( ), }, {}, - {}, 1, 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( { @@ -2647,14 +2610,9 @@ async def test_config_retention_copies_logic( ), }, {}, - {}, 1, 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ], ) @@ -2664,18 +2622,15 @@ async def test_config_retention_copies_logic_manual_backup( freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], create_backup: AsyncMock, - delete_backup: AsyncMock, get_backups: AsyncMock, config_command: dict[str, Any], backup_command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], backup_time: str, backup_calls: int, get_backups_calls: int, - delete_calls: int, - delete_args_list: Any, + delete_calls: dict[str, Any], ) -> None: """Test config backup retention copies logic for manual backup.""" created_backup: MagicMock = create_backup.return_value[1].result().backup @@ -2713,13 +2668,16 @@ async def test_config_retention_copies_logic_manual_backup( "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") await setup_backup_integration(hass, remote_agents=["test-agent"]) await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + for agent in manager.backup_agents.values(): + agent.async_delete_backup = AsyncMock(autospec=True) + await client.send_json_auto_id(config_command) result = await client.receive_json() assert result["success"] @@ -2734,8 +2692,10 @@ async def test_config_retention_copies_logic_manual_backup( assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in manager.backup_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() assert ( @@ -2754,13 +2714,12 @@ async def test_config_retention_copies_logic_manual_backup( "commands", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", + "agent_delete_backup_side_effects", "last_backup_time", "start_time", "next_time", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ # No config update - cleanup backups older than 2 days @@ -2793,8 +2752,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), # No config update - No cleanup ( @@ -2826,8 +2784,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 0, - 0, - [], + {}, ), # Unchanged config ( @@ -2866,8 +2823,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2905,8 +2861,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2944,8 +2899,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 0, - [], + {}, ), ( None, @@ -2989,11 +2943,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ( None, @@ -3031,8 +2981,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -3070,8 +3019,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -3115,11 +3063,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ], ) @@ -3128,19 +3072,17 @@ async def test_config_retention_days_logic( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], - delete_backup: AsyncMock, get_backups: AsyncMock, stored_retained_days: int | None, commands: list[dict[str, Any]], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], + agent_delete_backup_side_effects: dict[str, Exception], last_backup_time: str, start_time: str, next_time: str, get_backups_calls: int, - delete_calls: int, - delete_args_list: list[Any], + delete_calls: dict[str, Any], ) -> None: """Test config backup retention logic.""" client = await hass_ws_client(hass) @@ -3175,13 +3117,18 @@ async def test_config_retention_days_logic( "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to(start_time) - await setup_backup_integration(hass) + await setup_backup_integration(hass, remote_agents=["test-agent"]) await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + for agent_id, agent in manager.backup_agents.items(): + agent.async_delete_backup = AsyncMock( + side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True + ) + for command in commands: await client.send_json_auto_id(command) result = await client.receive_json() @@ -3191,8 +3138,10 @@ async def test_config_retention_days_logic( async_fire_time_changed(hass) await hass.async_block_till_done() assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in manager.backup_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() From 8b3421deb72a66afedbe4524c4abd7f933fa3c77 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 16:21:24 +0100 Subject: [PATCH 0420/1941] Add test helper for creating a mocked backup agent (#138294) * Add test helper for creating a mocked backup agent * Address review comments --- tests/components/backup/common.py | 32 ++++++++++++++++++++++++++++ tests/components/backup/test_http.py | 15 ++++++------- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 1e7278134d4..afdb5e47a2e 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -13,6 +13,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupAgentPlatformProtocol, + BackupNotFound, Folder, ) from homeassistant.components.backup.const import DATA_MANAGER @@ -134,6 +135,37 @@ class BackupAgentTest(BackupAgent): """Delete a backup file.""" +def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock: + """Create a mock backup agent.""" + + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None: + """Get a backup.""" + return next((b for b in backups if b.backup_id == backup_id), None) + + backups = backups or [] + mock_agent = Mock(spec=BackupAgent) + mock_agent.domain = "test" + mock_agent.name = name + mock_agent.unique_id = name + type(mock_agent).agent_id = BackupAgent.agent_id + mock_agent.async_delete_backup = AsyncMock( + spec_set=[BackupAgent.async_delete_backup] + ) + mock_agent.async_download_backup = AsyncMock( + side_effect=BackupNotFound, spec_set=[BackupAgent.async_download_backup] + ) + mock_agent.async_get_backup = AsyncMock( + side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] + ) + mock_agent.async_list_backups = AsyncMock( + return_value=backups, spec_set=[BackupAgent.async_list_backups] + ) + mock_agent.async_upload_backup = AsyncMock( + spec_set=[BackupAgent.async_upload_backup] + ) + return mock_agent + + async def setup_backup_integration( hass: HomeAssistant, with_hassio: bool = False, diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index 24fd15fc4fe..9ebf3e8bd40 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -25,6 +25,7 @@ from .common import ( TEST_BACKUP_ABC123, BackupAgentTest, aiter_from_iter, + mock_backup_agent, setup_backup_integration, ) @@ -112,16 +113,14 @@ async def test_downloading_local_encrypted_backup( await _test_downloading_encrypted_backup(hass_client, "backup.local") -@patch.object(BackupAgentTest, "async_download_backup") async def test_downloading_remote_encrypted_backup( - download_mock, hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> None: """Test downloading a local backup file.""" backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) await setup_backup_integration(hass) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest( + mock_agent = mock_backup_agent( "test", [ AgentBackup( @@ -139,11 +138,12 @@ async def test_downloading_remote_encrypted_backup( ) ], ) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: return aiter_from_iter((backup_path.read_bytes(),)) - download_mock.side_effect = download_backup + mock_agent.async_download_backup.side_effect = download_backup await _test_downloading_encrypted_backup(hass_client, "domain.test") @@ -154,9 +154,7 @@ async def test_downloading_remote_encrypted_backup( (BackupNotFound, 404), ], ) -@patch.object(BackupAgentTest, "async_download_backup") async def test_downloading_remote_encrypted_backup_with_error( - download_mock, hass: HomeAssistant, hass_client: ClientSessionGenerator, error: Exception, @@ -164,7 +162,7 @@ async def test_downloading_remote_encrypted_backup_with_error( ) -> None: """Test downloading a local backup file.""" await setup_backup_integration(hass) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest( + mock_agent = mock_backup_agent( "test", [ AgentBackup( @@ -182,8 +180,9 @@ async def test_downloading_remote_encrypted_backup_with_error( ) ], ) + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent - download_mock.side_effect = error + mock_agent.async_download_backup.side_effect = error client = await hass_client() resp = await client.get( "/api/backup/download/abc123?agent_id=domain.test&password=blah" From 1a4738b1d481cec5e0feba0045d6c3a8e2232a1e Mon Sep 17 00:00:00 2001 From: Steven Hartland Date: Tue, 11 Feb 2025 15:24:04 +0000 Subject: [PATCH 0421/1941] Fix scaffolding integration generation (#138247) * fix(scaffold): integration generation Fix script.scaffold integration generation which was failing due to hassfest quality check. Add the required `quality_scale` to the generated integration manifest.json. Use the new `--skip-plugins` flag to skip the hassfest quality check when generating integrations, as the quality scale rules are marked as todo, and only run against the generated integration. Correct typo in help for hassfest command `--plugins` flag. Update Integration.core method to use absolute path to ensure it returns the true if the integration is a core integration, which was causing other checks to fail, as the integration was not being marked as core. Always output subprocess output as it contains the error message when a command fails, without this the user would not know why the command failed. Fixes: #128639 * Adjust comment language --------- Co-authored-by: Martin Hjelmare --- script/hassfest/__main__.py | 11 +++++++- script/hassfest/model.py | 6 +++-- script/scaffold/__main__.py | 25 +++++++++++++------ .../integration/integration/manifest.json | 1 + 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index c93d8fd4499..277696c669b 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -107,7 +107,13 @@ def get_config() -> Config: "--plugins", type=validate_plugins, default=ALL_PLUGIN_NAMES, - help="Comma-separate list of plugins to run. Valid plugin names: %(default)s", + help="Comma-separated list of plugins to run. Valid plugin names: %(default)s", + ) + parser.add_argument( + "--skip-plugins", + type=validate_plugins, + default=[], + help=f"Comma-separated list of plugins to skip. Valid plugin names: {ALL_PLUGIN_NAMES}", ) parser.add_argument( "--core-path", @@ -131,6 +137,9 @@ def get_config() -> Config: ): raise RuntimeError("Run from Home Assistant root") + if parsed.skip_plugins: + parsed.plugins = set(parsed.plugins) - set(parsed.skip_plugins) + return Config( root=parsed.core_path.absolute(), specific_integrations=parsed.integration_path, diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 08ded687096..1ca4178d9c2 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -157,8 +157,10 @@ class Integration: @property def core(self) -> bool: """Core integration.""" - return self.path.as_posix().startswith( - self._config.core_integrations_path.as_posix() + return ( + self.path.absolute() + .as_posix() + .startswith(self._config.core_integrations_path.as_posix()) ) @property diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 93c787df50f..4c102083a74 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -60,20 +60,32 @@ def main() -> int: generate.generate(template, info) + hassfest_args = [ + "python", + "-m", + "script.hassfest", + ] + # If we wanted a new integration, we've already done our work. if args.template != "integration": generate.generate(args.template, info) + else: + hassfest_args.extend( + [ + "--integration-path", + info.integration_dir, + "--skip-plugins", + "quality_scale", # Skip quality scale as it will fail for newly generated integrations. + ] + ) - pipe_null = {} if args.develop else {"stdout": subprocess.DEVNULL} - + # Always output sub commands as the output will contain useful information if a command fails. print("Running hassfest to pick up new information.") - subprocess.run(["python", "-m", "script.hassfest"], **pipe_null, check=True) + subprocess.run(hassfest_args, check=True) print() print("Running gen_requirements_all to pick up new information.") - subprocess.run( - ["python", "-m", "script.gen_requirements_all"], **pipe_null, check=True - ) + subprocess.run(["python", "-m", "script.gen_requirements_all"], check=True) print() print("Running script/translations_develop to pick up new translation strings.") @@ -86,7 +98,6 @@ def main() -> int: "--integration", info.domain, ], - **pipe_null, check=True, ) print() diff --git a/script/scaffold/templates/integration/integration/manifest.json b/script/scaffold/templates/integration/integration/manifest.json index 7235500391d..15bc84a9b5e 100644 --- a/script/scaffold/templates/integration/integration/manifest.json +++ b/script/scaffold/templates/integration/integration/manifest.json @@ -7,6 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/NEW_DOMAIN", "homekit": {}, "iot_class": "IOT_CLASS", + "quality_scale": "bronze", "requirements": [], "ssdp": [], "zeroconf": [] From df4c718bac6df1e4883f097b1ab4f21ee925690b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:00:44 +0100 Subject: [PATCH 0422/1941] Use runtime_data in fjaraskupan (#138281) --- .../components/fjaraskupan/__init__.py | 32 ++++++------------- .../components/fjaraskupan/binary_sensor.py | 5 ++- .../components/fjaraskupan/coordinator.py | 6 ++-- homeassistant/components/fjaraskupan/fan.py | 5 ++- homeassistant/components/fjaraskupan/light.py | 5 ++- .../components/fjaraskupan/number.py | 5 ++- .../components/fjaraskupan/sensor.py | 5 ++- 7 files changed, 24 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 2703fc5a30e..961be04fd8d 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass import logging from fjaraskupan import Device @@ -16,7 +15,6 @@ from homeassistant.components.bluetooth import ( async_rediscover_address, async_register_callback, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -29,7 +27,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DISPATCH_DETECTION, DOMAIN -from .coordinator import FjaraskupanCoordinator +from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -42,26 +40,17 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -@dataclass -class EntryState: - """Store state of config entry.""" - - coordinators: dict[str, FjaraskupanCoordinator] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry) -> bool: """Set up Fjäråskupan from a config entry.""" - state = EntryState({}) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = state + entry.runtime_data = {} def detection_callback( service_info: BluetoothServiceInfoBleak, change: BluetoothChange ) -> None: if change != BluetoothChange.ADVERTISEMENT: return - if data := state.coordinators.get(service_info.address): + if data := entry.runtime_data.get(service_info.address): _LOGGER.debug("Update: %s", service_info) data.detection_callback(service_info) else: @@ -80,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator.detection_callback(service_info) - state.coordinators[service_info.address] = coordinator + entry.runtime_data[service_info.address] = coordinator async_dispatcher_send( hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator ) @@ -105,16 +94,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def async_setup_entry_platform( hass: HomeAssistant, - entry: ConfigEntry, + entry: FjaraskupanConfigEntry, async_add_entities: AddEntitiesCallback, constructor: Callable[[FjaraskupanCoordinator], list[Entity]], ) -> None: """Set up a platform with added entities.""" - entry_state: EntryState = hass.data[DOMAIN][entry.entry_id] async_add_entities( entity - for coordinator in entry_state.coordinators.values() + for coordinator in entry.runtime_data.values() for entity in constructor(coordinator) ) @@ -129,12 +117,12 @@ def async_setup_entry_platform( ) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: FjaraskupanConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - for device_entry in dr.async_entries_for_config_entry( dr.async_get(hass), entry.entry_id ): diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index eed2c6058bf..7364fa85b2e 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import async_setup_entry_platform -from .coordinator import FjaraskupanCoordinator +from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator @dataclass(frozen=True) @@ -48,7 +47,7 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FjaraskupanConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors dynamically through discovery.""" diff --git a/homeassistant/components/fjaraskupan/coordinator.py b/homeassistant/components/fjaraskupan/coordinator.py index bfea5e5f4fc..7fc4585a722 100644 --- a/homeassistant/components/fjaraskupan/coordinator.py +++ b/homeassistant/components/fjaraskupan/coordinator.py @@ -29,6 +29,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN +type FjaraskupanConfigEntry = ConfigEntry[dict[str, FjaraskupanCoordinator]] + _LOGGER = logging.getLogger(__name__) @@ -65,12 +67,12 @@ class UnableToConnect(HomeAssistantError): class FjaraskupanCoordinator(DataUpdateCoordinator[State]): """Update coordinator for each device.""" - config_entry: ConfigEntry + config_entry: FjaraskupanConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FjaraskupanConfigEntry, device: Device, device_info: DeviceInfo, ) -> None: diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index ac9a15017cb..b35bb728131 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -13,7 +13,6 @@ from fjaraskupan import ( ) from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -25,7 +24,7 @@ from homeassistant.util.percentage import ( ) from . import async_setup_entry_platform -from .coordinator import FjaraskupanCoordinator +from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator ORDERED_NAMED_FAN_SPEEDS = ["1", "2", "3", "4", "5", "6", "7", "8"] @@ -51,7 +50,7 @@ class UnsupportedPreset(HomeAssistantError): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FjaraskupanConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors dynamically through discovery.""" diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py index 6ac6017f3ee..c39e3ca4736 100644 --- a/homeassistant/components/fjaraskupan/light.py +++ b/homeassistant/components/fjaraskupan/light.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -13,12 +12,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import async_setup_entry_platform -from .coordinator import FjaraskupanCoordinator +from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FjaraskupanConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py index a69c31a5587..93fd31273e9 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -3,7 +3,6 @@ from __future__ import annotations from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -12,12 +11,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import async_setup_entry_platform -from .coordinator import FjaraskupanCoordinator +from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FjaraskupanConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up number entities dynamically through discovery.""" diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py index 21d524e6534..039feb5913c 100644 --- a/homeassistant/components/fjaraskupan/sensor.py +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -19,12 +18,12 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import async_setup_entry_platform -from .coordinator import FjaraskupanCoordinator +from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FjaraskupanConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors dynamically through discovery.""" From 62b563eb60a689e6cc07b88edd75794fbe5f75df Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:08:39 +0100 Subject: [PATCH 0423/1941] Use runtime_data in flexit_bacnet (#138280) --- .../components/flexit_bacnet/__init__.py | 15 +++++---------- .../components/flexit_bacnet/binary_sensor.py | 8 +++----- homeassistant/components/flexit_bacnet/climate.py | 10 +++------- .../components/flexit_bacnet/coordinator.py | 8 ++++++-- homeassistant/components/flexit_bacnet/number.py | 8 +++----- homeassistant/components/flexit_bacnet/sensor.py | 8 +++----- homeassistant/components/flexit_bacnet/switch.py | 8 +++----- 7 files changed, 26 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index b0ebc5a40fd..01e0051f53f 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import FlexitCoordinator +from .coordinator import FlexitConfigEntry, FlexitCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -18,21 +16,18 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FlexitConfigEntry) -> bool: """Set up Flexit Nordic (BACnet) from a config entry.""" coordinator = FlexitCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FlexitConfigEntry) -> bool: """Unload the Flexit Nordic (BACnet) config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/flexit_bacnet/binary_sensor.py b/homeassistant/components/flexit_bacnet/binary_sensor.py index e122d0321e6..faee803e915 100644 --- a/homeassistant/components/flexit_bacnet/binary_sensor.py +++ b/homeassistant/components/flexit_bacnet/binary_sensor.py @@ -10,12 +10,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 . import FlexitCoordinator -from .const import DOMAIN +from .coordinator import FlexitConfigEntry, FlexitCoordinator from .entity import FlexitEntity @@ -38,11 +36,11 @@ SENSOR_TYPES: tuple[FlexitBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FlexitConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Flexit (bacnet) binary sensor from a config entry.""" - coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( FlexitBinarySensor(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 0a97500afb1..abfa59d0a6d 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -19,32 +19,28 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, MAX_TEMP, MIN_TEMP, PRESET_TO_VENTILATION_MODE_MAP, VENTILATION_TO_PRESET_MODE_MAP, ) -from .coordinator import FlexitCoordinator +from .coordinator import FlexitConfigEntry, FlexitCoordinator from .entity import FlexitEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FlexitConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flexit Nordic unit.""" - coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] - - async_add_entities([FlexitClimateEntity(coordinator)]) + async_add_entities([FlexitClimateEntity(config_entry.runtime_data)]) class FlexitClimateEntity(FlexitEntity, ClimateEntity): diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py index f723117c9ef..da9415f2b87 100644 --- a/homeassistant/components/flexit_bacnet/coordinator.py +++ b/homeassistant/components/flexit_bacnet/coordinator.py @@ -1,5 +1,7 @@ """DataUpdateCoordinator for Flexit Nordic (BACnet) integration..""" +from __future__ import annotations + import asyncio.exceptions from datetime import timedelta import logging @@ -17,13 +19,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type FlexitConfigEntry = ConfigEntry[FlexitCoordinator] + class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]): """Class to manage fetching data from a Flexit Nordic (BACnet) device.""" - config_entry: ConfigEntry + config_entry: FlexitConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: FlexitConfigEntry) -> None: """Initialize my coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index e8fbce54b74..dfcfc193692 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -13,14 +13,12 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import FlexitCoordinator -from .const import DOMAIN +from .coordinator import FlexitConfigEntry, FlexitCoordinator from .entity import FlexitEntity _MAX_FAN_SETPOINT = 100 @@ -196,11 +194,11 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FlexitConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Flexit (bacnet) number from a config entry.""" - coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( FlexitNumber(coordinator, description) for description in NUMBERS diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py index a14c7559945..23d8f20da36 100644 --- a/homeassistant/components/flexit_bacnet/sensor.py +++ b/homeassistant/components/flexit_bacnet/sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -23,8 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import FlexitCoordinator -from .const import DOMAIN +from .coordinator import FlexitConfigEntry, FlexitCoordinator from .entity import FlexitEntity @@ -152,11 +150,11 @@ SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FlexitConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Flexit (bacnet) sensor from a config entry.""" - coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( FlexitSensor(coordinator, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index af6066410a1..283d0e1ec3b 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -13,13 +13,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import FlexitCoordinator -from .const import DOMAIN +from .coordinator import FlexitConfigEntry, FlexitCoordinator from .entity import FlexitEntity @@ -59,11 +57,11 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FlexitConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Flexit (bacnet) switch from a config entry.""" - coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( FlexitSwitch(coordinator, description) for description in SWITCHES From 7f376ff0045b500fc4419a0b1be4ddb44ddbbd0e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:09:06 +0100 Subject: [PATCH 0424/1941] Use runtime_data in flux_led (#138279) --- homeassistant/components/flux_led/__init__.py | 22 ++++++++++--------- homeassistant/components/flux_led/button.py | 10 ++++----- .../components/flux_led/config_flow.py | 4 ++-- .../components/flux_led/coordinator.py | 6 +++-- .../components/flux_led/diagnostics.py | 9 +++----- .../components/flux_led/discovery.py | 10 ++++----- homeassistant/components/flux_led/light.py | 8 +++---- homeassistant/components/flux_led/number.py | 8 +++---- homeassistant/components/flux_led/select.py | 17 ++++++-------- homeassistant/components/flux_led/sensor.py | 8 +++---- homeassistant/components/flux_led/switch.py | 10 ++++----- 11 files changed, 50 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 7597a7c9c9a..7515b6b8dfc 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -11,7 +11,6 @@ from flux_led.aio import AIOWifiLedBulb from flux_led.const import ATTR_ID, WhiteChannelType from flux_led.scanner import FluxLEDDiscovery -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -39,7 +38,7 @@ from .const import ( FLUX_LED_EXCEPTIONS, SIGNAL_STATE_UPDATED, ) -from .coordinator import FluxLedUpdateCoordinator +from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator from .discovery import ( async_build_cached_discovery, async_clear_discovery_cache, @@ -113,7 +112,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_migrate_unique_ids( + hass: HomeAssistant, entry: FluxLedConfigEntry +) -> None: """Migrate entities when the mac address gets discovered.""" @callback @@ -146,14 +147,16 @@ async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: FluxLedConfigEntry +) -> None: """Handle options update.""" - coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if entry.title != coordinator.title: await hass.config_entries.async_reload(entry.entry_id) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FluxLedConfigEntry) -> bool: """Set up Flux LED/MagicLight from a config entry.""" host = entry.data[CONF_HOST] discovery_cached = True @@ -206,7 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await _async_migrate_unique_ids(hass, entry) coordinator = FluxLedUpdateCoordinator(hass, device, entry) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator platforms = PLATFORMS_BY_TYPE[device.device_type] await hass.config_entries.async_forward_entry_setups(entry, platforms) @@ -239,13 +242,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FluxLedConfigEntry) -> bool: """Unload a config entry.""" - device: AIOWifiLedBulb = hass.data[DOMAIN][entry.entry_id].device + device = entry.runtime_data.device platforms = PLATFORMS_BY_TYPE[device.device_type] if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): # Make sure we probe the device again in case something has changed externally async_clear_discovery_cache(hass, entry.data[CONF_HOST]) - del hass.data[DOMAIN][entry.entry_id] await device.async_stop() return unload_ok diff --git a/homeassistant/components/flux_led/button.py b/homeassistant/components/flux_led/button.py index 58844a20397..c4a7ff6569c 100644 --- a/homeassistant/components/flux_led/button.py +++ b/homeassistant/components/flux_led/button.py @@ -5,7 +5,6 @@ from __future__ import annotations from flux_led.aio import AIOWifiLedBulb from flux_led.protocol import RemoteConfig -from homeassistant import config_entries from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -15,8 +14,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import FluxLedUpdateCoordinator +from .coordinator import FluxLedConfigEntry from .entity import FluxBaseEntity _RESTART_KEY = "restart" @@ -34,11 +32,11 @@ UNPAIR_REMOTES_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Magic Home button based on a config entry.""" - coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data device = coordinator.device entities: list[FluxButton] = [ FluxButton(coordinator.device, entry, RESTART_BUTTON_DESCRIPTION) @@ -59,7 +57,7 @@ class FluxButton(FluxBaseEntity, ButtonEntity): def __init__( self, device: AIOWifiLedBulb, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, description: ButtonEntityDescription, ) -> None: """Initialize the button.""" diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 035be5b115c..754ed0525b9 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -18,7 +18,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_IGNORE, - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -46,6 +45,7 @@ from .const import ( TRANSITION_JUMP, TRANSITION_STROBE, ) +from .coordinator import FluxLedConfigEntry from .discovery import ( async_discover_device, async_discover_devices, @@ -72,7 +72,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: FluxLedConfigEntry, ) -> FluxLedOptionsFlow: """Get the options flow for the Flux LED component.""" return FluxLedOptionsFlow() diff --git a/homeassistant/components/flux_led/coordinator.py b/homeassistant/components/flux_led/coordinator.py index a879d894bcc..78d8bb947fd 100644 --- a/homeassistant/components/flux_led/coordinator.py +++ b/homeassistant/components/flux_led/coordinator.py @@ -20,14 +20,16 @@ _LOGGER = logging.getLogger(__name__) REQUEST_REFRESH_DELAY: Final = 2.0 +type FluxLedConfigEntry = ConfigEntry[FluxLedUpdateCoordinator] + class FluxLedUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific flux_led device.""" - config_entry: ConfigEntry + config_entry: FluxLedConfigEntry def __init__( - self, hass: HomeAssistant, device: AIOWifiLedBulb, entry: ConfigEntry + self, hass: HomeAssistant, device: AIOWifiLedBulb, entry: FluxLedConfigEntry ) -> None: """Initialize DataUpdateCoordinator to gather data for specific device.""" self.device = device diff --git a/homeassistant/components/flux_led/diagnostics.py b/homeassistant/components/flux_led/diagnostics.py index e24c1aff9a4..683aa362377 100644 --- a/homeassistant/components/flux_led/diagnostics.py +++ b/homeassistant/components/flux_led/diagnostics.py @@ -4,22 +4,19 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import FluxLedUpdateCoordinator +from .coordinator import FluxLedConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: FluxLedConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return { "entry": { "title": entry.title, "data": dict(entry.data), }, - "data": coordinator.device.diagnostics, + "data": entry.runtime_data.device.diagnostics, } diff --git a/homeassistant/components/flux_led/discovery.py b/homeassistant/components/flux_led/discovery.py index d55f560193f..c3a3c5df3a7 100644 --- a/homeassistant/components/flux_led/discovery.py +++ b/homeassistant/components/flux_led/discovery.py @@ -23,9 +23,8 @@ from flux_led.const import ( from flux_led.models_db import get_model_description from flux_led.scanner import FluxLEDDiscovery -from homeassistant import config_entries from homeassistant.components import network -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, discovery_flow @@ -44,6 +43,7 @@ from .const import ( DOMAIN, FLUX_LED_DISCOVERY, ) +from .coordinator import FluxLedConfigEntry from .util import format_as_flux_mac, mac_matches_by_one _LOGGER = logging.getLogger(__name__) @@ -63,7 +63,7 @@ CONF_TO_DISCOVERY: Final = { @callback -def async_build_cached_discovery(entry: ConfigEntry) -> FluxLEDDiscovery: +def async_build_cached_discovery(entry: FluxLedConfigEntry) -> FluxLEDDiscovery: """When discovery is unavailable, load it from the config entry.""" data = entry.data return FluxLEDDiscovery( @@ -116,7 +116,7 @@ def async_populate_data_from_discovery( @callback def async_update_entry_from_discovery( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, device: FluxLEDDiscovery, model_num: int | None, allow_update_mac: bool, @@ -230,6 +230,6 @@ def async_trigger_discovery( discovery_flow.async_create_flow( hass, DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + context={"source": SOURCE_INTEGRATION_DISCOVERY}, data={**device}, ) diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 2dd719a1fc4..79dae33a2a5 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -11,7 +11,6 @@ from flux_led.protocol import MusicMode from flux_led.utils import rgbcw_brightness, rgbcw_to_rgbwc, rgbw_brightness import voluptuous as vol -from homeassistant import config_entries from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -38,7 +37,6 @@ from .const import ( CONF_SPEED_PCT, CONF_TRANSITION, DEFAULT_EFFECT_SPEED, - DOMAIN, MIN_CCT_BRIGHTNESS, MIN_RGB_BRIGHTNESS, MULTI_BRIGHTNESS_COLOR_MODES, @@ -46,7 +44,7 @@ from .const import ( TRANSITION_JUMP, TRANSITION_STROBE, ) -from .coordinator import FluxLedUpdateCoordinator +from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator from .entity import FluxOnOffEntity from .util import ( _effect_brightness, @@ -134,11 +132,11 @@ SET_ZONES_DICT: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flux lights.""" - coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py index 7ca3ccbb38b..edf6b8c9654 100644 --- a/homeassistant/components/flux_led/number.py +++ b/homeassistant/components/flux_led/number.py @@ -16,7 +16,6 @@ from flux_led.protocol import ( SEGMENTS_MAX, ) -from homeassistant import config_entries from homeassistant.components.light import EFFECT_RANDOM from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.const import EntityCategory @@ -26,8 +25,7 @@ from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import FluxLedUpdateCoordinator +from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator from .entity import FluxEntity from .util import _effect_brightness @@ -38,11 +36,11 @@ DEBOUNCE_TIME = 1 async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flux lights.""" - coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data device = coordinator.device entities: list[ FluxSpeedNumber diff --git a/homeassistant/components/flux_led/select.py b/homeassistant/components/flux_led/select.py index 2b489d8ec53..bcb44c995b8 100644 --- a/homeassistant/components/flux_led/select.py +++ b/homeassistant/components/flux_led/select.py @@ -13,14 +13,13 @@ from flux_led.const import ( ) from flux_led.protocol import PowerRestoreState, RemoteConfig -from homeassistant import config_entries from homeassistant.components.select import SelectEntity from homeassistant.const import CONF_NAME, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_WHITE_CHANNEL_TYPE, DOMAIN, FLUX_COLOR_MODE_RGBW -from .coordinator import FluxLedUpdateCoordinator +from .const import CONF_WHITE_CHANNEL_TYPE, FLUX_COLOR_MODE_RGBW +from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator from .entity import FluxBaseEntity, FluxEntity from .util import _human_readable_option @@ -29,9 +28,7 @@ NAME_TO_POWER_RESTORE_STATE = { } -async def _async_delayed_reload( - hass: HomeAssistant, entry: config_entries.ConfigEntry -) -> None: +async def _async_delayed_reload(hass: HomeAssistant, entry: FluxLedConfigEntry) -> None: """Reload after making a change that will effect the operation of the device.""" await asyncio.sleep(STATE_CHANGE_LATENCY) hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) @@ -39,11 +36,11 @@ async def _async_delayed_reload( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flux selects.""" - coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data device = coordinator.device entities: list[ FluxPowerStateSelect @@ -97,7 +94,7 @@ class FluxPowerStateSelect(FluxConfigAtStartSelect, SelectEntity): def __init__( self, device: AIOWifiLedBulb, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, ) -> None: """Initialize the power state select.""" super().__init__(device, entry) @@ -228,7 +225,7 @@ class FluxWhiteChannelSelect(FluxConfigAtStartSelect): def __init__( self, device: AIOWifiLedBulb, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, ) -> None: """Initialize the white channel select.""" super().__init__(device, entry) diff --git a/homeassistant/components/flux_led/sensor.py b/homeassistant/components/flux_led/sensor.py index 8e5e1b742f1..ad4b9bacbbe 100644 --- a/homeassistant/components/flux_led/sensor.py +++ b/homeassistant/components/flux_led/sensor.py @@ -2,24 +2,22 @@ from __future__ import annotations -from homeassistant import config_entries from homeassistant.components.sensor import SensorEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import FluxLedUpdateCoordinator +from .coordinator import FluxLedConfigEntry from .entity import FluxEntity async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Magic Home sensors.""" - coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.device.paired_remotes is not None: async_add_entities( [ diff --git a/homeassistant/components/flux_led/switch.py b/homeassistant/components/flux_led/switch.py index 54311a08a34..5dea5408c84 100644 --- a/homeassistant/components/flux_led/switch.py +++ b/homeassistant/components/flux_led/switch.py @@ -8,7 +8,6 @@ from flux_led import DeviceType from flux_led.aio import AIOWifiLedBulb from flux_led.const import MODE_MUSIC -from homeassistant import config_entries from homeassistant.components.switch import SwitchEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -19,20 +18,19 @@ from .const import ( CONF_REMOTE_ACCESS_ENABLED, CONF_REMOTE_ACCESS_HOST, CONF_REMOTE_ACCESS_PORT, - DOMAIN, ) -from .coordinator import FluxLedUpdateCoordinator +from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator from .discovery import async_clear_discovery_cache from .entity import FluxBaseEntity, FluxEntity, FluxOnOffEntity async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flux lights.""" - coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[FluxSwitch | FluxRemoteAccessSwitch | FluxMusicSwitch] = [] base_unique_id = entry.unique_id or entry.entry_id @@ -70,7 +68,7 @@ class FluxRemoteAccessSwitch(FluxBaseEntity, SwitchEntity): def __init__( self, device: AIOWifiLedBulb, - entry: config_entries.ConfigEntry, + entry: FluxLedConfigEntry, ) -> None: """Initialize the light.""" super().__init__(device, entry) From 6226542e4d274d9649b99d15b8bed0ae8d135198 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 11 Feb 2025 10:21:41 -0600 Subject: [PATCH 0425/1941] Keep responding state on wake word start (#138244) * Keep responding state on wake word start * Add comment --- .../components/assist_satellite/entity.py | 5 +- .../assist_satellite/test_entity.py | 51 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index e43abb4539c..8c63525294c 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -405,7 +405,10 @@ class AssistSatelliteEntity(entity.Entity): def _internal_on_pipeline_event(self, event: PipelineEvent) -> None: """Set state based on pipeline stage.""" if event.type is PipelineEventType.WAKE_WORD_START: - self._set_state(AssistSatelliteState.IDLE) + # Only return to idle if we're not currently responding. + # The state will return to idle in tts_response_finished. + if self.state != AssistSatelliteState.RESPONDING: + self._set_state(AssistSatelliteState.IDLE) elif event.type is PipelineEventType.STT_START: self._set_state(AssistSatelliteState.LISTENING) elif event.type is PipelineEventType.INTENT_START: diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index b3437bf5c5d..42b4adf742c 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -590,3 +590,54 @@ async def test_start_conversation_reject_builtin_agent( target={"entity_id": "assist_satellite.test_entity"}, blocking=True, ) + + +async def test_wake_word_start_keeps_responding( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test entity state stays responding on wake word start event.""" + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == AssistSatelliteState.IDLE + + # Get into responding state + audio_stream = object() + + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" + ) as mock_start_pipeline: + await entity.async_accept_pipeline_from_satellite( + audio_stream, start_stage=PipelineStage.TTS + ) + + assert mock_start_pipeline.called + kwargs = mock_start_pipeline.call_args[1] + event_callback = kwargs["event_callback"] + event_callback(PipelineEvent(PipelineEventType.TTS_START, {})) + + state = hass.states.get(ENTITY_ID) + assert state.state == AssistSatelliteState.RESPONDING + + # Verify that starting a new wake word stream keeps the state + audio_stream = object() + + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" + ) as mock_start_pipeline: + await entity.async_accept_pipeline_from_satellite( + audio_stream, start_stage=PipelineStage.WAKE_WORD + ) + + assert mock_start_pipeline.called + kwargs = mock_start_pipeline.call_args[1] + event_callback = kwargs["event_callback"] + event_callback(PipelineEvent(PipelineEventType.WAKE_WORD_START, {})) + + state = hass.states.get(ENTITY_ID) + assert state.state == AssistSatelliteState.RESPONDING + + # Only return to idle once TTS is finished + entity.tts_response_finished() + state = hass.states.get(ENTITY_ID) + assert state.state == AssistSatelliteState.IDLE From ab1e1c06b6eb8c1ade8b3de550b4d12ccf8390fb Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:22:51 +0100 Subject: [PATCH 0426/1941] Set PARALLEL_UPDATES for MotionMount integration (#138264) Set PARALLEL_UPDATES --- homeassistant/components/motionmount/binary_sensor.py | 2 ++ homeassistant/components/motionmount/number.py | 2 ++ homeassistant/components/motionmount/quality_scale.yaml | 2 +- homeassistant/components/motionmount/select.py | 1 + homeassistant/components/motionmount/sensor.py | 2 ++ 5 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index 104c5e65830..c9d76ebb8d5 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -12,6 +12,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MotionMountConfigEntry from .entity import MotionMountEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py index b764306a6a3..3e2c1b067aa 100644 --- a/homeassistant/components/motionmount/number.py +++ b/homeassistant/components/motionmount/number.py @@ -14,6 +14,8 @@ from . import MotionMountConfigEntry from .const import DOMAIN from .entity import MotionMountEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index e4a6a04ceeb..f8fee8739e9 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -37,7 +37,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: todo diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 832a39208c6..a8fcc84f2ec 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -17,6 +17,7 @@ from .entity import MotionMountEntity _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) +PARALLEL_UPDATES = 0 async def async_setup_entry( diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 3545581dae3..4950e5d6662 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -12,6 +12,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import MotionMountConfigEntry from .entity import MotionMountEntity +PARALLEL_UPDATES = 0 + ERROR_MESSAGES: Final = { MotionMountSystemError.MotorError: "motor", MotionMountSystemError.ObstructionDetected: "obstruction", From a85bb98743de41e863463b8fce525d31052c8f00 Mon Sep 17 00:00:00 2001 From: balazs92117 Date: Tue, 11 Feb 2025 17:25:57 +0100 Subject: [PATCH 0427/1941] Dsmr eon hungary (#138162) Add EON hungary --- homeassistant/components/dsmr/config_flow.py | 2 +- homeassistant/components/dsmr/const.py | 2 +- homeassistant/components/dsmr/sensor.py | 160 ++++++++++++++++++- homeassistant/components/dsmr/strings.json | 63 ++++++++ tests/components/dsmr/conftest.py | 6 + tests/components/dsmr/test_config_flow.py | 10 ++ tests/components/dsmr/test_sensor.py | 70 ++++++++ 7 files changed, 303 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 7d6a641b006..6f15f99517b 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -59,7 +59,7 @@ class DSMRConnection: self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER if dsmr_version == "5B": self._equipment_identifier = obis_ref.BELGIUM_EQUIPMENT_IDENTIFIER - if dsmr_version == "5L": + if dsmr_version in ("5L", "5EONHU"): self._equipment_identifier = obis_ref.LUXEMBOURG_EQUIPMENT_IDENTIFIER if dsmr_version == "Q3D": self._equipment_identifier = obis_ref.Q3D_EQUIPMENT_IDENTIFIER diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 4c6cb31ca4d..2682b4df1cc 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -28,7 +28,7 @@ DEVICE_NAME_GAS = "Gas Meter" DEVICE_NAME_WATER = "Water Meter" DEVICE_NAME_HEAT = "Heat Meter" -DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} +DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D", "5EONHU"} DSMR_PROTOCOL = "dsmr_protocol" RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 245a28c62db..ba528271824 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -115,7 +115,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="electricity_active_tariff", translation_key="electricity_active_tariff", obis_reference="ELECTRICITY_ACTIVE_TARIFF", - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"}, device_class=SensorDeviceClass.ENUM, options=["low", "normal"], ), @@ -123,7 +123,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="electricity_used_tariff_1", translation_key="electricity_used_tariff_1", obis_reference="ELECTRICITY_USED_TARIFF_1", - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -131,7 +131,25 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="electricity_used_tariff_2", translation_key="electricity_used_tariff_2", obis_reference="ELECTRICITY_USED_TARIFF_2", - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key="electricity_used_tariff_3", + translation_key="electricity_used_tariff_3", + obis_reference="ELECTRICITY_USED_TARIFF_3", + dsmr_versions={"5EONHU"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key="electricity_used_tariff_4", + translation_key="electricity_used_tariff_4", + obis_reference="ELECTRICITY_USED_TARIFF_4", + dsmr_versions={"5EONHU"}, + force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -139,7 +157,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="electricity_delivered_tariff_1", translation_key="electricity_delivered_tariff_1", obis_reference="ELECTRICITY_DELIVERED_TARIFF_1", - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -147,7 +165,25 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="electricity_delivered_tariff_2", translation_key="electricity_delivered_tariff_2", obis_reference="ELECTRICITY_DELIVERED_TARIFF_2", - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key="electricity_delivered_tariff_3", + translation_key="electricity_delivered_tariff_3", + obis_reference="ELECTRICITY_DELIVERED_TARIFF_3", + dsmr_versions={"5EONHU"}, + force_update=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + DSMRSensorEntityDescription( + key="electricity_delivered_tariff_4", + translation_key="electricity_delivered_tariff_4", + obis_reference="ELECTRICITY_DELIVERED_TARIFF_4", + dsmr_versions={"5EONHU"}, + force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -341,7 +377,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="electricity_imported_total", translation_key="electricity_imported_total", obis_reference="ELECTRICITY_IMPORTED_TOTAL", - dsmr_versions={"5L", "5S", "Q3D"}, + dsmr_versions={"5L", "5S", "Q3D", "5EONHU"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -349,7 +385,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="electricity_exported_total", translation_key="electricity_exported_total", obis_reference="ELECTRICITY_EXPORTED_TOTAL", - dsmr_versions={"5L", "5S", "Q3D"}, + dsmr_versions={"5L", "5S", "Q3D", "5EONHU"}, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -387,6 +423,113 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL_INCREASING, ), + DSMRSensorEntityDescription( + key="actual_threshold_electricity", + translation_key="actual_threshold_electricity", + obis_reference="ACTUAL_TRESHOLD_ELECTRICITY", # Misspelled in external tool + dsmr_versions={"5EONHU"}, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key="eon_hu_electricity_combined", + translation_key="electricity_combined", + obis_reference="EON_HU_ELECTRICITY_COMBINED", + dsmr_versions={"5EONHU"}, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + DSMRSensorEntityDescription( + key="eon_hu_instantaneous_power_factor_total", + translation_key="instantaneous_power_factor_total", + obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_TOTAL", + dsmr_versions={"5EONHU"}, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key="eon_hu_instantaneous_power_factor_l1", + translation_key="instantaneous_power_factor_l1", + obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_L1", + dsmr_versions={"5EONHU"}, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key="eon_hu_instantaneous_power_factor_l2", + translation_key="instantaneous_power_factor_l2", + obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_L2", + dsmr_versions={"5EONHU"}, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key="eon_hu_instantaneous_power_factor_l3", + translation_key="instantaneous_power_factor_l3", + obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_L3", + dsmr_versions={"5EONHU"}, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key="eon_hu_frequency", + translation_key="frequency", + obis_reference="EON_HU_FREQUENCY", + dsmr_versions={"5EONHU"}, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key="fuse_threshold_l1", + translation_key="fuse_threshold_l1", + obis_reference="FUSE_THRESHOLD_L1", + dsmr_versions={"5EONHU"}, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key="fuse_threshold_l2", + translation_key="fuse_threshold_l2", + obis_reference="FUSE_THRESHOLD_L2", + dsmr_versions={"5EONHU"}, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key="fuse_threshold_l3", + translation_key="fuse_threshold_l3", + obis_reference="FUSE_THRESHOLD_L3", + dsmr_versions={"5EONHU"}, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key="text_message", + translation_key="text_message", + obis_reference="TEXT_MESSAGE", + dsmr_versions={"5EONHU"}, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), ) SENSORS_MBUS_DEVICE_TYPE: dict[int, tuple[DSMRSensorEntityDescription, ...]] = { @@ -844,8 +987,9 @@ class DSMREntity(SensorEntity): def translate_tariff(value: str, dsmr_version: str) -> str | None: """Convert 2/1 to normal/low depending on DSMR version.""" # DSMR V5B: Note: In Belgium values are swapped: + # DSMR V5EONHU: Note: In EON HUngary values are swapped: # Rate code 2 is used for low rate and rate code 1 is used for normal rate. - if dsmr_version == "5B": + if dsmr_version in ("5B", "5EONHU"): if value == "0001": value = "0002" elif value == "0002": diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 055c0c41264..871dd382f2b 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -61,6 +61,12 @@ "electricity_delivered_tariff_2": { "name": "Energy production (tarif 2)" }, + "electricity_delivered_tariff_3": { + "name": "Energy production (tarif 3)" + }, + "electricity_delivered_tariff_4": { + "name": "Energy production (tarif 4)" + }, "electricity_exported_total": { "name": "Energy production (total)" }, @@ -73,6 +79,12 @@ "electricity_used_tariff_2": { "name": "Energy consumption (tarif 2)" }, + "electricity_used_tariff_3": { + "name": "Energy consumption (tarif 3)" + }, + "electricity_used_tariff_4": { + "name": "Energy consumption (tarif 4)" + }, "gas_meter_reading": { "name": "Gas consumption" }, @@ -150,6 +162,57 @@ }, "water_meter_reading": { "name": "Water consumption" + }, + "actual_threshold_electricity": { + "name": "Actual threshold electricity" + }, + "electricity_reactive_imported_total": { + "name": "Imported reactive electricity (total)" + }, + "electricity_reactive_exported_total": { + "name": "Exported reactive electricity (total)" + }, + "electricity_reactive_total_q1": { + "name": "Reactive electricity (Q1)" + }, + "electricity_reactive_total_q2": { + "name": "Reactive electricity (Q2)" + }, + "electricity_reactive_total_q3": { + "name": "Reactive electricity (Q3)" + }, + "electricity_reactive_total_q4": { + "name": "Reactive electricity (Q4)" + }, + "electricity_combined": { + "name": "Energy combined" + }, + "instantaneous_power_factor_total": { + "name": "Instantaneous power factor (total)" + }, + "instantaneous_power_factor_l1": { + "name": "Instantaneous power factor L1" + }, + "instantaneous_power_factor_l2": { + "name": "Instantaneous power factor L2" + }, + "instantaneous_power_factor_l3": { + "name": "Instantaneous power factor L3" + }, + "frequency": { + "name": "Frequency" + }, + "fuse_threshold_l1": { + "name": "Fuse threshold on L1" + }, + "fuse_threshold_l2": { + "name": "Fuse threshold on L2" + }, + "fuse_threshold_l3": { + "name": "Fuse threshold on L3" + }, + "text_message": { + "name": "Text message" } } }, diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index ccb7920e141..769e98f6db4 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -113,6 +113,12 @@ def dsmr_connection_send_validate_fixture() -> Generator[ EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] ), } + if args[1] == "5EONHU": + protocol.telegram = { + LUXEMBOURG_EQUIPMENT_IDENTIFIER: CosemObject( + LUXEMBOURG_EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] + ), + } if args[1] == "5S": protocol.telegram = { P1_MESSAGE_TIMESTAMP: CosemObject( diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 91adf38eacf..961c9831f44 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -163,6 +163,16 @@ async def test_setup_network_rfxtrx( "serial_id_gas": "123456789", }, ), + ( + "5EONHU", + { + "port": "/dev/ttyUSB1234", + "dsmr_version": "5EONHU", + "protocol": "dsmr_protocol", + "serial_id": "12345678", + "serial_id_gas": None, + }, + ), ( "5S", { diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index fbe14b38aa3..5657c5999ce 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -512,6 +512,76 @@ async def test_luxembourg_meter( ) +async def test_eonhu_meter( + hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] +) -> None: + """Test if v5 meter is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5EONHU", + "serial_id": "1234", + } + entry_options = { + "time_between_update": 0, + } + + telegram = Telegram() + telegram.add( + ELECTRICITY_IMPORTED_TOTAL, + CosemObject( + (0, 0), + [{"value": Decimal("123.456"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + ), + "ELECTRICITY_IMPORTED_TOTAL", + ) + telegram.add( + ELECTRICITY_EXPORTED_TOTAL, + CosemObject( + (0, 0), + [{"value": Decimal("654.321"), "unit": UnitOfEnergy.KILO_WATT_HOUR}], + ), + "ELECTRICITY_EXPORTED_TOTAL", + ) + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") + assert active_tariff.state == "123.456" + assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + assert ( + active_tariff.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + assert ( + active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfEnergy.KILO_WATT_HOUR + ) + + active_tariff = hass.states.get("sensor.electricity_meter_energy_production_total") + assert active_tariff.state == "654.321" + assert ( + active_tariff.attributes.get("unit_of_measurement") + == UnitOfEnergy.KILO_WATT_HOUR + ) + + async def test_belgian_meter( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: From 14e1b55b5a870842b39e29017b367eaca8b3a0a2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:26:58 +0100 Subject: [PATCH 0428/1941] Do not test internals in flo tests (#138306) * Do not test internals in flo tests * fix --- tests/components/flo/snapshots/test_init.ambr | 75 +++++++++++++++++++ tests/components/flo/test_binary_sensor.py | 18 +---- tests/components/flo/test_device.py | 60 +-------------- tests/components/flo/test_init.py | 27 ++++--- tests/components/flo/test_sensor.py | 17 +---- tests/components/flo/test_services.py | 10 +-- tests/components/flo/test_switch.py | 12 +-- 7 files changed, 103 insertions(+), 116 deletions(-) create mode 100644 tests/components/flo/snapshots/test_init.ambr diff --git a/tests/components/flo/snapshots/test_init.ambr b/tests/components/flo/snapshots/test_init.ambr new file mode 100644 index 00000000000..edba0ebe162 --- /dev/null +++ b/tests/components/flo/snapshots/test_init.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_setup_entry + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '11:11:11:11:11:11', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'flo', + '98765', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Flo by Moen', + 'model': 'flo_device_075_v2', + 'model_id': None, + 'name': 'Smart water shutoff', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111111', + 'suggested_area': None, + 'sw_version': '6.1.1', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '1a:2b:3c:4d:5e:6f', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'flo', + '32839', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Flo by Moen', + 'model': 'puck_v1', + 'model_id': None, + 'name': 'Kitchen sink', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '111111111112', + 'suggested_area': None, + 'sw_version': '1.1.15', + 'via_device_id': None, + }), + ]) +# --- diff --git a/tests/components/flo/test_binary_sensor.py b/tests/components/flo/test_binary_sensor.py index 23a84734b0d..9c174abb0d6 100644 --- a/tests/components/flo/test_binary_sensor.py +++ b/tests/components/flo/test_binary_sensor.py @@ -2,18 +2,8 @@ import pytest -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - CONF_PASSWORD, - CONF_USERNAME, - STATE_OFF, - STATE_ON, -) +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID from tests.common import MockConfigEntry @@ -24,13 +14,9 @@ async def test_binary_sensors( ) -> None: """Test Flo by Moen sensors.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - valve_state = hass.states.get( "binary_sensor.smart_water_shutoff_pending_system_alerts" ) diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index c3e26e77370..b89d5a1e68c 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -7,13 +7,8 @@ from aioflo.errors import RequestError from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN -from homeassistant.components.flo.coordinator import FloDeviceDataUpdateCoordinator -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -28,59 +23,8 @@ async def test_device( ) -> None: """Test Flo by Moen devices.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - - valve: FloDeviceDataUpdateCoordinator = hass.data[FLO_DOMAIN][ - config_entry.entry_id - ]["devices"][0] - assert valve.api_client is not None - assert valve.available - assert valve.consumption_today == 3.674 - assert valve.current_flow_rate == 0 - assert valve.current_psi == 54.20000076293945 - assert valve.current_system_mode == "home" - assert valve.target_system_mode == "home" - assert valve.firmware_version == "6.1.1" - assert valve.device_type == "flo_device_v2" - assert valve.id == "98765" - assert valve.last_heard_from_time == "2020-07-24T12:45:00Z" - assert valve.location_id == "mmnnoopp" - assert valve.hass is not None - assert valve.temperature == 70 - assert valve.mac_address == "111111111111" - assert valve.model == "flo_device_075_v2" - assert valve.manufacturer == "Flo by Moen" - assert valve.device_name == "Smart Water Shutoff" - assert valve.rssi == -47 - assert valve.pending_info_alerts_count == 0 - assert valve.pending_critical_alerts_count == 0 - assert valve.pending_warning_alerts_count == 2 - assert valve.has_alerts is True - assert valve.last_known_valve_state == "open" - assert valve.target_valve_state == "open" - - detector: FloDeviceDataUpdateCoordinator = hass.data[FLO_DOMAIN][ - config_entry.entry_id - ]["devices"][1] - assert detector.api_client is not None - assert detector.available - assert detector.device_type == "puck_oem" - assert detector.id == "32839" - assert detector.last_heard_from_time == "2021-03-07T14:05:00Z" - assert detector.location_id == "mmnnoopp" - assert detector.hass is not None - assert detector.temperature == 61 - assert detector.humidity == 43 - assert detector.water_detected is False - assert detector.mac_address == "1a2b3c4d5e6f" - assert detector.model == "puck_v1" - assert detector.manufacturer == "Flo by Moen" - assert detector.device_name == "Kitchen Sink" - assert detector.serial_number == "111111111112" call_count = aioclient_mock.call_count diff --git a/tests/components/flo/test_init.py b/tests/components/flo/test_init.py index 805a6278395..c1983b898da 100644 --- a/tests/components/flo/test_init.py +++ b/tests/components/flo/test_init.py @@ -1,25 +1,32 @@ """Test init.""" import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @pytest.mark.usefixtures("aioclient_mock_fixture") -async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_setup_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test migration of config entry from v1.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 + assert config_entry.state is ConfigEntryState.LOADED + + assert ( + dr.async_entries_for_config_entry(device_registry, config_entry.entry_id) + == snapshot + ) assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/flo/test_sensor.py b/tests/components/flo/test_sensor.py index 0c763927296..828e4f3b4d5 100644 --- a/tests/components/flo/test_sensor.py +++ b/tests/components/flo/test_sensor.py @@ -2,15 +2,12 @@ import pytest -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .common import TEST_PASSWORD, TEST_USER_ID - from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -20,13 +17,9 @@ async def test_sensors(hass: HomeAssistant, config_entry: MockConfigEntry) -> No """Test Flo by Moen sensors.""" hass.config.units = US_CUSTOMARY_SYSTEM config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - # we should have 5 entities for the valve assert ( hass.states.get("sensor.smart_water_shutoff_current_system_mode").state @@ -95,13 +88,9 @@ async def test_manual_update_entity( ) -> None: """Test manual update entity via service homeasasistant/update_entity.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - await async_setup_component(hass, "homeassistant", {}) call_count = aioclient_mock.call_count diff --git a/tests/components/flo/test_services.py b/tests/components/flo/test_services.py index 565f39f69fe..980d5906a56 100644 --- a/tests/components/flo/test_services.py +++ b/tests/components/flo/test_services.py @@ -13,11 +13,8 @@ from homeassistant.components.flo.switch import ( SERVICE_SET_SLEEP_MODE, SYSTEM_MODE_HOME, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -33,12 +30,9 @@ async def test_services( ) -> None: """Test Flo services.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 assert aioclient_mock.call_count == 8 await hass.services.async_call( diff --git a/tests/components/flo/test_switch.py b/tests/components/flo/test_switch.py index 5c124d312a7..86fa8dd522d 100644 --- a/tests/components/flo/test_switch.py +++ b/tests/components/flo/test_switch.py @@ -2,13 +2,9 @@ import pytest -from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_PASSWORD, TEST_USER_ID from tests.common import MockConfigEntry @@ -19,13 +15,9 @@ async def test_valve_switches( ) -> None: """Test Flo by Moen valve switches.""" config_entry.add_to_hass(hass) - assert await async_setup_component( - hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD} - ) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 2 - entity_id = "switch.smart_water_shutoff_shutoff_valve" assert hass.states.get(entity_id).state == STATE_ON From 8e7f35aa7d0d2571b5d8f4f6f23b7995bab03bc6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:42:58 +0100 Subject: [PATCH 0429/1941] Use runtime_data in flo (#138307) --- homeassistant/components/flo/__init__.py | 20 +++++++------------ homeassistant/components/flo/binary_sensor.py | 10 +++------- homeassistant/components/flo/const.py | 1 - homeassistant/components/flo/coordinator.py | 11 ++++++++++ homeassistant/components/flo/sensor.py | 10 +++------- homeassistant/components/flo/switch.py | 10 +++------- 6 files changed, 27 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/flo/__init__.py b/homeassistant/components/flo/__init__.py index 6a497f5140d..88824b041e7 100644 --- a/homeassistant/components/flo/__init__.py +++ b/homeassistant/components/flo/__init__.py @@ -6,27 +6,23 @@ import logging from aioflo import async_get_api from aioflo.errors import RequestError -from homeassistant.config_entries import ConfigEntry 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 .const import CLIENT, DOMAIN -from .coordinator import FloDeviceDataUpdateCoordinator +from .coordinator import FloConfigEntry, FloDeviceDataUpdateCoordinator, FloRuntimeData _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FloConfigEntry) -> bool: """Set up flo from a config entry.""" session = async_get_clientsession(hass) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = {} try: - hass.data[DOMAIN][entry.entry_id][CLIENT] = client = await async_get_api( + client = await async_get_api( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session ) except RequestError as err: @@ -36,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Flo user information with locations: %s", user_info) - hass.data[DOMAIN][entry.entry_id]["devices"] = devices = [ + devices = [ FloDeviceDataUpdateCoordinator( hass, entry, client, location["id"], device["id"] ) @@ -47,14 +43,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: tasks = [device.async_refresh() for device in devices] await asyncio.gather(*tasks) + entry.runtime_data = FloRuntimeData(client=client, devices=devices) 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: FloConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/flo/binary_sensor.py b/homeassistant/components/flo/binary_sensor.py index b510bff84d7..89f317fd3c6 100644 --- a/homeassistant/components/flo/binary_sensor.py +++ b/homeassistant/components/flo/binary_sensor.py @@ -6,24 +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 AddConfigEntryEntitiesCallback -from .const import DOMAIN as FLO_DOMAIN -from .coordinator import FloDeviceDataUpdateCoordinator +from .coordinator import FloConfigEntry from .entity import FloEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FloConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flo sensors from config entry.""" - devices: list[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][ - config_entry.entry_id - ]["devices"] + devices = config_entry.runtime_data.devices entities: list[BinarySensorEntity] = [] for device in devices: if device.device_type == "puck_oem": diff --git a/homeassistant/components/flo/const.py b/homeassistant/components/flo/const.py index 9eb00ebfa62..5b1d926d9f4 100644 --- a/homeassistant/components/flo/const.py +++ b/homeassistant/components/flo/const.py @@ -4,7 +4,6 @@ import logging LOGGER = logging.getLogger(__package__) -CLIENT = "client" DOMAIN = "flo" FLO_HOME = "home" FLO_AWAY = "away" diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py index f5dc34a50cd..9f540b230f4 100644 --- a/homeassistant/components/flo/coordinator.py +++ b/homeassistant/components/flo/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any @@ -17,6 +18,16 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN as FLO_DOMAIN, LOGGER +type FloConfigEntry = ConfigEntry[FloRuntimeData] + + +@dataclass +class FloRuntimeData: + """Flo runtime data.""" + + client: API + devices: list[FloDeviceDataUpdateCoordinator] + class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): """Flo device object.""" diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index 71e5f921067..ca763839b87 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -7,7 +7,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfPressure, @@ -18,20 +17,17 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as FLO_DOMAIN -from .coordinator import FloDeviceDataUpdateCoordinator +from .coordinator import FloConfigEntry from .entity import FloEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FloConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flo sensors from config entry.""" - devices: list[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][ - config_entry.entry_id - ]["devices"] + devices = config_entry.runtime_data.devices entities = [] for device in devices: if device.device_type == "puck_oem": diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 076dcc5e21c..12e242db5c8 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -8,13 +8,11 @@ from aioflo.location import SLEEP_MINUTE_OPTIONS, SYSTEM_MODE_HOME, SYSTEM_REVER import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN as FLO_DOMAIN -from .coordinator import FloDeviceDataUpdateCoordinator +from .coordinator import FloConfigEntry, FloDeviceDataUpdateCoordinator from .entity import FloEntity ATTR_REVERT_TO_MODE = "revert_to_mode" @@ -27,13 +25,11 @@ SERVICE_RUN_HEALTH_TEST = "run_health_test" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FloConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Flo switches from config entry.""" - devices: list[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][ - config_entry.entry_id - ]["devices"] + devices = config_entry.runtime_data.devices async_add_entities( [FloSwitch(device) for device in devices if device.device_type != "puck_oem"] From 3489b20e86c5e37112caddbbff133504c7bdeb84 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Feb 2025 18:14:13 +0100 Subject: [PATCH 0430/1941] Refactor SmartThings sensor platform (#138313) --- .../components/smartthings/sensor.py | 1366 +++++++++-------- tests/components/smartthings/test_sensor.py | 27 +- 2 files changed, 716 insertions(+), 677 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index c0b079da070..3a283bb806b 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -2,15 +2,18 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import NamedTuple +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Any from pysmartthings import Attribute, Capability -from pysmartthings.device import DeviceEntity +from pysmartthings.device import DeviceEntity, DeviceStatus from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -35,530 +38,693 @@ from .const import DATA_BROKERS, DOMAIN from .entity import SmartThingsEntity -class Map(NamedTuple): - """Tuple for mapping Smartthings capabilities to Home Assistant sensors.""" - - attribute: str - name: str - default_unit: str | None - device_class: SensorDeviceClass | None - state_class: SensorStateClass | None - entity_category: EntityCategory | None +def power_attributes(status: DeviceStatus) -> 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 + return state -CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { - Capability.activity_lighting_mode: [ - Map( - Attribute.lighting_mode, - "Activity Lighting Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.air_conditioner_mode: [ - Map( - Attribute.air_conditioner_mode, - "Air Conditioner Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.air_quality_sensor: [ - Map( - Attribute.air_quality, - "Air Quality", - "CAQI", - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None, None, None)], - Capability.audio_volume: [ - Map(Attribute.volume, "Volume", PERCENTAGE, None, None, None) - ], - Capability.battery: [ - Map( - Attribute.battery, - "Battery", - PERCENTAGE, - SensorDeviceClass.BATTERY, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.body_mass_index_measurement: [ - Map( - Attribute.bmi_measurement, - "Body Mass Index", - f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.body_weight_measurement: [ - Map( - Attribute.body_weight_measurement, - "Body Weight", - UnitOfMass.KILOGRAMS, - SensorDeviceClass.WEIGHT, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.carbon_dioxide_measurement: [ - Map( - Attribute.carbon_dioxide, - "Carbon Dioxide Measurement", - CONCENTRATION_PARTS_PER_MILLION, - SensorDeviceClass.CO2, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.carbon_monoxide_detector: [ - Map( - Attribute.carbon_monoxide, - "Carbon Monoxide Detector", - None, - None, - None, - None, - ) - ], - Capability.carbon_monoxide_measurement: [ - Map( - Attribute.carbon_monoxide_level, - "Carbon Monoxide Measurement", - CONCENTRATION_PARTS_PER_MILLION, - SensorDeviceClass.CO, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.dishwasher_operating_state: [ - Map( - Attribute.machine_state, "Dishwasher Machine State", None, None, None, None - ), - Map( - Attribute.dishwasher_job_state, - "Dishwasher Job State", - None, - None, - None, - None, - ), - Map( - Attribute.completion_time, - "Dishwasher Completion Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - ], - Capability.dryer_mode: [ - Map( - Attribute.dryer_mode, - "Dryer Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.dryer_operating_state: [ - Map(Attribute.machine_state, "Dryer Machine State", None, None, None, None), - Map(Attribute.dryer_job_state, "Dryer Job State", None, None, None, None), - Map( - Attribute.completion_time, - "Dryer Completion Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - ], - Capability.dust_sensor: [ - Map( - Attribute.fine_dust_level, - "Fine Dust Level", - None, - None, - SensorStateClass.MEASUREMENT, - None, - ), - Map( - Attribute.dust_level, - "Dust Level", - None, - None, - SensorStateClass.MEASUREMENT, - None, - ), - ], - Capability.energy_meter: [ - Map( - Attribute.energy, - "Energy Meter", - UnitOfEnergy.KILO_WATT_HOUR, - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL_INCREASING, - None, - ) - ], - Capability.equivalent_carbon_dioxide_measurement: [ - Map( - Attribute.equivalent_carbon_dioxide_measurement, - "Equivalent Carbon Dioxide Measurement", - CONCENTRATION_PARTS_PER_MILLION, - SensorDeviceClass.CO2, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.formaldehyde_measurement: [ - Map( - Attribute.formaldehyde_level, - "Formaldehyde Measurement", - CONCENTRATION_PARTS_PER_MILLION, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.gas_meter: [ - Map( - Attribute.gas_meter, - "Gas Meter", - UnitOfEnergy.KILO_WATT_HOUR, - SensorDeviceClass.ENERGY, - SensorStateClass.MEASUREMENT, - None, - ), - Map( - Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None, None, None - ), - Map( - Attribute.gas_meter_time, - "Gas Meter Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - Map( - Attribute.gas_meter_volume, - "Gas Meter Volume", - UnitOfVolume.CUBIC_METERS, - SensorDeviceClass.GAS, - SensorStateClass.MEASUREMENT, - None, - ), - ], - Capability.illuminance_measurement: [ - Map( - Attribute.illuminance, - "Illuminance", - LIGHT_LUX, - SensorDeviceClass.ILLUMINANCE, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.infrared_level: [ - Map( - Attribute.infrared_level, - "Infrared Level", - PERCENTAGE, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.media_input_source: [ - Map(Attribute.input_source, "Media Input Source", None, None, None, None) - ], - Capability.media_playback_repeat: [ - Map( - Attribute.playback_repeat_mode, - "Media Playback Repeat", - None, - None, - None, - None, - ) - ], - Capability.media_playback_shuffle: [ - Map( - Attribute.playback_shuffle, "Media Playback Shuffle", None, None, None, None - ) - ], - Capability.media_playback: [ - Map(Attribute.playback_status, "Media Playback Status", None, None, None, None) - ], - Capability.odor_sensor: [ - Map(Attribute.odor_level, "Odor Sensor", None, None, None, None) - ], - Capability.oven_mode: [ - Map( - Attribute.oven_mode, - "Oven Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.oven_operating_state: [ - Map(Attribute.machine_state, "Oven Machine State", None, None, None, None), - Map(Attribute.oven_job_state, "Oven Job State", None, None, None, None), - Map(Attribute.completion_time, "Oven Completion Time", None, None, None, None), - ], - Capability.oven_setpoint: [ - Map(Attribute.oven_setpoint, "Oven Set Point", None, None, None, None) - ], - Capability.power_consumption_report: [], - Capability.power_meter: [ - Map( - Attribute.power, - "Power Meter", - UnitOfPower.WATT, - SensorDeviceClass.POWER, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.power_source: [ - Map( - Attribute.power_source, - "Power Source", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.refrigeration_setpoint: [ - Map( - Attribute.refrigeration_setpoint, - "Refrigeration Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - None, - ) - ], - Capability.relative_humidity_measurement: [ - Map( - Attribute.humidity, - "Relative Humidity Measurement", - PERCENTAGE, - SensorDeviceClass.HUMIDITY, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.robot_cleaner_cleaning_mode: [ - Map( - Attribute.robot_cleaner_cleaning_mode, - "Robot Cleaner Cleaning Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.robot_cleaner_movement: [ - Map( - Attribute.robot_cleaner_movement, - "Robot Cleaner Movement", - None, - None, - None, - None, - ) - ], - Capability.robot_cleaner_turbo_mode: [ - Map( - Attribute.robot_cleaner_turbo_mode, - "Robot Cleaner Turbo Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.signal_strength: [ - Map( - Attribute.lqi, - "LQI Signal Strength", - None, - None, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ), - Map( - Attribute.rssi, - "RSSI Signal Strength", - None, - SensorDeviceClass.SIGNAL_STRENGTH, - SensorStateClass.MEASUREMENT, - EntityCategory.DIAGNOSTIC, - ), - ], - Capability.smoke_detector: [ - Map(Attribute.smoke, "Smoke Detector", None, None, None, None) - ], - Capability.temperature_measurement: [ - Map( - Attribute.temperature, - "Temperature Measurement", - None, - SensorDeviceClass.TEMPERATURE, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.thermostat_cooling_setpoint: [ - Map( - Attribute.cooling_setpoint, - "Thermostat Cooling Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - None, - ) - ], - Capability.thermostat_fan_mode: [ - Map( - Attribute.thermostat_fan_mode, - "Thermostat Fan Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.thermostat_heating_setpoint: [ - Map( - Attribute.heating_setpoint, - "Thermostat Heating Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.thermostat_mode: [ - Map( - Attribute.thermostat_mode, - "Thermostat Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.thermostat_operating_state: [ - Map( - Attribute.thermostat_operating_state, - "Thermostat Operating State", - None, - None, - None, - None, - ) - ], - Capability.thermostat_setpoint: [ - Map( - Attribute.thermostat_setpoint, - "Thermostat Setpoint", - None, - SensorDeviceClass.TEMPERATURE, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.three_axis: [], - Capability.tv_channel: [ - Map(Attribute.tv_channel, "Tv Channel", None, None, None, None), - Map(Attribute.tv_channel_name, "Tv Channel Name", None, None, None, None), - ], - Capability.tvoc_measurement: [ - Map( - Attribute.tvoc_level, - "Tvoc Measurement", - CONCENTRATION_PARTS_PER_MILLION, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.ultraviolet_index: [ - Map( - Attribute.ultraviolet_index, - "Ultraviolet Index", - None, - None, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.voltage_measurement: [ - Map( - Attribute.voltage, - "Voltage Measurement", - UnitOfElectricPotential.VOLT, - SensorDeviceClass.VOLTAGE, - SensorStateClass.MEASUREMENT, - None, - ) - ], - Capability.washer_mode: [ - Map( - Attribute.washer_mode, - "Washer Mode", - None, - None, - None, - EntityCategory.DIAGNOSTIC, - ) - ], - Capability.washer_operating_state: [ - Map(Attribute.machine_state, "Washer Machine State", None, None, None, None), - Map(Attribute.washer_job_state, "Washer Job State", None, None, None, None), - Map( - Attribute.completion_time, - "Washer Completion Time", - None, - SensorDeviceClass.TIMESTAMP, - None, - None, - ), - ], +@dataclass(frozen=True, kw_only=True) +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 + unique_id_separator: str = "." + + +CAPABILITY_TO_SENSORS: dict[ + str, dict[str, list[SmartThingsSensorEntityDescription]] +] = { + Capability.activity_lighting_mode: { + Attribute.lighting_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.lighting_mode, + name="Activity Lighting Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.air_conditioner_mode: { + Attribute.air_conditioner_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.air_conditioner_mode, + name="Air Conditioner Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.air_quality_sensor: { + Attribute.air_quality: [ + SmartThingsSensorEntityDescription( + key=Attribute.air_quality, + name="Air Quality", + native_unit_of_measurement="CAQI", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.alarm: { + Attribute.alarm: [ + SmartThingsSensorEntityDescription( + key=Attribute.alarm, + name="Alarm", + ) + ] + }, + Capability.audio_volume: { + Attribute.volume: [ + SmartThingsSensorEntityDescription( + key=Attribute.volume, + name="Volume", + native_unit_of_measurement=PERCENTAGE, + ) + ] + }, + Capability.battery: { + Attribute.battery: [ + SmartThingsSensorEntityDescription( + key=Attribute.battery, + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.body_mass_index_measurement: { + Attribute.bmi_measurement: [ + SmartThingsSensorEntityDescription( + 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: [ + SmartThingsSensorEntityDescription( + key=Attribute.body_weight_measurement, + name="Body Weight", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.carbon_dioxide_measurement: { + 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, + ) + ] + }, + Capability.carbon_monoxide_detector: { + Attribute.carbon_monoxide: [ + SmartThingsSensorEntityDescription( + key=Attribute.carbon_monoxide, + name="Carbon Monoxide Detector", + ) + ] + }, + Capability.carbon_monoxide_measurement: { + 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, + ) + ] + }, + Capability.dishwasher_operating_state: { + Attribute.machine_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.machine_state, + name="Dishwasher Machine State", + ) + ], + Attribute.dishwasher_job_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.dishwasher_job_state, + name="Dishwasher Job State", + ) + ], + Attribute.completion_time: [ + SmartThingsSensorEntityDescription( + key=Attribute.completion_time, + name="Dishwasher Completion Time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + }, + Capability.dryer_mode: { + Attribute.dryer_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.dryer_mode, + name="Dryer Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.dryer_operating_state: { + Attribute.machine_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.machine_state, + name="Dryer Machine State", + ) + ], + Attribute.dryer_job_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.dryer_job_state, + name="Dryer Job State", + ) + ], + Attribute.completion_time: [ + SmartThingsSensorEntityDescription( + 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: [ + SmartThingsSensorEntityDescription( + key=Attribute.fine_dust_level, + name="Fine Dust Level", + state_class=SensorStateClass.MEASUREMENT, + ) + ], + Attribute.dust_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.dust_level, + name="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, + state_class=SensorStateClass.TOTAL_INCREASING, + ) + ] + }, + Capability.equivalent_carbon_dioxide_measurement: { + Attribute.equivalent_carbon_dioxide_measurement: [ + SmartThingsSensorEntityDescription( + key=Attribute.equivalent_carbon_dioxide_measurement, + name="Equivalent Carbon Dioxide Measurement", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.formaldehyde_measurement: { + Attribute.formaldehyde_level: [ + SmartThingsSensorEntityDescription( + 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: [ + SmartThingsSensorEntityDescription( + 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: [ + SmartThingsSensorEntityDescription( + key=Attribute.gas_meter_calorific, + name="Gas Meter Calorific", + ) + ], + Attribute.gas_meter_time: [ + SmartThingsSensorEntityDescription( + key=Attribute.gas_meter_time, + name="Gas Meter Time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + 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, + ) + ], + }, + Capability.illuminance_measurement: { + Attribute.illuminance: [ + SmartThingsSensorEntityDescription( + key=Attribute.illuminance, + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + ) + ] + }, + Capability.infrared_level: { + Attribute.infrared_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.infrared_level, + name="Infrared Level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.media_input_source: { + Attribute.input_source: [ + SmartThingsSensorEntityDescription( + key=Attribute.input_source, + name="Media Input Source", + ) + ] + }, + Capability.media_playback_repeat: { + Attribute.playback_repeat_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.playback_repeat_mode, + name="Media Playback Repeat", + ) + ] + }, + Capability.media_playback_shuffle: { + Attribute.playback_shuffle: [ + SmartThingsSensorEntityDescription( + key=Attribute.playback_shuffle, + name="Media Playback Shuffle", + ) + ] + }, + Capability.media_playback: { + Attribute.playback_status: [ + SmartThingsSensorEntityDescription( + key=Attribute.playback_status, + name="Media Playback Status", + ) + ] + }, + Capability.odor_sensor: { + Attribute.odor_level: [ + SmartThingsSensorEntityDescription( + key=Attribute.odor_level, + name="Odor Sensor", + ) + ] + }, + Capability.oven_mode: { + Attribute.oven_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.oven_mode, + name="Oven Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.oven_operating_state: { + Attribute.machine_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.machine_state, + name="Oven Machine State", + ) + ], + Attribute.oven_job_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.oven_job_state, + name="Oven Job State", + ) + ], + Attribute.completion_time: [ + SmartThingsSensorEntityDescription( + key=Attribute.completion_time, + name="Oven Completion Time", + ) + ], + }, + Capability.oven_setpoint: { + Attribute.oven_setpoint: [ + SmartThingsSensorEntityDescription( + key=Attribute.oven_setpoint, + name="Oven Set Point", + ) + ] + }, + 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 + ), + ), + SmartThingsSensorEntityDescription( + key="power_meter", + name="power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda value: value.get("power"), + extra_state_attributes_fn=power_attributes, + ), + SmartThingsSensorEntityDescription( + key="deltaEnergy_meter", + name="deltaEnergy", + 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 + ), + ), + SmartThingsSensorEntityDescription( + key="powerEnergy_meter", + name="powerEnergy", + 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 + ), + ), + SmartThingsSensorEntityDescription( + key="energySaved_meter", + name="energySaved", + 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 + ), + ), + ] + }, + Capability.power_meter: { + Attribute.power: [ + SmartThingsSensorEntityDescription( + key=Attribute.power, + name="Power Meter", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.power_source: { + Attribute.power_source: [ + SmartThingsSensorEntityDescription( + key=Attribute.power_source, + name="Power Source", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.refrigeration_setpoint: { + Attribute.refrigeration_setpoint: [ + SmartThingsSensorEntityDescription( + key=Attribute.refrigeration_setpoint, + name="Refrigeration Setpoint", + ) + ] + }, + Capability.relative_humidity_measurement: { + Attribute.humidity: [ + SmartThingsSensorEntityDescription( + key=Attribute.humidity, + name="Relative Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.robot_cleaner_cleaning_mode: { + Attribute.robot_cleaner_cleaning_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.robot_cleaner_cleaning_mode, + name="Robot Cleaner Cleaning Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.robot_cleaner_movement: { + Attribute.robot_cleaner_movement: [ + SmartThingsSensorEntityDescription( + key=Attribute.robot_cleaner_movement, + name="Robot Cleaner Movement", + ) + ] + }, + Capability.robot_cleaner_turbo_mode: { + Attribute.robot_cleaner_turbo_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.robot_cleaner_turbo_mode, + name="Robot Cleaner Turbo Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.signal_strength: { + Attribute.lqi: [ + SmartThingsSensorEntityDescription( + key=Attribute.lqi, + name="LQI Signal Strength", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ], + Attribute.rssi: [ + SmartThingsSensorEntityDescription( + key=Attribute.rssi, + name="RSSI Signal Strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ], + }, + Capability.smoke_detector: { + Attribute.smoke: [ + SmartThingsSensorEntityDescription( + key=Attribute.smoke, + name="Smoke Detector", + ) + ] + }, + Capability.temperature_measurement: { + Attribute.temperature: [ + SmartThingsSensorEntityDescription( + 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: [ + SmartThingsSensorEntityDescription( + key=Attribute.cooling_setpoint, + name="Thermostat Cooling Setpoint", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ) + ] + }, + Capability.thermostat_fan_mode: { + Attribute.thermostat_fan_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.thermostat_fan_mode, + name="Thermostat Fan Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.thermostat_heating_setpoint: { + Attribute.heating_setpoint: [ + SmartThingsSensorEntityDescription( + key=Attribute.heating_setpoint, + name="Thermostat Heating Setpoint", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.thermostat_mode: { + Attribute.thermostat_mode: [ + SmartThingsSensorEntityDescription( + key=Attribute.thermostat_mode, + name="Thermostat Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.thermostat_operating_state: { + Attribute.thermostat_operating_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.thermostat_operating_state, + name="Thermostat Operating State", + ) + ] + }, + Capability.thermostat_setpoint: { + Attribute.thermostat_setpoint: [ + SmartThingsSensorEntityDescription( + 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: [ + SmartThingsSensorEntityDescription( + key="X Coordinate", + name="X Coordinate", + unique_id_separator=" ", + value_fn=lambda value: value[0], + ), + SmartThingsSensorEntityDescription( + key="Y Coordinate", + name="Y Coordinate", + unique_id_separator=" ", + value_fn=lambda value: value[1], + ), + SmartThingsSensorEntityDescription( + key="Z Coordinate", + name="Z Coordinate", + unique_id_separator=" ", + value_fn=lambda value: value[2], + ), + ] + }, + Capability.tv_channel: { + Attribute.tv_channel: [ + SmartThingsSensorEntityDescription( + key=Attribute.tv_channel, + name="Tv Channel", + ) + ], + Attribute.tv_channel_name: [ + SmartThingsSensorEntityDescription( + key=Attribute.tv_channel_name, + name="Tv Channel Name", + ) + ], + }, + Capability.tvoc_measurement: { + Attribute.tvoc_level: [ + SmartThingsSensorEntityDescription( + 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: [ + SmartThingsSensorEntityDescription( + key=Attribute.ultraviolet_index, + name="Ultraviolet Index", + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, + Capability.voltage_measurement: { + Attribute.voltage: [ + SmartThingsSensorEntityDescription( + 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: [ + SmartThingsSensorEntityDescription( + key=Attribute.washer_mode, + name="Washer Mode", + entity_category=EntityCategory.DIAGNOSTIC, + ) + ] + }, + Capability.washer_operating_state: { + Attribute.machine_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.machine_state, + name="Washer Machine State", + ) + ], + Attribute.washer_job_state: [ + SmartThingsSensorEntityDescription( + key=Attribute.washer_job_state, + name="Washer Job State", + ) + ], + Attribute.completion_time: [ + SmartThingsSensorEntityDescription( + key=Attribute.completion_time, + name="Washer Completion Time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, + ) + ], + }, } 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 } -THREE_AXIS_NAMES = ["X Coordinate", "Y Coordinate", "Z Coordinate"] -POWER_CONSUMPTION_REPORT_NAMES = [ - "energy", - "power", - "deltaEnergy", - "powerEnergy", - "energySaved", -] - async def async_setup_entry( hass: HomeAssistant, @@ -567,59 +733,13 @@ async def async_setup_entry( ) -> None: """Add sensors for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - entities: list[SensorEntity] = [] - for device in broker.devices.values(): - for capability in broker.get_assigned(device.device_id, "sensor"): - if capability == Capability.three_axis: - entities.extend( - [ - SmartThingsThreeAxisSensor(device, index) - for index in range(len(THREE_AXIS_NAMES)) - ] - ) - elif capability == Capability.power_consumption_report: - entities.extend( - [ - SmartThingsPowerConsumptionSensor(device, report_name) - for report_name in POWER_CONSUMPTION_REPORT_NAMES - ] - ) - else: - maps = CAPABILITY_TO_SENSORS[capability] - entities.extend( - [ - SmartThingsSensor( - device, - m.attribute, - m.name, - m.default_unit, - m.device_class, - m.state_class, - m.entity_category, - ) - for m in maps - ] - ) - - if broker.any_assigned(device.device_id, "switch"): - for capability in (Capability.energy_meter, Capability.power_meter): - maps = CAPABILITY_TO_SENSORS[capability] - entities.extend( - [ - SmartThingsSensor( - device, - m.attribute, - m.name, - m.default_unit, - m.device_class, - m.state_class, - m.entity_category, - ) - for m in maps - ] - ) - - async_add_entities(entities) + 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 + ) def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: @@ -632,107 +752,43 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Sensor.""" + entity_description: SmartThingsSensorEntityDescription + def __init__( self, device: DeviceEntity, attribute: str, - name: str, - default_unit: str | None, - device_class: SensorDeviceClass | None, - state_class: str | None, - entity_category: EntityCategory | None, + entity_description: SmartThingsSensorEntityDescription, ) -> None: """Init the class.""" super().__init__(device) self._attribute = attribute - self._attr_name = f"{device.label} {name}" - self._attr_unique_id = f"{device.device_id}.{attribute}" - self._attr_device_class = device_class - self._default_unit = default_unit - self._attr_state_class = state_class - self._attr_entity_category = entity_category + 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.entity_description = entity_description @property - def native_value(self): + def native_value(self) -> str | float | int | datetime | None: """Return the state of the sensor.""" - value = self._device.status.attributes[self._attribute].value - - if self.device_class != SensorDeviceClass.TIMESTAMP: - return value - - return dt_util.parse_datetime(value) + return self.entity_description.value_fn( + self._device.status.attributes[self._attribute].value + ) @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" unit = self._device.status.attributes[self._attribute].unit - return UNITS.get(unit, unit) if unit else self._default_unit - - -class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): - """Define a SmartThings Three Axis Sensor.""" - - def __init__(self, device, index): - """Init the class.""" - super().__init__(device) - self._index = index - self._attr_name = f"{device.label} {THREE_AXIS_NAMES[index]}" - self._attr_unique_id = f"{device.device_id} {THREE_AXIS_NAMES[index]}" + return ( + UNITS.get(unit, unit) + if unit + else self.entity_description.native_unit_of_measurement + ) @property - def native_value(self): - """Return the state of the sensor.""" - three_axis = self._device.status.attributes[Attribute.three_axis].value - try: - return three_axis[self._index] - except (TypeError, IndexError): - return None - - -class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): - """Define a SmartThings Sensor.""" - - def __init__( - self, - device: DeviceEntity, - report_name: str, - ) -> None: - """Init the class.""" - super().__init__(device) - self.report_name = report_name - self._attr_name = f"{device.label} {report_name}" - self._attr_unique_id = f"{device.device_id}.{report_name}_meter" - if self.report_name == "power": - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_device_class = SensorDeviceClass.POWER - self._attr_native_unit_of_measurement = UnitOfPower.WATT - else: - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - self._attr_device_class = SensorDeviceClass.ENERGY - self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR - - @property - def native_value(self): - """Return the state of the sensor.""" - value = self._device.status.attributes[Attribute.power_consumption].value - if value is None or value.get(self.report_name) is None: - return None - if self.report_name == "power": - return value[self.report_name] - return value[self.report_name] / 1000 - - @property - def extra_state_attributes(self): - """Return specific state attributes.""" - if self.report_name == "power": - attributes = [ - "power_consumption_start", - "power_consumption_end", - ] - state_attributes = {} - for attribute in attributes: - value = getattr(self._device.status, attribute) - if value is not None: - state_attributes[attribute] = value - return state_attributes + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.extra_state_attributes_fn: + return self.entity_description.extra_state_attributes_fn( + self._device.status + ) return None diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 7e6768e4d7d..a6a48202f1d 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -4,14 +4,9 @@ The only mocking required is of the underlying SmartThings API object so real HTTP calls are not initiated during testing. """ -from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability +from pysmartthings import Attribute, Capability -from homeassistant.components.sensor import ( - DEVICE_CLASSES, - DOMAIN as SENSOR_DOMAIN, - STATE_CLASSES, -) -from homeassistant.components.smartthings import sensor +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 ( @@ -29,20 +24,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform -async def test_mapping_integrity() -> None: - """Test ensures the map dicts have proper integrity.""" - for capability, maps in sensor.CAPABILITY_TO_SENSORS.items(): - assert capability in CAPABILITIES, capability - for sensor_map in maps: - assert sensor_map.attribute in ATTRIBUTES, sensor_map.attribute - if sensor_map.device_class: - assert sensor_map.device_class in DEVICE_CLASSES, ( - sensor_map.device_class - ) - if sensor_map.state_class: - assert sensor_map.state_class in STATE_CLASSES, sensor_map.state_class - - 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}) @@ -75,7 +56,9 @@ async def test_entity_three_axis_invalid_state( ) -> None: """Tests the state attributes properly match the three axis types.""" device = device_factory( - "Three Axis", [Capability.three_axis], {Attribute.three_axis: []} + "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") From fe3d6f93d7f61fe897406dda70d4bca202f13930 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 18:56:18 +0100 Subject: [PATCH 0431/1941] Fix data_entry_flow.UnknownStep error message (#138288) --- homeassistant/data_entry_flow.py | 2 +- tests/components/config/test_config_entries.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e5ee5a79922..251e22e7990 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -561,7 +561,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): if not hasattr(flow, method): self._async_remove_flow_progress(flow.flow_id) raise UnknownStep( - f"Handler {self.__class__.__name__} doesn't support step {step_id}" + f"Handler {flow.__class__.__name__} doesn't support step {step_id}" ) async def _async_setup_preview( diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 9b5ff3c9f3e..28161c0182c 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -2887,9 +2887,7 @@ async def test_does_not_support_reconfigure( assert resp.status == HTTPStatus.BAD_REQUEST response = await resp.json() - assert response == { - "message": "Handler ConfigEntriesFlowManager doesn't support step reconfigure" - } + assert response == {"message": "Handler TestFlow doesn't support step reconfigure"} async def test_list_subentries( From 444b9a95792ead26bbcff0f80301e7f92c51315a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 19:26:59 +0100 Subject: [PATCH 0432/1941] Improve user-facing strings of denonavr for better translations (#138322) - fix sentence-casing for "network receiver" as this should be translated - change "Ethernet" to upper-case - replace "True/false for enable/disable" with UI-friendly description --- homeassistant/components/denonavr/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/denonavr/strings.json b/homeassistant/components/denonavr/strings.json index 6c055c5932a..192ab3bd71f 100644 --- a/homeassistant/components/denonavr/strings.json +++ b/homeassistant/components/denonavr/strings.json @@ -23,14 +23,14 @@ } }, "error": { - "discovery_error": "Failed to discover a Denon AVR Network Receiver" + "discovery_error": "Failed to discover a Denon AVR network receiver" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "cannot_connect": "Failed to connect, please try again, disconnecting mains power and ethernet cables and reconnecting them may help", - "not_denonavr_manufacturer": "Not a Denon AVR Network Receiver, discovered manufacturer did not match", - "not_denonavr_missing": "Not a Denon AVR Network Receiver, discovery information not complete" + "cannot_connect": "Failed to connect, please try again, disconnecting mains power and Ethernet cables and reconnecting them may help", + "not_denonavr_manufacturer": "Not a Denon AVR network receiver, discovered manufacturer did not match", + "not_denonavr_missing": "Not a Denon AVR network receiver, discovery information not complete" } }, "options": { @@ -64,7 +64,7 @@ "fields": { "dynamic_eq": { "name": "Dynamic equalizer", - "description": "True/false for enable/disable." + "description": "Whether DynamicEQ should be enabled or disabled." } } }, From 2cea2258a2affa0577659fc146365810a6595785 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Feb 2025 19:27:27 +0100 Subject: [PATCH 0433/1941] Improve type hints in forked_daapd coordinator (#138287) --- .../components/forked_daapd/coordinator.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py index 7a03a9075ed..2db0a75c429 100644 --- a/homeassistant/components/forked_daapd/coordinator.py +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -3,8 +3,13 @@ from __future__ import annotations import asyncio +from collections.abc import Sequence import logging +from typing import Any +from pyforked_daapd import ForkedDaapdAPI + +from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -26,15 +31,15 @@ WEBSOCKET_RECONNECT_TIME = 30 # seconds class ForkedDaapdUpdater: """Manage updates for the forked-daapd device.""" - def __init__(self, hass, api, entry_id): + def __init__(self, hass: HomeAssistant, api: ForkedDaapdAPI, entry_id: str) -> None: """Initialize.""" self.hass = hass self._api = api - self.websocket_handler = None - self._all_output_ids = set() + self.websocket_handler: asyncio.Task[None] | None = None + self._all_output_ids: set[str] = set() self._entry_id = entry_id - async def async_init(self): + async def async_init(self) -> None: """Perform async portion of class initialization.""" if not (server_config := await self._api.get_request("config")): raise PlatformNotReady @@ -51,7 +56,7 @@ class ForkedDaapdUpdater: else: _LOGGER.error("Invalid websocket port") - async def _disconnected_callback(self): + async def _disconnected_callback(self) -> None: """Send update signals when the websocket gets disconnected.""" async_dispatcher_send( self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), False @@ -60,9 +65,9 @@ class ForkedDaapdUpdater: self.hass, SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), [] ) - async def _update(self, update_types): + async def _update(self, update_types_sequence: Sequence[str]) -> None: """Private update method.""" - update_types = set(update_types) + update_types = set(update_types_sequence) update_events = {} _LOGGER.debug("Updating %s", update_types) if ( @@ -127,8 +132,8 @@ class ForkedDaapdUpdater: self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), True ) - def _add_zones(self, outputs): - outputs_to_add = [] + def _add_zones(self, outputs: list[dict[str, Any]]) -> None: + outputs_to_add: list[dict[str, Any]] = [] for output in outputs: if output["id"] not in self._all_output_ids: self._all_output_ids.add(output["id"]) From 857e35b7fdaa1ed1fe34ecabe07b8d0b61a44433 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 20:22:22 +0100 Subject: [PATCH 0434/1941] Remove remaining occurrences of "true" / "false" in telegram_bot (#138329) Make the field description UI-friendly. --- homeassistant/components/telegram_bot/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 714e7b74db0..8f4894f42a7 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -96,7 +96,7 @@ }, "verify_ssl": { "name": "Verify SSL", - "description": "Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server." + "description": "Enable or disable SSL certificate verification. Disable if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server." }, "timeout": { "name": "Read timeout", @@ -530,11 +530,11 @@ }, "is_anonymous": { "name": "Is anonymous", - "description": "If the poll needs to be anonymous, defaults to True." + "description": "If the poll needs to be anonymous. This is the default." }, "allows_multiple_answers": { "name": "Allow multiple answers", - "description": "If the poll allows multiple answers, defaults to False." + "description": "If the poll allows multiple answers." }, "open_period": { "name": "Open period", From 7a556ac3ec6aee789f982ba4aebb5e7e37f5e7c0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Feb 2025 20:48:50 +0100 Subject: [PATCH 0435/1941] Remove "true" / "false" and key name from yeelight.set_music_mode action (#138334) --- homeassistant/components/yeelight/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index 72e400b7cf3..d53c28cb64a 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -161,11 +161,11 @@ }, "set_music_mode": { "name": "Set music mode", - "description": "Enables or disables music_mode.", + "description": "Enables or disables music mode.", "fields": { "music_mode": { "name": "Music mode", - "description": "Use true or false to enable / disable music_mode." + "description": "Whether to enable or disable music mode." } } } From 17089e822e395ac4acd829e612e39e376cdfdcd4 Mon Sep 17 00:00:00 2001 From: rrooggiieerr Date: Tue, 11 Feb 2025 21:26:13 +0100 Subject: [PATCH 0436/1941] Allow timer.finish on paused timers (#134552) * Add test for finishing already finished timer * Add test for finishing a paused timer * Allow canceled timer to be finished --- homeassistant/components/timer/__init__.py | 4 +++- tests/components/timer/test_init.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index b0ade17b9c9..b472e94a5c3 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -389,13 +389,15 @@ class Timer(collection.CollectionEntity, RestoreEntity): @callback def async_finish(self) -> None: """Reset and updates the states, fire finished event.""" - if self._state != STATUS_ACTIVE or self._end is None: + if self._state == STATUS_IDLE: return if self._listener: self._listener() self._listener = None end = self._end + if end is None: + end = dt_util.utcnow().replace(microsecond=0) self._state = STATUS_IDLE self._end = None self._remaining = None diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 95baa07eaa9..3e5ecc58b5a 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -208,6 +208,12 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "event": EVENT_TIMER_FINISHED, "data": {}, }, + { + "call": SERVICE_FINISH, + "state": STATUS_IDLE, + "event": None, + "data": {}, + }, { "call": SERVICE_START, "state": STATUS_ACTIVE, @@ -244,6 +250,18 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "event": EVENT_TIMER_RESTARTED, "data": {}, }, + { + "call": SERVICE_PAUSE, + "state": STATUS_PAUSED, + "event": EVENT_TIMER_PAUSED, + "data": {}, + }, + { + "call": SERVICE_FINISH, + "state": STATUS_IDLE, + "event": EVENT_TIMER_FINISHED, + "data": {}, + }, ] expected_events = 0 From 6115def083054130da0cb488c8d3c4430f5b6c8e Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:35:03 +0100 Subject: [PATCH 0437/1941] Bump pyenphase to 1.25.1 (#138327) * Bump pyenphase to 1.25.1 * Add new opt_schedules to nephase_envoy test fixtures --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/enphase_envoy/fixtures/envoy_1p_metered.json | 3 ++- tests/components/enphase_envoy/fixtures/envoy_acb_batt.json | 3 ++- tests/components/enphase_envoy/fixtures/envoy_eu_batt.json | 3 ++- .../enphase_envoy/fixtures/envoy_metered_batt_relay.json | 3 ++- .../enphase_envoy/fixtures/envoy_nobatt_metered_3p.json | 3 ++- .../enphase_envoy/fixtures/envoy_tot_cons_metered.json | 3 ++- 9 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 0b1fd8b04b9..e51a7427504 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.23.1"], + "requirements": ["pyenphase==1.25.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 5ca792b1705..84e5619cd47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1933,7 +1933,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.1 +pyenphase==1.25.1 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0bfebe5673..08c3f8287bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1577,7 +1577,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.1 +pyenphase==1.25.1 # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json index 05a6f265dfb..22aeca50ca0 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json @@ -93,7 +93,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json index 618b40027b8..52e812f979e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json @@ -235,7 +235,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json index 8118630200f..30fbc8d0f4f 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json @@ -223,7 +223,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 7affc1bea0d..6cfbfed1e8e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -427,7 +427,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json index ff975b690ed..8c2767e33e5 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json +++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json @@ -242,7 +242,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 62df69c6d88..15cf2c173cb 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -88,7 +88,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, From 6abf7b525abfcab4e0b7e439764d105a65053553 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 21:46:56 +0100 Subject: [PATCH 0438/1941] Improve test coverage of config subentries and fix related issues (#138321) Improve test coverage of config subentries --- homeassistant/config_entries.py | 6 +- .../components/config/test_config_entries.py | 93 ++- tests/test_config_entries.py | 564 +++++++++++++++++- 3 files changed, 657 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b4de9749250..a103148e3b1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1952,7 +1952,7 @@ class ConfigEntries: Raises UnknownEntry if entry is not found. """ if (entry := self.async_get_entry(entry_id)) is None: - raise UnknownEntry + raise UnknownEntry(entry_id) return entry @callback @@ -3423,7 +3423,7 @@ class ConfigSubentryFlow( if data_updates is not UNDEFINED: if data is not UNDEFINED: raise ValueError("Cannot set both data and data_updates") - data = entry.data | data_updates + data = subentry.data | data_updates self.hass.config_entries.async_update_subentry( entry=entry, subentry=subentry, @@ -3462,7 +3462,7 @@ class ConfigSubentryFlow( ) subentry_id = self._reconfigure_subentry_id if subentry_id not in entry.subentries: - raise UnknownEntry + raise UnknownSubEntry(subentry_id) return entry.subentries[subentry_id] diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 28161c0182c..a31836b598c 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1192,7 +1192,7 @@ async def test_subentry_flow(hass: HomeAssistant, client) -> None: async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: - """Test we can start a subentry reconfigure flow.""" + """Test we can start and finish a subentry reconfigure flow.""" class TestFlow(core_ce.ConfigFlow): class SubentryFlowHandler(core_ce.ConfigSubentryFlow): @@ -1203,6 +1203,14 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: raise NotImplementedError async def async_step_reconfigure(self, user_input=None): + if user_input is not None: + return self.async_update_and_abort( + self._get_reconfigure_entry(), + self._get_reconfigure_subentry(), + title="Test Entry", + data={"test": "blah"}, + ) + return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema({vol.Required("enabled"): bool}), @@ -1243,7 +1251,7 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: assert resp.status == HTTPStatus.OK data = await resp.json() - data.pop("flow_id") + flow_id = data.pop("flow_id") assert data == { "type": "form", "handler": ["test1", "test"], @@ -1255,6 +1263,87 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: "preview": None, } + with mock_config_flow("test", TestFlow): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"enabled": True}, + ) + assert resp.status == HTTPStatus.OK + + entries = hass.config_entries.async_entries("test") + assert len(entries) == 1 + + data = await resp.json() + data.pop("flow_id") + assert data == { + "handler": ["test1", "test"], + "reason": "reconfigure_successful", + "type": "abort", + "description_placeholders": None, + } + + entry = hass.config_entries.async_entries()[0] + assert entry.subentries == { + "mock_id": core_ce.ConfigSubentry( + data={"test": "blah"}, + subentry_id="mock_id", + subentry_type="test", + title="Test Entry", + unique_id=None, + ), + } + + +async def test_subentry_does_not_support_reconfigure( + hass: HomeAssistant, client: TestClient +) -> None: + """Test a subentry flow that does not support reconfigure step.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_init(self, user_input=None): + raise NotImplementedError + + async def async_step_user(self, user_input=None): + raise NotImplementedError + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + subentries_data=[ + core_ce.ConfigSubentryData( + data={}, + subentry_id="mock_id", + subentry_type="test", + title="Title", + unique_id=None, + ) + ], + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post( + url, json={"handler": [entry.entry_id, "test"], "subentry_id": "mock_id"} + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + response = await resp.json() + assert response == { + "message": "Handler SubentryFlowHandler doesn't support step reconfigure" + } + @pytest.mark.parametrize( ("endpoint", "method"), diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index a5cf3ad3a1a..cf022c42e94 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -536,6 +536,118 @@ async def test_remove_entry( assert not entity_entry_list +async def test_remove_subentry( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + entity_registry: er.EntityRegistry, +) -> None: + """Test that we can remove a subentry.""" + subentry_id = "blabla" + update_listener_calls = [] + + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock setting up entry.""" + await hass.config_entries.async_forward_entry_setups(entry, ["light"]) + return True + + mock_remove_entry = AsyncMock(return_value=None) + + entry_entity = MockEntity(unique_id="0001", name="Test Entry Entity") + subentry_entity = MockEntity(unique_id="0002", name="Test Subentry Entity") + + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Mock setting up platform.""" + async_add_entities([entry_entity]) + async_add_entities([subentry_entity], config_subentry_id=subentry_id) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=mock_setup_entry, + async_remove_entry=mock_remove_entry, + ), + ) + mock_platform( + hass, "test.light", MockPlatform(async_setup_entry=mock_setup_entry_platform) + ) + mock_platform(hass, "test.config_flow", None) + + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={"first": True}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="unique", + title="Mock title", + ) + ] + ) + + async def update_listener( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Test function.""" + assert entry.subentries == {} + update_listener_calls.append(None) + + entry.add_update_listener(update_listener) + entry.add_to_manager(manager) + + # Setup entry + await manager.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Check entity states got added + assert hass.states.get("light.test_entry_entity") is not None + assert hass.states.get("light.test_subentry_entity") is not None + assert len(hass.states.async_all()) == 2 + + # Check entities got added to entity registry + assert len(entity_registry.entities) == 2 + entry_entity_entry = entity_registry.entities["light.test_entry_entity"] + assert entry_entity_entry.config_entry_id == entry.entry_id + assert entry_entity_entry.config_subentry_id is None + subentry_entity_entry = entity_registry.entities["light.test_subentry_entity"] + assert subentry_entity_entry.config_entry_id == entry.entry_id + assert subentry_entity_entry.config_subentry_id == subentry_id + + # Remove subentry + result = manager.async_remove_subentry(entry, subentry_id) + assert len(update_listener_calls) == 1 + await hass.async_block_till_done() + + # Check that remove went well + assert result is True + + # Check the remove callback was not invoked. + assert mock_remove_entry.call_count == 0 + + # Check that the config subentry was removed. + assert entry.subentries == {} + + # Check that entity state has been removed + assert hass.states.get("light.test_entry_entity") is not None + assert hass.states.get("light.test_subentry_entity") is None + assert len(hass.states.async_all()) == 1 + + # Check that entity registry entry has been removed + entity_entry_list = list(entity_registry.entities) + assert entity_entry_list == ["light.test_entry_entity"] + + # Try to remove the subentry again + with pytest.raises(config_entries.UnknownSubEntry): + manager.async_remove_subentry(entry, subentry_id) + assert len(update_listener_calls) == 1 + + async def test_remove_entry_non_unique_unique_id( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -1544,6 +1656,63 @@ async def test_update_entry_options_and_trigger_listener( assert len(update_listener_calls) == 1 +async def test_updating_subentry_data( + manager: config_entries.ConfigEntries, freezer: FrozenDateTimeFactory +) -> None: + """Test that we can update an entry data.""" + created = dt_util.utcnow() + subentry_id = "blabla" + entry = MockConfigEntry( + subentries_data=[ + config_entries.ConfigSubentryData( + data={"first": True}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="unique", + title="Mock title", + ) + ] + ) + subentry = entry.subentries[subentry_id] + entry.add_to_manager(manager) + + assert len(manager.async_entries()) == 1 + assert manager.async_entries()[0] == entry + assert entry.created_at == created + assert entry.modified_at == created + + freezer.tick() + + assert manager.async_update_subentry(entry, subentry) is False + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"first": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="unique", + ) + } + assert entry.modified_at == created + assert manager.async_entries()[0].modified_at == created + + freezer.tick() + modified = dt_util.utcnow() + + assert manager.async_update_subentry(entry, subentry, data={"second": True}) is True + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="unique", + ) + } + assert entry.modified_at == modified + assert manager.async_entries()[0].modified_at == modified + + async def test_update_subentry_and_trigger_listener( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -1575,12 +1744,27 @@ async def test_update_subentry_and_trigger_listener( assert entry.subentries == expected_subentries assert len(update_listener_calls) == 1 + assert ( + manager.async_update_subentry( + entry, + subentry, + data={"test": "test2"}, + title="New title", + unique_id="test2", + ) + is True + ) + + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.subentries == expected_subentries + assert len(update_listener_calls) == 2 + expected_subentries = {} assert manager.async_remove_subentry(entry, subentry.subentry_id) is True await hass.async_block_till_done(wait_background_tasks=True) assert entry.subentries == expected_subentries - assert len(update_listener_calls) == 2 + assert len(update_listener_calls) == 3 async def test_setup_raise_not_ready( @@ -2039,6 +2223,58 @@ async def test_entry_subentry( } +async def test_subentry_flow( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can execute a subentry flow.""" + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + entry = MockConfigEntry(domain="test", data={"first": True}) + entry.add_to_manager(manager) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + """Test subentry flow handler.""" + + async def async_step_user(self, user_input=None): + return self.async_create_entry( + title="Mock title", + data={"second": True}, + unique_id="test", + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("test", TestFlow): + result = await manager.subentries.async_init( + (entry.entry_id, "test"), context={"source": "user"} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + assert entry.data == {"first": True} + assert entry.options == {} + subentry_id = list(entry.subentries)[0] + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"second": True}, + subentry_id=subentry_id, + subentry_type="test", + title="Mock title", + unique_id="test", + ) + } + assert entry.supported_subentry_types == { + "test": {"supports_reconfigure": False} + } + + async def test_entry_subentry_non_string( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -6002,6 +6238,207 @@ async def test_update_entry_and_reload( assert len(comp.async_unload_entry.mock_calls) == calls_entry_load_unload[1] +@pytest.mark.parametrize( + ( + "kwargs", + "expected_title", + "expected_unique_id", + "expected_data", + "raises", + ), + [ + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + }, + "Updated title", + "5678", + {"vendor": "data2"}, + None, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + None, + ), + ( + {}, + "Test", + "1234", + {"vendor": "data"}, + None, + ), + ( + { + "data": {"buyer": "me"}, + }, + "Test", + "1234", + {"buyer": "me"}, + None, + ), + ( + {"data_updates": {"buyer": "me"}}, + "Test", + "1234", + {"vendor": "data", "buyer": "me"}, + None, + ), + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + "data_updates": {"buyer": "me"}, + }, + "Test", + "1234", + {"vendor": "data"}, + ValueError, + ), + ], + ids=[ + "changed_entry_default", + "unchanged_entry_default", + "no_kwargs", + "replace_data", + "update_data", + "update_and_data_raises", + ], +) +async def test_update_subentry_and_abort( + hass: HomeAssistant, + expected_title: str, + expected_unique_id: str, + expected_data: dict[str, Any], + kwargs: dict[str, Any], + raises: type[Exception] | None, +) -> None: + """Test updating an entry and reloading.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + subentry = entry.subentries[subentry_id] + + comp = MockModule("comp") + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_and_abort( + self._get_reconfigure_entry(), + self._get_reconfigure_subentry(), + **kwargs, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + err: Exception + with mock_config_flow("comp", TestFlow): + try: + result = await entry.start_subentry_reconfigure_flow( + hass, "test", subentry_id + ) + except Exception as ex: # noqa: BLE001 + err = ex + + await hass.async_block_till_done() + + subentry = entry.subentries[subentry_id] + assert subentry.title == expected_title + assert subentry.unique_id == expected_unique_id + assert subentry.data == expected_data + if raises: + assert isinstance(err, raises) + else: + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +async def test_reconfigure_subentry_create_subentry(hass: HomeAssistant) -> None: + """Test it's not allowed to create a subentry from a subentry reconfigure flow.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + + comp = MockModule("comp") + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_create_entry(title="New Subentry", data={}) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with ( + mock_config_flow("comp", TestFlow), + pytest.raises(ValueError, match="Source is reconfigure, expected user"), + ): + await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) + + await hass.async_block_till_done() + + assert entry.subentries == { + subentry_id: config_entries.ConfigSubentry( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + title="Test", + unique_id="1234", + ) + } + + @pytest.mark.parametrize("unique_id", [["blah", "bleh"], {"key": "value"}]) async def test_unhashable_unique_id_fails( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, unique_id: Any @@ -6545,6 +6982,23 @@ async def test_updating_non_added_entry_raises(hass: HomeAssistant) -> None: hass.config_entries.async_update_entry(entry, unique_id="new_id") +async def test_updating_non_added_subentry_raises(hass: HomeAssistant) -> None: + """Test updating a non added entry raises UnknownEntry.""" + entry = MockConfigEntry(domain="test") + subentry = config_entries.ConfigSubentry( + data={}, + subentry_type="test", + title="Mock title", + unique_id="unique", + ) + + with pytest.raises(config_entries.UnknownEntry, match=entry.entry_id): + hass.config_entries.async_update_subentry(entry, subentry, unique_id="new_id") + entry.add_to_hass(hass) + with pytest.raises(config_entries.UnknownSubEntry, match=subentry.subentry_id): + hass.config_entries.async_update_subentry(entry, subentry, unique_id="new_id") + + async def test_reload_during_setup(hass: HomeAssistant) -> None: """Test reload during setup waits.""" entry = MockConfigEntry(domain="comp", data={"value": "initial"}) @@ -7488,6 +7942,114 @@ async def test_get_reconfigure_entry( assert result["reason"] == "Source is user, expected reconfigure: -" +async def test_subentry_get_reconfigure_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test subentry _get_reconfigure_entry and _get_reconfigure_subentry behavior.""" + subentry_id = "mock_subentry_id" + entry = MockConfigEntry( + data={}, + domain="test", + entry_id="mock_entry_id", + title="entry_title", + unique_id="entry_unique_id", + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + + entry.add_to_hass(hass) + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + """Test user step.""" + return await self._async_step_confirm() + + async def async_step_reconfigure(self, user_input=None): + """Test reauth step.""" + return await self._async_step_confirm() + + async def _async_step_confirm(self): + """Confirm input.""" + try: + entry = self._get_reconfigure_entry() + except ValueError as err: + reason = str(err) + else: + reason = f"Found entry {entry.title}" + try: + entry_id = self._reconfigure_entry_id + except ValueError: + reason = f"{reason}: -" + else: + reason = f"{reason}: {entry_id}" + + try: + subentry = self._get_reconfigure_subentry() + except ValueError as err: + reason = f"{reason}/{err}" + except config_entries.UnknownSubEntry: + reason = f"{reason}/Subentry not found" + else: + reason = f"{reason}/Found subentry {subentry.title}" + try: + subentry_id = self._reconfigure_subentry_id + except ValueError: + reason = f"{reason}: -" + else: + reason = f"{reason}: {subentry_id}" + return self.async_abort(reason=reason) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + # A reconfigure flow finds the config entry + with mock_config_flow("test", TestFlow): + result = await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) + assert ( + result["reason"] + == "Found entry entry_title: mock_entry_id/Found subentry Test: mock_subentry_id" + ) + + # The subentry_id does not exist + with mock_config_flow("test", TestFlow): + result = await manager.subentries.async_init( + (entry.entry_id, "test"), + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "subentry_id": "01JRemoved", + }, + ) + assert ( + result["reason"] + == "Found entry entry_title: mock_entry_id/Subentry not found: 01JRemoved" + ) + + # A user flow does not have access to the config entry or subentry + with mock_config_flow("test", TestFlow): + result = await manager.subentries.async_init( + (entry.entry_id, "test"), context={"source": config_entries.SOURCE_USER} + ) + assert ( + result["reason"] + == "Source is user, expected reconfigure: -/Source is user, expected reconfigure: -" + ) + + async def test_reauth_helper_alignment( hass: HomeAssistant, manager: config_entries.ConfigEntries, From 8d5f927b42b2de36edfb8a49c7466f6dbb4c6357 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Feb 2025 12:47:36 -0800 Subject: [PATCH 0439/1941] Fix next authentication token error handling (#138299) --- homeassistant/components/nest/__init__.py | 13 +++--- tests/components/nest/test_init.py | 54 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 67c14bbf544..af85f1fc5ae 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus import logging -from aiohttp import web +from aiohttp import ClientError, ClientResponseError, web from google_nest_sdm.camera_traits import CameraClipPreviewTrait from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage @@ -201,11 +201,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool auth = await api.new_auth(hass, entry) try: await auth.async_get_access_token() - except AuthException as err: - raise ConfigEntryAuthFailed(f"Authentication error: {err!s}") from err - except ConfigurationException as err: - _LOGGER.error("Configuration error: %s", err) - return False + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err subscriber = await api.new_subscriber(hass, entry, auth) if not subscriber: diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index 7d04624dcc8..c7ac5875403 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -9,10 +9,12 @@ relevant modes. """ from collections.abc import Generator +import datetime from http import HTTPStatus import logging from unittest.mock import AsyncMock, patch +import aiohttp from google_nest_sdm.exceptions import ( ApiException, AuthException, @@ -22,6 +24,7 @@ from google_nest_sdm.exceptions import ( import pytest from homeassistant.components.nest import DOMAIN +from homeassistant.components.nest.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -36,6 +39,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker PLATFORM = "sensor" +EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() + @pytest.fixture def platforms() -> list[str]: @@ -139,6 +144,55 @@ async def test_setup_device_manager_failure( assert entries[0].state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("token_expiration_time", [EXPIRED_TOKEN_TIMESTAMP]) +@pytest.mark.parametrize( + ("token_response_args", "expected_state", "expected_steps"), + [ + # Cases that retry integration setup + ( + {"status": HTTPStatus.INTERNAL_SERVER_ERROR}, + ConfigEntryState.SETUP_RETRY, + [], + ), + ({"exc": aiohttp.ClientError("No internet")}, ConfigEntryState.SETUP_RETRY, []), + # Cases that require the user to reauthenticate in a config flow + ( + {"status": HTTPStatus.BAD_REQUEST}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ( + {"status": HTTPStatus.FORBIDDEN}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ], +) +async def test_expired_token_refresh_error( + hass: HomeAssistant, + setup_base_platform: PlatformSetup, + aioclient_mock: AiohttpClientMocker, + token_response_args: dict, + expected_state: ConfigEntryState, + expected_steps: list[str], +) -> None: + """Test errors when attempting to refresh the auth token.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + **token_response_args, + ) + + await setup_base_platform() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is expected_state + + flows = hass.config_entries.flow.async_progress() + assert expected_steps == [flow["step_id"] for flow in flows] + + @pytest.mark.parametrize("subscriber_side_effect", [AuthException()]) async def test_subscriber_auth_failure( hass: HomeAssistant, From 0ffbe076beb9c9a0016cb9e6423fdcfce4440e43 Mon Sep 17 00:00:00 2001 From: rrooggiieerr Date: Tue, 11 Feb 2025 22:08:18 +0100 Subject: [PATCH 0440/1941] Fix timer.cancel action fires timer.cancelled event even on canceled timers (#134507) * Fixes https://github.com/home-assistant/core/issues/116105 * Fixes unit test in accordance to documentation Timer needs to be active before it can be canceled * Allow canceling of paused timers * Add test for canceling/finishing already canceled/finished timers * Add test for finishing a paused timer, this should not be possible * Revert finish related tests * Merge branch 'timer.cancelled_fix' of git@github.com:rrooggiieerr/homeassistant-core.git into timer.cancelled_fix --------- Co-authored-by: Franck Nijhof --- homeassistant/components/timer/__init__.py | 3 +++ tests/components/timer/test_init.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index b472e94a5c3..3cf8307e9b3 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -374,6 +374,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): @callback def async_cancel(self) -> None: """Cancel a timer.""" + if self._state == STATUS_IDLE: + return + if self._listener: self._listener() self._listener = None diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 3e5ecc58b5a..6e68b354087 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -196,6 +196,12 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: "event": EVENT_TIMER_CANCELLED, "data": {}, }, + { + "call": SERVICE_CANCEL, + "state": STATUS_IDLE, + "event": None, + "data": {}, + }, { "call": SERVICE_START, "state": STATUS_ACTIVE, From 48b8ec01e3eca3c3f985133366eb16e65363ba60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 11 Feb 2025 22:05:19 +0000 Subject: [PATCH 0441/1941] Add logs to Cloud component support package (#138230) * Add logs to Cloud component support package * Add section for logs * Replace list with deque * Copy the deque to avoid mutation during iteration --- homeassistant/components/cloud/__init__.py | 26 +++++++++++++++- homeassistant/components/cloud/const.py | 2 ++ homeassistant/components/cloud/helpers.py | 31 +++++++++++++++++++ homeassistant/components/cloud/http_api.py | 21 +++++++++++-- .../cloud/snapshots/test_http_api.ambr | 11 +++++++ tests/components/cloud/test_http_api.py | 16 ++++++++++ 6 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/cloud/helpers.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 55ffedd2781..4528d9aa225 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Awaitable, Callable from datetime import datetime, timedelta from enum import Enum +import logging from typing import cast from hass_nabucasa import Cloud @@ -19,6 +20,7 @@ from homeassistant.const import ( CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_STOP, + FORMAT_DATETIME, Platform, ) from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback @@ -33,7 +35,7 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass +from homeassistant.loader import async_get_integration, bind_hass from homeassistant.util.signal_type import SignalType # Pre-import backup to avoid it being imported @@ -62,11 +64,13 @@ from .const import ( CONF_THINGTALK_SERVER, CONF_USER_POOL_ID, DATA_CLOUD, + DATA_CLOUD_LOG_HANDLER, DATA_PLATFORMS_SETUP, DOMAIN, MODE_DEV, MODE_PROD, ) +from .helpers import FixedSizeQueueLogHandler from .prefs import CloudPreferences from .repairs import async_manage_legacy_subscription_issue from .subscription import async_subscription_info @@ -245,6 +249,8 @@ def async_remote_ui_url(hass: HomeAssistant) -> str: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the Home Assistant cloud.""" + log_handler = hass.data[DATA_CLOUD_LOG_HANDLER] = await _setup_log_handler(hass) + # Process configs if DOMAIN in config: kwargs = dict(config[DOMAIN]) @@ -267,6 +273,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _shutdown(event: Event) -> None: """Shutdown event.""" await cloud.stop() + logging.root.removeHandler(log_handler) + del hass.data[DATA_CLOUD_LOG_HANDLER] hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) @@ -405,3 +413,19 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None: async_register_admin_service( hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) + + +async def _setup_log_handler(hass: HomeAssistant) -> FixedSizeQueueLogHandler: + fmt = ( + "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s" + ) + handler = FixedSizeQueueLogHandler() + handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) + + integration = await async_get_integration(hass, DOMAIN) + loggers: set[str] = {"snitun", integration.pkg_path, *(integration.loggers or [])} + + for logger_name in loggers: + logging.getLogger(logger_name).addHandler(handler) + + return handler diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 3883f19d1b7..e0c15c74cab 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -12,12 +12,14 @@ if TYPE_CHECKING: from hass_nabucasa import Cloud from .client import CloudClient + from .helpers import FixedSizeQueueLogHandler DOMAIN = "cloud" DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN) DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey( "cloud_platforms_setup" ) +DATA_CLOUD_LOG_HANDLER: HassKey[FixedSizeQueueLogHandler] = HassKey("cloud_log_handler") EVENT_CLOUD_EVENT = "cloud_event" REQUEST_TIMEOUT = 10 diff --git a/homeassistant/components/cloud/helpers.py b/homeassistant/components/cloud/helpers.py new file mode 100644 index 00000000000..7795a314fb7 --- /dev/null +++ b/homeassistant/components/cloud/helpers.py @@ -0,0 +1,31 @@ +"""Helpers for the cloud component.""" + +from collections import deque +import logging + +from homeassistant.core import HomeAssistant + + +class FixedSizeQueueLogHandler(logging.Handler): + """Log handler to store messages, with auto rotation.""" + + MAX_RECORDS = 500 + + def __init__(self) -> None: + """Initialize a new LogHandler.""" + super().__init__() + self._records: deque[logging.LogRecord] = deque(maxlen=self.MAX_RECORDS) + + def emit(self, record: logging.LogRecord) -> None: + """Store log message.""" + self._records.append(record) + + async def get_logs(self, hass: HomeAssistant) -> list[str]: + """Get stored logs.""" + + def _get_logs() -> list[str]: + # copy the queue since it can mutate while iterating + records = self._records.copy() + return [self.format(record) for record in records] + + return await hass.async_add_executor_job(_get_logs) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index b1a845ef8b0..af1c72f54f6 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -43,6 +43,7 @@ from .assist_pipeline import async_create_cloud_pipeline from .client import CloudClient from .const import ( DATA_CLOUD, + DATA_CLOUD_LOG_HANDLER, EVENT_CLOUD_EVENT, LOGIN_MFA_TIMEOUT, PREF_ALEXA_REPORT_STATE, @@ -397,8 +398,11 @@ class DownloadSupportPackageView(HomeAssistantView): url = "/api/cloud/support_package" name = "api:cloud:support_package" - def _generate_markdown( - self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]] + async def _generate_markdown( + self, + hass: HomeAssistant, + hass_info: dict[str, Any], + domains_info: dict[str, dict[str, str]], ) -> str: def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: if len(domain_info) == 0: @@ -424,6 +428,17 @@ class DownloadSupportPackageView(HomeAssistantView): "\n\n" ) + log_handler = hass.data[DATA_CLOUD_LOG_HANDLER] + logs = "\n".join(await log_handler.get_logs(hass)) + markdown += ( + "## Full logs\n\n" + "
Logs\n\n" + "```logs\n" + f"{logs}\n" + "```\n\n" + "
\n" + ) + return markdown async def get(self, request: web.Request) -> web.Response: @@ -433,7 +448,7 @@ class DownloadSupportPackageView(HomeAssistantView): domain_health = await get_system_health_info(hass) hass_info = domain_health.pop("homeassistant", {}) - markdown = self._generate_markdown(hass_info, domain_health) + markdown = await self._generate_markdown(hass, hass_info, domain_health) return web.Response( body=markdown, diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index 9b2f2e0eb33..b15cd08c23a 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -44,6 +44,17 @@ + ## Full logs + +
Logs + + ```logs + 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log + 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log + 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log + ``` + +
''' # --- diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index e4a526ceadd..c8852b911e9 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -2,12 +2,15 @@ from collections.abc import Callable, Coroutine from copy import deepcopy +import datetime from http import HTTPStatus import json +import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp +from freezegun.api import FrozenDateTimeFactory from hass_nabucasa import thingtalk from hass_nabucasa.auth import ( InvalidTotpCode, @@ -1869,15 +1872,18 @@ async def test_logout_view_dispatch_event( assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"} +@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3) async def test_download_support_package( hass: HomeAssistant, cloud: MagicMock, set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, ) -> None: """Test downloading a support package file.""" + aioclient_mock.get("https://cloud.bla.com/status", text="") aioclient_mock.get( "https://cert-server/directory", exc=Exception("Unexpected exception") @@ -1936,6 +1942,16 @@ async def test_download_support_package( } ) + now = dt_util.utcnow() + freezer.move_to(datetime.datetime.fromisoformat("2025-02-10T12:00:00.0+00:00")) + logging.getLogger("hass_nabucasa.iot").info( + "This message will be dropped since this test patches MAX_RECORDS" + ) + logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log") + logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log") + logging.getLogger("homeassistant.components.cloud.client").error("Cloud log") + freezer.move_to(now) # Reset time otherwise hass_client auth fails + cloud_client = await hass_client() with ( patch.object(hass.config, "config_dir", new="config"), From 117a71cb672cbb9d43ddbba8ea38bc1863f07e6b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 01:04:05 +0100 Subject: [PATCH 0442/1941] Bump sentry-sdk to 1.45.1 (#138349) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 425225e07ef..4c3a7518085 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.40.3"] + "requirements": ["sentry-sdk==1.45.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 84e5619cd47..cf33b7966c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2703,7 +2703,7 @@ sensorpush-ble==1.7.1 sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08c3f8287bb..5c5482fde6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2182,7 +2182,7 @@ sensorpush-ble==1.7.1 sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 From da1e3c29edc4954cb08e62d4dbcb8343da00a34c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Feb 2025 16:05:23 -0800 Subject: [PATCH 0443/1941] Update anthropic to use the streaming API (#138256) --- .../components/anthropic/conversation.py | 117 +++++--- .../components/anthropic/test_conversation.py | 272 ++++++++++++------ 2 files changed, 262 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 9f513509ce7..5511119d377 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -1,16 +1,23 @@ """Conversation support for Anthropic.""" -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable import json -from typing import Any, Literal, cast +from typing import Any, Literal import anthropic +from anthropic import AsyncStream from anthropic._types import NOT_GIVEN from anthropic.types import ( + InputJSONDelta, Message, MessageParam, + MessageStreamEvent, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawContentBlockStopEvent, TextBlock, TextBlockParam, + TextDelta, ToolParam, ToolResultBlockParam, ToolUseBlock, @@ -109,7 +116,7 @@ def _convert_content(chat_content: conversation.Content) -> MessageParam: type="tool_use", id=tool_call.id, name=tool_call.tool_name, - input=json.dumps(tool_call.tool_args), + input=tool_call.tool_args, ) for tool_call in chat_content.tool_calls or () ], @@ -124,6 +131,66 @@ def _convert_content(chat_content: conversation.Content) -> MessageParam: raise ValueError(f"Unexpected content type: {type(chat_content)}") +async def _transform_stream( + result: AsyncStream[MessageStreamEvent], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + A typical stream of responses might look something like the following: + - RawMessageStartEvent with no content + - RawContentBlockStartEvent with an empty TextBlock + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - ... + - RawContentBlockStopEvent + - RawContentBlockStartEvent with ToolUseBlock specifying the function name + - RawContentBlockDeltaEvent with a InputJSONDelta + - RawContentBlockDeltaEvent with a InputJSONDelta + - ... + - RawContentBlockStopEvent + - RawMessageDeltaEvent with a stop_reason='tool_use' + - RawMessageStopEvent(type='message_stop') + """ + if result is None: + raise TypeError("Expected a stream of messages") + + current_tool_call: dict | None = None + + async for response in result: + LOGGER.debug("Received response: %s", response) + + if isinstance(response, RawContentBlockStartEvent): + if isinstance(response.content_block, ToolUseBlock): + current_tool_call = { + "id": response.content_block.id, + "name": response.content_block.name, + "input": "", + } + elif isinstance(response.content_block, TextBlock): + yield {"role": "assistant"} + elif isinstance(response, RawContentBlockDeltaEvent): + if isinstance(response.delta, InputJSONDelta): + if current_tool_call is None: + raise ValueError("Unexpected delta without a tool call") + current_tool_call["input"] += response.delta.partial_json + elif isinstance(response.delta, TextDelta): + LOGGER.debug("yielding delta: %s", response.delta.text) + yield {"content": response.delta.text} + elif isinstance(response, RawContentBlockStopEvent): + if current_tool_call: + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call["id"], + tool_name=current_tool_call["name"], + tool_args=json.loads(current_tool_call["input"]), + ) + ] + } + current_tool_call = None + + class AnthropicConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -206,58 +273,30 @@ class AnthropicConversationEntity( # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: - response = await client.messages.create( + stream = await client.messages.create( model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), messages=messages, tools=tools or NOT_GIVEN, max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), system=system.content, temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + stream=True, ) except anthropic.AnthropicError as err: raise HomeAssistantError( f"Sorry, I had a problem talking to Anthropic: {err}" ) from err - LOGGER.debug("Response %s", response) - - messages.append(_message_convert(response)) - - text = "".join( + messages.extend( [ - content.text - for content in response.content - if isinstance(content, TextBlock) + _convert_content(content) + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(stream) + ) ] ) - tool_inputs = [ - llm.ToolInput( - id=tool_call.id, - tool_name=tool_call.name, - tool_args=cast(dict[str, Any], tool_call.input), - ) - for tool_call in response.content - if isinstance(tool_call, ToolUseBlock) - ] - tool_results = [ - ToolResultBlockParam( - type="tool_result", - tool_use_id=tool_response.tool_call_id, - content=json.dumps(tool_response.tool_result), - ) - async for tool_response in chat_log.async_add_assistant_content( - conversation.AssistantContent( - agent_id=user_input.agent_id, - content=text, - tool_calls=tool_inputs or None, - ) - ) - ] - if tool_results: - messages.append(MessageParam(role="user", content=tool_results)) - - if not tool_inputs: + if not chat_log.unresponded_tool_results: break response_content = chat_log.content[-1] diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 2f1de3a2db9..bda9ca32b34 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -1,9 +1,24 @@ """Tests for the Anthropic integration.""" +from collections.abc import AsyncGenerator +from typing import Any from unittest.mock import AsyncMock, Mock, patch from anthropic import RateLimitError -from anthropic.types import Message, TextBlock, ToolUseBlock, Usage +from anthropic.types import ( + InputJSONDelta, + Message, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawContentBlockStopEvent, + RawMessageStartEvent, + RawMessageStopEvent, + RawMessageStreamEvent, + TextBlock, + TextDelta, + ToolUseBlock, + Usage, +) from freezegun import freeze_time from httpx import URL, Request, Response from syrupy.assertion import SnapshotAssertion @@ -20,6 +35,81 @@ from homeassistant.util import ulid as ulid_util from tests.common import MockConfigEntry +async def stream_generator( + responses: list[RawMessageStreamEvent], +) -> AsyncGenerator[RawMessageStreamEvent]: + """Generate a response from the assistant.""" + for msg in responses: + yield msg + + +def create_messages( + content_blocks: list[RawMessageStreamEvent], +) -> list[RawMessageStreamEvent]: + """Create a stream of messages with the specified content blocks.""" + return [ + RawMessageStartEvent( + message=Message( + type="message", + id="msg_1234567890ABCDEFGHIJKLMN", + content=[], + role="assistant", + model="claude-3-5-sonnet-20240620", + usage=Usage(input_tokens=0, output_tokens=0), + ), + type="message_start", + ), + *content_blocks, + RawMessageStopEvent(type="message_stop"), + ] + + +def create_content_block( + index: int, text_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a text content block with the specified deltas.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=TextBlock(text="", type="text"), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=TextDelta(text=text_part, type="text_delta"), + index=index, + type="content_block_delta", + ) + for text_part in text_parts + ], + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + +def create_tool_use_block( + index: int, tool_id: str, tool_name: str, json_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a tool use content block with the specified deltas.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=ToolUseBlock( + id=tool_id, name=tool_name, input={}, type="tool_use" + ), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json=json_part, type="input_json_delta"), + index=index, + type="content_block_delta", + ) + for json_part in json_parts + ], + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + async def test_entity( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -120,6 +210,13 @@ async def test_template_variables( ) as mock_create, patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), ): + mock_create.return_value = stream_generator( + create_messages( + create_content_block( + 0, ["Okay, let", " me take care of that for you", "."] + ) + ) + ) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( @@ -129,6 +226,10 @@ async def test_template_variables( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, ( result ) + assert ( + result.response.speech["plain"]["speech"] + == "Okay, let me take care of that for you." + ) assert "The user name is Test User." in mock_create.mock_calls[1][2]["system"] assert "The user id is 12345." in mock_create.mock_calls[1][2]["system"] @@ -168,39 +269,26 @@ async def test_function_call( for message in messages: for content in message["content"]: if not isinstance(content, str) and content["type"] == "tool_use": - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock( - type="text", - text="I have successfully called the function", - ) - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return stream_generator( + create_messages( + create_content_block( + 0, ["I have ", "successfully called ", "the function"] + ), + ) ) - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock(type="text", text="Certainly, calling it now!"), - ToolUseBlock( - type="tool_use", - id="toolu_0123456789AbCdEfGhIjKlM", - name="test_tool", - input={"param1": "test_value"}, - ), - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="tool_use", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return stream_generator( + create_messages( + [ + *create_content_block(0, ["Certainly, calling it now!"]), + *create_tool_use_block( + 1, + "toolu_0123456789AbCdEfGhIjKlM", + "test_tool", + ['{"para', 'm1": "test_valu', 'e"}'], + ), + ] + ) ) with ( @@ -222,6 +310,10 @@ async def test_function_call( assert "Today's date is 2024-06-03." in mock_create.mock_calls[1][2]["system"] assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.speech["plain"]["speech"] + == "I have successfully called the function" + ) assert mock_create.mock_calls[1][2]["messages"][2] == { "role": "user", "content": [ @@ -275,39 +367,27 @@ async def test_function_exception( for message in messages: for content in message["content"]: if not isinstance(content, str) and content["type"] == "tool_use": - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock( - type="text", - text="There was an error calling the function", + return stream_generator( + create_messages( + create_content_block( + 0, + ["There was an error calling the function"], ) - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + ) ) - return Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[ - TextBlock(type="text", text="Certainly, calling it now!"), - ToolUseBlock( - type="tool_use", - id="toolu_0123456789AbCdEfGhIjKlM", - name="test_tool", - input={"param1": "test_value"}, - ), - ], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="tool_use", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return stream_generator( + create_messages( + [ + *create_content_block(0, "Certainly, calling it now!"), + *create_tool_use_block( + 1, + "toolu_0123456789AbCdEfGhIjKlM", + "test_tool", + ['{"param1": "test_value"}'], + ), + ] + ) ) with patch( @@ -324,6 +404,10 @@ async def test_function_exception( ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert ( + result.response.speech["plain"]["speech"] + == "There was an error calling the function" + ) assert mock_create.mock_calls[1][2]["messages"][2] == { "role": "user", "content": [ @@ -376,15 +460,10 @@ async def test_assist_api_tools_conversion( with patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock, - return_value=Message( - type="message", - id="msg_1234567890ABCDEFGHIJKLMN", - content=[TextBlock(type="text", text="Hello, how can I help you?")], - model="claude-3-5-sonnet-20240620", - role="assistant", - stop_reason="end_turn", - stop_sequence=None, - usage=Usage(input_tokens=8, output_tokens=12), + return_value=stream_generator( + create_messages( + create_content_block(0, "Hello, how can I help you?"), + ), ), ) as mock_create: await conversation.async_converse( @@ -425,28 +504,45 @@ async def test_conversation_id( mock_init_component, ) -> None: """Test conversation ID is honored.""" - result = await conversation.async_converse( - hass, "hello", None, None, agent_id="conversation.claude" - ) - conversation_id = result.conversation_id + def create_stream_generator(*args, **kwargs) -> Any: + return stream_generator( + create_messages( + create_content_block(0, "Hello, how can I help you?"), + ), + ) - result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id="conversation.claude" - ) + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + side_effect=create_stream_generator, + ): + result = await conversation.async_converse( + hass, "hello", "1234", Context(), agent_id="conversation.claude" + ) - assert result.conversation_id == conversation_id + result = await conversation.async_converse( + hass, "hello", None, None, agent_id="conversation.claude" + ) - unknown_id = ulid_util.ulid() + conversation_id = result.conversation_id - result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id="conversation.claude" - ) + result = await conversation.async_converse( + hass, "hello", conversation_id, None, agent_id="conversation.claude" + ) - assert result.conversation_id != unknown_id + assert result.conversation_id == conversation_id - result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id="conversation.claude" - ) + unknown_id = ulid_util.ulid() - assert result.conversation_id == "koala" + result = await conversation.async_converse( + hass, "hello", unknown_id, None, agent_id="conversation.claude" + ) + + assert result.conversation_id != unknown_id + + result = await conversation.async_converse( + hass, "hello", "koala", None, agent_id="conversation.claude" + ) + + assert result.conversation_id == "koala" From 1393f417ed5a77168151a92afbc4449a47a6b6c1 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 11 Feb 2025 19:06:13 -0500 Subject: [PATCH 0444/1941] Expose media_player async_browse_media as service (#116452) * initial commit * make fields optional * x * ruff issues * ruff issues * ruff issues * ruff issues * update example * update description * use constants * Update homeassistant/components/media_player/strings.json Co-authored-by: Joost Lekkerkerker * update service call metadata * update description * patch the demo * Update homeassistant/components/media_player/strings.json Co-authored-by: Martin Hjelmare * revert unrelated change * update test metadata * update test metadata * change patch target to be more specific --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- .../components/media_player/__init__.py | 18 ++++- .../components/media_player/const.py | 1 + .../components/media_player/icons.json | 3 + .../components/media_player/services.yaml | 16 +++++ .../components/media_player/strings.json | 14 ++++ tests/components/media_player/test_init.py | 72 +++++++++++++++++++ 6 files changed, 123 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index e109b0418c9..a30b01694fa 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -52,7 +52,7 @@ from homeassistant.const import ( # noqa: F401 STATE_PLAYING, STATE_STANDBY, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.deprecation import ( @@ -124,6 +124,7 @@ from .const import ( # noqa: F401 CONTENT_AUTH_EXPIRY_TIME, DOMAIN, REPEAT_MODES, + SERVICE_BROWSE_MEDIA, SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, SERVICE_PLAY_MEDIA, @@ -201,6 +202,12 @@ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = { vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict, } +MEDIA_PLAYER_BROWSE_MEDIA_SCHEMA = { + vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string, + vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string, +} + + ATTR_TO_PROPERTY = [ ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, @@ -431,6 +438,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_play_media", [MediaPlayerEntityFeature.PLAY_MEDIA], ) + component.async_register_entity_service( + SERVICE_BROWSE_MEDIA, + { + vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string, + vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string, + }, + "async_browse_media", + supports_response=SupportsResponse.ONLY, + ) component.async_register_entity_service( SERVICE_SHUFFLE_SET, {vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean}, diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index ca2f3307846..387fdb05401 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -173,6 +173,7 @@ _DEPRECATED_MEDIA_TYPE_VIDEO = DeprecatedConstantEnum(MediaType.VIDEO, "2025.10" SERVICE_CLEAR_PLAYLIST = "clear_playlist" SERVICE_JOIN = "join" SERVICE_PLAY_MEDIA = "play_media" +SERVICE_BROWSE_MEDIA = "browse_media" SERVICE_SELECT_SOUND_MODE = "select_sound_mode" SERVICE_SELECT_SOURCE = "select_source" SERVICE_UNJOIN = "unjoin" diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json index c11211c38ec..5008ea62d2e 100644 --- a/homeassistant/components/media_player/icons.json +++ b/homeassistant/components/media_player/icons.json @@ -32,6 +32,9 @@ } }, "services": { + "browse_media": { + "service": "mdi:folder-search" + }, "clear_playlist": { "service": "mdi:playlist-remove" }, diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 7338747b545..6b13a6b9c09 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -165,6 +165,22 @@ play_media: selector: boolean: +browse_media: + target: + entity: + domain: media_player + fields: + media_content_type: + required: false + example: "music" + selector: + text: + media_content_id: + required: false + example: "A:ALBUMARTIST/Beatles" + selector: + text: + select_source: target: entity: diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index be06ae22cdc..2127716cd66 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -260,6 +260,20 @@ } } }, + "browse_media": { + "name": "Browse media", + "description": "Browses the available media.", + "fields": { + "media_content_id": { + "name": "Content ID", + "description": "The ID of the content to browse. Integration dependent." + }, + "media_content_type": { + "name": "Content type", + "description": "The type of the content to browse, such as image, music, tv show, video, episode, channel, or playlist." + } + } + }, "select_source": { "name": "Select source", "description": "Sends the media player the command to change input source.", diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 9db2621f84f..38486fe5911 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -10,12 +10,15 @@ import voluptuous as vol from homeassistant.components import media_player from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, BrowseMedia, MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, ) +from homeassistant.components.media_player.const import SERVICE_BROWSE_MEDIA from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant @@ -339,6 +342,75 @@ async def test_media_browse( assert msg["result"] == {"bla": "yo"} +async def test_media_browse_service(hass: HomeAssistant) -> None: + """Test browsing media using service call.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.demo.media_player.DemoBrowsePlayer.async_browse_media", + return_value=BrowseMedia( + media_class=MediaClass.DIRECTORY, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, + children=[ + BrowseMedia( + media_class=MediaClass.ALBUM, + media_content_id="album1 content id", + media_content_type="album", + title="Album 1", + can_play=True, + can_expand=True, + ), + BrowseMedia( + media_class=MediaClass.ALBUM, + media_content_id="album2 content id", + media_content_type="album", + title="Album 2", + can_play=True, + can_expand=True, + ), + ], + ), + ) as mock_browse_media: + result = await hass.services.async_call( + "media_player", + SERVICE_BROWSE_MEDIA, + { + ATTR_ENTITY_ID: "media_player.browse", + ATTR_MEDIA_CONTENT_TYPE: "album", + ATTR_MEDIA_CONTENT_ID: "title=Album*", + }, + blocking=True, + return_response=True, + ) + + mock_browse_media.assert_called_with( + media_content_type="album", media_content_id="title=Album*" + ) + browse_res: BrowseMedia = result["media_player.browse"] + assert browse_res.title == "Mock Title" + assert browse_res.media_class == "directory" + assert browse_res.media_content_type == "mock-type" + assert browse_res.media_content_id == "mock-id" + assert browse_res.can_play is False + assert browse_res.can_expand is True + assert len(browse_res.children) == 2 + assert browse_res.children[0].title == "Album 1" + assert browse_res.children[0].media_class == "album" + assert browse_res.children[0].media_content_id == "album1 content id" + assert browse_res.children[0].media_content_type == "album" + assert browse_res.children[1].title == "Album 2" + assert browse_res.children[1].media_class == "album" + assert browse_res.children[1].media_content_id == "album2 content id" + assert browse_res.children[1].media_content_type == "album" + + async def test_group_members_available_when_off(hass: HomeAssistant) -> None: """Test that group_members are still available when media_player is off.""" await async_setup_component( From a6c51440e51d6ca16c856b2745a1367445412d31 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 08:55:16 +0100 Subject: [PATCH 0445/1941] Use test helper for creating a mocked backup agent in backup tests (#138312) * Use test helper for creating a mocked backup agent in backup tests * Adjust according to discussion --- tests/components/backup/common.py | 126 +++++---------- .../backup/snapshots/test_websocket.ambr | 48 +++--- tests/components/backup/test_http.py | 14 +- tests/components/backup/test_init.py | 7 +- tests/components/backup/test_manager.py | 81 +++++----- tests/components/backup/test_websocket.py | 150 +++++++++--------- 6 files changed, 189 insertions(+), 237 deletions(-) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index afdb5e47a2e..b4ebfd70fcd 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -18,7 +18,6 @@ from homeassistant.components.backup import ( ) from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import MockPlatform, mock_platform @@ -64,87 +63,37 @@ async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: yield i -class BackupAgentTest(BackupAgent): - """Test backup agent.""" +def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock: + """Create a mock backup agent.""" - domain = "test" + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + """Mock download.""" + if not await get_backup(backup_id): + raise BackupNotFound + return aiter_from_iter((backups_data.get(backup_id, b"backup data"),)) - def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None: - """Initialize the backup agent.""" - self.name = name - self.unique_id = name - if backups is None: - backups = [ - AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="abc123", - database_included=True, - date="1970-01-01T00:00:00Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=False, - size=13, - ) - ] + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None: + """Get a backup.""" + return next((b for b in backups if b.backup_id == backup_id), None) - self._backup_data: bytearray | None = None - self._backups = {backup.backup_id: backup for backup in backups} - - async def async_download_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AsyncIterator[bytes]: - """Download a backup file.""" - return AsyncMock(spec_set=["__aiter__"]) - - async def async_upload_backup( - self, + async def upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, **kwargs: Any, ) -> None: """Upload a backup.""" - self._backups[backup.backup_id] = backup + backups.append(backup) backup_stream = await open_stream() - self._backup_data = bytearray() + backup_data = bytearray() async for chunk in backup_stream: - self._backup_data += chunk - - async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: - """List backups.""" - return list(self._backups.values()) - - async def async_get_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> AgentBackup | None: - """Return a backup.""" - return self._backups.get(backup_id) - - async def async_delete_backup( - self, - backup_id: str, - **kwargs: Any, - ) -> None: - """Delete a backup file.""" - - -def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock: - """Create a mock backup agent.""" - - async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None: - """Get a backup.""" - return next((b for b in backups if b.backup_id == backup_id), None) + backup_data += chunk + backups_data[backup.backup_id] = backup_data backups = backups or [] + backups_data: dict[str, bytes] = {} mock_agent = Mock(spec=BackupAgent) - mock_agent.domain = "test" + mock_agent.domain = TEST_DOMAIN mock_agent.name = name mock_agent.unique_id = name type(mock_agent).agent_id = BackupAgent.agent_id @@ -152,7 +101,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo spec_set=[BackupAgent.async_delete_backup] ) mock_agent.async_download_backup = AsyncMock( - side_effect=BackupNotFound, spec_set=[BackupAgent.async_download_backup] + side_effect=download_backup, spec_set=[BackupAgent.async_download_backup] ) mock_agent.async_get_backup = AsyncMock( side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] @@ -161,7 +110,8 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo return_value=backups, spec_set=[BackupAgent.async_list_backups] ) mock_agent.async_upload_backup = AsyncMock( - spec_set=[BackupAgent.async_upload_backup] + side_effect=upload_backup, + spec_set=[BackupAgent.async_upload_backup], ) return mock_agent @@ -169,12 +119,12 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo async def setup_backup_integration( hass: HomeAssistant, with_hassio: bool = False, - configuration: ConfigType | None = None, *, backups: dict[str, list[AgentBackup]] | None = None, remote_agents: list[str] | None = None, -) -> bool: +) -> dict[str, Mock]: """Set up the Backup integration.""" + backups = backups or {} with ( patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), patch( @@ -182,36 +132,34 @@ async def setup_backup_integration( ), ): remote_agents = remote_agents or [] + remote_agents_dict = {} + for agent in remote_agents: + if not agent.startswith(f"{TEST_DOMAIN}."): + raise ValueError(f"Invalid agent_id: {agent}") + name = agent.partition(".")[2] + remote_agents_dict[agent] = mock_backup_agent(name, backups.get(agent)) platform = Mock( async_get_backup_agents=AsyncMock( - return_value=[BackupAgentTest(agent, []) for agent in remote_agents] + return_value=list(remote_agents_dict.values()) ), spec_set=BackupAgentPlatformProtocol, ) mock_platform(hass, f"{TEST_DOMAIN}.backup", platform or MockPlatform()) assert await async_setup_component(hass, TEST_DOMAIN, {}) - - result = await async_setup_component(hass, DOMAIN, configuration or {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - if not backups: - return result - for agent_id, agent_backups in backups.items(): - if with_hassio and agent_id == LOCAL_AGENT_ID: - continue - agent = hass.data[DATA_MANAGER].backup_agents[agent_id] + if LOCAL_AGENT_ID not in backups or with_hassio: + return remote_agents_dict - async def open_stream() -> AsyncIterator[bytes]: - """Open a stream.""" - return aiter_from_iter((b"backup data",)) + agent = hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID] - 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 + for backup in backups[LOCAL_AGENT_ID]: + await agent.async_upload_backup(open_stream=None, backup=backup) + agent._loaded_backups = True - return result + return remote_agents_dict async def setup_backup_platform( diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 2f063262f34..9236a0cbe0f 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -3482,13 +3482,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ ]), @@ -3499,7 +3501,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3543,13 +3545,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ 'test.remote', @@ -3561,7 +3565,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3604,13 +3608,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ ]), @@ -3621,7 +3627,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3664,13 +3670,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ ]), @@ -3681,7 +3689,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3725,13 +3733,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ ]), @@ -3742,7 +3752,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, @@ -3786,13 +3796,15 @@ 'agents': dict({ 'domain.test': dict({ 'protected': False, - 'size': 13, + 'size': 0, }), }), 'backup_id': 'abc123', 'database_included': True, - 'date': '1970-01-01T00:00:00Z', + 'date': '1970-01-01T00:00:00.000Z', 'extra_metadata': dict({ + 'instance_id': 'our_uuid', + 'with_automatic_settings': True, }), 'failed_agent_ids': list([ 'test.remote', @@ -3804,7 +3816,7 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'with_automatic_settings': None, + 'with_automatic_settings': True, }), ]), 'last_attempted_automatic_backup': None, diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index 9ebf3e8bd40..a2f32d93fc3 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant from .common import ( TEST_BACKUP_ABC123, - BackupAgentTest, aiter_from_iter, mock_backup_agent, setup_backup_integration, @@ -65,19 +64,16 @@ async def test_downloading_remote_backup( hass_client: ClientSessionGenerator, ) -> None: """Test downloading a remote backup.""" + await setup_backup_integration( - hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test"] + hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test.test"] ) client = await hass_client() - with ( - patch.object(BackupAgentTest, "async_download_backup") as download_mock, - ): - download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) - resp = await client.get("/api/backup/download/abc123?agent_id=test.test") - assert resp.status == 200 - assert await resp.content.read() == b"backup data" + resp = await client.get("/api/backup/download/abc123?agent_id=test.test") + assert resp.status == 200 + assert await resp.content.read() == b"backup data" async def test_downloading_local_encrypted_backup_file_not_found( diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py index 925e2cb9b7a..8a0cc2b97c0 100644 --- a/tests/components/backup/test_init.py +++ b/tests/components/backup/test_init.py @@ -20,11 +20,7 @@ async def test_setup_with_hassio( caplog: pytest.LogCaptureFixture, ) -> None: """Test the setup of the integration with hassio enabled.""" - assert await setup_backup_integration( - hass=hass, - with_hassio=True, - configuration={DOMAIN: {}}, - ) + await setup_backup_integration(hass=hass, with_hassio=True) manager = hass.data[DATA_MANAGER] assert not manager.backup_agents @@ -59,6 +55,7 @@ async def test_create_service( ) +@pytest.mark.usefixtures("supervisor_client") async def test_create_service_with_hassio(hass: HomeAssistant) -> None: """Test action backup.create does not exist with hassio.""" await setup_backup_integration(hass, with_hassio=True) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index bdcb9f068b6..b2c01774531 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Generator +from collections.abc import Callable, Generator from dataclasses import replace from io import StringIO import json @@ -58,7 +58,7 @@ from .common import ( TEST_BACKUP_DEF456, TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456, - BackupAgentTest, + mock_backup_agent, setup_backup_platform, ) @@ -524,7 +524,7 @@ async def test_initiate_backup( ) -> None: """Test generate backup.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -771,7 +771,7 @@ async def test_initiate_backup_with_agent_error( "with_automatic_settings": True, }, ] - remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) + remote_agent = mock_backup_agent("remote", backups=[backup_1, backup_2, backup_3]) with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -1120,7 +1120,7 @@ async def test_create_backup_failure_raises_issue( issues_after_create_backup: dict[tuple[str, str], dict[str, Any]], ) -> None: """Test backup issue is cleared after backup is created.""" - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -1180,7 +1180,7 @@ async def test_initiate_backup_non_agent_upload_error( """Test an unknown or writer upload error during backup generation.""" agent_ids = [LOCAL_AGENT_ID, "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -1298,7 +1298,7 @@ async def test_initiate_backup_with_task_error( create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task) agent_ids = [LOCAL_AGENT_ID, "test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -1409,7 +1409,8 @@ async def test_initiate_backup_file_error( """Test file error during generate backup.""" agent_ids = ["test.remote"] local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" ) as core_get_backup_agents: @@ -1513,26 +1514,21 @@ async def test_initiate_backup_file_error( assert unlink_mock.call_count == unlink_call_count -class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent): - """Local backup agent.""" - - def get_backup_path(self, backup_id: str) -> Path: - """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") +def _mock_local_backup_agent(name: str) -> Mock: + local_agent = mock_backup_agent(name) + # This makes the local_agent pass isinstance checks for LocalBackupAgent + local_agent.mock_add_spec(LocalBackupAgent) + return local_agent @pytest.mark.parametrize( - ("agent_class", "num_local_agents"), - [(LocalBackupAgentTest, 2), (BackupAgentTest, 1)], + ("agent_creator", "num_local_agents"), + [(_mock_local_backup_agent, 2), (mock_backup_agent, 1)], ) async def test_loading_platform_with_listener( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - agent_class: type[BackupAgentTest], + agent_creator: Callable[[str], Mock], num_local_agents: int, ) -> None: """Test loading a backup agent platform which can be listened to.""" @@ -1540,7 +1536,7 @@ async def test_loading_platform_with_listener( assert await async_setup_component(hass, DOMAIN, {}) manager = hass.data[DATA_MANAGER] - get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])]) + get_agents_mock = AsyncMock(return_value=[agent_creator("remote1")]) register_listener_mock = Mock() await setup_backup_platform( @@ -1565,7 +1561,7 @@ async def test_loading_platform_with_listener( register_listener_mock.assert_called_once_with(hass, listener=ANY) get_agents_mock.reset_mock() - get_agents_mock.return_value = [agent_class("remote2", backups=[])] + get_agents_mock.return_value = [agent_creator("remote2")] listener = register_listener_mock.call_args[1]["listener"] listener() @@ -1609,7 +1605,7 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", @@ -1639,7 +1635,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: async def _mock_step(hass: HomeAssistant) -> None: raise HomeAssistantError("Test exception") - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", @@ -1678,7 +1674,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: 2, 1, ["Test_1970-01-01_00.00_00000000.tar"], - {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + {TEST_BACKUP_ABC123.backup_id: (TEST_BACKUP_ABC123, b"test")}, b"test", 0, ), @@ -1696,7 +1692,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: 2, 0, [], - {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123}, + {TEST_BACKUP_ABC123.backup_id: (TEST_BACKUP_ABC123, b"test")}, b"test", 1, ), @@ -1714,7 +1710,7 @@ async def test_receive_backup( temp_file_unlink_call_count: int, ) -> None: """Test receive backup and upload to the local and a remote agent.""" - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", @@ -1754,8 +1750,12 @@ async def test_receive_backup( assert move_mock.call_count == move_call_count for index, name in enumerate(move_path_names): assert move_mock.call_args_list[index].args[1].name == name - assert remote_agent._backups == remote_agent_backups - assert remote_agent._backup_data == remote_agent_backup_data + for backup_id, (backup, expected_backup_data) in remote_agent_backups.items(): + assert await remote_agent.async_get_backup(backup_id) == backup + backup_data = bytearray() + async for chunk in await remote_agent.async_download_backup(backup_id): + backup_data += chunk + assert backup_data == expected_backup_data assert unlink_mock.call_count == temp_file_unlink_call_count @@ -1911,7 +1911,7 @@ async def test_receive_backup_agent_error( "with_automatic_settings": True, }, ] - remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3]) + remote_agent = mock_backup_agent("remote", backups=[backup_1, backup_2, backup_3]) with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -2065,7 +2065,7 @@ async def test_receive_backup_non_agent_upload_error( ) -> None: """Test non agent upload error during backup receive.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" @@ -2193,7 +2193,7 @@ async def test_receive_backup_file_write_error( ) -> None: """Test file write error during backup receive.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" ) as core_get_backup_agents: @@ -2304,7 +2304,7 @@ async def test_receive_backup_read_tar_error( ) -> None: """Test read tar error during backup receive.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" ) as core_get_backup_agents: @@ -2484,7 +2484,8 @@ async def test_receive_backup_file_read_error( ) -> None: """Test file read error during backup receive.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" ) as core_get_backup_agents: @@ -2654,7 +2655,7 @@ async def test_restore_backup( ) -> None: """Test restore backup.""" password = password_param.get("password") - remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await setup_backup_platform( @@ -2761,7 +2762,7 @@ async def test_restore_backup_wrong_password( ) -> None: """Test restore backup wrong password.""" password = "hunter2" - remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await setup_backup_platform( @@ -2988,7 +2989,7 @@ async def test_restore_backup_agent_error( expected_reason: str, ) -> None: """Test restore backup with agent error.""" - remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await setup_backup_platform( @@ -3128,7 +3129,7 @@ async def test_restore_backup_file_error( validate_password_call_count: int, ) -> None: """Test restore backup with file error.""" - remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123]) + remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() await setup_backup_platform( @@ -3346,7 +3347,7 @@ async def test_initiate_backup_per_agent_encryption( ) -> None: """Test generate backup where encryption is selectively set on agents.""" local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") with patch( "homeassistant.components.backup.backup.async_get_backup_agents" diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 773256bdd0b..e97183fc53f 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -34,7 +34,7 @@ from .common import ( LOCAL_AGENT_ID, TEST_BACKUP_ABC123, TEST_BACKUP_DEF456, - BackupAgentTest, + mock_backup_agent, setup_backup_integration, setup_backup_platform, ) @@ -112,9 +112,9 @@ def mock_get_backups() -> Generator[AsyncMock]: ("remote_agents", "remote_backups"), [ ([], {}), - (["remote"], {}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + (["test.remote"], {}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ], ) async def test_info( @@ -153,25 +153,26 @@ async def test_info_with_errors( await setup_backup_integration( hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + mock_agent = mock_backup_agent("test") + mock_agent.async_list_backups.side_effect = side_effect + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch.object(BackupAgentTest, "async_list_backups", side_effect=side_effect): - await client.send_json_auto_id({"type": "backup/info"}) - assert await client.receive_json() == snapshot + await client.send_json_auto_id({"type": "backup/info"}) + assert await client.receive_json() == snapshot @pytest.mark.parametrize( ("remote_agents", "backups"), [ ([], {}), - (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + (["test.remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ( - ["remote"], + ["test.remote"], { LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], "test.remote": [TEST_BACKUP_ABC123], @@ -215,15 +216,14 @@ async def test_details_with_errors( await setup_backup_integration( hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + mock_agent = mock_backup_agent("test") + mock_agent.async_get_backup.side_effect = side_effect + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent client = await hass_ws_client(hass) await hass.async_block_till_done() - with ( - patch("pathlib.Path.exists", return_value=True), - patch.object(BackupAgentTest, "async_get_backup", side_effect=side_effect), - ): + with patch("pathlib.Path.exists", return_value=True): await client.send_json_auto_id( {"type": "backup/details", "backup_id": "abc123"} ) @@ -234,11 +234,11 @@ async def test_details_with_errors( ("remote_agents", "backups"), [ ([], {}), - (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), - (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}), + (["test.remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}), ( - ["remote"], + ["test.remote"], { LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], "test.remote": [TEST_BACKUP_ABC123], @@ -307,14 +307,15 @@ async def test_delete_with_errors( await setup_backup_integration( hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + mock_agent = mock_backup_agent("test", [TEST_BACKUP_ABC123]) + mock_agent.async_delete_backup.side_effect = side_effect + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch.object(BackupAgentTest, "async_delete_backup", side_effect=side_effect): - await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) - assert await client.receive_json() == snapshot + await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"}) + assert await client.receive_json() == snapshot await client.send_json_auto_id({"type": "backup/info"}) assert await client.receive_json() == snapshot @@ -327,21 +328,21 @@ async def test_agent_delete_backup( ) -> None: """Test deleting a backup file with a mock agent.""" await setup_backup_integration(hass) - hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")} + mock_agent = mock_backup_agent("test") + hass.data[DATA_MANAGER].backup_agents = {"domain.test": mock_agent} client = await hass_ws_client(hass) await hass.async_block_till_done() - with patch.object(BackupAgentTest, "async_delete_backup") as delete_mock: - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": "abc123", - } - ) - assert await client.receive_json() == snapshot + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": "abc123", + } + ) + assert await client.receive_json() == snapshot - assert delete_mock.call_args == call("abc123") + assert mock_agent.async_delete_backup.call_args == call("abc123") @pytest.mark.parametrize( @@ -588,7 +589,7 @@ async def test_generate_with_default_settings_calls_create( client = await hass_ws_client(hass) await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-13T12:01:00+01:00") - remote_agent = BackupAgentTest("remote", backups=[]) + remote_agent = mock_backup_agent("remote") await setup_backup_platform( hass, domain="test", @@ -688,8 +689,8 @@ async def test_restore_local_agent( @pytest.mark.parametrize( ("remote_agents", "backups"), [ - (["remote"], {}), - (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}), + (["test.remote"], {}), + (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}), ], ) async def test_restore_remote_agent( @@ -700,6 +701,7 @@ async def test_restore_remote_agent( snapshot: SnapshotAssertion, ) -> None: """Test calling the restore command.""" + await setup_backup_integration( hass, with_hassio=False, backups=backups, remote_agents=remote_agents ) @@ -892,7 +894,7 @@ async def test_agents_info( ) -> None: """Test getting backup agents info.""" await setup_backup_integration(hass, with_hassio=False) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test") + hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_backup_agent("test") client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -1730,7 +1732,7 @@ async def test_config_schedule_logic( await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + await setup_backup_integration(hass, remote_agents=["test.test-agent"]) await hass.async_block_till_done() for command in commands: @@ -1773,7 +1775,7 @@ async def test_config_schedule_logic( "command", "backups", "get_backups_agent_errors", - "agent_delete_backup_side_effects", + "delete_backup_side_effects", "last_backup_time", "next_time", "backup_time", @@ -2345,7 +2347,7 @@ async def test_config_retention_copies_logic( command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - agent_delete_backup_side_effects: dict[str, Exception], + delete_backup_side_effects: dict[str, Exception], last_backup_time: str, next_time: str, backup_time: str, @@ -2392,14 +2394,13 @@ async def test_config_retention_copies_logic( await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent", "test-agent2"]) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test-agent", "test.test-agent2"] + ) await hass.async_block_till_done() - manager = hass.data[DATA_MANAGER] - for agent_id, agent in manager.backup_agents.items(): - agent.async_delete_backup = AsyncMock( - side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True - ) + for agent_id, agent in mock_agents.items(): + agent.async_delete_backup.side_effect = delete_backup_side_effects.get(agent_id) await client.send_json_auto_id(command) result = await client.receive_json() @@ -2411,7 +2412,7 @@ async def test_config_retention_copies_logic( await hass.async_block_till_done() assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - for agent_id, agent in manager.backup_agents.items(): + for agent_id, agent in mock_agents.items(): agent_delete_calls = delete_calls.get(agent_id, []) assert agent.async_delete_backup.call_count == len(agent_delete_calls) assert agent.async_delete_backup.call_args_list == agent_delete_calls @@ -2671,13 +2672,11 @@ async def test_config_retention_copies_logic_manual_backup( await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test-agent"] + ) await hass.async_block_till_done() - manager = hass.data[DATA_MANAGER] - for agent in manager.backup_agents.values(): - agent.async_delete_backup = AsyncMock(autospec=True) - await client.send_json_auto_id(config_command) result = await client.receive_json() assert result["success"] @@ -2692,7 +2691,7 @@ async def test_config_retention_copies_logic_manual_backup( assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - for agent_id, agent in manager.backup_agents.items(): + for agent_id, agent in mock_agents.items(): agent_delete_calls = delete_calls.get(agent_id, []) assert agent.async_delete_backup.call_count == len(agent_delete_calls) assert agent.async_delete_backup.call_args_list == agent_delete_calls @@ -2714,7 +2713,7 @@ async def test_config_retention_copies_logic_manual_backup( "commands", "backups", "get_backups_agent_errors", - "agent_delete_backup_side_effects", + "delete_backup_side_effects", "last_backup_time", "start_time", "next_time", @@ -3077,7 +3076,7 @@ async def test_config_retention_days_logic( commands: list[dict[str, Any]], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - agent_delete_backup_side_effects: dict[str, Exception], + delete_backup_side_effects: dict[str, Exception], last_backup_time: str, start_time: str, next_time: str, @@ -3120,14 +3119,13 @@ async def test_config_retention_days_logic( await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to(start_time) - await setup_backup_integration(hass, remote_agents=["test-agent"]) + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test-agent"] + ) await hass.async_block_till_done() - manager = hass.data[DATA_MANAGER] - for agent_id, agent in manager.backup_agents.items(): - agent.async_delete_backup = AsyncMock( - side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True - ) + for agent_id, agent in mock_agents.items(): + agent.async_delete_backup.side_effect = delete_backup_side_effects.get(agent_id) for command in commands: await client.send_json_auto_id(command) @@ -3138,7 +3136,7 @@ async def test_config_retention_days_logic( async_fire_time_changed(hass) await hass.async_block_till_done() assert get_backups.call_count == get_backups_calls - for agent_id, agent in manager.backup_agents.items(): + for agent_id, agent in mock_agents.items(): agent_delete_calls = delete_calls.get(agent_id, []) assert agent.async_delete_backup.call_count == len(agent_delete_calls) assert agent.async_delete_backup.call_args_list == agent_delete_calls @@ -3222,21 +3220,21 @@ async def test_can_decrypt_on_download_with_agent_error( ) -> None: """Test can decrypt on download.""" - await setup_backup_integration( + mock_agents = await setup_backup_integration( hass, with_hassio=False, backups={"test.remote": [TEST_BACKUP_ABC123]}, - remote_agents=["remote"], + remote_agents=["test.remote"], ) client = await hass_ws_client(hass) - with patch.object(BackupAgentTest, "async_download_backup", side_effect=error): - await client.send_json_auto_id( - { - "type": "backup/can_decrypt_on_download", - "backup_id": TEST_BACKUP_ABC123.backup_id, - "agent_id": "test.remote", - "password": "hunter2", - } - ) - assert await client.receive_json() == snapshot + mock_agents["test.remote"].async_download_backup.side_effect = error + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } + ) + assert await client.receive_json() == snapshot From 2033dbdd9016fed910f636cc2234d11257d923a7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Feb 2025 09:22:35 +0100 Subject: [PATCH 0446/1941] Use entry.async_on_unload in fireservicerota (#138360) --- homeassistant/components/fireservicerota/__init__.py | 5 +---- homeassistant/components/fireservicerota/coordinator.py | 4 ++++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index bf5385b6f2a..6f48dcfc4bc 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -27,6 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if client.token_refresh_failure: return False + entry.async_on_unload(client.async_stop_listener) coordinator = FireServiceUpdateCoordinator(hass, client, entry) await coordinator.async_config_entry_first_refresh() @@ -43,10 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload FireServiceRota config entry.""" - - await hass.async_add_executor_job( - hass.data[DOMAIN][entry.entry_id][DATA_CLIENT].websocket.stop_listener - ) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: del hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py index 14a8c40e469..c452421d57b 100644 --- a/homeassistant/components/fireservicerota/coordinator.py +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -213,3 +213,7 @@ class FireServiceRotaClient: ) await self.update_call(self.fsr.set_incident_response, self.incident_id, value) + + async def async_stop_listener(self) -> None: + """Stop listener.""" + await self._hass.async_add_executor_job(self.websocket.stop_listener) From 6ef1178a35e54e10ebc94bfb9131db671df0dcba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 09:49:01 +0100 Subject: [PATCH 0447/1941] Use setup_backup_integration test helper in backup tests (#138362) --- tests/components/backup/common.py | 18 +- .../backup/snapshots/test_websocket.ambr | 40 +- tests/components/backup/test_http.py | 73 ++-- tests/components/backup/test_manager.py | 389 ++++-------------- tests/components/backup/test_websocket.py | 79 ++-- 5 files changed, 158 insertions(+), 441 deletions(-) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index b4ebfd70fcd..b21698bf365 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -20,7 +20,7 @@ from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, mock_platform +from tests.common import mock_platform LOCAL_AGENT_ID = f"{DOMAIN}.local" @@ -138,15 +138,15 @@ async def setup_backup_integration( raise ValueError(f"Invalid agent_id: {agent}") name = agent.partition(".")[2] remote_agents_dict[agent] = mock_backup_agent(name, backups.get(agent)) - platform = Mock( - async_get_backup_agents=AsyncMock( - return_value=list(remote_agents_dict.values()) - ), - spec_set=BackupAgentPlatformProtocol, - ) + if remote_agents: + platform = Mock( + async_get_backup_agents=AsyncMock( + return_value=list(remote_agents_dict.values()) + ), + spec_set=BackupAgentPlatformProtocol, + ) + await setup_backup_platform(hass, domain=TEST_DOMAIN, platform=platform) - mock_platform(hass, f"{TEST_DOMAIN}.backup", platform or MockPlatform()) - assert await async_setup_component(hass, TEST_DOMAIN, {}) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 9236a0cbe0f..4452d191d5a 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -16,12 +16,12 @@ 'result': dict({ 'agents': list([ dict({ - 'agent_id': 'backup.local', - 'name': 'local', + 'agent_id': 'test.remote', + 'name': 'remote', }), dict({ - 'agent_id': 'test.test', - 'name': 'test', + 'agent_id': 'backup.local', + 'name': 'local', }), ]), }), @@ -3457,7 +3457,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), }), 'success': True, @@ -3480,7 +3480,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3520,7 +3520,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), }), 'success': True, @@ -3543,7 +3543,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3606,7 +3606,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3668,7 +3668,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3708,7 +3708,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Boom!', + 'test.remote': 'Boom!', }), }), 'success': True, @@ -3731,7 +3731,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3771,7 +3771,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Boom!', + 'test.remote': 'Boom!', }), }), 'success': True, @@ -3794,7 +3794,7 @@ }), ]), 'agents': dict({ - 'domain.test': dict({ + 'test.remote': dict({ 'protected': False, 'size': 0, }), @@ -3992,7 +3992,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), 'backup': dict({ 'addons': list([ @@ -4036,7 +4036,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Oops', + 'test.remote': 'Oops', }), 'backup': dict({ 'addons': list([ @@ -4080,7 +4080,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Boom!', + 'test.remote': 'Boom!', }), 'backup': dict({ 'addons': list([ @@ -4584,7 +4584,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'The backup agent is unreachable.', + 'test.remote': 'The backup agent is unreachable.', }), 'backups': list([ dict({ @@ -4636,7 +4636,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Oops', + 'test.remote': 'Oops', }), 'backups': list([ dict({ @@ -4688,7 +4688,7 @@ 'id': 1, 'result': dict({ 'agent_errors': dict({ - 'domain.test': 'Boom!', + 'test.remote': 'Boom!', }), 'backups': list([ dict({ diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index a2f32d93fc3..a03217beac2 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -18,19 +18,28 @@ from homeassistant.components.backup import ( BackupNotFound, Folder, ) -from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN +from homeassistant.components.backup.const import DOMAIN from homeassistant.core import HomeAssistant -from .common import ( - TEST_BACKUP_ABC123, - aiter_from_iter, - mock_backup_agent, - setup_backup_integration, -) +from .common import TEST_BACKUP_ABC123, aiter_from_iter, setup_backup_integration from tests.common import MockUser, get_fixture_path from tests.typing import ClientSessionGenerator +PROTECTED_BACKUP = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id="c0cb53bd", + database_included=True, + date="1970-01-01T00:00:00Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=13, +) + async def test_downloading_local_backup( hass: HomeAssistant, @@ -115,32 +124,15 @@ async def test_downloading_remote_encrypted_backup( ) -> None: """Test downloading a local backup file.""" backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) - await setup_backup_integration(hass) - mock_agent = mock_backup_agent( - "test", - [ - AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="c0cb53bd", - database_included=True, - date="1970-01-01T00:00:00Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=True, - size=13, - ) - ], + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test"], backups={"test.test": [PROTECTED_BACKUP]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: return aiter_from_iter((backup_path.read_bytes(),)) - mock_agent.async_download_backup.side_effect = download_backup - await _test_downloading_encrypted_backup(hass_client, "domain.test") + mock_agents["test.test"].async_download_backup.side_effect = download_backup + await _test_downloading_encrypted_backup(hass_client, "test.test") @pytest.mark.parametrize( @@ -157,31 +149,14 @@ async def test_downloading_remote_encrypted_backup_with_error( status: int, ) -> None: """Test downloading a local backup file.""" - await setup_backup_integration(hass) - mock_agent = mock_backup_agent( - "test", - [ - AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id="abc123", - database_included=True, - date="1970-01-01T00:00:00Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=True, - size=13, - ) - ], + mock_agents = await setup_backup_integration( + hass, remote_agents=["test.test"], backups={"test.test": [PROTECTED_BACKUP]} ) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent - mock_agent.async_download_backup.side_effect = error + mock_agents["test.test"].async_download_backup.side_effect = error client = await hass_client() resp = await client.get( - "/api/backup/download/abc123?agent_id=domain.test&password=blah" + f"/api/backup/download/{PROTECTED_BACKUP.backup_id}?agent_id=test.test&password=blah" ) assert resp.status == status diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index b2c01774531..b2b7e083a51 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -27,11 +27,9 @@ import pytest from homeassistant.components.backup import ( DOMAIN, AgentBackup, - BackupAgentPlatformProtocol, BackupReaderWriterError, Folder, LocalBackupAgent, - backup as local_backup_platform, ) from homeassistant.components.backup.agent import BackupAgentError from homeassistant.components.backup.const import DATA_MANAGER @@ -50,7 +48,6 @@ from homeassistant.components.backup.util import password_to_key from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, @@ -59,6 +56,7 @@ from .common import ( TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456, mock_backup_agent, + setup_backup_integration, setup_backup_platform, ) @@ -110,8 +108,7 @@ async def test_create_backup_service( mocked_tarfile: Mock, ) -> None: """Test create backup service.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) new_backup = NewBackup(backup_job_id="time-123") backup_task = AsyncMock( @@ -307,8 +304,7 @@ async def test_async_create_backup( expected_writer_kwargs: dict[str, Any], ) -> None: """Test create backup.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) manager = hass.data[DATA_MANAGER] new_backup = NewBackup(backup_job_id="time-123") @@ -336,8 +332,7 @@ async def test_create_backup_when_busy( hass_ws_client: WebSocketGenerator, ) -> None: """Test generate backup with busy manager.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) await ws_client.send_json_auto_id( @@ -385,8 +380,7 @@ async def test_create_backup_wrong_parameters( expected_error: str, ) -> None: """Test create backup with wrong parameters.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) @@ -523,23 +517,7 @@ async def test_initiate_backup( temp_file_unlink_call_count: int, ) -> None: """Test generate backup.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) freezer.move_to("2025-01-30 13:42:12.345678") @@ -693,7 +671,6 @@ async def test_initiate_backup_with_agent_error( ) -> None: """Test agent upload error during backup generation.""" agent_ids = [LOCAL_AGENT_ID, "test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id @@ -771,22 +748,12 @@ async def test_initiate_backup_with_agent_error( "with_automatic_settings": True, }, ] - remote_agent = mock_backup_agent("remote", backups=[backup_1, backup_2, backup_3]) - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration( + hass, + remote_agents=["test.remote"], + backups={"test.remote": [backup_1, backup_2, backup_3]}, + ) ws_client = await hass_ws_client(hass) @@ -821,17 +788,8 @@ async def test_initiate_backup_with_agent_error( result = await ws_client.receive_json() assert result["success"] is True - delete_backup = AsyncMock() - - with ( - patch("pathlib.Path.open", mock_open(read_data=b"test")), - patch.object( - remote_agent, - "async_upload_backup", - side_effect=exception, - ), - patch.object(remote_agent, "async_delete_backup", delete_backup), - ): + mock_agents["test.remote"].async_upload_backup.side_effect = exception + with patch("pathlib.Path.open", mock_open(read_data=b"test")): await ws_client.send_json_auto_id( {"type": "backup/generate", "agent_ids": agent_ids} ) @@ -922,7 +880,7 @@ async def test_initiate_backup_with_agent_error( ] # one of the two matching backups with the remote agent should have been deleted - assert delete_backup.call_count == 1 + assert mock_agents["test.remote"].async_delete_backup.call_count == 1 @pytest.mark.usefixtures("mock_backup_generation") @@ -946,8 +904,7 @@ async def test_create_backup_success_clears_issue( issues_after_create_backup: set[tuple[str, str]], ) -> None: """Test backup issue is cleared after backup is created.""" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) # Create a backup issue ir.async_create_issue( @@ -996,7 +953,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]: "automatic_agents", "create_backup_command", "create_backup_side_effect", - "agent_upload_side_effect", + "upload_side_effect", "create_backup_result", "issues_after_create_backup", ), @@ -1115,26 +1072,12 @@ async def test_create_backup_failure_raises_issue( automatic_agents: list[str], create_backup_command: dict[str, Any], create_backup_side_effect: Exception | None, - agent_upload_side_effect: Exception | None, + upload_side_effect: Exception | None, create_backup_result: bool, issues_after_create_backup: dict[tuple[str, str], dict[str, Any]], ) -> None: """Test backup issue is cleared after backup is created.""" - remote_agent = mock_backup_agent("remote") - - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) - - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -1149,13 +1092,11 @@ async def test_create_backup_failure_raises_issue( result = await ws_client.receive_json() assert result["success"] is True - with patch.object( - remote_agent, "async_upload_backup", side_effect=agent_upload_side_effect - ): - await ws_client.send_json_auto_id(create_backup_command) - result = await ws_client.receive_json() - assert result["success"] == create_backup_result - await hass.async_block_till_done() + mock_agents["test.remote"].async_upload_backup.side_effect = upload_side_effect + await ws_client.send_json_auto_id(create_backup_command) + result = await ws_client.receive_json() + assert result["success"] == create_backup_result + await hass.async_block_till_done() issue_registry = ir.async_get(hass) assert set(issue_registry.issues) == set(issues_after_create_backup) @@ -1179,23 +1120,7 @@ async def test_initiate_backup_non_agent_upload_error( ) -> None: """Test an unknown or writer upload error during backup generation.""" agent_ids = [LOCAL_AGENT_ID, "test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -1224,14 +1149,8 @@ async def test_initiate_backup_non_agent_upload_error( result = await ws_client.receive_json() assert result["success"] is True - with ( - patch("pathlib.Path.open", mock_open(read_data=b"test")), - patch.object( - remote_agent, - "async_upload_backup", - side_effect=exception, - ), - ): + mock_agents["test.remote"].async_upload_backup.side_effect = exception + with patch("pathlib.Path.open", mock_open(read_data=b"test")): await ws_client.send_json_auto_id( {"type": "backup/generate", "agent_ids": agent_ids} ) @@ -1297,23 +1216,8 @@ async def test_initiate_backup_with_task_error( backup_task.set_exception(exception) create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task) agent_ids = [LOCAL_AGENT_ID, "test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -1408,23 +1312,8 @@ async def test_initiate_backup_file_error( ) -> None: """Test file error during generate backup.""" agent_ids = ["test.remote"] - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -1533,7 +1422,7 @@ async def test_loading_platform_with_listener( ) -> None: """Test loading a backup agent platform which can be listened to.""" ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, DOMAIN, {}) + await setup_backup_integration(hass) manager = hass.data[DATA_MANAGER] get_agents_mock = AsyncMock(return_value=[agent_creator("remote1")]) @@ -1593,8 +1482,7 @@ async def test_not_loading_bad_platforms( domain="test", platform=platform_mock, ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) assert platform_mock.mock_calls == [] @@ -1615,8 +1503,7 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None: async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) with pytest.raises(BackupManagerError) as err: await hass.services.async_call( @@ -1645,8 +1532,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None: async_get_backup_agents=AsyncMock(return_value=[remote_agent]), ), ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) with pytest.raises(BackupManagerError) as err: await hass.services.async_call( @@ -1710,17 +1596,7 @@ async def test_receive_backup( temp_file_unlink_call_count: int, ) -> None: """Test receive backup and upload to the local and a remote agent.""" - remote_agent = mock_backup_agent("remote") - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() upload_data = "test" @@ -1750,6 +1626,7 @@ async def test_receive_backup( assert move_mock.call_count == move_call_count for index, name in enumerate(move_path_names): assert move_mock.call_args_list[index].args[1].name == name + remote_agent = mock_agents["test.remote"] for backup_id, (backup, expected_backup_data) in remote_agent_backups.items(): assert await remote_agent.async_get_backup(backup_id) == backup backup_data = bytearray() @@ -1770,8 +1647,7 @@ async def test_receive_backup_busy_manager( new_backup = NewBackup(backup_job_id="time-123") backup_task: asyncio.Future[WrittenBackup] = asyncio.Future() create_backup.return_value = (new_backup, backup_task) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -1833,7 +1709,6 @@ async def test_receive_backup_agent_error( exception: Exception, ) -> None: """Test upload error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id @@ -1911,22 +1786,12 @@ async def test_receive_backup_agent_error( "with_automatic_settings": True, }, ] - remote_agent = mock_backup_agent("remote", backups=[backup_1, backup_2, backup_3]) - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration( + hass, + remote_agents=["test.remote"], + backups={"test.remote": [backup_1, backup_2, backup_3]}, + ) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -1962,13 +1827,11 @@ async def test_receive_backup_agent_error( result = await ws_client.receive_json() assert result["success"] is True - delete_backup = AsyncMock() upload_data = "test" open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + mock_agents["test.remote"].async_upload_backup.side_effect = exception with ( - patch.object(remote_agent, "async_delete_backup", delete_backup), - patch.object(remote_agent, "async_upload_backup", side_effect=exception), patch("pathlib.Path.open", open_mock), patch("shutil.move") as move_mock, patch( @@ -2050,7 +1913,7 @@ async def test_receive_backup_agent_error( assert open_mock.call_count == 1 assert move_mock.call_count == 0 assert unlink_mock.call_count == 1 - assert delete_backup.call_count == 0 + assert mock_agents["test.remote"].async_delete_backup.call_count == 0 @pytest.mark.usefixtures("mock_backup_generation") @@ -2064,23 +1927,7 @@ async def test_receive_backup_non_agent_upload_error( exception: Exception, ) -> None: """Test non agent upload error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -2113,8 +1960,8 @@ async def test_receive_backup_non_agent_upload_error( upload_data = "test" open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8")) + mock_agents["test.remote"].async_upload_backup.side_effect = exception with ( - patch.object(remote_agent, "async_upload_backup", side_effect=exception), patch("pathlib.Path.open", open_mock), patch("shutil.move") as move_mock, patch( @@ -2192,22 +2039,7 @@ async def test_receive_backup_file_write_error( close_exception: Exception | None, ) -> None: """Test file write error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -2303,22 +2135,7 @@ async def test_receive_backup_read_tar_error( exception: Exception, ) -> None: """Test read tar error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -2483,23 +2300,7 @@ async def test_receive_backup_file_read_error( response_status: int, ) -> None: """Test file read error during backup receive.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - - remote_agent = mock_backup_agent("remote") - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) client = await hass_client() ws_client = await hass_ws_client(hass) @@ -2655,16 +2456,10 @@ async def test_restore_backup( ) -> None: """Test restore backup.""" password = password_param.get("password") - remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( + await setup_backup_integration( hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, ) ws_client = await hass_ws_client(hass) @@ -2685,13 +2480,11 @@ async def test_restore_backup( patch( "homeassistant.components.backup.manager.validate_password" ) as validate_password_mock, - patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", 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", @@ -2762,16 +2555,10 @@ async def test_restore_backup_wrong_password( ) -> None: """Test restore backup wrong password.""" password = "hunter2" - remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( + await setup_backup_integration( hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, ) ws_client = await hass_ws_client(hass) @@ -2792,13 +2579,11 @@ async def test_restore_backup_wrong_password( patch( "homeassistant.components.backup.manager.validate_password" ) as validate_password_mock, - patch.object(remote_agent, "async_download_backup") as download_mock, patch( "homeassistant.components.backup.backup.read_backup", side_effect=mock_read_backup, ), ): - download_mock.return_value.__aiter__.return_value = iter((b"backup data",)) validate_password_mock.return_value = False await ws_client.send_json_auto_id( { @@ -2872,8 +2657,7 @@ async def test_restore_backup_wrong_parameters( expected_reason: str, ) -> None: """Test restore backup wrong parameters.""" - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) @@ -2937,8 +2721,7 @@ async def test_restore_backup_when_busy( hass_ws_client: WebSocketGenerator, ) -> None: """Test restore backup with busy manager.""" - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) await ws_client.send_json_auto_id( @@ -2989,16 +2772,10 @@ async def test_restore_backup_agent_error( expected_reason: str, ) -> None: """Test restore backup with agent error.""" - remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( + mock_agents = await setup_backup_integration( hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, ) ws_client = await hass_ws_client(hass) @@ -3010,19 +2787,17 @@ async def test_restore_backup_agent_error( result = await ws_client.receive_json() assert result["success"] is True + mock_agents["test.remote"].async_download_backup.side_effect = exception with ( patch("pathlib.Path.open"), patch("pathlib.Path.write_text") as mocked_write_text, patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, - patch.object( - remote_agent, "async_download_backup", side_effect=exception - ) as download_mock, ): await ws_client.send_json_auto_id( { "type": "backup/restore", "backup_id": TEST_BACKUP_ABC123.backup_id, - "agent_id": remote_agent.agent_id, + "agent_id": "test.remote", } ) @@ -3050,7 +2825,7 @@ async def test_restore_backup_agent_error( assert result["error"]["code"] == error_code assert result["error"]["message"] == error_message - assert download_mock.call_count == 1 + assert mock_agents["test.remote"].async_download_backup.call_count == 1 assert mocked_write_text.call_count == 0 assert mocked_service_call.call_count == 0 @@ -3129,16 +2904,10 @@ async def test_restore_backup_file_error( validate_password_call_count: int, ) -> None: """Test restore backup with file error.""" - remote_agent = mock_backup_agent("remote", backups=[TEST_BACKUP_ABC123]) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( + mock_agents = await setup_backup_integration( hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + remote_agents=["test.remote"], + backups={"test.remote": [TEST_BACKUP_ABC123]}, ) ws_client = await hass_ws_client(hass) @@ -3164,14 +2933,12 @@ async def test_restore_backup_file_error( patch( "homeassistant.components.backup.manager.validate_password" ) as validate_password_mock, - patch.object(remote_agent, "async_download_backup") as download_mock, ): - 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, - "agent_id": remote_agent.agent_id, + "agent_id": "test.remote", } ) @@ -3199,7 +2966,7 @@ async def test_restore_backup_file_error( assert result["error"]["code"] == "unknown_error" assert result["error"]["message"] == "Unknown error" - assert download_mock.call_count == 1 + assert mock_agents["test.remote"].async_download_backup.call_count == 1 assert validate_password_mock.call_count == validate_password_call_count assert open_mock.call_count == open_call_count assert open_mock.return_value.write.call_count == write_call_count @@ -3346,23 +3113,7 @@ async def test_initiate_backup_per_agent_encryption( inner_tar_key: bytes | None, ) -> None: """Test generate backup where encryption is selectively set on agents.""" - local_agent = local_backup_platform.CoreLocalBackupAgent(hass) - remote_agent = mock_backup_agent("remote") - - with patch( - "homeassistant.components.backup.backup.async_get_backup_agents" - ) as core_get_backup_agents: - core_get_backup_agents.return_value = [local_agent] - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), - ) + await setup_backup_integration(hass, remote_agents=["test.remote"]) ws_client = await hass_ws_client(hass) @@ -3512,8 +3263,7 @@ async def test_restore_progress_after_restart( with patch( "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() ): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) await ws_client.send_json_auto_id({"type": "backup/info"}) @@ -3539,8 +3289,7 @@ async def test_restore_progress_after_restart_fail_to_remove( """Test restore backup progress after restart when failing to remove result file.""" with patch("pathlib.Path.unlink", side_effect=OSError("Boom!")): - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_backup_integration(hass) ws_client = await hass_ws_client(hass) await ws_client.send_json_auto_id({"type": "backup/info"}) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index e97183fc53f..8632fb1e957 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -11,7 +11,6 @@ from syrupy import SnapshotAssertion from homeassistant.components.backup import ( AgentBackup, BackupAgentError, - BackupAgentPlatformProtocol, BackupNotFound, BackupReaderWriterError, Folder, @@ -28,15 +27,12 @@ from homeassistant.components.backup.manager import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component 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 @@ -150,12 +146,13 @@ async def test_info_with_errors( snapshot: SnapshotAssertion, ) -> None: """Test getting backup info with one unavailable agent.""" - await setup_backup_integration( - hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}, + remote_agents=["test.remote"], ) - mock_agent = mock_backup_agent("test") - mock_agent.async_list_backups.side_effect = side_effect - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent + mock_agents["test.remote"].async_list_backups.side_effect = side_effect client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -213,12 +210,13 @@ async def test_details_with_errors( snapshot: SnapshotAssertion, ) -> None: """Test getting backup info with one unavailable agent.""" - await setup_backup_integration( - hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}, + remote_agents=["test.remote"], ) - mock_agent = mock_backup_agent("test") - mock_agent.async_get_backup.side_effect = side_effect - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent + mock_agents["test.remote"].async_get_backup.side_effect = side_effect client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -304,12 +302,16 @@ async def test_delete_with_errors( "version": store.STORAGE_VERSION, "minor_version": store.STORAGE_VERSION_MINOR, } - await setup_backup_integration( - hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} + mock_agents = await setup_backup_integration( + hass, + with_hassio=False, + backups={ + LOCAL_AGENT_ID: [TEST_BACKUP_ABC123], + "test.remote": [TEST_BACKUP_ABC123], + }, + remote_agents=["test.remote"], ) - mock_agent = mock_backup_agent("test", [TEST_BACKUP_ABC123]) - mock_agent.async_delete_backup.side_effect = side_effect - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent + mock_agents["test.remote"].async_delete_backup.side_effect = side_effect client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -327,9 +329,9 @@ async def test_agent_delete_backup( snapshot: SnapshotAssertion, ) -> None: """Test deleting a backup file with a mock agent.""" - await setup_backup_integration(hass) - mock_agent = mock_backup_agent("test") - hass.data[DATA_MANAGER].backup_agents = {"domain.test": mock_agent} + mock_agents = await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] + ) client = await hass_ws_client(hass) await hass.async_block_till_done() @@ -342,7 +344,7 @@ async def test_agent_delete_backup( ) assert await client.receive_json() == snapshot - assert mock_agent.async_delete_backup.call_args == call("abc123") + assert mock_agents["test.remote"].async_delete_backup.call_args == call("abc123") @pytest.mark.parametrize( @@ -589,17 +591,9 @@ async def test_generate_with_default_settings_calls_create( client = await hass_ws_client(hass) await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-13T12:01:00+01:00") - remote_agent = mock_backup_agent("remote") - await setup_backup_platform( - hass, - domain="test", - platform=Mock( - async_get_backup_agents=AsyncMock(return_value=[remote_agent]), - spec_set=BackupAgentPlatformProtocol, - ), + mock_agents = await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] ) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() await client.send_json_auto_id( {"type": "backup/config/update", "create_backup": create_backup_settings} @@ -624,15 +618,13 @@ async def test_generate_with_default_settings_calls_create( is None ) - with patch.object(remote_agent, "async_upload_backup", side_effect=side_effect): - await client.send_json_auto_id( - {"type": "backup/generate_with_automatic_settings"} - ) - result = await client.receive_json() - assert result["success"] - assert result["result"] == {"backup_job_id": "abc123"} + mock_agents["test.remote"].async_upload_backup.side_effect = side_effect + await client.send_json_auto_id({"type": "backup/generate_with_automatic_settings"}) + result = await client.receive_json() + assert result["success"] + assert result["result"] == {"backup_job_id": "abc123"} - await hass.async_block_till_done() + await hass.async_block_till_done() create_backup.assert_called_once_with(**expected_call_params) @@ -893,8 +885,9 @@ async def test_agents_info( snapshot: SnapshotAssertion, ) -> None: """Test getting backup agents info.""" - await setup_backup_integration(hass, with_hassio=False) - hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_backup_agent("test") + await setup_backup_integration( + hass, with_hassio=False, remote_agents=["test.remote"] + ) client = await hass_ws_client(hass) await hass.async_block_till_done() From a3cde3d8abee9bdafbe03552f0bcde8a397e4600 Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 12 Feb 2025 22:22:58 +1100 Subject: [PATCH 0448/1941] Fix authentication error when adding new devices to SMLIGHT (#138373) * Fix authentication issue Fixes #138216 * Fix incorrect mocks in unsupported device tests * set _device_name in auth flow also * Update get_info Mock to handle authentication * Update tests --- .../components/smlight/config_flow.py | 12 +++-- tests/components/smlight/conftest.py | 14 ++++-- tests/components/smlight/test_button.py | 7 ++- tests/components/smlight/test_config_flow.py | 50 +++++++++++++++++-- tests/components/smlight/test_init.py | 1 + tests/components/smlight/test_update.py | 5 ++ 6 files changed, 74 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 667e6e2884b..fcfc364d983 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -77,12 +77,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - info = await self.client.get_info() - - if info.model not in Devices: - return self.async_abort(reason="unsupported_device") - if not await self._async_check_auth_required(user_input): + info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) + + if info.model not in Devices: + return self.async_abort(reason="unsupported_device") + return await self._async_complete_entry(user_input) except SmlightConnectionError: return self.async_abort(reason="cannot_connect") diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 0b1bf24c19a..7a1b16f1d6b 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch +from pysmlight.exceptions import SmlightAuthError from pysmlight.sse import sseClient from pysmlight.web import CmdWrapper, Firmware, Info, Sensors import pytest @@ -81,9 +82,16 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: ): api = smlight_mock.return_value api.host = MOCK_HOST - api.get_info.return_value = Info.from_dict( - load_json_object_fixture("info.json", DOMAIN) - ) + + def get_info_side_effect(*args, **kwargs) -> Info: + """Return the info.""" + if api.check_auth_needed.return_value and not api.authenticate.called: + raise SmlightAuthError + + return Info.from_dict(load_json_object_fixture("info.json", DOMAIN)) + + api.get_info.side_effect = get_info_side_effect + api.get_sensors.return_value = Sensors.from_dict( load_json_object_fixture("sensors.json", DOMAIN) ) diff --git a/tests/components/smlight/test_button.py b/tests/components/smlight/test_button.py index 3721ee815e6..51e9414c00e 100644 --- a/tests/components/smlight/test_button.py +++ b/tests/components/smlight/test_button.py @@ -45,6 +45,7 @@ async def test_buttons( mock_smlight_client: MagicMock, ) -> None: """Test creation of button entities.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = MOCK_ROUTER await setup_integration(hass, mock_config_entry) @@ -78,6 +79,7 @@ async def test_disabled_by_default_buttons( mock_smlight_client: MagicMock, ) -> None: """Test the disabled by default buttons.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = MOCK_ROUTER await setup_integration(hass, mock_config_entry) @@ -96,7 +98,8 @@ async def test_remove_router_reconnect( mock_smlight_client: MagicMock, ) -> None: """Test removal of orphaned router reconnect button.""" - save_mock = mock_smlight_client.get_info.return_value + save_mock = mock_smlight_client.get_info.side_effect + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = MOCK_ROUTER mock_config_entry = await setup_integration(hass, mock_config_entry) @@ -106,7 +109,7 @@ async def test_remove_router_reconnect( assert len(entities) == 4 assert entities[3].unique_id == "aa:bb:cc:dd:ee:ff-reconnect_zigbee_router" - mock_smlight_client.get_info.return_value = save_mock + mock_smlight_client.get_info.side_effect = save_mock freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index a1c9c9d6945..c8933029ce6 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -66,6 +66,46 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_flow_auth( + hass: HomeAssistant, mock_smlight_client: MagicMock, mock_setup_entry: AsyncMock +) -> None: + """Test the full manual user flow with authentication.""" + + mock_smlight_client.check_auth_needed.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "slzb-06p7.local", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "auth" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "SLZB-06p7" + assert result3["data"] == { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_HOST: MOCK_HOST, + } + assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_zeroconf_flow( hass: HomeAssistant, mock_smlight_client: MagicMock, @@ -145,7 +185,7 @@ async def test_zeroconf_flow_auth( assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["context"]["source"] == "zeroconf" assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" - assert result3["title"] == "slzb-06" + assert result3["title"] == "SLZB-06p7" assert result3["data"] == { CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, @@ -162,6 +202,7 @@ async def test_zeroconf_unsupported_abort( mock_smlight_client: MagicMock, ) -> None: """Test we abort zeroconf flow if device unsupported.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info(model="SLZB-X") result = await hass.config_entries.flow.async_init( @@ -186,6 +227,7 @@ async def test_user_unsupported_abort( mock_smlight_client: MagicMock, ) -> None: """Test we abort user flow if unsupported device.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info(model="SLZB-X") result = await hass.config_entries.flow.async_init( @@ -206,15 +248,13 @@ async def test_user_unsupported_abort( assert result2["reason"] == "unsupported_device" -async def test_user_unsupported_abort_auth( +async def test_user_unsupported_device_abort_auth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_smlight_client: MagicMock, ) -> None: """Test we abort user flow if unsupported device (with auth).""" mock_smlight_client.check_auth_needed.return_value = True - mock_smlight_client.authenticate.side_effect = SmlightAuthError - mock_smlight_client.get_info.side_effect = SmlightAuthError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -366,7 +406,7 @@ async def test_user_invalid_auth( } assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_smlight_client.get_info.mock_calls) == 4 + assert len(mock_smlight_client.get_info.mock_calls) == 3 async def test_user_cannot_connect( diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index 0acbab9f3a4..692255a53e6 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -165,6 +165,7 @@ async def test_device_legacy_firmware( """Test device setup for old firmware version that dont support required API.""" LEGACY_VERSION = "v0.9.9" mock_smlight_client.get_sensors.side_effect = SmlightError + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( legacy_api=2, sw_version=LEGACY_VERSION, MAC="AA:BB:CC:DD:EE:FF" ) diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index 632f1b5f26b..86d19968910 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -132,6 +132,7 @@ async def test_update_firmware( event_function(MOCK_FIRMWARE_DONE) + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( sw_version="v2.7.5", ) @@ -153,6 +154,7 @@ async def test_update_zigbee2_firmware( mock_smlight_client: MagicMock, ) -> None: """Test update of zigbee2 firmware where available.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info.from_dict( load_json_object_fixture("info-MR1.json", DOMAIN) ) @@ -195,6 +197,7 @@ async def test_update_legacy_firmware_v2( mock_smlight_client: MagicMock, ) -> None: """Test firmware update for legacy v2 firmware.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( sw_version="v2.0.18", legacy_api=1, @@ -220,6 +223,7 @@ async def test_update_legacy_firmware_v2( event_function(MOCK_FIRMWARE_DONE) + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info( sw_version="v2.7.5", ) @@ -333,6 +337,7 @@ async def test_update_release_notes( hass_ws_client: WebSocketGenerator, ) -> None: """Test firmware release notes.""" + mock_smlight_client.get_info.side_effect = None mock_smlight_client.get_info.return_value = Info.from_dict( load_json_object_fixture("info-MR1.json", DOMAIN) ) From 487a4ac5c4305863a2c888f52dfd8acee975330e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 12 Feb 2025 12:28:15 +0100 Subject: [PATCH 0449/1941] Improve field names and descriptions of easyEnergy actions (#138319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/easyenergy/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index 96afffdf78f..502db7920a3 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -60,12 +60,12 @@ "description": "Requests gas prices from easyEnergy.", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "The configuration entry to use for this action." }, "incl_vat": { - "name": "VAT Included", - "description": "Include or exclude VAT in the prices, default is true." + "name": "VAT included", + "description": "Whether the prices should include VAT." }, "start": { "name": "Start", From 88b444fa5ba5d5789dde90ce3343bc8153244525 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Wed, 12 Feb 2025 12:35:36 +0100 Subject: [PATCH 0450/1941] Add Homee sensor tests (#137200) --- tests/components/homee/__init__.py | 9 + tests/components/homee/fixtures/sensors.json | 715 +++++++ .../homee/snapshots/test_sensor.ambr | 1811 +++++++++++++++++ tests/components/homee/test_sensor.py | 124 ++ 4 files changed, 2659 insertions(+) create mode 100644 tests/components/homee/fixtures/sensors.json create mode 100644 tests/components/homee/snapshots/test_sensor.ambr create mode 100644 tests/components/homee/test_sensor.py diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py index a5f8ae00d1e..432e2d68516 100644 --- a/tests/components/homee/__init__.py +++ b/tests/components/homee/__init__.py @@ -49,3 +49,12 @@ def build_mock_node(file: str) -> AsyncMock: mock_node.get_attribute_by_type = attribute_by_type return mock_node + + +async def async_update_attribute_value( + hass: HomeAssistant, attribute: AsyncMock, value: float +) -> None: + """Set the current_value of an attribute and notify hass.""" + attribute.current_value = value + attribute.add_on_changed_listener.call_args_list[0][0][0](attribute) + await hass.async_block_till_done() diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json new file mode 100644 index 00000000000..f4a7f462218 --- /dev/null +++ b/tests/components/homee/fixtures/sensors.json @@ -0,0 +1,715 @@ +{ + "id": 1, + "name": "Test MultiSensor", + "profile": 4010, + "image": "default", + "favorite": 0, + "order": 20, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1709379826, + "added": 1676199446, + "history": 1, + "cube_type": 1, + "note": "", + "services": 5, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 200000, + "current_value": 555.591, + "target_value": 555.591, + "last_value": 555.586, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 4, + "state": 1, + "last_changed": 1694175270, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 2, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 200000, + "current_value": 1730.812, + "target_value": 1730.812, + "last_value": 1730.679, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 4, + "state": 1, + "last_changed": 1694175270, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "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, + "instance": 0, + "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": 5, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 65000, + "current_value": 175.0, + "target_value": 175.0, + "last_value": 66.0, + "unit": "lx", + "step_value": 1.0, + "editable": 0, + "type": 11, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 6, + "node_id": 1, + "instance": 2, + "minimum": 1, + "maximum": 100, + "current_value": 7.0, + "target_value": 7.0, + "last_value": 8.0, + "unit": "klx", + "step_value": 0.5, + "editable": 0, + "type": 11, + "state": 1, + "last_changed": 1700056686, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 70, + "current_value": 0.249, + "target_value": 0.249, + "last_value": 0.249, + "unit": "A", + "step_value": 1.0, + "editable": 0, + "type": 193, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 70, + "current_value": 0.812, + "target_value": 0.812, + "last_value": 0.252, + "unit": "A", + "step_value": 1.0, + "editable": 0, + "type": 193, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 70.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 18, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 500, + "current_value": 500.0, + "target_value": 500.0, + "last_value": 500.0, + "unit": "lx", + "step_value": 2.0, + "editable": 0, + "type": 301, + "state": 1, + "last_changed": 1700056347, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": -40, + "maximum": 100, + "current_value": 44.12, + "target_value": 44.12, + "last_value": 44.27, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 92, + "state": 1, + "last_changed": 1694176210, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4095, + "current_value": 2000.0, + "target_value": 0.0, + "last_value": 1800.0, + "unit": "1/min", + "step_value": 1.0, + "editable": 0, + "type": 103, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 47.0, + "target_value": 47.0, + "last_value": 47.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 96, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": -64, + "maximum": 63, + "current_value": 18.0, + "target_value": 18.0, + "last_value": 18.0, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 98, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4095, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "1/min", + "step_value": 1.0, + "editable": 0, + "type": 102, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 16, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 99999, + "current_value": 2490.0, + "target_value": 2490.0, + "last_value": 2516.0, + "unit": "L", + "step_value": 1.0, + "editable": 0, + "type": 22, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 17, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 4.0, + "target_value": 4.0, + "last_value": 4.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 33, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 18, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 196605, + "current_value": 5478.0, + "target_value": 5478.0, + "last_value": 5478.0, + "unit": "h", + "step_value": 1.0, + "editable": 0, + "type": 104, + "state": 1, + "last_changed": 1736105231, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 19, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 33.0, + "target_value": 33.0, + "last_value": 32.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 95, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 20, + "node_id": 1, + "instance": 0, + "minimum": -64, + "maximum": 63, + "current_value": 17.0, + "target_value": 17.0, + "last_value": 17.0, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 97, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 21, + "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": 0, + "type": 15, + "state": 1, + "last_changed": 1694176210, + "changed_by": 2, + "changed_by_id": 2, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 22, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 51.0, + "target_value": 51.0, + "last_value": 51.0, + "unit": "%", + "step_value": 1.0, + "editable": 0, + "type": 7, + "state": 1, + "last_changed": 1709982925, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 23, + "node_id": 1, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 20.3, + "target_value": 20.3, + "last_value": 20.3, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1709982925, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 24, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 600000, + "current_value": 3657.822, + "target_value": 3657.822, + "last_value": 3657.377, + "unit": "kWh", + "step_value": 1.0, + "editable": 0, + "type": 240, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 25, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 200, + "current_value": 2.223, + "target_value": 2.223, + "last_value": 2.21, + "unit": "A", + "step_value": 1.0, + "editable": 0, + "type": 272, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 26, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 80000, + "current_value": 195.384, + "target_value": 195.384, + "last_value": 248.412, + "unit": "W", + "step_value": 1.0, + "editable": 0, + "type": 239, + "state": 1, + "last_changed": 1694176076, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 27, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 420, + "current_value": 239.823, + "target_value": 239.823, + "last_value": 235.775, + "unit": "V", + "step_value": 1.0, + "editable": 0, + "type": 51, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 28, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 4, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 3.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 135, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 29, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 15, + "current_value": 6.0, + "target_value": 6.0, + "last_value": 6.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 173, + "state": 1, + "last_changed": 1709982926, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 30, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 420, + "current_value": 239.823, + "target_value": 239.823, + "last_value": 239.559, + "unit": "V", + "step_value": 1.0, + "editable": 0, + "type": 195, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 31, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 420, + "current_value": 236.867, + "target_value": 236.867, + "last_value": 237.634, + "unit": "V", + "step_value": 1.0, + "editable": 0, + "type": 195, + "state": 1, + "last_changed": 1694175269, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 32, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 25, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 2.5, + "unit": "m/s", + "step_value": 1.0, + "editable": 0, + "type": 146, + "state": 1, + "last_changed": 1700056836, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 33, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 10, + "state": 1, + "last_changed": 1687175680, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3101723232e --- /dev/null +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -0,0 +1,1811 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.test_multisensor_battery-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': , + 'entity_id': 'sensor.test_multisensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test MultiSensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-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': , + 'entity_id': 'sensor.test_multisensor_battery_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test MultiSensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_battery_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_1-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_current_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_instance', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test MultiSensor Current 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_current_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.249', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_2-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_current_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_instance', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_current_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test MultiSensor Current 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_current_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.812', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_dawn-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_dawn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dawn', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dawn', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_dawn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Dawn', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_dawn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_device_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.test_multisensor_device_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': 'Device temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_temperature', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_device_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Device temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_device_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44.12', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_1-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_energy_1', + '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 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_instance', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test MultiSensor Energy 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_energy_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '555.591', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_2-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_energy_2', + '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 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_instance', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_energy_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test MultiSensor Energy 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_energy_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1730.812', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_exhaust_motor_speed-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': , + 'entity_id': 'sensor.test_multisensor_exhaust_motor_speed', + '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': 'Exhaust motor speed', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exhaust_motor_revs', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_exhaust_motor_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Exhaust motor speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_exhaust_motor_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2000.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_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.test_multisensor_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': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '00055511EECC-1-22', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test MultiSensor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-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_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_instance', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Illuminance 1', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '175.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-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_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness_instance', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Test MultiSensor Illuminance 2', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_illuminance_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7000.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_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.test_multisensor_indoor_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': 'Indoor humidity', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_humidity', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test MultiSensor Indoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_indoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_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.test_multisensor_indoor_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': 'Indoor temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'indoor_temperature', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_indoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Indoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_indoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_intake_motor_speed-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': , + 'entity_id': 'sensor.test_multisensor_intake_motor_speed', + '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': 'Intake motor speed', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'intake_motor_revs', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_intake_motor_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Intake motor speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_intake_motor_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_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.test_multisensor_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'level', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Test MultiSensor Level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2490.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_link_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': , + 'entity_id': 'sensor.test_multisensor_link_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': 'Link quality', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_quality', + 'unique_id': '00055511EECC-1-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_link_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Link quality', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_link_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_node_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'unavailable', + 'update_in_progress', + 'waiting_for_attributes', + 'initializing', + 'user_interaction_required', + 'password_required', + 'host_unavailable', + 'delete_in_progress', + 'cosi_connected', + 'blocked', + 'waiting_for_wakeup', + 'remote_node_deleted', + 'firmware_update_in_progress', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_multisensor_node_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Node state', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'node_state', + 'unique_id': '00055511EECC-1-state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_node_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test MultiSensor Node state', + 'options': list([ + 'available', + 'unavailable', + 'update_in_progress', + 'waiting_for_attributes', + 'initializing', + 'user_interaction_required', + 'password_required', + 'host_unavailable', + 'delete_in_progress', + 'cosi_connected', + 'blocked', + 'waiting_for_wakeup', + 'remote_node_deleted', + 'firmware_update_in_progress', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_node_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_operating_hours-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': , + 'entity_id': 'sensor.test_multisensor_operating_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Operating hours', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_hours', + 'unique_id': '00055511EECC-1-18', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_operating_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Test MultiSensor Operating hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_operating_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5478.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_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.test_multisensor_outdoor_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': 'Outdoor humidity', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_humidity', + 'unique_id': '00055511EECC-1-19', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test MultiSensor Outdoor humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_outdoor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_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.test_multisensor_outdoor_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': 'Outdoor temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outdoor_temperature', + 'unique_id': '00055511EECC-1-20', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_outdoor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Outdoor temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_outdoor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_position-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_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': 'Position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'position', + 'unique_id': '00055511EECC-1-21', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'open', + 'closed', + 'partial', + 'opening', + 'closing', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'up_down', + 'unique_id': '00055511EECC-1-28', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test MultiSensor State', + 'options': list([ + 'open', + 'closed', + 'partial', + 'opening', + 'closing', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_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.test_multisensor_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': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00055511EECC-1-23', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.3', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_current-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_total_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total current', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_current', + 'unique_id': '00055511EECC-1-25', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test MultiSensor Total current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.223', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_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.test_multisensor_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': '00055511EECC-1-24', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test MultiSensor Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3657.822', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_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.test_multisensor_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': '00055511EECC-1-26', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test MultiSensor Total power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '195.384', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_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.test_multisensor_total_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': 'Total voltage', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_voltage', + 'unique_id': '00055511EECC-1-27', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_total_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test MultiSensor Total voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_total_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.823', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_ultraviolet-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_ultraviolet', + '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': 'Ultraviolet', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv', + 'unique_id': '00055511EECC-1-29', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_ultraviolet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Ultraviolet', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_ultraviolet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-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': , + 'entity_id': 'sensor.test_multisensor_valve_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': 'Valve position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'valve_position', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_valve_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test MultiSensor Valve position', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_valve_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_1-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_voltage_1', + '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 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_instance', + 'unique_id': '00055511EECC-1-30', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test MultiSensor Voltage 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_voltage_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '239.823', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_2-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_voltage_2', + '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 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_instance', + 'unique_id': '00055511EECC-1-31', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_voltage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test MultiSensor Voltage 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_voltage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '236.867', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_wind_speed-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_wind_speed', + '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': 'Wind speed', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed', + 'unique_id': '00055511EECC-1-32', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'wind_speed', + 'friendly_name': 'Test MultiSensor Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.2', + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_window_position-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'closed', + 'open', + 'tilted', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_multisensor_window_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': 'Window position', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'window_position', + 'unique_id': '00055511EECC-1-33', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_window_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test MultiSensor Window position', + 'options': list([ + 'closed', + 'open', + 'tilted', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_window_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 new file mode 100644 index 00000000000..8ee48d3ea97 --- /dev/null +++ b/tests/components/homee/test_sensor.py @@ -0,0 +1,124 @@ +"""Test homee sensors.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.homee.const import ( + OPEN_CLOSE_MAP, + OPEN_CLOSE_MAP_REVERSED, + WINDOW_MAP, + WINDOW_MAP_REVERSED, +) +from homeassistant.const import LIGHT_LUX +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 + + +async def test_up_down_values( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test values for up/down sensor.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] + + attribute = mock_homee.nodes[0].attributes[27] + for i in range(1, 5): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[i] + ) + + # Test reversed up/down sensor + attribute.is_reversed = True + for i in range(5): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_state").state + == OPEN_CLOSE_MAP_REVERSED[i] + ) + + +async def test_window_position( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test values for window handle position.""" + mock_homee.nodes = [build_mock_node("sensors.json")] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("sensor.test_multisensor_window_position").state + == WINDOW_MAP[0] + ) + + attribute = mock_homee.nodes[0].attributes[32] + for i in range(1, 3): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_window_position").state + == WINDOW_MAP[i] + ) + + # Test reversed window handle. + attribute.is_reversed = True + for i in range(3): + await async_update_attribute_value(hass, attribute, i) + assert ( + hass.states.get("sensor.test_multisensor_window_position").state + == WINDOW_MAP_REVERSED[i] + ) + + +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")] + 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, + 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")] + 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() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 64fa9b78f8114eee47518471010c22ecc020c18e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 12 Feb 2025 12:39:43 +0100 Subject: [PATCH 0451/1941] Fix typos in user-facing strings of Bayesian integration (#138364) --- homeassistant/components/bayesian/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bayesian/strings.json b/homeassistant/components/bayesian/strings.json index 9ebccedc88d..00de79a2229 100644 --- a/homeassistant/components/bayesian/strings.json +++ b/homeassistant/components/bayesian/strings.json @@ -5,14 +5,14 @@ "title": "Manual YAML fix required for Bayesian" }, "no_prob_given_false": { - "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yml` for `bayesian/{entity}`. These observations will be ignored until you do.", + "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yaml` for `bayesian/{entity}`. These observations will be ignored until you do.", "title": "Manual YAML addition required for Bayesian" } }, "services": { "reload": { "name": "[%key:common::action::reload%]", - "description": "Reloads bayesian sensors from the YAML-configuration." + "description": "Reloads Bayesian sensors from the YAML-configuration." } } } From f1471f143c02ffd92e0c467f8c335575a86c3e87 Mon Sep 17 00:00:00 2001 From: jdanders Date: Wed, 12 Feb 2025 03:41:52 -0800 Subject: [PATCH 0452/1941] Fix broken issue creation in econet (#137773) * econet: Fix broken issue creation * econet: fix broken issue creation with create_issue --- homeassistant/components/econet/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index b9673869046..cb2374bd69b 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -23,7 +23,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from . import EconetConfigEntry from .const import DOMAIN @@ -209,7 +209,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - async_create_issue( + create_issue( self.hass, DOMAIN, "migrate_aux_heat", @@ -223,7 +223,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - async_create_issue( + create_issue( self.hass, DOMAIN, "migrate_aux_heat", From 2bb582f8e6a3988640074c75f212e5c8f63bb05a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Feb 2025 12:42:22 +0100 Subject: [PATCH 0453/1941] Use runtime_data in geo_json_events (#138366) * Use runtime_data in geo_json_events * Update __init__.py --- .../components/geo_json_events/__init__.py | 22 +++++++++---------- .../geo_json_events/geo_location.py | 15 ++++--------- .../components/geo_json_events/manager.py | 2 ++ tests/components/geo_json_events/conftest.py | 2 +- .../geo_json_events/test_config_flow.py | 2 +- tests/components/geo_json_events/test_init.py | 6 ++--- 6 files changed, 21 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py index d55fe6e3ee6..e38c17008a5 100644 --- a/homeassistant/components/geo_json_events/__init__.py +++ b/homeassistant/components/geo_json_events/__init__.py @@ -4,25 +4,27 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, PLATFORMS -from .manager import GeoJsonFeedEntityManager +from .const import PLATFORMS +from .manager import GeoJsonConfigEntry, GeoJsonFeedEntityManager _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: GeoJsonConfigEntry +) -> bool: """Set up the GeoJSON events component as config entry.""" - feeds = hass.data.setdefault(DOMAIN, {}) # Create feed entity manager for all platforms. manager = GeoJsonFeedEntityManager(hass, config_entry) - feeds[config_entry.entry_id] = manager _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) await remove_orphaned_entities(hass, config_entry.entry_id) + + config_entry.runtime_data = manager + config_entry.async_on_unload(manager.async_stop) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) await manager.async_init() return True @@ -46,10 +48,6 @@ async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None: entity_registry.async_remove(entry.entity_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GeoJsonConfigEntry) -> bool: """Unload the GeoJSON events config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - manager: GeoJsonFeedEntityManager = hass.data[DOMAIN].pop(entry.entry_id) - await manager.async_stop() - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index dce4aac1630..a119571a0ca 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -9,31 +9,24 @@ from typing import Any from aio_geojson_generic_client.feed_entry import GenericFeedEntry from homeassistant.components.geo_location import GeolocationEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import GeoJsonFeedEntityManager -from .const import ( - ATTR_EXTERNAL_ID, - DOMAIN, - SIGNAL_DELETE_ENTITY, - SIGNAL_UPDATE_ENTITY, - SOURCE, -) +from .const import ATTR_EXTERNAL_ID, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY, SOURCE +from .manager import GeoJsonConfigEntry, GeoJsonFeedEntityManager _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: GeoJsonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the GeoJSON Events platform.""" - manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id] + manager = entry.runtime_data @callback def async_add_geolocation( diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py index deff15436a6..223d3bf571f 100644 --- a/homeassistant/components/geo_json_events/manager.py +++ b/homeassistant/components/geo_json_events/manager.py @@ -25,6 +25,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type GeoJsonConfigEntry = ConfigEntry[GeoJsonFeedEntityManager] + class GeoJsonFeedEntityManager: """Feed Entity Manager for GeoJSON feeds.""" diff --git a/tests/components/geo_json_events/conftest.py b/tests/components/geo_json_events/conftest.py index 11928e6f012..a4fff4563be 100644 --- a/tests/components/geo_json_events/conftest.py +++ b/tests/components/geo_json_events/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.components.geo_json_events.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL from tests.common import MockConfigEntry diff --git a/tests/components/geo_json_events/test_config_flow.py b/tests/components/geo_json_events/test_config_flow.py index fe21bccc7aa..9a52cb599b2 100644 --- a/tests/components/geo_json_events/test_config_flow.py +++ b/tests/components/geo_json_events/test_config_flow.py @@ -3,7 +3,7 @@ import pytest from homeassistant import config_entries -from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.components.geo_json_events.const import DOMAIN from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, diff --git a/tests/components/geo_json_events/test_init.py b/tests/components/geo_json_events/test_init.py index e90e663d8b6..0553190395d 100644 --- a/tests/components/geo_json_events/test_init.py +++ b/tests/components/geo_json_events/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import patch -from homeassistant.components.geo_json_events.const import DOMAIN from homeassistant.components.geo_location import DOMAIN as GEO_LOCATION_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,11 +24,11 @@ async def test_component_unload_config_entry( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][config_entry.entry_id] is not None + assert config_entry.state is ConfigEntryState.LOADED # Unload config entry. assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN].get(config_entry.entry_id) is None + assert config_entry.state is ConfigEntryState.NOT_LOADED async def test_remove_orphaned_entities( From ef9d5dd568bca77fe6127ab9d7a624529b772846 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 12:46:53 +0100 Subject: [PATCH 0454/1941] Bump cryptography to 44.0.1 (#138371) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4f52f49ce09..43752fb558b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -cryptography==44.0.0 +cryptography==44.0.1 dbus-fast==2.33.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 diff --git a/pyproject.toml b/pyproject.toml index 3936fdb3a1e..11bda6bf1fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.0", + "cryptography==44.0.1", "Pillow==11.1.0", "propcache==0.2.1", "pyOpenSSL==25.0.0", diff --git a/requirements.txt b/requirements.txt index f0ff3b8054a..fcfdded632b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 PyJWT==2.10.1 -cryptography==44.0.0 +cryptography==44.0.1 Pillow==11.1.0 propcache==0.2.1 pyOpenSSL==25.0.0 From e12b100a3743f4405f22284c3bef58daed7d8ded Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Feb 2025 12:49:26 +0100 Subject: [PATCH 0455/1941] Use runtime_data in fireservicerota (#138361) --- .../components/fireservicerota/__init__.py | 26 ++++++++----------- .../fireservicerota/binary_sensor.py | 18 ++++++------- .../components/fireservicerota/coordinator.py | 9 ++++++- .../components/fireservicerota/sensor.py | 11 +++----- .../components/fireservicerota/switch.py | 15 ++++++----- 5 files changed, 40 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py index 6f48dcfc4bc..0f30a29cfba 100644 --- a/homeassistant/components/fireservicerota/__init__.py +++ b/homeassistant/components/fireservicerota/__init__.py @@ -4,23 +4,23 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN -from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator +from .coordinator import ( + FireServiceConfigEntry, + FireServiceRotaClient, + FireServiceUpdateCoordinator, +) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FireServiceConfigEntry) -> bool: """Set up FireServiceRota from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - client = FireServiceRotaClient(hass, entry) await client.setup() @@ -32,19 +32,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - DATA_CLIENT: client, - DATA_COORDINATOR: coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: FireServiceConfigEntry +) -> bool: """Unload FireServiceRota config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index b8a542cf37c..be7add191c0 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -10,24 +10,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN -from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator +from .coordinator import ( + FireServiceConfigEntry, + FireServiceRotaClient, + FireServiceUpdateCoordinator, +) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FireServiceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up FireServiceRota binary sensor based on a config entry.""" - client: FireServiceRotaClient = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][ - DATA_CLIENT - ] - - coordinator: FireServiceUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][ - entry.entry_id - ][DATA_COORDINATOR] + coordinator = entry.runtime_data + client = coordinator.client async_add_entities([ResponseBinarySensor(coordinator, client, entry)]) diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py index c452421d57b..6815bf39104 100644 --- a/homeassistant/components/fireservicerota/coordinator.py +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -28,12 +28,19 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] +type FireServiceConfigEntry = ConfigEntry[FireServiceUpdateCoordinator] + class FireServiceUpdateCoordinator(DataUpdateCoordinator[dict | None]): """Data update coordinator for FireServiceRota.""" + config_entry: FireServiceConfigEntry + def __init__( - self, hass: HomeAssistant, client: FireServiceRotaClient, entry: ConfigEntry + self, + hass: HomeAssistant, + client: FireServiceRotaClient, + entry: FireServiceConfigEntry, ) -> None: """Initialize the FireServiceRota DataUpdateCoordinator.""" super().__init__( diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 682c7bcc0fd..5ed65609dc8 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -4,27 +4,24 @@ import logging from typing import Any from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import DATA_CLIENT, DOMAIN as FIRESERVICEROTA_DOMAIN -from .coordinator import FireServiceRotaClient +from .const import DOMAIN as FIRESERVICEROTA_DOMAIN +from .coordinator import FireServiceConfigEntry, FireServiceRotaClient _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FireServiceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up FireServiceRota sensor based on a config entry.""" - client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT] - - async_add_entities([IncidentsSensor(client)]) + async_add_entities([IncidentsSensor(entry.runtime_data.client)]) # pylint: disable-next=hass-invalid-inheritance # needs fixing diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py index 602a02a8e4a..d9fe382e4b1 100644 --- a/homeassistant/components/fireservicerota/switch.py +++ b/homeassistant/components/fireservicerota/switch.py @@ -9,21 +9,24 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN -from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator +from .const import DOMAIN as FIRESERVICEROTA_DOMAIN +from .coordinator import ( + FireServiceConfigEntry, + FireServiceRotaClient, + FireServiceUpdateCoordinator, +) _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: FireServiceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up FireServiceRota switch based on a config entry.""" - client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT] - - coordinator = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator = entry.runtime_data + client = coordinator.client async_add_entities([ResponseSwitch(coordinator, client, entry)]) From bc11444fb298ea577b81606b96f298b1a98b6ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 12 Feb 2025 12:14:31 +0000 Subject: [PATCH 0456/1941] Add missing loggers to Cloud (#138374) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4528d9aa225..97210b4197c 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -423,7 +423,7 @@ async def _setup_log_handler(hass: HomeAssistant) -> FixedSizeQueueLogHandler: handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) integration = await async_get_integration(hass, DOMAIN) - loggers: set[str] = {"snitun", integration.pkg_path, *(integration.loggers or [])} + loggers: set[str] = {integration.pkg_path, *(integration.loggers or [])} for logger_name in loggers: logging.getLogger(logger_name).addHandler(handler) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 8e8ff4335db..73225b5ea56 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -12,7 +12,7 @@ "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", - "loggers": ["hass_nabucasa"], + "loggers": ["acme", "hass_nabucasa", "snitun"], "requirements": ["hass-nabucasa==0.89.0"], "single_config_entry": true } From 6084bee2d5403f42dd3058390ed0b0acfa4a78ac Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 14:14:52 +0100 Subject: [PATCH 0457/1941] Bump deebot-client to 12.1.0 (#138382) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 33a251c22dc..79e0c34e4b9 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf33b7966c7..8b757aa4aa2 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==12.0.0 +deebot-client==12.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c5482fde6e..0f1929fc585 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.0.0 +deebot-client==12.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 327bb34be1ccc98916bc3bef82a892e8a0013d14 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:15:32 +0100 Subject: [PATCH 0458/1941] Bump stookwijzer to 1.5.2 (#138384) Bump stookwijzer==1.5.2 --- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 3fe16fb3d33..0c97d1b20ed 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.1"] + "requirements": ["stookwijzer==1.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b757aa4aa2..b7aec98bc96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2796,7 +2796,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.1 +stookwijzer==1.5.2 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f1929fc585..e972f2d32b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2257,7 +2257,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.1 +stookwijzer==1.5.2 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 4807682fc55935072d24a0b6e66ffd2d50e48ee1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:18:10 +0100 Subject: [PATCH 0459/1941] Remove unused arguments in forked_daapd initialisation (#138289) --- .../components/forked_daapd/media_player.py | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 6bc69a64eaa..8cbf33460aa 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -85,9 +85,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up forked-daapd from a config entry.""" - host = config_entry.data[CONF_HOST] - port = config_entry.data[CONF_PORT] - password = config_entry.data[CONF_PASSWORD] + host: str = config_entry.data[CONF_HOST] + port: int = config_entry.data[CONF_PORT] + password: str = config_entry.data[CONF_PASSWORD] forked_daapd_api = ForkedDaapdAPI( async_get_clientsession(hass), host, port, password ) @@ -95,8 +95,6 @@ async def async_setup_entry( clientsession=async_get_clientsession(hass), api=forked_daapd_api, ip_address=host, - api_port=port, - api_password=password, config_entry=config_entry, ) @@ -240,9 +238,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): _attr_should_poll = False - def __init__( - self, clientsession, api, ip_address, api_port, api_password, config_entry - ): + def __init__(self, clientsession, api, ip_address, config_entry): """Initialize the ForkedDaapd Master Device.""" # Leave the api public so the browse media helpers can use it self.api = api @@ -269,7 +265,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): self._on_remove = None self._available = False self._clientsession = clientsession - self._config_entry = config_entry + self._entry_id = config_entry.entry_id self.update_options(config_entry.options) self._paused_event = asyncio.Event() self._pause_requested = False @@ -282,42 +278,42 @@ class ForkedDaapdMaster(MediaPlayerEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_PLAYER.format(self._config_entry.entry_id), + SIGNAL_UPDATE_PLAYER.format(self._entry_id), self._update_player, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_QUEUE.format(self._config_entry.entry_id), + SIGNAL_UPDATE_QUEUE.format(self._entry_id), self._update_queue, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_OUTPUTS.format(self._config_entry.entry_id), + SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), self._update_outputs, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_MASTER.format(self._config_entry.entry_id), + SIGNAL_UPDATE_MASTER.format(self._entry_id), self._update_callback, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_CONFIG_OPTIONS_UPDATE.format(self._config_entry.entry_id), + SIGNAL_CONFIG_OPTIONS_UPDATE.format(self._entry_id), self.update_options, ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_DATABASE.format(self._config_entry.entry_id), + SIGNAL_UPDATE_DATABASE.format(self._entry_id), self._update_database, ) ) @@ -411,9 +407,9 @@ class ForkedDaapdMaster(MediaPlayerEntity): self._track_info = defaultdict(str) @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID.""" - return self._config_entry.entry_id + return self._entry_id @property def available(self) -> bool: From 910711ecba01f92bf0c3c4699fc89bf8338eff64 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Wed, 12 Feb 2025 13:54:21 +0000 Subject: [PATCH 0460/1941] Bump ohmepy to 1.3.0 (#138380) * Bump ohmepy to 1.3.0 * CI fix for enum change --- homeassistant/components/ohme/manifest.json | 2 +- homeassistant/components/ohme/strings.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ohme/snapshots/test_sensor.ambr | 2 ++ 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 100967f819f..c1ca2bac62f 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["ohme==1.2.9"] + "requirements": ["ohme==1.3.0"] } diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index eb5bbffda52..b337c013727 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -73,7 +73,8 @@ "plugged_in": "Plugged in", "charging": "Charging", "paused": "[%key:common::state::paused%]", - "pending_approval": "Pending approval" + "pending_approval": "Pending approval", + "finished": "Finished charging" } }, "ct_current": { diff --git a/requirements_all.txt b/requirements_all.txt index b7aec98bc96..f258715dd28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1547,7 +1547,7 @@ odp-amsterdam==6.0.2 oemthermostat==1.1.1 # homeassistant.components.ohme -ohme==1.2.9 +ohme==1.3.0 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e972f2d32b1..447d21630f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1295,7 +1295,7 @@ objgraph==3.5.0 odp-amsterdam==6.0.2 # homeassistant.components.ohme -ohme==1.2.9 +ohme==1.3.0 # homeassistant.components.ollama ollama==0.4.7 diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index b5c3c3b96d5..fc28b3b011c 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -222,6 +222,7 @@ 'charging', 'plugged_in', 'paused', + 'finished', ]), }), 'config_entry_id': , @@ -263,6 +264,7 @@ 'charging', 'plugged_in', 'paused', + 'finished', ]), }), 'context': , From 281c2bfb7bd1095a794f62fd81042786420b7718 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 15:29:42 +0100 Subject: [PATCH 0461/1941] Bump hass-nabucasa from 0.89.0 to 0.90.0 (#138387) * Bump hass-nabucasa from 0.89.0 to 0.90.0 * Use new shiny enum --- homeassistant/components/cloud/backup.py | 14 ++++++++------ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 9531604ccc7..83dc44c0ef7 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -8,12 +8,13 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib import logging import random -from typing import Any, Literal +from typing import Any from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list +from hass_nabucasa.files import StorageType from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -24,7 +25,6 @@ from .client import CloudClient from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) -_STORAGE_BACKUP: Literal["backup"] = "backup" _RETRY_LIMIT = 5 _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 @@ -106,7 +106,7 @@ class CloudBackupAgent(BackupAgent): try: content = await self._cloud.files.download( - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, filename=self._get_backup_filename(), ) except CloudError as err: @@ -138,7 +138,7 @@ class CloudBackupAgent(BackupAgent): while tries <= _RETRY_LIMIT: try: await self._cloud.files.upload( - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, open_stream=open_stream, filename=filename, base64md5hash=base64md5hash, @@ -185,7 +185,7 @@ class CloudBackupAgent(BackupAgent): try: await async_files_delete_file( self._cloud, - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, filename=self._get_backup_filename(), ) except (ClientError, CloudError) as err: @@ -194,7 +194,9 @@ class CloudBackupAgent(BackupAgent): async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" try: - backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP) + backups = await async_files_list( + self._cloud, storage_type=StorageType.BACKUP + ) _LOGGER.debug("Cloud backups: %s", backups) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 73225b5ea56..7598dde6cf3 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.89.0"], + "requirements": ["hass-nabucasa==0.90.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 43752fb558b..5b7ad8b5118 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index 11bda6bf1fc..64163342723 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.89.0", + "hass-nabucasa==0.90.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index fcfdded632b..6e428a96767 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f258715dd28..7b6ef5b23f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1106,7 +1106,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 447d21630f4..436186a9edd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 # homeassistant.components.conversation hassil==2.2.3 From 8bf870f296258647c55af42e236012cec726c4eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Feb 2025 08:57:26 -0600 Subject: [PATCH 0462/1941] Bump zeroconf to 0.144.1 (#138353) * Bump zeroconf to 0.143.1 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.143.0...0.143.1 fixes #138324 fixes https://github.com/home-assistant/core/issues/137731 fixes https://github.com/home-assistant/core/issues/138298 * one more --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index f4a78cd99e9..ddc74fba8bf 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.143.0"] + "requirements": ["zeroconf==0.144.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5b7ad8b5118..b35d5589182 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.143.0 +zeroconf==0.144.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 64163342723..c0d83b05f00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.143.0" + "zeroconf==0.144.1" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 6e428a96767..4afa122ba7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.143.0 +zeroconf==0.144.1 diff --git a/requirements_all.txt b/requirements_all.txt index 7b6ef5b23f8..5f59bbdbd54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.143.0 +zeroconf==0.144.1 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 436186a9edd..eb62baad569 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.143.0 +zeroconf==0.144.1 # homeassistant.components.zeversolar zeversolar==0.3.2 From 620141cfb16762ca64e9b6d6bcb447763c9946f1 Mon Sep 17 00:00:00 2001 From: "Andre W." <10945277+alfwro13@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:24:39 +0000 Subject: [PATCH 0463/1941] Fix version extraction for APsystems (#138023) Co-authored-by: Marlon --- homeassistant/components/apsystems/entity.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py index 9ba7d046b60..2ce8becbf80 100644 --- a/homeassistant/components/apsystems/entity.py +++ b/homeassistant/components/apsystems/entity.py @@ -19,10 +19,20 @@ class ApSystemsEntity(Entity): data: ApSystemsData, ) -> None: """Initialize the APsystems entity.""" + + # Handle device version safely + sw_version = None + if data.coordinator.device_version: + version_parts = data.coordinator.device_version.split(" ") + if len(version_parts) > 1: + sw_version = version_parts[1] + else: + sw_version = version_parts[0] + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, data.device_id)}, manufacturer="APsystems", model="EZ1-M", serial_number=data.device_id, - sw_version=data.coordinator.device_version.split(" ")[1], + sw_version=sw_version, ) From ff5ddce7b0c07ea731f5fd92d52930bbc71f7b4e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 12 Feb 2025 18:37:30 +0100 Subject: [PATCH 0464/1941] Add sensor platform to OneDrive for drive usage (#138232) --- homeassistant/components/onedrive/__init__.py | 28 +-- homeassistant/components/onedrive/backup.py | 2 +- .../components/onedrive/coordinator.py | 70 ++++++ homeassistant/components/onedrive/icons.json | 24 ++ .../components/onedrive/quality_scale.yaml | 80 ++---- homeassistant/components/onedrive/sensor.py | 122 ++++++++++ .../components/onedrive/strings.json | 25 ++ tests/components/onedrive/conftest.py | 3 +- tests/components/onedrive/const.py | 18 ++ .../onedrive/snapshots/test_init.ambr | 34 +++ .../onedrive/snapshots/test_sensor.ambr | 227 ++++++++++++++++++ tests/components/onedrive/test_init.py | 32 ++- tests/components/onedrive/test_sensor.py | 64 +++++ 13 files changed, 647 insertions(+), 82 deletions(-) create mode 100644 homeassistant/components/onedrive/coordinator.py create mode 100644 homeassistant/components/onedrive/icons.json create mode 100644 homeassistant/components/onedrive/sensor.py create mode 100644 tests/components/onedrive/snapshots/test_init.ambr create mode 100644 tests/components/onedrive/snapshots/test_sensor.ambr create mode 100644 tests/components/onedrive/test_sensor.py diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 8355cddb0b5..c82757dca31 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable -from dataclasses import dataclass from html import unescape from json import dumps, loads import logging @@ -17,8 +15,7 @@ from onedrive_personal_sdk.exceptions import ( ) from onedrive_personal_sdk.models.items import ItemUpdate -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -29,18 +26,14 @@ 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 .coordinator import ( + OneDriveConfigEntry, + OneDriveRuntimeData, + OneDriveUpdateCoordinator, +) +PLATFORMS = [Platform.SENSOR] -@dataclass -class OneDriveRuntimeData: - """Runtime data for the OneDrive integration.""" - - client: OneDriveClient - token_function: Callable[[], Awaitable[str]] - backup_folder_id: str - - -type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] _LOGGER = logging.getLogger(__name__) @@ -85,10 +78,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> translation_placeholders={"folder": backup_folder_name}, ) from err + coordinator = OneDriveUpdateCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = OneDriveRuntimeData( client=client, token_function=get_access_token, backup_folder_id=backup_folder.id, + coordinator=coordinator, ) try: @@ -100,6 +97,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> ) from err _async_notify_backup_listeners_soon(hass) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -107,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Unload a OneDrive config entry.""" _async_notify_backup_listeners_soon(hass) - return True + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) def _async_notify_backup_listeners(hass: HomeAssistant) -> None: diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 343c332f384..0e89f1b590f 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -30,8 +30,8 @@ from homeassistant.components.backup import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from . import OneDriveConfigEntry from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .coordinator import OneDriveConfigEntry _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py new file mode 100644 index 00000000000..cc759437c07 --- /dev/null +++ b/homeassistant/components/onedrive/coordinator.py @@ -0,0 +1,70 @@ +"""Coordinator for OneDrive.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +import logging + +from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException +from onedrive_personal_sdk.models.items import Drive + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class OneDriveRuntimeData: + """Runtime data for the OneDrive integration.""" + + client: OneDriveClient + token_function: Callable[[], Awaitable[str]] + backup_folder_id: str + coordinator: OneDriveUpdateCoordinator + + +type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData] + + +class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): + """Class to handle fetching data from the Graph API centrally.""" + + config_entry: OneDriveConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: OneDriveConfigEntry, client: OneDriveClient + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._client = client + + async def _async_update_data(self) -> Drive: + """Fetch data from API endpoint.""" + + try: + drive = await self._client.get_drive() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except OneDriveException as err: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_failed" + ) from err + return drive diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json new file mode 100644 index 00000000000..b693f69934e --- /dev/null +++ b/homeassistant/components/onedrive/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "total_size": { + "default": "mdi:database" + }, + "used_size": { + "default": "mdi:database" + }, + "remaining_size": { + "default": "mdi:database" + }, + "drive_state": { + "default": "mdi:harddisk", + "state": { + "normal": "mdi:harddisk", + "nearing": "mdi:alert-circle-outline", + "critical": "mdi:alert", + "exceeded": "mdi:alert-octagon" + } + } + } + } +} diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index f0d58d89c9a..ff95364859a 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -3,10 +3,7 @@ rules: action-setup: status: exempt comment: Integration does not register custom actions. - appropriate-polling: - status: exempt - comment: | - This integration does not poll. + appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done @@ -23,14 +20,8 @@ rules: 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. + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -44,27 +35,15 @@ rules: comment: | No Options flow. docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: | - This integration does not have entities. + entity-unavailable: done 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. + log-when-unavailable: done + parallel-updates: done reauthentication-flow: done test-coverage: todo # Gold - devices: - status: exempt - comment: | - This integration connects to a single service. + devices: done diagnostics: status: exempt comment: | @@ -77,53 +56,26 @@ rules: 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-data-update: done + docs-examples: done 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: - status: exempt - comment: | - No issues known to troubleshoot. + docs-supported-functions: done + 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. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: done - icon-translations: - status: exempt - comment: | - This integration does not have entities. + icon-translations: done reconfiguration-flow: status: exempt comment: | diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py new file mode 100644 index 00000000000..35c59d0c644 --- /dev/null +++ b/homeassistant/components/onedrive/sensor.py @@ -0,0 +1,122 @@ +"""Sensors for OneDrive.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from onedrive_personal_sdk.const import DriveState +from onedrive_personal_sdk.models.items import DriveQuota + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OneDriveConfigEntry, OneDriveUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class OneDriveSensorEntityDescription(SensorEntityDescription): + """Describes OneDrive sensor entity.""" + + value_fn: Callable[[DriveQuota], StateType] + + +DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = ( + OneDriveSensorEntityDescription( + key="total_size", + value_fn=lambda quota: quota.total, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=0, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + OneDriveSensorEntityDescription( + key="used_size", + value_fn=lambda quota: quota.used, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveSensorEntityDescription( + key="remaining_size", + value_fn=lambda quota: quota.remaining, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_display_precision=2, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OneDriveSensorEntityDescription( + key="drive_state", + value_fn=lambda quota: quota.state.value, + options=[state.value for state in DriveState], + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OneDriveConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OneDrive sensors based on a config entry.""" + coordinator = entry.runtime_data.coordinator + async_add_entities( + OneDriveDriveStateSensor(coordinator, description) + for description in DRIVE_STATE_ENTITIES + ) + + +class OneDriveDriveStateSensor( + CoordinatorEntity[OneDriveUpdateCoordinator], SensorEntity +): + """Define a OneDrive sensor.""" + + entity_description: OneDriveSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OneDriveUpdateCoordinator, + description: OneDriveSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_translation_key = description.key + self._attr_unique_id = f"{coordinator.data.id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.data.name, + identifiers={(DOMAIN, coordinator.data.id)}, + manufacturer="Microsoft", + model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}", + configuration_url=f"https://onedrive.live.com/?id=root&cid={coordinator.data.id}", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + assert self.coordinator.data.quota + return self.entity_description.value_fn(self.coordinator.data.quota) + + @property + def available(self) -> bool: + """Availability of the sensor.""" + return super().available and self.coordinator.data.quota is not None diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index ebc46d3eb12..c3087d435b8 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -38,6 +38,31 @@ }, "failed_to_migrate_files": { "message": "Failed to migrate metadata to separate files" + }, + "update_failed": { + "message": "Failed to update drive state" + } + }, + "entity": { + "sensor": { + "total_size": { + "name": "Total available storage" + }, + "used_size": { + "name": "Used storage" + }, + "remaining_size": { + "name": "Remaining storage" + }, + "drive_state": { + "name": "Drive state", + "state": { + "normal": "Normal", + "nearing": "Nearing limit", + "critical": "Critical", + "exceeded": "Exceeded" + } + } } } } diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 8a0da9f584e..ed419c820a9 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -22,6 +22,7 @@ from .const import ( MOCK_APPROOT, MOCK_BACKUP_FILE, MOCK_BACKUP_FOLDER, + MOCK_DRIVE, MOCK_METADATA_FILE, ) @@ -104,7 +105,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 return client diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 3ba54dc40d7..44f50aa625d 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -3,8 +3,11 @@ 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, @@ -98,3 +101,18 @@ 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=750000000, + state=DriveState.NEARING, + total=5000000000, + used=4250000000, + ), +) diff --git a/tests/components/onedrive/snapshots/test_init.ambr b/tests/components/onedrive/snapshots/test_init.ambr new file mode 100644 index 00000000000..9b2ed7e4d94 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://onedrive.live.com/?id=root&cid=mock_drive_id', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'onedrive', + 'mock_drive_id', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Microsoft', + 'model': 'OneDrive Personal', + 'model_id': None, + 'name': 'My Drive', + '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/onedrive/snapshots/test_sensor.ambr b/tests/components/onedrive/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..43c6921b0e5 --- /dev/null +++ b/tests/components/onedrive/snapshots/test_sensor.ambr @@ -0,0 +1,227 @@ +# serializer version: 1 +# name: test_sensors[sensor.my_drive_drive_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'normal', + 'nearing', + 'critical', + 'exceeded', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_drive_drive_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Drive state', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drive_state', + 'unique_id': 'mock_drive_id_drive_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.my_drive_drive_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'My Drive Drive state', + 'options': list([ + 'normal', + 'nearing', + 'critical', + 'exceeded', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_drive_drive_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'nearing', + }) +# --- +# name: test_sensors[sensor.my_drive_remaining_storage-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.my_drive_remaining_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining storage', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_size', + 'unique_id': 'mock_drive_id_remaining_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_remaining_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Remaining storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_remaining_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.75', + }) +# --- +# name: test_sensors[sensor.my_drive_total_available_storage-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.my_drive_total_available_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total available storage', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_size', + 'unique_id': 'mock_drive_id_total_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_total_available_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Total available storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_total_available_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_sensors[sensor.my_drive_used_storage-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.my_drive_used_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Used storage', + 'platform': 'onedrive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'used_size', + 'unique_id': 'mock_drive_id_used_size', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.my_drive_used_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'My Drive Used storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_drive_used_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.25', + }) +# --- diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 7ceab98ff21..65c3e62629c 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -6,12 +6,15 @@ from unittest.mock import MagicMock from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException import pytest +from syrupy import SnapshotAssertion +from homeassistant.components.onedrive.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration -from .const import BACKUP_METADATA, MOCK_BACKUP_FILE +from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_DRIVE from tests.common import MockConfigEntry @@ -101,3 +104,30 @@ async def test_migrate_metadata_files_errors( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_auth_error_during_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test auth error during update.""" + mock_onedrive_client.get_drive.side_effect = AuthenticationError(403, "Auth failed") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the device.""" + + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device({(DOMAIN, MOCK_DRIVE.id)}) + assert device + assert device == snapshot diff --git a/tests/components/onedrive/test_sensor.py b/tests/components/onedrive/test_sensor.py new file mode 100644 index 00000000000..ea9d93a9a7b --- /dev/null +++ b/tests/components/onedrive/test_sensor.py @@ -0,0 +1,64 @@ +"""Tests for OneDrive sensors.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from onedrive_personal_sdk.const import DriveType +from onedrive_personal_sdk.exceptions import HttpRequestException +from onedrive_personal_sdk.models.items import Drive +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_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, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the OneDrive sensors.""" + + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("attr", "side_effect"), + [ + ("side_effect", HttpRequestException(503, "Service Unavailable")), + ("return_value", Drive(id="id", name="name", drive_type=DriveType.PERSONAL)), + ], +) +async def test_update_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + freezer: FrozenDateTimeFactory, + attr: str, + side_effect: Any, +) -> None: + """Ensure sensors are going unavailable on update failure.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.my_drive_remaining_storage") + assert state.state == "0.75" + + setattr(mock_onedrive_client.get_drive, attr, side_effect) + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.my_drive_remaining_storage") + assert state.state == STATE_UNAVAILABLE From d9108cc0035e05ae0560114382c753862ef317da Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:46:11 +0000 Subject: [PATCH 0465/1941] Fix tplink iot strip sensor refresh (#138375) --- .../components/tplink/coordinator.py | 20 ++++++------------- homeassistant/components/tplink/entity.py | 16 +++++++-------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index fcd1335a77a..1a7b40457f0 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -9,6 +9,7 @@ import logging from kasa import AuthenticationError, Credentials, Device, KasaException from kasa.iot import IotStrip +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -46,11 +47,9 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): device: Device, update_interval: timedelta, config_entry: TPLinkConfigEntry, - parent_coordinator: TPLinkDataUpdateCoordinator | None = None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device - self.parent_coordinator = parent_coordinator # The iot HS300 allows a limited number of concurrent requests and # fetching the emeter information requires separate ones, so child @@ -97,12 +96,6 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) from ex await self._process_child_devices() - if not self._update_children: - # If the children are not being updated, it means this is an - # IotStrip, and we need to tell the children to write state - # since the power state is provided by the parent. - for child_coordinator in self._child_coordinators.values(): - child_coordinator.async_set_updated_data(None) async def _process_child_devices(self) -> None: """Process child devices and remove stale devices.""" @@ -131,20 +124,19 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): def get_child_coordinator( self, child: Device, + platform_domain: str, ) -> TPLinkDataUpdateCoordinator: """Get separate child coordinator for a device or self if not needed.""" # The iot HS300 allows a limited number of concurrent requests and fetching the # emeter information requires separate ones so create child coordinators here. - if isinstance(self.device, IotStrip): + # This does not happen for switches as the state is available on the + # parent device info. + if isinstance(self.device, IotStrip) and platform_domain != SWITCH_DOMAIN: if not (child_coordinator := self._child_coordinators.get(child.device_id)): # The child coordinators only update energy data so we can # set a longer update interval to avoid flooding the device child_coordinator = TPLinkDataUpdateCoordinator( - self.hass, - child, - timedelta(seconds=60), - self.config_entry, - parent_coordinator=self, + self.hass, child, timedelta(seconds=60), self.config_entry ) self._child_coordinators[child.device_id] = child_coordinator return child_coordinator diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 7a0d811b30d..7c1e9e72b85 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -151,13 +151,7 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( "exc": str(ex), }, ) from ex - coordinator = self.coordinator - if coordinator.parent_coordinator: - # If there is a parent coordinator we need to refresh - # the parent as its what provides the power state data - # for the child entities. - coordinator = coordinator.parent_coordinator - await coordinator.async_request_refresh() + await self.coordinator.async_request_refresh() return _async_wrap @@ -514,7 +508,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): ) for child in children: - child_coordinator = coordinator.get_child_coordinator(child) + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) child_entities = cls._entities_for_device( hass, @@ -657,7 +653,9 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): device.host, ) for child in children: - child_coordinator = coordinator.get_child_coordinator(child) + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) child_entities: list[_E] = cls._entities_for_device( hass, From 400dbc8d1b89b3d0e0330e22d4336a7f983b9ac5 Mon Sep 17 00:00:00 2001 From: jdanders Date: Wed, 12 Feb 2025 10:56:42 -0700 Subject: [PATCH 0466/1941] Add missing thermostat state EMERGENCY_HEAT to econet (#137623) * Add missing thermostat state EMERGENCY_HEAT to econet * econet: fix overloaded reverse dictionary * Update homeassistant/components/econet/climate.py --------- Co-authored-by: Robert Resch --- homeassistant/components/econet/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index cb2374bd69b..e7ccec33310 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -35,8 +35,13 @@ ECONET_STATE_TO_HA = { ThermostatOperationMode.OFF: HVACMode.OFF, ThermostatOperationMode.AUTO: HVACMode.HEAT_COOL, ThermostatOperationMode.FAN_ONLY: HVACMode.FAN_ONLY, + ThermostatOperationMode.EMERGENCY_HEAT: HVACMode.HEAT, +} +HA_STATE_TO_ECONET = { + value: key + for key, value in ECONET_STATE_TO_HA.items() + if key != ThermostatOperationMode.EMERGENCY_HEAT } -HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()} ECONET_FAN_STATE_TO_HA = { ThermostatFanMode.AUTO: FAN_AUTO, From 03b3097c348b7950c443732b477fb65a1d7b6350 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 19:11:20 +0100 Subject: [PATCH 0467/1941] Update cloud backup agent to use calculate_b64md5 from lib (#138391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update cloud backup agent to use calculate_b64md5 from lib * Catch error, add test * Address review comments * Update tests/components/cloud/test_backup.py Co-authored-by: Abílio Costa --------- Co-authored-by: Abílio Costa --- homeassistant/components/cloud/backup.py | 19 ++----- tests/components/cloud/test_backup.py | 72 ++++++++++++++++++++---- 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 83dc44c0ef7..61edeccdd9c 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -3,9 +3,7 @@ from __future__ import annotations import asyncio -import base64 from collections.abc import AsyncIterator, Callable, Coroutine, Mapping -import hashlib import logging import random from typing import Any @@ -14,7 +12,7 @@ from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list -from hass_nabucasa.files import StorageType +from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -30,14 +28,6 @@ _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 -async def _b64md5(stream: AsyncIterator[bytes]) -> str: - """Calculate the MD5 hash of a file.""" - file_hash = hashlib.md5() - async for chunk in stream: - file_hash.update(chunk) - return base64.b64encode(file_hash.digest()).decode() - - async def async_get_backup_agents( hass: HomeAssistant, **kwargs: Any, @@ -129,10 +119,13 @@ class CloudBackupAgent(BackupAgent): if not backup.protected: raise BackupAgentError("Cloud backups must be protected") - base64md5hash = await _b64md5(await open_stream()) + size = backup.size + try: + base64md5hash = await calculate_b64md5(open_stream, size) + except FilesError as err: + raise BackupAgentError(err) from err filename = self._get_backup_filename() metadata = backup.as_dict() - size = backup.size tries = 1 while tries <= _RETRY_LIMIT: diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 6e59b7d983e..c6bb0bdad54 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -285,6 +285,7 @@ async def test_agents_upload( ) -> None: """Test agent upload backup.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -297,7 +298,7 @@ async def test_agents_upload( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) with ( patch( @@ -309,11 +310,11 @@ async def test_agents_upload( ), patch("pathlib.Path.open") as mocked_open, ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) assert len(cloud.files.upload.mock_calls) == 1 @@ -336,6 +337,7 @@ async def test_agents_upload_fail( ) -> None: """Test agent upload backup fails.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -348,7 +350,7 @@ async def test_agents_upload_fail( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) cloud.files.upload.side_effect = side_effect @@ -366,11 +368,11 @@ async def test_agents_upload_fail( patch("homeassistant.components.cloud.backup.random.randint", return_value=60), patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -409,6 +411,7 @@ async def test_agents_upload_fail_non_retryable( ) -> None: """Test agent upload backup fails with non-retryable error.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -435,12 +438,13 @@ async def test_agents_upload_fail_non_retryable( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.calculate_b64md5"), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -461,6 +465,7 @@ async def test_agents_upload_not_protected( ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -473,7 +478,7 @@ async def test_agents_upload_not_protected( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0, + size=len(backup_data), ) with ( patch("pathlib.Path.open"), @@ -484,7 +489,7 @@ async def test_agents_upload_not_protected( ): resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -496,6 +501,53 @@ async def test_agents_upload_not_protected( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_wrong_size( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + cloud: Mock, +) -> None: + """Test agent upload backup with the wrong size.""" + client = await hass_client() + backup_data = "test" + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=len(backup_data) - 1, + ) + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + + assert len(cloud.files.upload.mock_calls) == 0 + + assert resp.status == 201 + assert "Upload failed for cloud.cloud" in caplog.text + + @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_delete( hass: HomeAssistant, From 239ba9b1cc5bfcbf80daeb6715676e81c5e7cd7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 4 Feb 2025 13:54:50 +0100 Subject: [PATCH 0468/1941] Bump hass-nabucasa from 0.88.1 to 0.89.0 (#137321) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 0f415b1738a..8e8ff4335db 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.88.1"], + "requirements": ["hass-nabucasa==0.89.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 91d73428f80..622497c6554 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 hassil==2.2.3 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index 8fb18fa7f07..f80133b17c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.88.1", + "hass-nabucasa==0.89.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index a99034ee9cf..2eb9aa4252e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 httpx==0.28.1 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0f44da6d6f9..6b4e7a15441 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2dbd991472c..9a3c4926b86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.88.1 +hass-nabucasa==0.89.0 # homeassistant.components.conversation hassil==2.2.3 From b45d7cbbc3a0f7f4f5f6ce39f87fca31b222ed45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 6 Feb 2025 07:32:46 +0100 Subject: [PATCH 0469/1941] Move cloud backup upload/download handlers to lib (#137416) * Move cloud backup upload/download handlers to lib * Update backup.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/cloud/backup.py | 77 ++--------- tests/components/cloud/conftest.py | 2 + tests/components/cloud/test_backup.py | 166 ++++------------------- 3 files changed, 39 insertions(+), 206 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index d42e846259c..f6d24656ccb 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -8,16 +8,11 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib import logging import random -from typing import Any +from typing import Any, Literal -from aiohttp import ClientError, ClientTimeout +from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError -from hass_nabucasa.cloud_api import ( - async_files_delete_file, - async_files_download_details, - async_files_list, - async_files_upload_details, -) +from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -28,7 +23,7 @@ from .client import CloudClient from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) -_STORAGE_BACKUP = "backup" +_STORAGE_BACKUP: Literal["backup"] = "backup" _RETRY_LIMIT = 5 _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 @@ -109,63 +104,14 @@ class CloudBackupAgent(BackupAgent): raise BackupAgentError("Backup not found") try: - details = await async_files_download_details( - self._cloud, + content = await self._cloud.files.download( storage_type=_STORAGE_BACKUP, filename=self._get_backup_filename(), ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get download details") from err + except CloudError as err: + raise BackupAgentError(f"Failed to download backup: {err}") from err - try: - resp = await self._cloud.websession.get( - details["url"], - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - - resp.raise_for_status() - except ClientError as err: - raise BackupAgentError("Failed to download backup") from err - - return ChunkAsyncStreamIterator(resp.content) - - async def _async_do_upload_backup( - self, - *, - open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - filename: str, - base64md5hash: str, - metadata: dict[str, Any], - size: int, - ) -> None: - """Upload a backup.""" - try: - details = await async_files_upload_details( - self._cloud, - storage_type=_STORAGE_BACKUP, - filename=filename, - metadata=metadata, - size=size, - base64md5hash=base64md5hash, - ) - except (ClientError, CloudError) as err: - raise BackupAgentError("Failed to get upload details") from err - - try: - upload_status = await self._cloud.websession.put( - details["url"], - data=await open_stream(), - headers=details["headers"] | {"content-length": str(size)}, - timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h - ) - _LOGGER.log( - logging.DEBUG if upload_status.status < 400 else logging.WARNING, - "Backup upload status: %s", - upload_status.status, - ) - upload_status.raise_for_status() - except (TimeoutError, ClientError) as err: - raise BackupAgentError("Failed to upload backup") from err + return ChunkAsyncStreamIterator(content) async def async_upload_backup( self, @@ -190,7 +136,8 @@ class CloudBackupAgent(BackupAgent): tries = 1 while tries <= _RETRY_LIMIT: try: - await self._async_do_upload_backup( + await self._cloud.files.upload( + storage_type=_STORAGE_BACKUP, open_stream=open_stream, filename=filename, base64md5hash=base64md5hash, @@ -198,9 +145,9 @@ class CloudBackupAgent(BackupAgent): size=size, ) break - except BackupAgentError as err: + except CloudError as err: if tries == _RETRY_LIMIT: - raise + raise BackupAgentError("Failed to upload backup") from err tries += 1 retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) _LOGGER.info( diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 7002f7c39ec..276a06a7f46 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -9,6 +9,7 @@ from hass_nabucasa import Cloud from hass_nabucasa.auth import CognitoAuth from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED +from hass_nabucasa.files import Files from hass_nabucasa.google_report_state import GoogleReportState from hass_nabucasa.ice_servers import IceServers from hass_nabucasa.iot import CloudIoT @@ -68,6 +69,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED ) mock_cloud.voice = MagicMock(spec=Voice) + mock_cloud.files = MagicMock(spec=Files) mock_cloud.started = None mock_cloud.ice_servers = MagicMock( spec=IceServers, diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5b2b8751311..ba789e093ff 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -1,14 +1,14 @@ """Test the cloud backup platform.""" -from collections.abc import AsyncGenerator, AsyncIterator, Generator +from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any from unittest.mock import Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError +from hass_nabucasa.files import FilesError import pytest -from yarl import URL from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -22,11 +22,20 @@ from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component +from homeassistant.util.aiohttp import MockStreamReader from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator +class MockStreamReaderChunked(MockStreamReader): + """Mock a stream reader with simulated chunked data.""" + + async def readchunk(self) -> tuple[bytes, bool]: + """Read bytes.""" + return (self._content.read(), False) + + @pytest.fixture(autouse=True) async def setup_integration( hass: HomeAssistant, @@ -55,49 +64,6 @@ def mock_delete_file() -> Generator[MagicMock]: yield delete_file -@pytest.fixture -def mock_get_download_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_download_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "462e16810d6841228828d9dd2f9e341e.tar?X-Amz-Algorithm=blah" - ), - } - yield download_details - - -@pytest.fixture -def mock_get_upload_details() -> Generator[MagicMock]: - """Mock list files.""" - with patch( - "homeassistant.components.cloud.backup.async_files_upload_details", - spec_set=True, - ) as download_details: - download_details.return_value = { - "url": ( - "https://blabla.cloudflarestorage.com/blabla/backup/" - "ea5c969e492c49df89d432a1483b8dc3.tar?X-Amz-Algorithm=blah" - ), - "headers": { - "content-md5": "HOhSM3WZkpHRYGiz4YRGIQ==", - "x-amz-meta-storage-type": "backup", - "x-amz-meta-b64json": ( - "eyJhZGRvbnMiOltdLCJiYWNrdXBfaWQiOiJjNDNiNWU2MCIsImRhdGUiOiIyMDI0LT" - "EyLTAzVDA0OjI1OjUwLjMyMDcwMy0wNTowMCIsImRhdGFiYXNlX2luY2x1ZGVkIjpm" - "YWxzZSwiZm9sZGVycyI6W10sImhvbWVhc3Npc3RhbnRfaW5jbHVkZWQiOnRydWUsIm" - "hvbWVhc3Npc3RhbnRfdmVyc2lvbiI6IjIwMjQuMTIuMC5kZXYwIiwibmFtZSI6ImVy" - "aWsiLCJwcm90ZWN0ZWQiOnRydWUsInNpemUiOjM1NjI0OTYwfQ==" - ), - }, - } - yield download_details - - @pytest.fixture def mock_list_files() -> Generator[MagicMock]: """Mock list files.""" @@ -264,52 +230,30 @@ async def test_agents_download( hass: HomeAssistant, hass_client: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get( - mock_get_download_details.return_value["url"], content=b"backup data" - ) + cloud.files.download.return_value = MockStreamReaderChunked(b"backup data") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 200 assert await resp.content.read() == b"backup data" -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_download_fail_cloud( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - mock_get_download_details: Mock, - side_effect: Exception, -) -> None: - """Test agent download backup, when cloud user is logged in.""" - client = await hass_client() - backup_id = "23e64aec" - mock_get_download_details.side_effect = side_effect - - resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") - assert resp.status == 500 - content = await resp.content.read() - assert "Failed to get download details" in content.decode() - - @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_download_fail_get( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, - mock_get_download_details: Mock, + cloud: Mock, ) -> None: """Test agent download backup, when cloud user is logged in.""" client = await hass_client() backup_id = "23e64aec" - aioclient_mock.get(mock_get_download_details.return_value["url"], status=500) + cloud.files.download.side_effect = FilesError("Oh no :(") resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 500 @@ -336,8 +280,7 @@ async def test_agents_upload( hass: HomeAssistant, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, + cloud: Mock, ) -> None: """Test agent upload backup.""" client = await hass_client() @@ -355,8 +298,6 @@ async def test_agents_upload( protected=True, size=0, ) - aioclient_mock.put(mock_get_upload_details.return_value["url"]) - with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -374,26 +315,22 @@ async def test_agents_upload( data={"file": StringIO("test")}, ) - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][0] == "PUT" - assert aioclient_mock.mock_calls[-1][1] == URL( - mock_get_upload_details.return_value["url"] - ) - assert isinstance(aioclient_mock.mock_calls[-1][2], AsyncIterator) + assert len(cloud.files.upload.mock_calls) == 1 + metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] + assert metadata["backup_id"] == backup_id assert resp.status == 201 assert f"Uploading backup {backup_id}" in caplog.text -@pytest.mark.parametrize("put_mock_kwargs", [{"status": 500}, {"exc": TimeoutError}]) +@pytest.mark.parametrize("side_effect", [FilesError("Boom!"), CloudError("Boom!")]) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") -async def test_agents_upload_fail_put( +async def test_agents_upload_fail( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_storage: dict[str, Any], - aioclient_mock: AiohttpClientMocker, - mock_get_upload_details: Mock, - put_mock_kwargs: dict[str, Any], + side_effect: Exception, + cloud: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test agent upload backup fails.""" @@ -412,7 +349,8 @@ async def test_agents_upload_fail_put( protected=True, size=0, ) - aioclient_mock.put(mock_get_upload_details.return_value["url"], **put_mock_kwargs) + + cloud.files.upload.side_effect = side_effect with ( patch( @@ -435,7 +373,6 @@ async def test_agents_upload_fail_put( ) await hass.async_block_till_done() - assert len(aioclient_mock.mock_calls) == 2 assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] @@ -445,59 +382,6 @@ async def test_agents_upload_fail_put( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] -@pytest.mark.parametrize("side_effect", [ClientError, CloudError]) -@pytest.mark.usefixtures("cloud_logged_in") -async def test_agents_upload_fail_cloud( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_storage: dict[str, Any], - mock_get_upload_details: Mock, - side_effect: Exception, -) -> None: - """Test agent upload backup, when cloud user is logged in.""" - client = await hass_client() - backup_id = "test-backup" - mock_get_upload_details.side_effect = side_effect - test_backup = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id=backup_id, - database_included=True, - date="1970-01-01T00:00:00.000Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=True, - size=0, - ) - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.cloud.backup.asyncio.sleep"), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, - ) - await hass.async_block_till_done() - - assert resp.status == 201 - store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] - assert len(store_backups) == 1 - stored_backup = store_backups[0] - assert stored_backup["backup_id"] == backup_id - assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] - - async def test_agents_upload_not_protected( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 8dfe483b38384266411f0ed69604c6da39b4fff3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 6 Feb 2025 09:57:10 +0100 Subject: [PATCH 0470/1941] Handle non-retryable errors when uploading cloud backup (#137517) --- homeassistant/components/cloud/backup.py | 13 +++- homeassistant/components/cloud/strings.json | 5 ++ tests/components/cloud/test_backup.py | 72 +++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index f6d24656ccb..9531604ccc7 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -12,6 +12,7 @@ from typing import Any, Literal from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError +from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError @@ -145,9 +146,19 @@ class CloudBackupAgent(BackupAgent): size=size, ) break + except CloudApiNonRetryableError as err: + if err.code == "NC-SH-FH-03": + raise BackupAgentError( + translation_domain=DOMAIN, + translation_key="backup_size_too_large", + translation_placeholders={ + "size": str(round(size / (1024**3), 2)) + }, + ) from err + raise BackupAgentError(f"Failed to upload backup {err}") from err except CloudError as err: if tries == _RETRY_LIMIT: - raise BackupAgentError("Failed to upload backup") from err + raise BackupAgentError(f"Failed to upload backup {err}") from err tries += 1 retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) _LOGGER.info( diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 1da91f67813..6380ee9c312 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -17,6 +17,11 @@ "subscription_expiration": "Subscription expiration" } }, + "exceptions": { + "backup_size_too_large": { + "message": "The backup size of {size}GB is too large to be uploaded to Home Assistant Cloud." + } + }, "issues": { "deprecated_gender": { "title": "The {deprecated_option} text-to-speech option is deprecated", diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index ba789e093ff..6e59b7d983e 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError +from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.files import FilesError import pytest @@ -375,6 +376,77 @@ async def test_agents_upload_fail( assert "Failed to upload backup, retrying (2/2) in 60s" in caplog.text assert resp.status == 201 + assert cloud.files.upload.call_count == 2 + store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] + assert len(store_backups) == 1 + stored_backup = store_backups[0] + assert stored_backup["backup_id"] == backup_id + assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] + + +@pytest.mark.parametrize( + ("side_effect", "logmsg"), + [ + ( + CloudApiNonRetryableError("Boom!", code="NC-SH-FH-03"), + "The backup size of 13.37GB is too large to be uploaded to Home Assistant Cloud", + ), + ( + CloudApiNonRetryableError("Boom!", code="NC-CE-01"), + "Failed to upload backup Boom!", + ), + ], +) +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_fail_non_retryable( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + side_effect: Exception, + logmsg: str, + cloud: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test agent upload backup fails with non-retryable error.""" + client = await hass_client() + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=14358124749, + ) + + cloud.files.upload.side_effect = side_effect + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO("test")}, + ) + await hass.async_block_till_done() + + assert logmsg in caplog.text + assert resp.status == 201 + assert cloud.files.upload.call_count == 1 store_backups = hass_storage[BACKUP_DOMAIN]["data"]["backups"] assert len(store_backups) == 1 stored_backup = store_backups[0] From df49c53bb6e2c56163c06113643c90b4ecd5f4b5 Mon Sep 17 00:00:00 2001 From: jdanders Date: Wed, 12 Feb 2025 10:56:42 -0700 Subject: [PATCH 0471/1941] Add missing thermostat state EMERGENCY_HEAT to econet (#137623) * Add missing thermostat state EMERGENCY_HEAT to econet * econet: fix overloaded reverse dictionary * Update homeassistant/components/econet/climate.py --------- Co-authored-by: Robert Resch --- homeassistant/components/econet/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index d46dbd8750a..d1f3c24855e 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -35,8 +35,13 @@ ECONET_STATE_TO_HA = { ThermostatOperationMode.OFF: HVACMode.OFF, ThermostatOperationMode.AUTO: HVACMode.HEAT_COOL, ThermostatOperationMode.FAN_ONLY: HVACMode.FAN_ONLY, + ThermostatOperationMode.EMERGENCY_HEAT: HVACMode.HEAT, +} +HA_STATE_TO_ECONET = { + value: key + for key, value in ECONET_STATE_TO_HA.items() + if key != ThermostatOperationMode.EMERGENCY_HEAT } -HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()} ECONET_FAN_STATE_TO_HA = { ThermostatFanMode.AUTO: FAN_AUTO, From b4ef00659c7447e8f2a2db03765e7a843aef8e8d Mon Sep 17 00:00:00 2001 From: jdanders Date: Wed, 12 Feb 2025 03:41:52 -0800 Subject: [PATCH 0472/1941] Fix broken issue creation in econet (#137773) * econet: Fix broken issue creation * econet: fix broken issue creation with create_issue --- homeassistant/components/econet/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index d1f3c24855e..da4d0601f07 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -23,7 +23,7 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from . import EconetConfigEntry from .const import DOMAIN @@ -214,7 +214,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - async_create_issue( + create_issue( self.hass, DOMAIN, "migrate_aux_heat", @@ -228,7 +228,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity): def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - async_create_issue( + create_issue( self.hass, DOMAIN, "migrate_aux_heat", From f8763c49ef34801b27620bdeae37e5a3fbcf0197 Mon Sep 17 00:00:00 2001 From: "Andre W." <10945277+alfwro13@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:24:39 +0000 Subject: [PATCH 0473/1941] Fix version extraction for APsystems (#138023) Co-authored-by: Marlon --- homeassistant/components/apsystems/entity.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py index 7770b451680..c3e67f52c82 100644 --- a/homeassistant/components/apsystems/entity.py +++ b/homeassistant/components/apsystems/entity.py @@ -19,10 +19,20 @@ class ApSystemsEntity(Entity): data: ApSystemsData, ) -> None: """Initialize the APsystems entity.""" + + # Handle device version safely + sw_version = None + if data.coordinator.device_version: + version_parts = data.coordinator.device_version.split(" ") + if len(version_parts) > 1: + sw_version = version_parts[1] + else: + sw_version = version_parts[0] + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, data.device_id)}, manufacturer="APsystems", model="EZ1-M", serial_number=data.device_id, - sw_version=data.coordinator.device_version.split(" ")[1], + sw_version=sw_version, ) From 9772014bce22c40aad7db0ef6cfc1de05d72836e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Feb 2025 02:51:30 -0800 Subject: [PATCH 0474/1941] Refresh nest access token before before building subscriber Credentials (#138259) --- homeassistant/components/nest/api.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 727b126dda4..d55826f7ed0 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -50,13 +50,14 @@ class AsyncConfigEntryAuth(AbstractAuth): return cast(str, self._oauth_session.token["access_token"]) async def async_get_creds(self) -> Credentials: - """Return an OAuth credential for Pub/Sub Subscriber.""" - # We don't have a way for Home Assistant to refresh creds on behalf - # of the google pub/sub subscriber. Instead, build a full - # Credentials object with enough information for the subscriber to - # handle this on its own. We purposely don't refresh the token here - # even when it is expired to fully hand off this responsibility and - # know it is working at startup (then if not, fail loudly). + """Return an OAuth credential for Pub/Sub Subscriber. + + The subscriber will call this when connecting to the stream to refresh + the token. We construct a credentials object using the underlying + OAuth2Session since the subscriber may expect the expiry fields to + be present. + """ + await self.async_get_access_token() token = self._oauth_session.token creds = Credentials( # type: ignore[no-untyped-call] token=token["access_token"], From 979b3d42691466051bef691db7b0044882a4a0ab Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 11 Feb 2025 14:53:07 +0100 Subject: [PATCH 0475/1941] Fix BackupManager.async_delete_backup (#138286) --- homeassistant/components/backup/manager.py | 4 +- tests/components/backup/test_websocket.py | 233 ++++++++------------- 2 files changed, 93 insertions(+), 144 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index e175ff9c03d..81826ffcb24 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -688,8 +688,8 @@ class BackupManager: delete_backup_results = await asyncio.gather( *( - agent.async_delete_backup(backup_id) - for agent in self.backup_agents.values() + self.backup_agents[agent_id].async_delete_backup(backup_id) + for agent_id in agent_ids ), return_exceptions=True, ) diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 966cfbbef78..773256bdd0b 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -6,7 +6,6 @@ from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch from freezegun.api import FrozenDateTimeFactory import pytest -from pytest_unordered import unordered from syrupy import SnapshotAssertion from homeassistant.components.backup import ( @@ -100,15 +99,6 @@ def mock_delay_save() -> Generator[None]: yield -@pytest.fixture(name="delete_backup") -def mock_delete_backup() -> Generator[AsyncMock]: - """Mock manager delete backup.""" - with patch( - "homeassistant.components.backup.BackupManager.async_delete_backup" - ) as mock_delete_backup: - yield mock_delete_backup - - @pytest.fixture(name="get_backups") def mock_get_backups() -> Generator[AsyncMock]: """Mock manager get backups.""" @@ -911,7 +901,7 @@ async def test_agents_info( assert await client.receive_json() == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "storage_data", [ @@ -1161,7 +1151,7 @@ async def test_config_info( assert await client.receive_json() == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "commands", [ @@ -1326,7 +1316,7 @@ async def test_config_update( assert hass_storage[DOMAIN] == snapshot -@pytest.mark.usefixtures("create_backup", "delete_backup", "get_backups") +@pytest.mark.usefixtures("get_backups") @pytest.mark.parametrize( "command", [ @@ -1783,14 +1773,13 @@ async def test_config_schedule_logic( "command", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", + "agent_delete_backup_side_effects", "last_backup_time", "next_time", "backup_time", "backup_calls", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ ( @@ -1833,8 +1822,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, # we get backups even if backup retention copies is None - 0, - [], + {}, ), ( { @@ -1876,8 +1864,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ( { @@ -1907,8 +1894,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ( { @@ -1971,13 +1957,10 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ) - ], + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1")], + }, ), ( { @@ -2039,13 +2022,10 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ) - ], + { + "test.test-agent": [call("backup-1")], + "test.test-agent2": [call("backup-1")], + }, ), ( { @@ -2093,11 +2073,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ( { @@ -2132,15 +2108,14 @@ async def test_config_schedule_logic( spec=ManagerBackup, ), }, - {"test-agent": BackupAgentError("Boom!")}, + {"test.test-agent": BackupAgentError("Boom!")}, {}, "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( { @@ -2176,14 +2151,13 @@ async def test_config_schedule_logic( ), }, {}, - {"test-agent": BackupAgentError("Boom!")}, + {"test.test-agent": BackupAgentError("Boom!")}, "2024-11-11T04:45:00+01:00", "2024-11-12T04:45:00+01:00", "2024-11-12T04:45:00+01:00", 1, 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( { @@ -2246,21 +2220,18 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 3, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call( - "backup-2", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call( - "backup-3", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - ], + { + "test.test-agent": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + "test.test-agent2": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + }, ), ( { @@ -2322,18 +2293,14 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 3, - [ - call( - "backup-1", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call( - "backup-2", - agent_ids=unordered(["test.test-agent", "test.test-agent2"]), - ), - call("backup-3", agent_ids=["test.test-agent"]), - ], + { + "test.test-agent": [ + call("backup-1"), + call("backup-2"), + call("backup-3"), + ], + "test.test-agent2": [call("backup-1"), call("backup-2")], + }, ), ( { @@ -2363,8 +2330,7 @@ async def test_config_schedule_logic( "2024-11-12T04:45:00+01:00", 1, 1, - 0, - [], + {}, ), ], ) @@ -2375,19 +2341,17 @@ async def test_config_retention_copies_logic( freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], create_backup: AsyncMock, - delete_backup: AsyncMock, get_backups: AsyncMock, command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], + agent_delete_backup_side_effects: dict[str, Exception], last_backup_time: str, next_time: str, backup_time: str, backup_calls: int, get_backups_calls: int, - delete_calls: int, - delete_args_list: Any, + delete_calls: dict[str, Any], ) -> None: """Test config backup retention copies logic.""" created_backup: MagicMock = create_backup.return_value[1].result().backup @@ -2425,13 +2389,18 @@ async def test_config_retention_copies_logic( "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") - await setup_backup_integration(hass, remote_agents=["test-agent"]) + await setup_backup_integration(hass, remote_agents=["test-agent", "test-agent2"]) await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + for agent_id, agent in manager.backup_agents.items(): + agent.async_delete_backup = AsyncMock( + side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True + ) + await client.send_json_auto_id(command) result = await client.receive_json() @@ -2442,8 +2411,10 @@ async def test_config_retention_copies_logic( await hass.async_block_till_done() assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in manager.backup_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() assert ( @@ -2474,11 +2445,9 @@ async def test_config_retention_copies_logic( "config_command", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", "backup_calls", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ ( @@ -2515,11 +2484,9 @@ async def test_config_retention_copies_logic( ), }, {}, - {}, 1, 1, # we get backups even if backup retention copies is None - 0, - [], + {}, ), ( { @@ -2555,11 +2522,9 @@ async def test_config_retention_copies_logic( ), }, {}, + 1, + 1, {}, - 1, - 1, - 0, - [], ), ( { @@ -2601,11 +2566,9 @@ async def test_config_retention_copies_logic( ), }, {}, - {}, 1, 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( { @@ -2647,14 +2610,9 @@ async def test_config_retention_copies_logic( ), }, {}, - {}, 1, 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ], ) @@ -2664,18 +2622,15 @@ async def test_config_retention_copies_logic_manual_backup( freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], create_backup: AsyncMock, - delete_backup: AsyncMock, get_backups: AsyncMock, config_command: dict[str, Any], backup_command: dict[str, Any], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], backup_time: str, backup_calls: int, get_backups_calls: int, - delete_calls: int, - delete_args_list: Any, + delete_calls: dict[str, Any], ) -> None: """Test config backup retention copies logic for manual backup.""" created_backup: MagicMock = create_backup.return_value[1].result().backup @@ -2713,13 +2668,16 @@ async def test_config_retention_copies_logic_manual_backup( "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-11 12:00:00+01:00") await setup_backup_integration(hass, remote_agents=["test-agent"]) await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + for agent in manager.backup_agents.values(): + agent.async_delete_backup = AsyncMock(autospec=True) + await client.send_json_auto_id(config_command) result = await client.receive_json() assert result["success"] @@ -2734,8 +2692,10 @@ async def test_config_retention_copies_logic_manual_backup( assert create_backup.call_count == backup_calls assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in manager.backup_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() assert ( @@ -2754,13 +2714,12 @@ async def test_config_retention_copies_logic_manual_backup( "commands", "backups", "get_backups_agent_errors", - "delete_backup_agent_errors", + "agent_delete_backup_side_effects", "last_backup_time", "start_time", "next_time", "get_backups_calls", "delete_calls", - "delete_args_list", ), [ # No config update - cleanup backups older than 2 days @@ -2793,8 +2752,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), # No config update - No cleanup ( @@ -2826,8 +2784,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 0, - 0, - [], + {}, ), # Unchanged config ( @@ -2866,8 +2823,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2905,8 +2861,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -2944,8 +2899,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 0, - [], + {}, ), ( None, @@ -2989,11 +2943,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ( None, @@ -3031,8 +2981,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -3070,8 +3019,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 1, - [call("backup-1", agent_ids=["test.test-agent"])], + {"test.test-agent": [call("backup-1")]}, ), ( None, @@ -3115,11 +3063,7 @@ async def test_config_retention_copies_logic_manual_backup( "2024-11-11T12:00:00+01:00", "2024-11-12T12:00:00+01:00", 1, - 2, - [ - call("backup-1", agent_ids=["test.test-agent"]), - call("backup-2", agent_ids=["test.test-agent"]), - ], + {"test.test-agent": [call("backup-1"), call("backup-2")]}, ), ], ) @@ -3128,19 +3072,17 @@ async def test_config_retention_days_logic( hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, hass_storage: dict[str, Any], - delete_backup: AsyncMock, get_backups: AsyncMock, stored_retained_days: int | None, commands: list[dict[str, Any]], backups: dict[str, Any], get_backups_agent_errors: dict[str, Exception], - delete_backup_agent_errors: dict[str, Exception], + agent_delete_backup_side_effects: dict[str, Exception], last_backup_time: str, start_time: str, next_time: str, get_backups_calls: int, - delete_calls: int, - delete_args_list: list[Any], + delete_calls: dict[str, Any], ) -> None: """Test config backup retention logic.""" client = await hass_ws_client(hass) @@ -3175,13 +3117,18 @@ async def test_config_retention_days_logic( "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) - delete_backup.return_value = delete_backup_agent_errors await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to(start_time) - await setup_backup_integration(hass) + await setup_backup_integration(hass, remote_agents=["test-agent"]) await hass.async_block_till_done() + manager = hass.data[DATA_MANAGER] + for agent_id, agent in manager.backup_agents.items(): + agent.async_delete_backup = AsyncMock( + side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True + ) + for command in commands: await client.send_json_auto_id(command) result = await client.receive_json() @@ -3191,8 +3138,10 @@ async def test_config_retention_days_logic( async_fire_time_changed(hass) await hass.async_block_till_done() assert get_backups.call_count == get_backups_calls - assert delete_backup.call_count == delete_calls - assert delete_backup.call_args_list == delete_args_list + for agent_id, agent in manager.backup_agents.items(): + agent_delete_calls = delete_calls.get(agent_id, []) + assert agent.async_delete_backup.call_count == len(agent_delete_calls) + assert agent.async_delete_backup.call_args_list == agent_delete_calls async_fire_time_changed(hass, fire_all=True) # flush out storage save await hass.async_block_till_done() From 7e52170789c8e1a6a5741eb1c156dfc46f2bfbea Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Feb 2025 12:47:36 -0800 Subject: [PATCH 0476/1941] Fix next authentication token error handling (#138299) --- homeassistant/components/nest/__init__.py | 13 +++--- tests/components/nest/test_init.py | 54 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 67c14bbf544..af85f1fc5ae 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus import logging -from aiohttp import web +from aiohttp import ClientError, ClientResponseError, web from google_nest_sdm.camera_traits import CameraClipPreviewTrait from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage @@ -201,11 +201,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool auth = await api.new_auth(hass, entry) try: await auth.async_get_access_token() - except AuthException as err: - raise ConfigEntryAuthFailed(f"Authentication error: {err!s}") from err - except ConfigurationException as err: - _LOGGER.error("Configuration error: %s", err) - return False + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err subscriber = await api.new_subscriber(hass, entry, auth) if not subscriber: diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index 7d04624dcc8..c7ac5875403 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -9,10 +9,12 @@ relevant modes. """ from collections.abc import Generator +import datetime from http import HTTPStatus import logging from unittest.mock import AsyncMock, patch +import aiohttp from google_nest_sdm.exceptions import ( ApiException, AuthException, @@ -22,6 +24,7 @@ from google_nest_sdm.exceptions import ( import pytest from homeassistant.components.nest import DOMAIN +from homeassistant.components.nest.const import OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -36,6 +39,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker PLATFORM = "sensor" +EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() + @pytest.fixture def platforms() -> list[str]: @@ -139,6 +144,55 @@ async def test_setup_device_manager_failure( assert entries[0].state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("token_expiration_time", [EXPIRED_TOKEN_TIMESTAMP]) +@pytest.mark.parametrize( + ("token_response_args", "expected_state", "expected_steps"), + [ + # Cases that retry integration setup + ( + {"status": HTTPStatus.INTERNAL_SERVER_ERROR}, + ConfigEntryState.SETUP_RETRY, + [], + ), + ({"exc": aiohttp.ClientError("No internet")}, ConfigEntryState.SETUP_RETRY, []), + # Cases that require the user to reauthenticate in a config flow + ( + {"status": HTTPStatus.BAD_REQUEST}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ( + {"status": HTTPStatus.FORBIDDEN}, + ConfigEntryState.SETUP_ERROR, + ["reauth_confirm"], + ), + ], +) +async def test_expired_token_refresh_error( + hass: HomeAssistant, + setup_base_platform: PlatformSetup, + aioclient_mock: AiohttpClientMocker, + token_response_args: dict, + expected_state: ConfigEntryState, + expected_steps: list[str], +) -> None: + """Test errors when attempting to refresh the auth token.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + **token_response_args, + ) + + await setup_base_platform() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is expected_state + + flows = hass.config_entries.flow.async_progress() + assert expected_steps == [flow["step_id"] for flow in flows] + + @pytest.mark.parametrize("subscriber_side_effect", [AuthException()]) async def test_subscriber_auth_failure( hass: HomeAssistant, From 2cb9682303c4644107b5a118c11f08c18f6f9c06 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 11 Feb 2025 21:35:03 +0100 Subject: [PATCH 0477/1941] Bump pyenphase to 1.25.1 (#138327) * Bump pyenphase to 1.25.1 * Add new opt_schedules to nephase_envoy test fixtures --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/enphase_envoy/fixtures/envoy_1p_metered.json | 3 ++- tests/components/enphase_envoy/fixtures/envoy_acb_batt.json | 3 ++- tests/components/enphase_envoy/fixtures/envoy_eu_batt.json | 3 ++- .../enphase_envoy/fixtures/envoy_metered_batt_relay.json | 3 ++- .../enphase_envoy/fixtures/envoy_nobatt_metered_3p.json | 3 ++- .../enphase_envoy/fixtures/envoy_tot_cons_metered.json | 3 ++- 9 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 0b1fd8b04b9..e51a7427504 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.23.1"], + "requirements": ["pyenphase==1.25.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 6b4e7a15441..f2d81906a2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1930,7 +1930,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.1 +pyenphase==1.25.1 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a3c4926b86..f0c6564cc9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1574,7 +1574,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.23.1 +pyenphase==1.25.1 # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json index 05a6f265dfb..22aeca50ca0 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json @@ -93,7 +93,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json index 618b40027b8..52e812f979e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json @@ -235,7 +235,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json index 8118630200f..30fbc8d0f4f 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json @@ -223,7 +223,8 @@ "reserved_soc": 0.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1714749724" + "date": "1714749724", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 7affc1bea0d..6cfbfed1e8e 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -427,7 +427,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json index ff975b690ed..8c2767e33e5 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json +++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json @@ -242,7 +242,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 62df69c6d88..15cf2c173cb 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -88,7 +88,8 @@ "reserved_soc": 15.0, "very_low_soc": 5, "charge_from_grid": true, - "date": "1695598084" + "date": "1695598084", + "opt_schedules": true }, "single_rate": { "rate": 0.0, From 288acfb51125f9539e415a86d5c63142657f3be7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 01:04:05 +0100 Subject: [PATCH 0478/1941] Bump sentry-sdk to 1.45.1 (#138349) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 425225e07ef..4c3a7518085 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.40.3"] + "requirements": ["sentry-sdk==1.45.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f2d81906a2d..15973545b5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2694,7 +2694,7 @@ sensorpush-ble==1.7.1 sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0c6564cc9e..ce9695b65e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2173,7 +2173,7 @@ sensorpush-ble==1.7.1 sensoterra==2.0.1 # homeassistant.components.sentry -sentry-sdk==1.40.3 +sentry-sdk==1.45.1 # homeassistant.components.sfr_box sfrbox-api==0.0.11 From b166c32eb85a15a881afcf2f2c30746ec98a3526 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Feb 2025 08:57:26 -0600 Subject: [PATCH 0479/1941] Bump zeroconf to 0.144.1 (#138353) * Bump zeroconf to 0.143.1 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.143.0...0.143.1 fixes #138324 fixes https://github.com/home-assistant/core/issues/137731 fixes https://github.com/home-assistant/core/issues/138298 * one more --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index f4a78cd99e9..ddc74fba8bf 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.143.0"] + "requirements": ["zeroconf==0.144.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 622497c6554..64268be2ca2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.143.0 +zeroconf==0.144.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index f80133b17c9..47a16ea9284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.143.0" + "zeroconf==0.144.1" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 2eb9aa4252e..4eda126171b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.143.0 +zeroconf==0.144.1 diff --git a/requirements_all.txt b/requirements_all.txt index 15973545b5b..3ee3e2df40e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3125,7 +3125,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.143.0 +zeroconf==0.144.1 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce9695b65e8..ea1e8ee1c62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2514,7 +2514,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.143.0 +zeroconf==0.144.1 # homeassistant.components.zeversolar zeversolar==0.3.2 From 41fb6a537f5601430864b46825994dc52f09301f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 12:46:53 +0100 Subject: [PATCH 0480/1941] Bump cryptography to 44.0.1 (#138371) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 64268be2ca2..f5e8fcdaf6f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ cached-ipaddress==0.8.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 -cryptography==44.0.0 +cryptography==44.0.1 dbus-fast==2.33.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 diff --git a/pyproject.toml b/pyproject.toml index 47a16ea9284..97ca6d9b047 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==44.0.0", + "cryptography==44.0.1", "Pillow==11.1.0", "propcache==0.2.1", "pyOpenSSL==24.3.0", diff --git a/requirements.txt b/requirements.txt index 4eda126171b..be815a2dd58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 PyJWT==2.10.1 -cryptography==44.0.0 +cryptography==44.0.1 Pillow==11.1.0 propcache==0.2.1 pyOpenSSL==24.3.0 From 5a257b090eeffc64fc4bde5ad24c8f6539712db4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:46:11 +0000 Subject: [PATCH 0481/1941] Fix tplink iot strip sensor refresh (#138375) --- .../components/tplink/coordinator.py | 20 ++++++------------- homeassistant/components/tplink/entity.py | 16 +++++++-------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py index fcd1335a77a..1a7b40457f0 100644 --- a/homeassistant/components/tplink/coordinator.py +++ b/homeassistant/components/tplink/coordinator.py @@ -9,6 +9,7 @@ import logging from kasa import AuthenticationError, Credentials, Device, KasaException from kasa.iot import IotStrip +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -46,11 +47,9 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): device: Device, update_interval: timedelta, config_entry: TPLinkConfigEntry, - parent_coordinator: TPLinkDataUpdateCoordinator | None = None, ) -> None: """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" self.device = device - self.parent_coordinator = parent_coordinator # The iot HS300 allows a limited number of concurrent requests and # fetching the emeter information requires separate ones, so child @@ -97,12 +96,6 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): ) from ex await self._process_child_devices() - if not self._update_children: - # If the children are not being updated, it means this is an - # IotStrip, and we need to tell the children to write state - # since the power state is provided by the parent. - for child_coordinator in self._child_coordinators.values(): - child_coordinator.async_set_updated_data(None) async def _process_child_devices(self) -> None: """Process child devices and remove stale devices.""" @@ -131,20 +124,19 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]): def get_child_coordinator( self, child: Device, + platform_domain: str, ) -> TPLinkDataUpdateCoordinator: """Get separate child coordinator for a device or self if not needed.""" # The iot HS300 allows a limited number of concurrent requests and fetching the # emeter information requires separate ones so create child coordinators here. - if isinstance(self.device, IotStrip): + # This does not happen for switches as the state is available on the + # parent device info. + if isinstance(self.device, IotStrip) and platform_domain != SWITCH_DOMAIN: if not (child_coordinator := self._child_coordinators.get(child.device_id)): # The child coordinators only update energy data so we can # set a longer update interval to avoid flooding the device child_coordinator = TPLinkDataUpdateCoordinator( - self.hass, - child, - timedelta(seconds=60), - self.config_entry, - parent_coordinator=self, + self.hass, child, timedelta(seconds=60), self.config_entry ) self._child_coordinators[child.device_id] = child_coordinator return child_coordinator diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 7a0d811b30d..7c1e9e72b85 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -151,13 +151,7 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P]( "exc": str(ex), }, ) from ex - coordinator = self.coordinator - if coordinator.parent_coordinator: - # If there is a parent coordinator we need to refresh - # the parent as its what provides the power state data - # for the child entities. - coordinator = coordinator.parent_coordinator - await coordinator.async_request_refresh() + await self.coordinator.async_request_refresh() return _async_wrap @@ -514,7 +508,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): ) for child in children: - child_coordinator = coordinator.get_child_coordinator(child) + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) child_entities = cls._entities_for_device( hass, @@ -657,7 +653,9 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC): device.host, ) for child in children: - child_coordinator = coordinator.get_child_coordinator(child) + child_coordinator = coordinator.get_child_coordinator( + child, platform_domain + ) child_entities: list[_E] = cls._entities_for_device( hass, From 0faa8efd5ac29eac6e89fa7d76ee1bde94fc8606 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 12 Feb 2025 14:14:52 +0100 Subject: [PATCH 0482/1941] Bump deebot-client to 12.1.0 (#138382) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 33a251c22dc..79e0c34e4b9 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3ee3e2df40e..1d8b788fd9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.0.0 +deebot-client==12.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea1e8ee1c62..f4ce10bc3e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.0.0 +deebot-client==12.1.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From a9c6a0670402b9f03a2aa526ab340f9d5b6f4239 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 15:29:42 +0100 Subject: [PATCH 0483/1941] Bump hass-nabucasa from 0.89.0 to 0.90.0 (#138387) * Bump hass-nabucasa from 0.89.0 to 0.90.0 * Use new shiny enum --- homeassistant/components/cloud/backup.py | 14 ++++++++------ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 9531604ccc7..83dc44c0ef7 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -8,12 +8,13 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping import hashlib import logging import random -from typing import Any, Literal +from typing import Any from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list +from hass_nabucasa.files import StorageType from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -24,7 +25,6 @@ from .client import CloudClient from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT _LOGGER = logging.getLogger(__name__) -_STORAGE_BACKUP: Literal["backup"] = "backup" _RETRY_LIMIT = 5 _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 @@ -106,7 +106,7 @@ class CloudBackupAgent(BackupAgent): try: content = await self._cloud.files.download( - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, filename=self._get_backup_filename(), ) except CloudError as err: @@ -138,7 +138,7 @@ class CloudBackupAgent(BackupAgent): while tries <= _RETRY_LIMIT: try: await self._cloud.files.upload( - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, open_stream=open_stream, filename=filename, base64md5hash=base64md5hash, @@ -185,7 +185,7 @@ class CloudBackupAgent(BackupAgent): try: await async_files_delete_file( self._cloud, - storage_type=_STORAGE_BACKUP, + storage_type=StorageType.BACKUP, filename=self._get_backup_filename(), ) except (ClientError, CloudError) as err: @@ -194,7 +194,9 @@ class CloudBackupAgent(BackupAgent): async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" try: - backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP) + backups = await async_files_list( + self._cloud, storage_type=StorageType.BACKUP + ) _LOGGER.debug("Cloud backups: %s", backups) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 8e8ff4335db..1335d9b81bf 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.89.0"], + "requirements": ["hass-nabucasa==0.90.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f5e8fcdaf6f..2855d41de04 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 hassil==2.2.3 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index 97ca6d9b047..32e7594894a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.89.0", + "hass-nabucasa==0.90.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index be815a2dd58..26626ca9fcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 httpx==0.28.1 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1d8b788fd9d..c0852e92e35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4ce10bc3e9..ae91df10624 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.89.0 +hass-nabucasa==0.90.0 # homeassistant.components.conversation hassil==2.2.3 From 4b5633d9d8f0abb03453eed2abd69ddf7f879d90 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 12 Feb 2025 19:11:20 +0100 Subject: [PATCH 0484/1941] Update cloud backup agent to use calculate_b64md5 from lib (#138391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update cloud backup agent to use calculate_b64md5 from lib * Catch error, add test * Address review comments * Update tests/components/cloud/test_backup.py Co-authored-by: Abílio Costa --------- Co-authored-by: Abílio Costa --- homeassistant/components/cloud/backup.py | 19 ++----- tests/components/cloud/test_backup.py | 72 ++++++++++++++++++++---- 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 83dc44c0ef7..61edeccdd9c 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -3,9 +3,7 @@ from __future__ import annotations import asyncio -import base64 from collections.abc import AsyncIterator, Callable, Coroutine, Mapping -import hashlib import logging import random from typing import Any @@ -14,7 +12,7 @@ from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list -from hass_nabucasa.files import StorageType +from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.core import HomeAssistant, callback @@ -30,14 +28,6 @@ _RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MAX = 600 -async def _b64md5(stream: AsyncIterator[bytes]) -> str: - """Calculate the MD5 hash of a file.""" - file_hash = hashlib.md5() - async for chunk in stream: - file_hash.update(chunk) - return base64.b64encode(file_hash.digest()).decode() - - async def async_get_backup_agents( hass: HomeAssistant, **kwargs: Any, @@ -129,10 +119,13 @@ class CloudBackupAgent(BackupAgent): if not backup.protected: raise BackupAgentError("Cloud backups must be protected") - base64md5hash = await _b64md5(await open_stream()) + size = backup.size + try: + base64md5hash = await calculate_b64md5(open_stream, size) + except FilesError as err: + raise BackupAgentError(err) from err filename = self._get_backup_filename() metadata = backup.as_dict() - size = backup.size tries = 1 while tries <= _RETRY_LIMIT: diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 6e59b7d983e..c6bb0bdad54 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -285,6 +285,7 @@ async def test_agents_upload( ) -> None: """Test agent upload backup.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -297,7 +298,7 @@ async def test_agents_upload( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) with ( patch( @@ -309,11 +310,11 @@ async def test_agents_upload( ), patch("pathlib.Path.open") as mocked_open, ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) assert len(cloud.files.upload.mock_calls) == 1 @@ -336,6 +337,7 @@ async def test_agents_upload_fail( ) -> None: """Test agent upload backup fails.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -348,7 +350,7 @@ async def test_agents_upload_fail( homeassistant_version="2024.12.0", name="Test", protected=True, - size=0, + size=len(backup_data), ) cloud.files.upload.side_effect = side_effect @@ -366,11 +368,11 @@ async def test_agents_upload_fail( patch("homeassistant.components.cloud.backup.random.randint", return_value=60), patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -409,6 +411,7 @@ async def test_agents_upload_fail_non_retryable( ) -> None: """Test agent upload backup fails with non-retryable error.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -435,12 +438,13 @@ async def test_agents_upload_fail_non_retryable( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, + patch("homeassistant.components.cloud.backup.calculate_b64md5"), ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) fetch_backup.return_value = test_backup resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -461,6 +465,7 @@ async def test_agents_upload_not_protected( ) -> None: """Test agent upload backup, when cloud user is logged in.""" client = await hass_client() + backup_data = "test" backup_id = "test-backup" test_backup = AgentBackup( addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], @@ -473,7 +478,7 @@ async def test_agents_upload_not_protected( homeassistant_version="2024.12.0", name="Test", protected=False, - size=0, + size=len(backup_data), ) with ( patch("pathlib.Path.open"), @@ -484,7 +489,7 @@ async def test_agents_upload_not_protected( ): resp = await client.post( "/api/backup/upload?agent_id=cloud.cloud", - data={"file": StringIO("test")}, + data={"file": StringIO(backup_data)}, ) await hass.async_block_till_done() @@ -496,6 +501,53 @@ async def test_agents_upload_not_protected( assert stored_backup["failed_agent_ids"] == ["cloud.cloud"] +@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") +async def test_agents_upload_wrong_size( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + cloud: Mock, +) -> None: + """Test agent upload backup with the wrong size.""" + client = await hass_client() + backup_data = "test" + backup_id = "test-backup" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=True, + size=len(backup_data) - 1, + ) + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + ) as fetch_backup, + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=test_backup, + ), + patch("pathlib.Path.open") as mocked_open, + ): + mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""]) + fetch_backup.return_value = test_backup + resp = await client.post( + "/api/backup/upload?agent_id=cloud.cloud", + data={"file": StringIO(backup_data)}, + ) + + assert len(cloud.files.upload.mock_calls) == 0 + + assert resp.status == 201 + assert "Upload failed for cloud.cloud" in caplog.text + + @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") async def test_agents_delete( hass: HomeAssistant, From e5fd08ae762857bca1d24de2c8b61b28ddef4148 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 12 Feb 2025 19:00:55 +0000 Subject: [PATCH 0485/1941] Bump version to 2025.2.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index bf9e76df60d..6d16b877e67 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) diff --git a/pyproject.toml b/pyproject.toml index 32e7594894a..1bc3c999421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.2" +version = "2025.2.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 641b487196103372432e7dc263b080b7ade20f2b Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 12 Feb 2025 20:44:39 +0100 Subject: [PATCH 0486/1941] Improve test coverage for onedrive (#138410) * Improve test coverage for onedrive * set done in quality scale --- homeassistant/components/onedrive/backup.py | 3 +- .../components/onedrive/quality_scale.yaml | 2 +- tests/components/onedrive/test_backup.py | 39 ++++++++++++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 0e89f1b590f..674708b0cb3 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -25,6 +25,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupAgentError, + BackupNotFound, suggested_filename, ) from homeassistant.core import HomeAssistant, callback @@ -137,7 +138,7 @@ class OneDriveBackupAgent(BackupAgent): """Download a backup file.""" backups = await self._list_cached_backups() if backup_id not in backups: - raise BackupAgentError("Backup not found") + raise BackupNotFound("Backup not found") stream = await self._client.download_drive_item( backups[backup_id].backup_file_id, timeout=TIMEOUT diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index ff95364859a..84b980c5e01 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -40,7 +40,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index dd4f4d253d0..41ecbdb240f 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -14,13 +14,16 @@ from onedrive_personal_sdk.exceptions import ( import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup -from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.components.onedrive.backup import ( + async_register_backup_agents_listener, +) +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.setup import async_setup_component from . import setup_integration -from .const import BACKUP_METADATA +from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_METADATA_FILE from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, MagicMock, WebSocketGenerator @@ -241,6 +244,26 @@ async def test_agents_download( assert await resp.content.read() == b"backup data" +async def test_error_on_agents_download( + hass_client: ClientSessionGenerator, + mock_onedrive_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> 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], + [], + ] + + with patch("homeassistant.components.onedrive.backup.CACHE_TTL", -1): + resp = await client.get( + f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.unique_id}" + ) + assert resp.status == 404 + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -349,3 +372,15 @@ async def test_reauth_on_403( assert "context" in flow assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == mock_config_entry.entry_id + + +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) + + # 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 From c0068e0891c32e38945b238af43e0a66125b5d20 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 20:42:07 +0000 Subject: [PATCH 0487/1941] Bump python-kasa to 0.10.2 (#138381) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index ff65211c9b3..cdd6ab57c6a 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -301,5 +301,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.10.1"] + "requirements": ["python-kasa[speedups]==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f59bbdbd54..c9d118adb8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2415,7 +2415,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.1 +python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay python-linkplay==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb62baad569..335326545a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1954,7 +1954,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.1 +python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay python-linkplay==0.1.3 From 81cac25bd01c26b8076c938a40a3677d9baf58b0 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 12 Feb 2025 15:48:09 -0500 Subject: [PATCH 0488/1941] OTBR firmware API for Home Assistant Hardware (#138330) * Implement `async_register_firmware_info_provider` for OTBR * Keep track of the current device for OTBR Keep track of the current device, part 2 * Fix unit tests * Revert keeping track of the current device * Fix existing unit tests * Increase test coverage * Remove unused code from tests * Reload OTBR when the addon reloads * Only reload if the current entry is running * Runtime test * Add a unit test for the reloading * Clarify the purpose of `ConfigEntryState.SETUP_IN_PROGRESS` * Simplify typing --- .../components/homeassistant_hardware/util.py | 60 +++-- homeassistant/components/otbr/__init__.py | 16 +- homeassistant/components/otbr/config_flow.py | 22 +- .../components/otbr/homeassistant_hardware.py | 76 ++++++ homeassistant/components/otbr/types.py | 7 + homeassistant/components/otbr/util.py | 5 + .../homeassistant_hardware/test_util.py | 35 ++- tests/components/otbr/__init__.py | 6 + tests/components/otbr/conftest.py | 13 + tests/components/otbr/test_config_flow.py | 82 +++++- .../otbr/test_homeassistant_hardware.py | 254 ++++++++++++++++++ tests/components/otbr/test_init.py | 3 + 12 files changed, 548 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/otbr/homeassistant_hardware.py create mode 100644 homeassistant/components/otbr/types.py create mode 100644 tests/components/otbr/test_homeassistant_hardware.py diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 53cbcbae5d4..bd1ff642d10 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -12,7 +12,7 @@ import logging from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType from universal_silabs_flasher.flasher import Flasher -from homeassistant.components.hassio import AddonError, AddonState +from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.hassio import is_hassio @@ -143,6 +143,31 @@ class FirmwareInfo: return all(states) +async def get_otbr_addon_firmware_info( + hass: HomeAssistant, otbr_addon_manager: AddonManager +) -> FirmwareInfo | None: + """Get firmware info from the OTBR add-on.""" + try: + otbr_addon_info = await otbr_addon_manager.async_get_addon_info() + except AddonError: + return None + + if otbr_addon_info.state == AddonState.NOT_INSTALLED: + return None + + if (otbr_path := otbr_addon_info.options.get("device")) is None: + return None + + # Only create a new entry if there are no existing OTBR ones + return FirmwareInfo( + device=otbr_path, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)], + ) + + async def guess_hardware_owners( hass: HomeAssistant, device_path: str ) -> list[FirmwareInfo]: @@ -155,28 +180,19 @@ async def guess_hardware_owners( # It may be possible for the OTBR addon to be present without the integration if is_hassio(hass): otbr_addon_manager = get_otbr_addon_manager(hass) + otbr_addon_fw_info = await get_otbr_addon_firmware_info( + hass, otbr_addon_manager + ) + otbr_path = ( + otbr_addon_fw_info.device if otbr_addon_fw_info is not None else None + ) - try: - otbr_addon_info = await otbr_addon_manager.async_get_addon_info() - except AddonError: - pass - else: - if otbr_addon_info.state != AddonState.NOT_INSTALLED: - otbr_path = otbr_addon_info.options.get("device") - - # Only create a new entry if there are no existing OTBR ones - if otbr_path is not None and not any( - info.source == "otbr" for info in device_guesses[otbr_path] - ): - device_guesses[otbr_path].append( - FirmwareInfo( - device=otbr_path, - firmware_type=ApplicationType.SPINEL, - firmware_version=None, - source="otbr", - owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)], - ) - ) + # Only create a new entry if there are no existing OTBR ones + if otbr_path is not None and not any( + info.source == "otbr" for info in device_guesses[otbr_path] + ): + assert otbr_addon_fw_info is not None + device_guesses[otbr_path].append(otbr_addon_fw_info) if is_hassio(hass): multipan_addon_manager = await get_multiprotocol_addon_manager(hass) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 4b95be1d40d..0756f32ab18 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -7,16 +7,20 @@ import logging import aiohttp import python_otbr_api +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_provider, +) from homeassistant.components.thread import async_add_dataset -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from . import websocket_api +from . import homeassistant_hardware, websocket_api from .const import DOMAIN +from .types import OTBRConfigEntry from .util import ( GetBorderAgentIdNotSupported, OTBRData, @@ -28,12 +32,13 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -type OTBRConfigEntry = ConfigEntry[OTBRData] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" websocket_api.async_setup(hass) + + async_register_firmware_info_provider(hass, DOMAIN, homeassistant_hardware) + return True @@ -77,6 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool entry.async_on_unload(entry.add_update_listener(async_reload_entry)) entry.runtime_data = otbrdata + if fw_info := await homeassistant_hardware.async_get_firmware_info(hass, entry): + await async_notify_firmware_info(hass, DOMAIN, fw_info) + return True diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index aff79ca4651..514f6c7617c 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -16,7 +16,12 @@ import yarl from homeassistant.components.hassio import AddonError, AddonManager from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_HASSIO, + ConfigEntryState, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -201,12 +206,23 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): # we have to assume it's the first version # This check can be removed in HA Core 2025.9 unique_id = discovery_info.uuid + + if unique_id != discovery_info.uuid: + continue + if ( - unique_id != discovery_info.uuid - or current_url.host != config["host"] + current_url.host != config["host"] or current_url.port == config["port"] ): + # Reload the entry since OTBR has restarted + if current_entry.state == ConfigEntryState.LOADED: + assert current_entry.unique_id is not None + await self.hass.config_entries.async_reload( + current_entry.entry_id + ) + continue + # Update URL with the new port self.hass.config_entries.async_update_entry( current_entry, diff --git a/homeassistant/components/otbr/homeassistant_hardware.py b/homeassistant/components/otbr/homeassistant_hardware.py new file mode 100644 index 00000000000..94193be1359 --- /dev/null +++ b/homeassistant/components/otbr/homeassistant_hardware.py @@ -0,0 +1,76 @@ +"""Home Assistant Hardware firmware utilities.""" + +from __future__ import annotations + +import logging + +from yarl import URL + +from homeassistant.components.hassio import AddonManager +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningAddon, + OwningIntegration, + get_otbr_addon_firmware_info, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.hassio import is_hassio + +from .const import DOMAIN +from .types import OTBRConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_firmware_info( + hass: HomeAssistant, config_entry: OTBRConfigEntry +) -> FirmwareInfo | None: + """Return firmware information for the OpenThread Border Router.""" + owners: list[OwningIntegration | OwningAddon] = [ + OwningIntegration(config_entry_id=config_entry.entry_id) + ] + + device = None + + if is_hassio(hass) and (host := URL(config_entry.data["url"]).host) is not None: + otbr_addon_manager = AddonManager( + hass=hass, + logger=_LOGGER, + addon_name="OpenThread Border Router", + addon_slug=host.replace("-", "_"), + ) + + if ( + addon_fw_info := await get_otbr_addon_firmware_info( + hass, otbr_addon_manager + ) + ) is not None: + device = addon_fw_info.device + owners.extend(addon_fw_info.owners) + + firmware_version = None + + if config_entry.state in ( + # This function is called during OTBR config entry setup so we need to account + # for both config entry states + ConfigEntryState.LOADED, + ConfigEntryState.SETUP_IN_PROGRESS, + ): + try: + firmware_version = await config_entry.runtime_data.get_coprocessor_version() + except HomeAssistantError: + firmware_version = None + + if device is None: + return None + + return FirmwareInfo( + device=device, + firmware_type=ApplicationType.SPINEL, + firmware_version=firmware_version, + source=DOMAIN, + owners=owners, + ) diff --git a/homeassistant/components/otbr/types.py b/homeassistant/components/otbr/types.py new file mode 100644 index 00000000000..eff6aa980d6 --- /dev/null +++ b/homeassistant/components/otbr/types.py @@ -0,0 +1,7 @@ +"""The Open Thread Border Router integration types.""" + +from homeassistant.config_entries import ConfigEntry + +from .util import OTBRData + +type OTBRConfigEntry = ConfigEntry[OTBRData] diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 351e23c7736..30e456e11a8 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -163,6 +163,11 @@ class OTBRData: """Get extended address (EUI-64).""" return await self.api.get_extended_address() + @_handle_otbr_error + async def get_coprocessor_version(self) -> str: + """Get coprocessor firmware version.""" + return await self.api.get_coprocessor_version() + async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: """Return the allowed channel, or None if there's no restriction.""" diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 047de3e452c..52739f16886 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -2,7 +2,12 @@ from unittest.mock import AsyncMock, MagicMock, patch -from homeassistant.components.hassio import AddonError, AddonInfo, AddonState +from homeassistant.components.hassio import ( + AddonError, + AddonInfo, + AddonManager, + AddonState, +) from homeassistant.components.homeassistant_hardware.helpers import ( async_register_firmware_info_provider, ) @@ -11,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.util import ( FirmwareInfo, OwningAddon, OwningIntegration, + get_otbr_addon_firmware_info, guess_firmware_info, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -247,3 +253,30 @@ async def test_firmware_info(hass: HomeAssistant) -> None: ) assert (await firmware_info2.is_running(hass)) is False + + +async def test_get_otbr_addon_firmware_info_failure(hass: HomeAssistant) -> None: + """Test getting OTBR addon firmware info failure due to bad API call.""" + + otbr_addon_manager = AsyncMock(spec_set=AddonManager) + otbr_addon_manager.async_get_addon_info.side_effect = AddonError() + + assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None + + +async def test_get_otbr_addon_firmware_info_failure_bad_options( + hass: HomeAssistant, +) -> None: + """Test getting OTBR addon firmware info failure due to bad addon options.""" + + otbr_addon_manager = AsyncMock(spec_set=AddonManager) + otbr_addon_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, # `device` is missing + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + + assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index 7d52318b477..5f778169e55 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -33,6 +33,8 @@ TEST_BORDER_AGENT_EXTENDED_ADDRESS = bytes.fromhex("AEEB2F594B570BBF") TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C") TEST_BORDER_AGENT_ID_2 = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52D") +COPROCESSOR_VERSION = "OPENTHREAD/thread-reference-20200818-1740-g33cc75ed3; NRF52840; Jun 2 2022 14:25:49" + ROUTER_DISCOVERY_HASS = { "type_": "_meshcop._udp.local.", "name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", @@ -60,3 +62,7 @@ ROUTER_DISCOVERY_HASS = { }, "interface_index": None, } + +TEST_COPROCESSOR_VERSION = ( + "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57" +) diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index 5ab3e442183..9140fcf6847 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -15,6 +15,7 @@ from . import ( DATASET_CH16, TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, + TEST_COPROCESSOR_VERSION, ) from tests.common import MockConfigEntry @@ -71,12 +72,23 @@ def get_extended_address_fixture() -> Generator[AsyncMock]: yield get_extended_address +@pytest.fixture(name="get_coprocessor_version") +def get_coprocessor_version_fixture() -> Generator[AsyncMock]: + """Mock get_coprocessor_version.""" + with patch( + "python_otbr_api.OTBR.get_coprocessor_version", + return_value=TEST_COPROCESSOR_VERSION, + ) as get_coprocessor_version: + yield get_coprocessor_version + + @pytest.fixture(name="otbr_config_entry_multipan") async def otbr_config_entry_multipan_fixture( hass: HomeAssistant, get_active_dataset_tlvs: AsyncMock, get_border_agent_id: AsyncMock, get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, ) -> str: """Mock Open Thread Border Router config entry.""" config_entry = MockConfigEntry( @@ -97,6 +109,7 @@ async def otbr_config_entry_thread_fixture( get_active_dataset_tlvs: AsyncMock, get_border_agent_id: AsyncMock, get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, ) -> None: """Mock Open Thread Border Router config entry.""" config_entry = MockConfigEntry( diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index d14fbc5cbd1..8384b905b9c 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -10,9 +10,18 @@ import pytest import python_otbr_api from homeassistant.components import otbr +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_callback, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningAddon, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from homeassistant.setup import async_setup_component from . import DATASET_CH15, DATASET_CH16, TEST_BORDER_AGENT_ID, TEST_BORDER_AGENT_ID_2 @@ -32,6 +41,19 @@ HASSIO_DATA_2 = HassioServiceInfo( uuid="23456", ) +HASSIO_DATA_OTBR = HassioServiceInfo( + config={ + "host": "core-openthread-border-router", + "port": 8081, + "device": "/dev/ttyUSB1", + "firmware": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57\r", + "addon": "OpenThread Border Router", + }, + name="OpenThread Border Router", + slug="core_openthread_border_router", + uuid="c58ba80fc88548008776bf8da903ef21", +) + @pytest.fixture(name="otbr_addon_info") def otbr_addon_info_fixture(addon_info: AsyncMock, addon_installed) -> AsyncMock: @@ -97,6 +119,7 @@ async def test_user_flow_additional_entry( @pytest.mark.usefixtures( "get_active_dataset_tlvs", "get_extended_address", + "get_coprocessor_version", ) async def test_user_flow_additional_entry_fail_get_address( hass: HomeAssistant, @@ -174,6 +197,7 @@ async def _finish_user_flow( "get_active_dataset_tlvs", "get_border_agent_id", "get_extended_address", + "get_coprocessor_version", ) async def test_user_flow_additional_entry_same_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -563,7 +587,11 @@ async def test_hassio_discovery_flow_2x_addons( assert config_entry.unique_id == HASSIO_DATA_2.uuid -@pytest.mark.usefixtures("get_active_dataset_tlvs", "get_extended_address") +@pytest.mark.usefixtures( + "get_active_dataset_tlvs", + "get_extended_address", + "get_coprocessor_version", +) async def test_hassio_discovery_flow_2x_addons_same_ext_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: @@ -963,3 +991,55 @@ async def test_config_flow_additional_entry( ) assert result["type"] is expected_result + + +@pytest.mark.usefixtures( + "get_border_agent_id", "get_extended_address", "get_coprocessor_version" +) +async def test_hassio_discovery_reload( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info +) -> None: + """Test the hassio discovery flow.""" + await async_setup_component(hass, "homeassistant_hardware", {}) + + aioclient_mock.get( + "http://core-openthread-border-router:8081/node/dataset/active", text="" + ) + + callback = Mock() + async_register_firmware_info_callback(hass, "/dev/ttyUSB1", callback) + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device="/dev/ttyUSB1", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA_OTBR + ) + + # OTBR is set up and calls the firmware info notification callback + assert len(callback.mock_calls) == 1 + assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1 + + # If we change discovery info and emit again, the integration will be reloaded + # and firmware information will be broadcast again + await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA_OTBR + ) + + assert len(callback.mock_calls) == 2 + assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1 diff --git a/tests/components/otbr/test_homeassistant_hardware.py b/tests/components/otbr/test_homeassistant_hardware.py new file mode 100644 index 00000000000..7f831656d06 --- /dev/null +++ b/tests/components/otbr/test_homeassistant_hardware.py @@ -0,0 +1,254 @@ +"""Test Home Assistant Hardware platform for OTBR.""" + +from unittest.mock import AsyncMock, Mock, call, patch + +import pytest + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_register_firmware_info_callback, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningAddon, + OwningIntegration, +) +from homeassistant.components.otbr.homeassistant_hardware import async_get_firmware_info +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from . import TEST_COPROCESSOR_VERSION + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +DEVICE_PATH = "/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9ab1da1ea4b3ed11956f4eaca7669f5d-if00-port0" + + +async def test_get_firmware_info(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info`.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://core_openthread_border_router:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + otbr.mock_state(hass, ConfigEntryState.LOADED) + + otbr.runtime_data = AsyncMock() + otbr.runtime_data.get_coprocessor_version.return_value = TEST_COPROCESSOR_VERSION + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + fw_info = await async_get_firmware_info(hass, otbr) + + assert fw_info == FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=TEST_COPROCESSOR_VERSION, + source="otbr", + owners=[ + OwningIntegration(config_entry_id=otbr.entry_id), + OwningAddon(slug="core_openthread_border_router"), + ], + ) + + +async def test_get_firmware_info_ignored(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info` with ignored entry.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={}, + version=1, + ) + otbr.add_to_hass(hass) + + fw_info = await async_get_firmware_info(hass, otbr) + assert fw_info is None + + +async def test_get_firmware_info_no_coprocessor_version(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info` with no coprocessor version support.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://core_openthread_border_router:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + otbr.mock_state(hass, ConfigEntryState.LOADED) + + otbr.runtime_data = AsyncMock() + otbr.runtime_data.get_coprocessor_version.side_effect = HomeAssistantError() + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + fw_info = await async_get_firmware_info(hass, otbr) + + assert fw_info == FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningIntegration(config_entry_id=otbr.entry_id), + OwningAddon(slug="core_openthread_border_router"), + ], + ) + + +@pytest.mark.parametrize( + ("version", "expected_version"), + [ + ((TEST_COPROCESSOR_VERSION,), TEST_COPROCESSOR_VERSION), + (HomeAssistantError(), None), + ], +) +async def test_hardware_firmware_info_provider_notification( + hass: HomeAssistant, + version: str | Exception, + expected_version: str | None, + get_active_dataset_tlvs: AsyncMock, + get_border_agent_id: AsyncMock, + get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that the OTBR provides hardware and firmware information.""" + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://core_openthread_border_router:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + + await async_setup_component(hass, "homeassistant_hardware", {}) + + callback = Mock() + async_register_firmware_info_callback(hass, DEVICE_PATH, callback) + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="otbr", + owners=[ + OwningAddon(slug="core_openthread_border_router"), + ], + ), + ), + ): + get_coprocessor_version.side_effect = version + await hass.config_entries.async_setup(otbr.entry_id) + + assert callback.mock_calls == [ + call( + FirmwareInfo( + device=DEVICE_PATH, + firmware_type=ApplicationType.SPINEL, + firmware_version=expected_version, + source="otbr", + owners=[ + OwningIntegration(config_entry_id=otbr.entry_id), + OwningAddon(slug="core_openthread_border_router"), + ], + ) + ) + ] + + +async def test_get_firmware_info_remote_otbr(hass: HomeAssistant) -> None: + """Test `async_get_firmware_info` with no coprocessor version support.""" + + otbr = MockConfigEntry( + domain="otbr", + unique_id="some_unique_id", + data={ + "url": "http://192.168.1.10:8888", + }, + version=1, + ) + otbr.add_to_hass(hass) + otbr.mock_state(hass, ConfigEntryState.LOADED) + + otbr.runtime_data = AsyncMock() + otbr.runtime_data.get_coprocessor_version.return_value = TEST_COPROCESSOR_VERSION + + with ( + patch( + "homeassistant.components.otbr.homeassistant_hardware.is_hassio", + return_value=True, + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.AddonManager", + ), + patch( + "homeassistant.components.otbr.homeassistant_hardware.get_otbr_addon_firmware_info", + return_value=None, + ), + ): + fw_info = await async_get_firmware_info(hass, otbr) + + assert fw_info is None diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index faf13786107..b14527165e6 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -26,6 +26,7 @@ from . import ( ROUTER_DISCOVERY_HASS, TEST_BORDER_AGENT_EXTENDED_ADDRESS, TEST_BORDER_AGENT_ID, + TEST_COPROCESSOR_VERSION, ) from tests.common import MockConfigEntry @@ -43,6 +44,7 @@ def enable_mocks_fixture( get_active_dataset_tlvs: AsyncMock, get_border_agent_id: AsyncMock, get_extended_address: AsyncMock, + get_coprocessor_version: AsyncMock, ) -> None: """Enable API mocks.""" @@ -298,6 +300,7 @@ async def test_config_entry_update(hass: HomeAssistant) -> None: mock_api.get_extended_address = AsyncMock( return_value=TEST_BORDER_AGENT_EXTENDED_ADDRESS ) + mock_api.get_coprocessor_version = AsyncMock(return_value=TEST_COPROCESSOR_VERSION) with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: assert await hass.config_entries.async_setup(config_entry.entry_id) From 1ac16f6dbf765659e37443afeb9b7af43a64b44b Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Thu, 13 Feb 2025 02:37:46 -0500 Subject: [PATCH 0489/1941] Set suggested display precision in La Crosse View (#138355) * Set suggested display precision in La Crosse View * Switch to entity descriptions --- homeassistant/components/lacrosse_view/sensor.py | 9 +++++++++ tests/components/lacrosse_view/test_sensor.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index df66b7ba96a..ea5a82a3df8 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -64,6 +64,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, ), "Humidity": LaCrosseSensorEntityDescription( key="Humidity", @@ -71,6 +72,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, ), "HeatIndex": LaCrosseSensorEntityDescription( key="HeatIndex", @@ -79,6 +81,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=2, ), "WindSpeed": LaCrosseSensorEntityDescription( key="WindSpeed", @@ -86,6 +89,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=2, ), "Rain": LaCrosseSensorEntityDescription( key="Rain", @@ -93,12 +97,14 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, + suggested_display_precision=2, ), "WindHeading": LaCrosseSensorEntityDescription( key="WindHeading", translation_key="wind_heading", value_fn=get_value, native_unit_of_measurement=DEGREE, + suggested_display_precision=2, ), "WetDry": LaCrosseSensorEntityDescription( key="WetDry", @@ -117,6 +123,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, + suggested_display_precision=2, ), "FeelsLike": LaCrosseSensorEntityDescription( key="FeelsLike", @@ -125,6 +132,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=2, ), "WindChill": LaCrosseSensorEntityDescription( key="WindChill", @@ -133,6 +141,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=2, ), } # map of API returned unit of measurement strings to their corresponding unit of measurement diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 74e9f001792..17ae56ed78d 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -117,7 +117,7 @@ async def test_field_not_supported( (TEST_STRING_SENSOR, "dry", "wet_dry"), (TEST_ALREADY_FLOAT_SENSOR, "-16.5", "heat_index"), (TEST_ALREADY_INT_SENSOR, "2", "wind_speed"), - (TEST_UNITS_OVERRIDE_SENSOR, "-16.6", "temperature"), + (TEST_UNITS_OVERRIDE_SENSOR, "-16.6111111111111", "temperature"), ], ) async def test_field_types( From 737baaef2b93e3a696bbc6751f567426ed75d50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 13 Feb 2025 09:22:05 +0100 Subject: [PATCH 0490/1941] Improve test coverage for letpot (#138420) --- .../components/letpot/quality_scale.yaml | 2 +- tests/components/letpot/test_init.py | 37 +++++++++++++++- tests/components/letpot/test_switch.py | 44 ++++++++++++++++++- tests/components/letpot/test_time.py | 20 +++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 0eda413a461..70f3bb52b82 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -44,7 +44,7 @@ rules: log-when-unavailable: todo parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py index 178227a6506..e3f78d87dc1 100644 --- a/tests/components/letpot/test_init.py +++ b/tests/components/letpot/test_init.py @@ -2,7 +2,11 @@ from unittest.mock import MagicMock -from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +from letpot.exceptions import ( + LetPotAuthenticationException, + LetPotConnectionException, + LetPotException, +) import pytest from homeassistant.config_entries import ConfigEntryState @@ -94,3 +98,34 @@ async def test_get_devices_exceptions( assert mock_config_entry.state is config_entry_state mock_client.get_devices.assert_called_once() mock_device_client.subscribe.assert_not_called() + + +async def test_device_subscribe_authentication_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry errors if it is not allowed to subscribe to device updates.""" + mock_device_client.subscribe.side_effect = LetPotAuthenticationException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_not_called() + + +async def test_device_refresh_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry errors with retry if getting a device state update fails.""" + mock_device_client.get_current_status.side_effect = LetPotException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_device_client.get_current_status.assert_called_once() diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index b166d551adb..0ba1f556bc9 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -6,7 +6,11 @@ from letpot.exceptions import LetPotConnectionException, LetPotException import pytest from syrupy import SnapshotAssertion -from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.components.switch import ( + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -32,6 +36,44 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.parametrize( + ("service", "parameter_value"), + [ + ( + SERVICE_TURN_ON, + True, + ), + ( + SERVICE_TURN_OFF, + False, + ), + ( + SERVICE_TOGGLE, + False, # Mock switch is on after setup, toggle will turn off + ), + ], +) +async def test_set_switch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + service: str, + parameter_value: bool, +) -> None: + """Test switch entity turned on/turned off/toggled.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "switch", + service, + blocking=True, + target={"entity_id": "switch.garden_power"}, + ) + + mock_device_client.set_power.assert_awaited_once_with(parameter_value) + + @pytest.mark.parametrize( ("service", "exception", "user_error"), [ diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index 82e69979067..e65ea4532e1 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -33,6 +33,26 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_set_time( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test setting the time entity.""" + await setup_integration(hass, mock_config_entry) + + 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"}, + ) + + mock_device_client.set_light_schedule.assert_awaited_once_with(time(7, 0), None) + + @pytest.mark.parametrize( ("exception", "user_error"), [ From 6bc4f04a079142ccf763d30e6ba19c3170f5a7ee Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Thu, 13 Feb 2025 03:24:28 -0500 Subject: [PATCH 0491/1941] Handle no_readings in La Crosse View (#138354) * Handle no_readings in La Crosse View * Fixes --- .../components/lacrosse_view/coordinator.py | 28 +++++++--- .../components/lacrosse_view/strings.json | 5 ++ tests/components/lacrosse_view/__init__.py | 22 ++++++++ tests/components/lacrosse_view/test_init.py | 17 ++++++ tests/components/lacrosse_view/test_sensor.py | 56 +++++++++++++++++++ 5 files changed, 120 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 3d741e8f1a8..16d7e8b2bb8 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -75,16 +75,28 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): try: # Fetch last hour of data for sensor in self.devices: - sensor.data = ( - await self.api.get_sensor_status( - sensor=sensor, - tz=self.hass.config.time_zone, + data = await self.api.get_sensor_status( + sensor=sensor, + tz=self.hass.config.time_zone, + ) + _LOGGER.debug("Got data: %s", data) + + if data_error := data.get("error"): + if data_error == "no_readings": + sensor.data = None + _LOGGER.debug("No readings for %s", sensor.name) + continue + _LOGGER.debug("Error: %s", data_error) + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" ) - )["data"]["current"] - _LOGGER.debug("Got data: %s", sensor.data) + + sensor.data = data["data"]["current"] except HTTPError as error: - raise UpdateFailed from error + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" + ) from error # Verify that we have permission to read the sensors for sensor in self.devices: diff --git a/homeassistant/components/lacrosse_view/strings.json b/homeassistant/components/lacrosse_view/strings.json index 8dc27ba259e..c5d9a11e49a 100644 --- a/homeassistant/components/lacrosse_view/strings.json +++ b/homeassistant/components/lacrosse_view/strings.json @@ -42,5 +42,10 @@ "name": "Wind chill" } } + }, + "exceptions": { + "update_error": { + "message": "Error updating data" + } } } diff --git a/tests/components/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 860156beb6c..7221fa4c071 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -165,3 +165,25 @@ TEST_UNITS_OVERRIDE_SENSOR = Sensor( permissions={"read": True}, model="Test", ) +TEST_NO_READINGS_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"error": "no_readings"}, + permissions={"read": True}, + model="Test", +) +TEST_OTHER_ERROR_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"error": "some_other_error"}, + permissions={"read": True}, + model="Test", +) diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index af92d0e64f1..0533dd2abee 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -83,6 +83,23 @@ async def test_http_error(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_RETRY + config_entry_2 = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry_2.add_to_hass(hass) + + # Start over, let get_devices succeed but get_sensor_status fail + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_sensor_status", side_effect=HTTPError), + ): + assert not await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 2 + assert entries[1].state is ConfigEntryState.SETUP_RETRY + async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test new token.""" diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 17ae56ed78d..e0dc1e5f35f 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -18,6 +18,8 @@ from . import ( TEST_MISSING_FIELD_DATA_SENSOR, TEST_NO_FIELD_SENSOR, TEST_NO_PERMISSION_SENSOR, + TEST_NO_READINGS_SENSOR, + TEST_OTHER_ERROR_SENSOR, TEST_SENSOR, TEST_STRING_SENSOR, TEST_UNITS_OVERRIDE_SENSOR, @@ -204,3 +206,57 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED assert hass.states.get("sensor.test_temperature").state == "unknown" + + +async def test_no_readings(hass: HomeAssistant) -> None: + """Test behavior when there are no readings.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + sensor = TEST_NO_READINGS_SENSOR.model_copy() + status = sensor.data + sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], + ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.data[DOMAIN] + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert hass.states.get("sensor.test_temperature").state == "unavailable" + + +async def test_other_error(hass: HomeAssistant) -> None: + """Test behavior when there is an error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + sensor = TEST_OTHER_ERROR_SENSOR.model_copy() + status = sensor.data + sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], + ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY From 07c304125aec142087348f2937ef73946e381741 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 13 Feb 2025 09:37:52 +0100 Subject: [PATCH 0492/1941] Add error handling to enphase_envoy select platform action (#136698) * Add error handling to enphase_envoy select platform action * Add translation key parameter to exception_handler decorator --- .../components/enphase_envoy/select.py | 4 +- tests/components/enphase_envoy/test_select.py | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 546470a19d5..42b47e5d793 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator -from .entity import EnvoyBaseEntity +from .entity import EnvoyBaseEntity, exception_handler PARALLEL_UPDATES = 1 @@ -192,6 +192,7 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): """Return the state of the Enpower switch.""" return self.entity_description.value_fn(self.relay) + @exception_handler async def async_select_option(self, option: str) -> None: """Update the relay.""" await self.entity_description.update_fn(self.envoy, self.relay, option) @@ -243,6 +244,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): 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_select_option(self, option: str) -> None: """Update the relay.""" await self.entity_description.update_fn(self.envoy, option) diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py index e13492c7f54..a81a06a3441 100644 --- a/tests/components/enphase_envoy/test_select.py +++ b/tests/components/enphase_envoy/test_select.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -17,6 +18,7 @@ from homeassistant.components.enphase_envoy.select import ( from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -157,6 +159,46 @@ async def test_select_relay_modes( ) +@pytest.mark.parametrize( + ("mock_envoy", "relay", "target", "action"), + [("envoy_metered_batt_relay", "NC1", "generator_action", "powered")], + indirect=["mock_envoy"], +) +async def test_update_dry_contact_actions_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + target: str, + relay: str, + action: str, +) -> None: + """Test select platform update dry contact action with error return.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SELECT}." + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}_{target}" + + mock_envoy.update_dry_contact.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_select_option for {test_entity}, host", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: action, + }, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ @@ -197,6 +239,44 @@ async def test_select_storage_modes( mock_envoy.set_storage_mode.assert_called_once_with(REVERSE_STORAGE_MODE_MAP[mode]) +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +@pytest.mark.parametrize(("mode"), ["backup"]) +async def test_set_storage_modes_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, + mode: str, +) -> None: + """Test select platform set storage mode with error return.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" + + mock_envoy.set_storage_mode.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_select_option for {test_entity}, host", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ From 0a9d134f49170a6e0836c35f81b27212168efc87 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Feb 2025 10:28:55 +0100 Subject: [PATCH 0493/1941] Make descriptions of `data` fields in notify actions UI-friendly (#138431) Also fixes a duplicated period at the end of the second string. --- homeassistant/components/notify/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index e832bfc248a..b33af360448 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -24,7 +24,7 @@ }, "data": { "name": "Data", - "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation." + "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation." } } }, @@ -56,7 +56,7 @@ }, "data": { "name": "Data", - "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation.." + "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation." } } } From a8f4ab73aebb747adc95f33ced5092d6a8d64471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 13 Feb 2025 12:40:55 +0100 Subject: [PATCH 0494/1941] Bump hass-nabucasa from 0.90.0 to 0.91.0 (#138441) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7598dde6cf3..16d340a480b 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.90.0"], + "requirements": ["hass-nabucasa==0.91.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b35d5589182..b49409d9ce7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index c0d83b05f00..e693b6ec9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.90.0", + "hass-nabucasa==0.91.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 4afa122ba7d..7baea71e608 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c9d118adb8e..92d1a2a62ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1106,7 +1106,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 335326545a2..6e24129a0fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 # homeassistant.components.conversation hassil==2.2.3 From 6a26d59142dfc14d718450ac2b3277ec0a722784 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Thu, 13 Feb 2025 05:45:09 -0600 Subject: [PATCH 0495/1941] Add night light brightness level setting to VeSync (#137544) --- homeassistant/components/vesync/__init__.py | 1 + homeassistant/components/vesync/const.py | 4 + homeassistant/components/vesync/select.py | 133 +++++++++++++++++++ homeassistant/components/vesync/strings.json | 10 ++ tests/components/vesync/common.py | 1 + tests/components/vesync/conftest.py | 45 ++++++- tests/components/vesync/test_init.py | 2 + tests/components/vesync/test_select.py | 54 ++++++++ 8 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vesync/select.py create mode 100644 tests/components/vesync/test_select.py diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 4951bdb2dc1..f9371d44507 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -27,6 +27,7 @@ PLATFORMS = [ Platform.HUMIDIFIER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 34454081567..897c8d2b745 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -29,6 +29,10 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" VS_HUMIDIFIER_MODE_MANUAL = "manual" VS_HUMIDIFIER_MODE_SLEEP = "sleep" +NIGHT_LIGHT_LEVEL_BRIGHT = "bright" +NIGHT_LIGHT_LEVEL_DIM = "dim" +NIGHT_LIGHT_LEVEL_OFF = "off" + VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S """Humidifier device types""" diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py new file mode 100644 index 00000000000..c266985fc2b --- /dev/null +++ b/homeassistant/components/vesync/select.py @@ -0,0 +1,133 @@ +"""Support for VeSync numeric entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import rgetattr +from .const import ( + DOMAIN, + NIGHT_LIGHT_LEVEL_BRIGHT, + NIGHT_LIGHT_LEVEL_DIM, + NIGHT_LIGHT_LEVEL_OFF, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, +) +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + +VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP = { + 100: NIGHT_LIGHT_LEVEL_BRIGHT, + 50: NIGHT_LIGHT_LEVEL_DIM, + 0: NIGHT_LIGHT_LEVEL_OFF, +} + +HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP = { + v: k for k, v in VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.items() +} + + +@dataclass(frozen=True, kw_only=True) +class VeSyncSelectEntityDescription(SelectEntityDescription): + """Class to describe a Vesync select entity.""" + + exists_fn: Callable[[VeSyncBaseDevice], bool] + current_option_fn: Callable[[VeSyncBaseDevice], str] + select_option_fn: Callable[[VeSyncBaseDevice, str], bool] + + +SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [ + VeSyncSelectEntityDescription( + key="night_light_level", + translation_key="night_light_level", + options=list(VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.values()), + icon="mdi:brightness-6", + exists_fn=lambda device: rgetattr(device, "night_light"), + # The select_option service framework ensures that only options specified are + # accepted. ServiceValidationError gets raised for invalid value. + select_option_fn=lambda device, value: device.set_night_light_brightness( + HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) + ), + # Reporting "off" as the choice for unhandled level. + current_option_fn=lambda device: VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light_brightness"), NIGHT_LIGHT_LEVEL_OFF + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up select entities.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddConfigEntryEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add select entities.""" + + async_add_entities( + VeSyncSelectEntity(dev, description, coordinator) + for dev in devices + for description in SELECT_DESCRIPTIONS + if description.exists_fn(dev) + ) + + +class VeSyncSelectEntity(VeSyncBaseEntity, SelectEntity): + """A class to set numeric options on Vesync device.""" + + entity_description: VeSyncSelectEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncSelectEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSync select device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def current_option(self) -> str | None: + """Return an option.""" + return self.entity_description.current_option_fn(self.device) + + async def async_select_option(self, option: str) -> None: + """Set an option.""" + if await self.hass.async_add_executor_job( + self.entity_description.select_option_fn, self.device, option + ): + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 3eb2a0c3fd5..2232b16329b 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -56,6 +56,16 @@ "name": "Mist level" } }, + "select": { + "night_light_level": { + "name": "Night light level", + "state": { + "bright": "Bright", + "dim": "Dim", + "off": "[%key:common::state::off%]" + } + } + }, "fan": { "vesync": { "state_attributes": { diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index ee9f9b94052..39a92778727 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -13,6 +13,7 @@ from tests.common import load_fixture, load_json_object_fixture ENTITY_HUMIDIFIER = "humidifier.humidifier_200s" ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" +ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 9ec7bd23fa5..df6ebbdf6e7 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -107,7 +107,7 @@ def outlet_fixture(): @pytest.fixture(name="humidifier") def humidifier_fixture(): - """Create a mock VeSync humidifier fixture.""" + """Create a mock VeSync Classic200S humidifier fixture.""" return Mock( VeSyncHumid200300S, cid="200s-humidifier", @@ -135,6 +135,34 @@ def humidifier_fixture(): ) +@pytest.fixture(name="humidifier_300s") +def humidifier_300s_fixture(): + """Create a mock VeSync Classic300S humidifier fixture.""" + return Mock( + VeSyncHumid200300S, + cid="300s-humidifier", + config={ + "auto_target_humidity": 40, + "display": "true", + "automatic_stop": "true", + }, + details={"humidity": 35, "mode": "manual", "night_light_brightness": 50}, + device_type="Classic300S", + device_name="Humidifier 300s", + device_status="on", + mist_level=6, + mist_modes=["auto", "manual"], + mode=None, + night_light=True, + sub_device_no=0, + config_module="configModule", + connection_status="online", + current_firm_version="1.0.0", + water_lacks=False, + water_tank_lifted=False, + ) + + @pytest.fixture(name="humidifier_config_entry") async def humidifier_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config @@ -155,6 +183,21 @@ async def humidifier_config_entry( return entry +@pytest.fixture +async def install_humidifier_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + manager, + request: pytest.FixtureRequest, +) -> None: + """Create a mock VeSync config entry with the specified humidifier device.""" + + # Install the defined humidifier + manager._dev_list["fans"].append(request.getfixturevalue(request.param)) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index f1fb3931bf9..011545af2ae 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -56,6 +56,7 @@ async def test_async_setup_entry__no_devices( Platform.HUMIDIFIER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -87,6 +88,7 @@ async def test_async_setup_entry__loads_fans( Platform.HUMIDIFIER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/tests/components/vesync/test_select.py b/tests/components/vesync/test_select.py new file mode 100644 index 00000000000..30c83c89e0e --- /dev/null +++ b/tests/components/vesync/test_select.py @@ -0,0 +1,54 @@ +"""Tests for the select platform.""" + +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.vesync.const import NIGHT_LIGHT_LEVEL_DIM +from homeassistant.components.vesync.select import HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT + + +@pytest.mark.parametrize( + "install_humidifier_device", ["humidifier_300s"], indirect=True +) +async def test_set_nightlight_level( + hass: HomeAssistant, manager, humidifier_300s, install_humidifier_device +) -> None: + """Test set of night light level.""" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT, + ATTR_OPTION: NIGHT_LIGHT_LEVEL_DIM, + }, + blocking=True, + ) + + # Assert that setter API was invoked with the expected translated value + humidifier_300s.set_night_light_brightness.assert_called_once_with( + HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP[NIGHT_LIGHT_LEVEL_DIM] + ) + # Assert that devices were refreshed + manager.update_all_devices.assert_called_once() + + +@pytest.mark.parametrize( + "install_humidifier_device", ["humidifier_300s"], indirect=True +) +async def test_nightlight_level(hass: HomeAssistant, install_humidifier_device) -> None: + """Test the state of night light level select entity.""" + + # The mocked device has night_light_brightness=50 which is "dim" + assert ( + hass.states.get(ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT).state + == NIGHT_LIGHT_LEVEL_DIM + ) From e9138a427da8943c174205561f37a73944ce01d3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Feb 2025 13:00:38 +0100 Subject: [PATCH 0496/1941] Replace wrong description reference of isy994.send_node_command (#138385) --- homeassistant/components/isy994/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 86a1f14ff91..8872226daba 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -58,7 +58,7 @@ "services": { "send_raw_node_command": { "name": "Send raw node command", - "description": "[%key:component::isy994::options::step::init::description%]", + "description": "Sends a “raw” (e.g., DON, DOF) ISY REST device command to a node using its Home Assistant entity ID. This is useful for devices that aren’t fully supported in Home Assistant yet, such as controls for many NodeServer nodes.", "fields": { "command": { "name": "Command", From 7021175e0da26a6f6144550c035349b2ecb6ff80 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 13 Feb 2025 13:07:24 +0100 Subject: [PATCH 0497/1941] Simplify stage 1 in bootstrap (#137668) * Simplify stage 1 in bootstrap * Add timeouts to STAGE 0 * Fix test * Clarify pre import language * Remove timeout for frontend and recorder * Address review --------- Co-authored-by: J. Nick Koston --- homeassistant/bootstrap.py | 132 ++++++++++++++------------------- tests/test_bootstrap.py | 14 ++-- tests/test_circular_imports.py | 14 ++-- 3 files changed, 67 insertions(+), 93 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 58150ae7926..7fd73af0053 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -134,14 +134,12 @@ DATA_REGISTRIES_LOADED: HassKey[None] = HassKey("bootstrap_registries_loaded") LOG_SLOW_STARTUP_INTERVAL = 60 SLOW_STARTUP_CHECK_INTERVAL = 1 +STAGE_0_SUBSTAGE_TIMEOUT = 60 STAGE_1_TIMEOUT = 120 STAGE_2_TIMEOUT = 300 WRAP_UP_TIMEOUT = 300 COOLDOWN_TIME = 60 - -DEBUGGER_INTEGRATIONS = {"debugpy"} - # Core integrations are unconditionally loaded CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"} @@ -172,12 +170,27 @@ FRONTEND_INTEGRATIONS = { # add it here. "backup", } -RECORDER_INTEGRATIONS = { - # Setup after frontend - # To record data - "recorder", -} -DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb", "zeroconf") +# 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. +# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts. +# The substages preceding it should also have no timeout, until we ensure that the recorder +# is not accidentally promoted as a dependency of any of the integrations in them. +# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode. +STAGE_0_INTEGRATIONS = ( + # Load logging and http deps as soon as possible + ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None), + # Setup frontend + ("frontend", FRONTEND_INTEGRATIONS, None), + # Setup recorder + ("recorder", {"recorder"}, None), + # Start up debuggers. Start these first in case they want to wait. + ("debugger", {"debugpy"}, STAGE_0_SUBSTAGE_TIMEOUT), + # Zeroconf is used for mdns resolution in aiohttp client helper. + ("zeroconf", {"zeroconf"}, STAGE_0_SUBSTAGE_TIMEOUT), +) + +DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb") +# Stage 1 integrations are not to be preimported in bootstrap. STAGE_1_INTEGRATIONS = { # We need to make sure discovery integrations # update their deps before stage 2 integrations @@ -189,9 +202,8 @@ STAGE_1_INTEGRATIONS = { "mqtt_eventstream", # To provide account link implementations "cloud", - # Ensure supervisor is available - "hassio", } + DEFAULT_INTEGRATIONS = { # These integrations are set up unless recovery mode is activated. # @@ -232,22 +244,12 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = { # These integrations are set up if using the Supervisor "hassio", } + CRITICAL_INTEGRATIONS = { # Recovery mode is activated if these integrations fail to set up "frontend", } -SETUP_ORDER = ( - # Load logging and http deps as soon as possible - ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS), - # Setup frontend - ("frontend", FRONTEND_INTEGRATIONS), - # Setup recorder - ("recorder", RECORDER_INTEGRATIONS), - # Start up debuggers. Start these first in case they want to wait. - ("debugger", DEBUGGER_INTEGRATIONS), -) - # # Storage keys we are likely to load during startup # in order of when we expect to load them. @@ -694,7 +696,6 @@ async def async_mount_local_lib_path(config_dir: str) -> str: return deps_dir -@core.callback def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" # Filter out the repeating and common config section [homeassistant] @@ -890,69 +891,48 @@ async def _async_set_up_integrations( domains_to_setup, integration_cache = await _async_resolve_domains_to_setup( hass, config ) + stage_2_domains = domains_to_setup.copy() # Initialize recorder if "recorder" in domains_to_setup: recorder.async_initialize_recorder(hass) - pre_stage_domains = [ - (name, domains_to_setup & domain_group) for name, domain_group in SETUP_ORDER + stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ + *( + (name, domain_group & domains_to_setup, timeout) + for name, domain_group, timeout in STAGE_0_INTEGRATIONS + ), + ("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT), ] - # calculate what components to setup in what stage - stage_1_domains: set[str] = set() + _LOGGER.info("Setting up stage 0 and 1") + for name, domain_group, timeout in stage_0_and_1_domains: + if not domain_group: + continue - # Find all dependencies of any dependency of any stage 1 integration that - # we plan on loading and promote them to stage 1. This is done only to not - # get misleading log messages - deps_promotion: set[str] = STAGE_1_INTEGRATIONS - while deps_promotion: - old_deps_promotion = deps_promotion - deps_promotion = set() + _LOGGER.info("Setting up %s: %s", name, domain_group) + to_be_loaded = domain_group.copy() + to_be_loaded.update( + dep + for domain in domain_group + if (integration := integration_cache.get(domain)) is not None + for dep in integration.all_dependencies + ) + async_set_domains_to_be_loaded(hass, to_be_loaded) + stage_2_domains -= to_be_loaded - for domain in old_deps_promotion: - if domain not in domains_to_setup or domain in stage_1_domains: - continue - - stage_1_domains.add(domain) - - if (dep_itg := integration_cache.get(domain)) is None: - continue - - deps_promotion.update(dep_itg.all_dependencies) - - stage_2_domains = domains_to_setup - stage_1_domains - - for name, domain_group in pre_stage_domains: - if domain_group: - stage_2_domains -= domain_group - _LOGGER.info("Setting up %s: %s", name, domain_group) - to_be_loaded = domain_group.copy() - to_be_loaded.update( - dep - for domain in domain_group - if (integration := integration_cache.get(domain)) is not None - for dep in integration.all_dependencies - ) - async_set_domains_to_be_loaded(hass, to_be_loaded) + if timeout is None: await _async_setup_multi_components(hass, domain_group, config) - - # Enables after dependencies when setting up stage 1 domains - async_set_domains_to_be_loaded(hass, stage_1_domains) - - # Start setup - if stage_1_domains: - _LOGGER.info("Setting up stage 1: %s", stage_1_domains) - try: - async with hass.timeout.async_timeout( - STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME - ): - await _async_setup_multi_components(hass, stage_1_domains, config) - except TimeoutError: - _LOGGER.warning( - "Setup timed out for stage 1 waiting on %s - moving forward", - hass._active_tasks, # noqa: SLF001 - ) + else: + try: + async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME): + await _async_setup_multi_components(hass, domain_group, config) + except TimeoutError: + _LOGGER.warning( + "Setup timed out for %s waiting on %s - moving forward", + name, + hass._active_tasks, # noqa: SLF001 + ) # Add after dependencies when setting up stage 2 domains async_set_domains_to_be_loaded(hass, stage_2_domains) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 4317df6cf4a..d554ca9449a 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1090,7 +1090,7 @@ async def test_tasks_logged_that_block_stage_1( patch.object(bootstrap, "STAGE_1_TIMEOUT", 0), patch.object(bootstrap, "COOLDOWN_TIME", 0), patch.object( - bootstrap, "STAGE_1_INTEGRATIONS", [*original_stage_1, "normal_integration"] + bootstrap, "STAGE_1_INTEGRATIONS", {*original_stage_1, "normal_integration"} ), ): await bootstrap._async_set_up_integrations(hass, {"normal_integration": {}}) @@ -1373,11 +1373,11 @@ async def test_pre_import_no_requirements(hass: HomeAssistant) -> None: @pytest.mark.timeout(20) -async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: - """Test that the bootstrap does not preload stage 1 integrations. +async def test_bootstrap_does_not_preimport_stage_1_integrations() -> None: + """Test that the bootstrap does not preimport stage 1 integrations. If this test fails it means that stage1 integrations are being - loaded too soon and will not get their requirements updated + imported too soon and will not get their requirements updated before they are loaded at runtime. """ @@ -1391,13 +1391,9 @@ async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: assert process.returncode == 0 decoded_stdout = stdout.decode() - disallowed_integrations = bootstrap.STAGE_1_INTEGRATIONS.copy() - # zeroconf is a top level dep now - disallowed_integrations.remove("zeroconf") - # Ensure no stage1 integrations have been imported # as a side effect of importing the pre-imports - for integration in disallowed_integrations: + for integration in bootstrap.STAGE_1_INTEGRATIONS: assert f"homeassistant.components.{integration}" not in decoded_stdout diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index dfdee65b2b0..d6e730aae5e 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -7,11 +7,8 @@ import pytest from homeassistant.bootstrap import ( CORE_INTEGRATIONS, - DEBUGGER_INTEGRATIONS, DEFAULT_INTEGRATIONS, - FRONTEND_INTEGRATIONS, - LOGGING_AND_HTTP_DEPS_INTEGRATIONS, - RECORDER_INTEGRATIONS, + STAGE_0_INTEGRATIONS, STAGE_1_INTEGRATIONS, ) @@ -21,11 +18,12 @@ from homeassistant.bootstrap import ( "component", sorted( { - *DEBUGGER_INTEGRATIONS, *CORE_INTEGRATIONS, - *LOGGING_AND_HTTP_DEPS_INTEGRATIONS, - *FRONTEND_INTEGRATIONS, - *RECORDER_INTEGRATIONS, + *( + domain + for name, domains, timeout in STAGE_0_INTEGRATIONS + for domain in domains + ), *STAGE_1_INTEGRATIONS, *DEFAULT_INTEGRATIONS, } From 82074a894075bd7795c057274d5a9e9142238295 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Thu, 13 Feb 2025 16:36:07 +0100 Subject: [PATCH 0498/1941] Starlink migration to `StarlinkConfigEntry` (#137896) * refactor: Utilize custom StarlinkConfigEntry * fix: ruff-format * fix: Init tests * fix: StarlinkConfigEntry in coordinator after recent PRs * fix: CONF_IP_ADDRESS constant * fix: After merge clean up * fix: Naming conventions * feat: Add runtime_data into init test * refactor: Remove runtime_data assert in unload entry test --- homeassistant/components/starlink/__init__.py | 26 ++++++++----------- .../components/starlink/binary_sensor.py | 10 +++---- homeassistant/components/starlink/button.py | 11 +++----- .../components/starlink/coordinator.py | 6 +++-- .../components/starlink/device_tracker.py | 11 +++----- .../components/starlink/diagnostics.py | 9 +++---- homeassistant/components/starlink/sensor.py | 11 +++----- homeassistant/components/starlink/switch.py | 11 +++----- homeassistant/components/starlink/time.py | 11 +++----- tests/components/starlink/test_init.py | 4 +-- 10 files changed, 43 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index 4528a35858c..0c512bb21c5 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -19,21 +17,19 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: StarlinkConfigEntry +) -> bool: """Set up Starlink from a config entry.""" - coordinator = StarlinkUpdateCoordinator(hass, entry) + config_entry.runtime_data = StarlinkUpdateCoordinator(hass, config_entry) + await config_entry.runtime_data.async_config_entry_first_refresh() - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: StarlinkConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index f5eaf2baba0..e06e79009c3 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -10,26 +10,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkBinarySensorEntity(coordinator, description) + StarlinkBinarySensorEntity(config_entry.runtime_data, description) for description in BINARY_SENSORS ) diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index dc23e31d8d2..15f35659b49 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -10,26 +10,23 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -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 DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkButtonEntity(coordinator, description) for description in BUTTONS + StarlinkButtonEntity(config_entry.runtime_data, description) + for description in BUTTONS ) diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 4ae771c9582..02d51cd805e 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -34,6 +34,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +type StarlinkConfigEntry = ConfigEntry[StarlinkUpdateCoordinator] + @dataclass class StarlinkData: @@ -51,9 +53,9 @@ class StarlinkData: class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): """Coordinates updates between all Starlink sensors defined in this file.""" - config_entry: ConfigEntry + config_entry: StarlinkConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: StarlinkConfigEntry) -> None: """Initialize an UpdateCoordinator for a group of sensors.""" self.channel_context = ChannelContext(target=config_entry.data[CONF_IP_ADDRESS]) self.history_stats_start = None diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index 53e7ab1cee0..dbe31947b55 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -8,25 +8,22 @@ from homeassistant.components.device_tracker import ( TrackerEntity, TrackerEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_ALTITUDE, DOMAIN -from .coordinator import StarlinkData +from .const import ATTR_ALTITUDE +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkDeviceTrackerEntity(coordinator, description) + StarlinkDeviceTrackerEntity(config_entry.runtime_data, description) for description in DEVICE_TRACKERS ) diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py index c619458b1dd..543fe9d8dde 100644 --- a/homeassistant/components/starlink/diagnostics.py +++ b/homeassistant/components/starlink/diagnostics.py @@ -4,18 +4,15 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry TO_REDACT = {"id", "latitude", "longitude", "altitude"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, config_entry: StarlinkConfigEntry ) -> dict[str, Any]: """Return diagnostics for Starlink config entries.""" - coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data(asdict(coordinator.data), TO_REDACT) + return async_redact_data(asdict(config_entry.runtime_data.data), TO_REDACT) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index dadbf8a061a..d07e8174b27 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -28,21 +27,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import now -from .const import DOMAIN -from .coordinator import StarlinkData +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkSensorEntity(coordinator, description) for description in SENSORS + StarlinkSensorEntity(config_entry.runtime_data, description) + for description in SENSORS ) diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index 51603850690..c6dc237643e 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -11,25 +11,22 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -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 StarlinkData, StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkSwitchEntity(coordinator, description) for description in SWITCHES + StarlinkSwitchEntity(config_entry.runtime_data, description) + for description in SWITCHES ) diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py index 3540123e1eb..9f564333218 100644 --- a/homeassistant/components/starlink/time.py +++ b/homeassistant/components/starlink/time.py @@ -8,26 +8,23 @@ from datetime import UTC, datetime, time, tzinfo import math from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData, StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all time entities for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkTimeEntity(coordinator, description) for description in TIMES + StarlinkTimeEntity(config_entry.runtime_data, description) + for description in TIMES ) diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py index 7e04c21562a..f15a80771cf 100644 --- a/tests/components/starlink/test_init.py +++ b/tests/components/starlink/test_init.py @@ -33,8 +33,9 @@ async def test_successful_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.runtime_data + assert entry.runtime_data.data assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] async def test_unload_entry(hass: HomeAssistant) -> None: @@ -59,4 +60,3 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert entry.entry_id not in hass.data[DOMAIN] From a03c5880021ad510b262d55650332c34721b7822 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:54:29 +0100 Subject: [PATCH 0499/1941] Mark entity-device-class as done for motionmount integration (#138459) All entities where a device class is available have a device class --- homeassistant/components/motionmount/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index f8fee8739e9..2648355c3af 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -57,7 +57,7 @@ rules: status: exempt comment: Single device per config entry entity-category: todo - entity-device-class: todo + entity-device-class: done entity-disabled-by-default: todo entity-translations: done exception-translations: done From d4c5479e503ae17d71e9edbe7e09b2fe57271ef1 Mon Sep 17 00:00:00 2001 From: Maghiel Dijksman Date: Thu, 13 Feb 2025 17:14:56 +0100 Subject: [PATCH 0500/1941] Fix Tuya unsupported cameras (#136960) --- homeassistant/components/tuya/camera.py | 3 ++ homeassistant/components/tuya/light.py | 14 ++++++ homeassistant/components/tuya/number.py | 9 ++++ homeassistant/components/tuya/select.py | 34 ++++++++++++++ homeassistant/components/tuya/sensor.py | 23 ++++++++++ homeassistant/components/tuya/siren.py | 7 +++ homeassistant/components/tuya/switch.py | 59 +++++++++++++++++++++++++ 7 files changed, 149 insertions(+) diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index b07b9e9959e..c04a8a043dc 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -20,6 +20,9 @@ CAMERAS: tuple[str, ...] = ( # Smart Camera (including doorbells) # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sp", + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj", ) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 7f4a964f47e..40d0fd73f0e 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -261,6 +261,20 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + TuyaLightEntityDescription( + key=DPCode.FLOODLIGHT_SWITCH, + brightness=DPCode.FLOODLIGHT_LIGHTNESS, + name="Floodlight", + ), + TuyaLightEntityDescription( + key=DPCode.BASIC_INDICATOR, + name="Indicator light", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Gardening system # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 "sz": ( diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 4e98cf34d4d..ce1f434bcdd 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -174,6 +174,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + NumberEntityDescription( + key=DPCode.BASIC_DEVICE_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), # Dimmer Switch # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o "tgkg": ( diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 766cdd295f1..0ae49cd127e 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -128,6 +128,40 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="motion_sensitivity", ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + SelectEntityDescription( + key=DPCode.IPC_WORK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="ipc_work_mode", + ), + SelectEntityDescription( + key=DPCode.DECIBEL_SENSITIVITY, + entity_category=EntityCategory.CONFIG, + translation_key="decibel_sensitivity", + ), + SelectEntityDescription( + key=DPCode.RECORD_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="record_mode", + ), + SelectEntityDescription( + key=DPCode.BASIC_NIGHTVISION, + entity_category=EntityCategory.CONFIG, + translation_key="basic_nightvision", + ), + SelectEntityDescription( + key=DPCode.BASIC_ANTI_FLICKER, + entity_category=EntityCategory.CONFIG, + translation_key="basic_anti_flicker", + ), + SelectEntityDescription( + key=DPCode.MOTION_SENSITIVITY, + entity_category=EntityCategory.CONFIG, + translation_key="motion_sensitivity", + ), + ), # IoT Switch? # Note: Undocumented "tdq": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index cb7602e24fe..76825e9c814 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -632,6 +632,29 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + TuyaSensorEntityDescription( + key=DPCode.SENSOR_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.SENSOR_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WIRELESS_ELECTRICITY, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Fingerbot "szjqr": BATTERY_SENSORS, # Solar Light diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 310385df93d..9c60f7bcaac 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -44,6 +44,13 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { key=DPCode.SIREN_SWITCH, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + SirenEntityDescription( + key=DPCode.SIREN_SWITCH, + ), + ), # CO2 Detector # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy "co2bj": ( diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index d0192b41ee6..519a9e83606 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -509,6 +509,65 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + SwitchEntityDescription( + key=DPCode.WIRELESS_BATTERYLOCK, + translation_key="battery_lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.CRY_DETECTION_SWITCH, + translation_key="cry_detection", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.DECIBEL_SWITCH, + translation_key="sound_detection", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.RECORD_SWITCH, + translation_key="video_recording", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_RECORD, + translation_key="motion_recording", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_PRIVATE, + translation_key="privacy_mode", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_FLIP, + translation_key="flip", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_OSD, + translation_key="time_watermark", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_WDR, + translation_key="wide_dynamic_range", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_TRACKING, + translation_key="motion_tracking", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_SWITCH, + translation_key="motion_alarm", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Gardening system # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 "sz": ( From bf27eeb861e33ae21a6920de036e79542b31ecbc Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Thu, 13 Feb 2025 13:46:50 -0500 Subject: [PATCH 0501/1941] Add sonos_websocket to Sonos loggers (#138470) --- homeassistant/components/sonos/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index bfdf0da9dbb..bb3d99c4c93 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", - "loggers": ["soco"], + "loggers": ["soco", "sonos_websocket"], "requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"], "ssdp": [ { From 2ea648f8aee3a264700b8c09b32a0d43fa8f6218 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Feb 2025 19:55:04 +0100 Subject: [PATCH 0502/1941] Replace `config.yaml` with correct `configuration.yaml` in folder_watcher (#138434) --- homeassistant/components/folder_watcher/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/folder_watcher/strings.json b/homeassistant/components/folder_watcher/strings.json index da1e3c1962a..5b1f72bf254 100644 --- a/homeassistant/components/folder_watcher/strings.json +++ b/homeassistant/components/folder_watcher/strings.json @@ -36,11 +36,11 @@ "issues": { "import_failed_not_allowed_path": { "title": "The Folder Watcher YAML configuration could not be imported", - "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in config.yaml and restart Home Assistant to import it and fix this issue." + "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in configuration.yaml and restart Home Assistant to import it and fix this issue." }, "setup_not_allowed_path": { "title": "The Folder Watcher configuration for {path} could not start", - "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue." + "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in configuration.yaml and restart Home Assistant to fix this issue." } }, "entity": { From ab2e075b410cbeeaacd6c3e0241c340f0af004c6 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 13 Feb 2025 11:35:58 -0800 Subject: [PATCH 0503/1941] Bump opower to 0.9.0 (#138433) Co-authored-by: Shay Levy --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index d168cba5752..2da4511c0aa 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.9"] + "requirements": ["opower==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 92d1a2a62ab..ba5aeee25df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1595,7 +1595,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.9 +opower==0.9.0 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e24129a0fe..3ea50ac1d32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1331,7 +1331,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.9 +opower==0.9.0 # homeassistant.components.oralb oralb-ble==0.17.6 From bbbad90ca29b9e9a356fe166fcf586ede8cf8973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Feb 2025 14:17:06 -0600 Subject: [PATCH 0504/1941] Fix race configuring zeroconf (#138425) --- homeassistant/bootstrap.py | 4 ++ homeassistant/components/network/__init__.py | 19 ++++++++- homeassistant/components/network/const.py | 2 - homeassistant/components/network/network.py | 13 +++++- homeassistant/components/zeroconf/__init__.py | 24 ++++++----- tests/components/zeroconf/test_init.py | 40 ++++++++++++++++--- tests/conftest.py | 34 +++++++++++----- 7 files changed, 106 insertions(+), 30 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7fd73af0053..7c5cb7dce4c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -150,6 +150,10 @@ LOGGING_AND_HTTP_DEPS_INTEGRATIONS = { "isal", # Set log levels "logger", + # Ensure network config is available + # before hassio or any other integration is + # loaded that might create an aiohttp client session + "network", # Error logging "system_log", "sentry", diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 10046f75127..200cce86997 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -20,7 +20,7 @@ from .const import ( PUBLIC_TARGET_IP, ) from .models import Adapter -from .network import Network, async_get_network +from .network import Network, async_get_loaded_network, async_get_network _LOGGER = logging.getLogger(__name__) @@ -34,6 +34,12 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: return network.adapters +@callback +def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]: + """Get the network adapter configuration.""" + return async_get_loaded_network(hass).adapters + + @bind_hass async def async_get_source_ip( hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED @@ -74,7 +80,14 @@ async def async_get_enabled_source_ips( hass: HomeAssistant, ) -> list[IPv4Address | IPv6Address]: """Build the list of enabled source ips.""" - adapters = await async_get_adapters(hass) + return async_get_enabled_source_ips_from_adapters(await async_get_adapters(hass)) + + +@callback +def async_get_enabled_source_ips_from_adapters( + adapters: list[Adapter], +) -> list[IPv4Address | IPv6Address]: + """Build the list of enabled source ips.""" sources: list[IPv4Address | IPv6Address] = [] for adapter in adapters: if not adapter["enabled"]: @@ -151,5 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_websocket_commands, ) + await async_get_network(hass) + async_register_websocket_commands(hass) return True diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 120ae9dfd7c..d8c8858be72 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -12,8 +12,6 @@ DOMAIN: Final = "network" STORAGE_KEY: Final = "core.network" STORAGE_VERSION: Final = 1 -DATA_NETWORK: Final = "network" - ATTR_ADAPTERS: Final = "adapters" ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters" DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py index 4158307bb1a..db25bedcaea 100644 --- a/homeassistant/components/network/network.py +++ b/homeassistant/components/network/network.py @@ -9,11 +9,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util.async_ import create_eager_task +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_CONFIGURED_ADAPTERS, - DATA_NETWORK, DEFAULT_CONFIGURED_ADAPTERS, + DOMAIN, STORAGE_KEY, STORAGE_VERSION, ) @@ -22,8 +23,16 @@ from .util import async_load_adapters, enable_adapters, enable_auto_detected_ada _LOGGER = logging.getLogger(__name__) +DATA_NETWORK: HassKey[Network] = HassKey(DOMAIN) -@singleton(DATA_NETWORK) + +@callback +def async_get_loaded_network(hass: HomeAssistant) -> Network: + """Get network singleton.""" + return hass.data[DATA_NETWORK] + + +@singleton(DOMAIN) async def async_get_network(hass: HomeAssistant) -> Network: """Get network singleton.""" network = Network(hass) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b748006336c..e80b6b8cfdb 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -141,13 +141,13 @@ def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf: return _async_get_instance(hass) -def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf: +def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf: if DOMAIN in hass.data: return cast(HaAsyncZeroconf, hass.data[DOMAIN]) logging.getLogger("zeroconf").setLevel(logging.NOTSET) - zeroconf = HaZeroconf(**zcargs) + zeroconf = HaZeroconf(**_async_get_zc_args(hass)) aio_zc = HaAsyncZeroconf(zc=zeroconf) install_multiple_zeroconf_catcher(zeroconf) @@ -175,12 +175,10 @@ def _async_zc_has_functional_dual_stack() -> bool: ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Zeroconf and make Home Assistant discoverable.""" - zc_args: dict = {"ip_version": IPVersion.V4Only} - - adapters = await network.async_get_adapters(hass) - +def _async_get_zc_args(hass: HomeAssistant) -> dict[str, Any]: + """Get zeroconf arguments from config.""" + zc_args: dict[str, Any] = {"ip_version": IPVersion.V4Only} + adapters = network.async_get_loaded_adapters(hass) ipv6 = False if _async_zc_has_functional_dual_stack(): if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): @@ -195,7 +193,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: zc_args["interfaces"] = [ str(source_ip) - for source_ip in await network.async_get_enabled_source_ips(hass) + for source_ip in network.async_get_enabled_source_ips_from_adapters( + adapters + ) if not source_ip.is_loopback and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) and not ( @@ -207,8 +207,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: and zc_args["ip_version"] == IPVersion.V6Only ) ] + return zc_args - aio_zc = _async_get_instance(hass, **zc_args) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Zeroconf and make Home Assistant discoverable.""" + aio_zc = _async_get_instance(hass) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 3586f54a59a..56262600511 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1090,7 +1090,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, ), patch( @@ -1178,7 +1178,7 @@ async def test_async_detect_interfaces_setting_empty_route_linux( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( @@ -1212,7 +1212,7 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( @@ -1263,7 +1263,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( @@ -1292,7 +1292,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( @@ -1310,6 +1310,36 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( ) +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_async_detect_interfaces_explicitly_before_setup( + hass: HomeAssistant, +) -> None: + """Test interfaces are explicitly set with IPv6 before setup is called.""" + with ( + patch("homeassistant.components.zeroconf.sys.platform", "linux"), + patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, + patch.object(hass.config_entries.flow, "async_init"), + patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch( + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", + return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, + ), + patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_service_info_mock, + ), + ): + # Call before async_setup has been called + await zeroconf.async_get_async_instance(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert mock_zc.mock_calls[0] == call( + interfaces=["192.168.1.5", "fe80::dead:beef:dead:beef%3"], + ip_version=IPVersion.All, + ) + + async def test_no_name(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> None: """Test fallback to Home for mDNS announcement if the name is missing.""" hass.config.location_name = "" diff --git a/tests/conftest.py b/tests/conftest.py index de627925941..7905439028c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1180,15 +1180,31 @@ async def mqtt_mock_entry( @pytest.fixture(autouse=True, scope="session") def mock_network() -> Generator[None]: """Mock network.""" - with patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[ - Mock( - nice_name="eth0", - ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], - index=0, - ) - ], + with ( + patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[ + Mock( + nice_name="eth0", + ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], + index=0, + ) + ], + ), + patch( + "homeassistant.components.network.async_get_loaded_adapters", + return_value=[ + { + "auto": True, + "default": True, + "enabled": True, + "index": 0, + "ipv4": [{"address": "10.10.10.10", "network_prefix": 24}], + "ipv6": [], + "name": "eth0", + } + ], + ), ): yield From d6b7762dd65c7814f7b816a28f589bb0a3899233 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 13 Feb 2025 22:13:19 +0100 Subject: [PATCH 0505/1941] Upgrade paho-mqtt API to v2 (#137613) * Upgrade paho-mqtt API to v2 * Refactor on_connect callback * Add tests * Fix Tasmota tests --- homeassistant/components/mqtt/async_client.py | 15 +++- homeassistant/components/mqtt/client.py | 86 ++++++++++++------- homeassistant/components/mqtt/config_flow.py | 12 +-- tests/common.py | 19 ++++ tests/components/mqtt/test_client.py | 83 ++++++++++-------- tests/components/mqtt/test_config_flow.py | 8 +- tests/components/mqtt/test_init.py | 10 ++- tests/components/tasmota/test_common.py | 14 +-- tests/conftest.py | 17 ++-- 9 files changed, 171 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py index 5f90136df44..0467eb3a289 100644 --- a/homeassistant/components/mqtt/async_client.py +++ b/homeassistant/components/mqtt/async_client.py @@ -6,7 +6,14 @@ from functools import lru_cache from types import TracebackType from typing import Self -from paho.mqtt.client import Client as MQTTClient +from paho.mqtt.client import ( + CallbackOnConnect_v2, + CallbackOnDisconnect_v2, + CallbackOnPublish_v2, + CallbackOnSubscribe_v2, + CallbackOnUnsubscribe_v2, + Client as MQTTClient, +) _MQTT_LOCK_COUNT = 7 @@ -44,6 +51,12 @@ class AsyncMQTTClient(MQTTClient): that is not needed since we are running in an async event loop. """ + on_connect: CallbackOnConnect_v2 + on_disconnect: CallbackOnDisconnect_v2 + on_publish: CallbackOnPublish_v2 + on_subscribe: CallbackOnSubscribe_v2 + on_unsubscribe: CallbackOnUnsubscribe_v2 + def setup(self) -> None: """Set up the client. diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 3aca566dbfc..af62851e15b 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -311,8 +311,8 @@ class MqttClientSetup: client_id = None transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( - mqtt.CallbackAPIVersion.VERSION1, - client_id, + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=client_id, protocol=proto, transport=transport, # type: ignore[arg-type] reconnect_on_failure=False, @@ -476,9 +476,9 @@ class MQTT: mqttc.on_connect = self._async_mqtt_on_connect mqttc.on_disconnect = self._async_mqtt_on_disconnect mqttc.on_message = self._async_mqtt_on_message - mqttc.on_publish = self._async_mqtt_on_callback - mqttc.on_subscribe = self._async_mqtt_on_callback - mqttc.on_unsubscribe = self._async_mqtt_on_callback + mqttc.on_publish = self._async_mqtt_on_publish + mqttc.on_subscribe = self._async_mqtt_on_subscribe_unsubscribe + mqttc.on_unsubscribe = self._async_mqtt_on_subscribe_unsubscribe # suppress exceptions at callback mqttc.suppress_exceptions = True @@ -498,7 +498,7 @@ class MQTT: def _async_reader_callback(self, client: mqtt.Client) -> None: """Handle reading data from the socket.""" if (status := client.loop_read(MAX_PACKETS_TO_READ)) != 0: - self._async_on_disconnect(status) + self._async_handle_callback_exception(status) @callback def _async_start_misc_periodic(self) -> None: @@ -593,7 +593,7 @@ class MQTT: def _async_writer_callback(self, client: mqtt.Client) -> None: """Handle writing data to the socket.""" if (status := client.loop_write()) != 0: - self._async_on_disconnect(status) + self._async_handle_callback_exception(status) def _on_socket_register_write( self, client: mqtt.Client, userdata: Any, sock: SocketType @@ -983,9 +983,9 @@ class MQTT: self, _mqttc: mqtt.Client, _userdata: None, - _flags: dict[str, int], - result_code: int, - properties: mqtt.Properties | None = None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, ) -> None: """On connect callback. @@ -993,19 +993,20 @@ class MQTT: message. """ # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt - if result_code != mqtt.CONNACK_ACCEPTED: - if result_code in ( - mqtt.CONNACK_REFUSED_BAD_USERNAME_PASSWORD, - mqtt.CONNACK_REFUSED_NOT_AUTHORIZED, - ): + if reason_code.is_failure: + # 24: Continue authentication + # 25: Re-authenticate + # 134: Bad user name or password + # 135: Not authorized + # 140: Bad authentication method + if reason_code.value in (24, 25, 134, 135, 140): self._should_reconnect = False self.hass.async_create_task(self.async_disconnect()) self.config_entry.async_start_reauth(self.hass) _LOGGER.error( "Unable to connect to the MQTT broker: %s", - mqtt.connack_string(result_code), + reason_code.getName(), # type: ignore[no-untyped-call] ) self._async_connection_result(False) return @@ -1016,7 +1017,7 @@ class MQTT: "Connected to MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), - result_code, + reason_code, ) birth: dict[str, Any] @@ -1153,18 +1154,32 @@ class MQTT: self._mqtt_data.state_write_requests.process_write_state_requests(msg) @callback - def _async_mqtt_on_callback( + def _async_mqtt_on_publish( self, _mqttc: mqtt.Client, _userdata: None, mid: int, - _granted_qos_reason: tuple[int, ...] | mqtt.ReasonCodes | None = None, - _properties_reason: mqtt.ReasonCodes | None = None, + _reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None, ) -> None: + """Publish callback.""" + self._async_mqtt_on_callback(mid) + + @callback + def _async_mqtt_on_subscribe_unsubscribe( + self, + _mqttc: mqtt.Client, + _userdata: None, + mid: int, + _reason_code: list[mqtt.ReasonCode], + _properties: mqtt.Properties | None, + ) -> None: + """Subscribe / Unsubscribe callback.""" + self._async_mqtt_on_callback(mid) + + @callback + def _async_mqtt_on_callback(self, mid: int) -> None: """Publish / Subscribe / Unsubscribe callback.""" - # The callback signature for on_unsubscribe is different from on_subscribe - # see https://github.com/eclipse/paho.mqtt.python/issues/687 - # properties and reason codes are not used in Home Assistant future = self._async_get_mid_future(mid) if future.done() and (future.cancelled() or future.exception()): # Timed out or cancelled @@ -1180,19 +1195,28 @@ class MQTT: self._pending_operations[mid] = future return future + @callback + def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None: + """Handle a callback exception.""" + # 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 + + _LOGGER.warning( + "Error returned from MQTT server: %s", + mqtt.error_string(status), + ) + @callback def _async_mqtt_on_disconnect( self, _mqttc: mqtt.Client, _userdata: None, - result_code: int, + _disconnect_flags: mqtt.DisconnectFlags, + reason_code: mqtt.ReasonCode, properties: mqtt.Properties | None = None, ) -> None: """Disconnected callback.""" - self._async_on_disconnect(result_code) - - @callback - def _async_on_disconnect(self, result_code: int) -> None: if not self.connected: # This function is re-entrant and may be called multiple times # when there is a broken pipe error. @@ -1203,11 +1227,11 @@ class MQTT: self.connected = False async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False) _LOGGER.log( - logging.INFO if result_code == 0 else logging.DEBUG, + logging.INFO if reason_code == 0 else logging.DEBUG, "Disconnected from MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), - result_code, + reason_code, ) @callback diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a9d417fc783..22568b0f2b8 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1023,14 +1023,14 @@ def try_connection( result: queue.Queue[bool] = queue.Queue(maxsize=1) def on_connect( - client_: mqtt.Client, - userdata: None, - flags: dict[str, Any], - result_code: int, - properties: mqtt.Properties | None = None, + _mqttc: mqtt.Client, + _userdata: None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, ) -> None: """Handle connection result.""" - result.put(result_code == mqtt.CONNACK_ACCEPTED) + result.put(not reason_code.is_failure) client.on_connect = on_connect diff --git a/tests/common.py b/tests/common.py index 65e84bc6f00..4d767f0611c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -410,6 +410,25 @@ def async_mock_intent(hass: HomeAssistant, intent_typ: str) -> list[intent.Inten return intents +class MockMqttReasonCode: + """Class to fake a MQTT ReasonCode.""" + + value: int + is_failure: bool + + def __init__( + self, value: int = 0, is_failure: bool = False, name: str = "Success" + ) -> None: + """Initialize the mock reason code.""" + self.value = value + self.is_failure = is_failure + self._name = name + + def getName(self) -> str: + """Return the name of the reason code.""" + return self._name + + @callback def async_fire_mqtt_message( hass: HomeAssistant, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 2faa9310548..b526d70490b 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -32,6 +32,7 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, + MockMqttReasonCode, async_fire_mqtt_message, async_fire_time_changed, ) @@ -94,7 +95,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: mqtt_client.connect = MagicMock( return_value=0, side_effect=lambda *args, **kwargs: hass.loop.call_soon_threadsafe( - mqtt_client.on_connect, mqtt_client, None, 0, 0, 0 + mqtt_client.on_connect, mqtt_client, None, 0, MockMqttReasonCode() ), ) mqtt_client.publish = MagicMock(return_value=FakeInfo()) @@ -119,7 +120,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: ) await asyncio.sleep(0) # Simulate late ACK callback from client with mid 100 - mqtt_client.on_publish(0, 0, 100) + mqtt_client.on_publish(0, 0, 100, MockMqttReasonCode(), None) # disconnect the MQTT client await hass.async_stop() await hass.async_block_till_done() @@ -778,10 +779,10 @@ async def test_replaying_payload_same_topic( calls_a = [] calls_b = [] mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() mqtt_client_mock.subscribe.assert_called() # Simulate a (retained) message played back after reconnecting @@ -908,10 +909,10 @@ async def test_replaying_payload_wildcard_topic( calls_a = [] calls_b = [] mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() mqtt_client_mock.subscribe.assert_called() @@ -1045,7 +1046,7 @@ async def test_restore_subscriptions_on_reconnect( assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) # Test to subscribe orther topic while the client is not connected await mqtt.async_subscribe(hass, "test/other", record_calls) @@ -1053,7 +1054,7 @@ async def test_restore_subscriptions_on_reconnect( assert ("test/other", 0) not in help_all_subscribe_calls(mqtt_client_mock) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() # Assert all subscriptions are performed at the broker assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) @@ -1089,10 +1090,10 @@ async def test_restore_all_active_subscriptions_on_reconnect( unsub() assert mqtt_client_mock.unsubscribe.call_count == 0 - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) # wait for cooldown await mock_debouncer.wait() @@ -1160,27 +1161,37 @@ async def test_logs_error_if_no_connect_broker( ) -> None: """Test for setup failure if connection to broker is missing.""" mqtt_client_mock = setup_with_birth_msg_client_mock - # test with rc = 3 -> broker unavailable - mqtt_client_mock.on_disconnect(Mock(), None, 0) - mqtt_client_mock.on_connect(Mock(), None, None, 3) - await hass.async_block_till_done() - assert ( - "Unable to connect to the MQTT broker: Connection Refused: broker unavailable." - in caplog.text + # test with reason code = 136 -> server unavailable + mqtt_client_mock.on_disconnect(Mock(), None, None, MockMqttReasonCode()) + mqtt_client_mock.on_connect( + Mock(), + None, + None, + MockMqttReasonCode(value=136, is_failure=True, name="Server unavailable"), ) + await hass.async_block_till_done() + assert "Unable to connect to the MQTT broker: Server unavailable" in caplog.text -@pytest.mark.parametrize("return_code", [4, 5]) +@pytest.mark.parametrize( + "reason_code", + [ + MockMqttReasonCode( + value=134, is_failure=True, name="Bad user name or password" + ), + MockMqttReasonCode(value=135, is_failure=True, name="Not authorized"), + ], +) async def test_triggers_reauth_flow_if_auth_fails( hass: HomeAssistant, setup_with_birth_msg_client_mock: MqttMockPahoClient, - return_code: int, + reason_code: MockMqttReasonCode, ) -> None: """Test re-auth is triggered if authentication is failing.""" mqtt_client_mock = setup_with_birth_msg_client_mock # test with rc = 4 -> CONNACK_REFUSED_NOT_AUTHORIZED and 5 -> CONNACK_REFUSED_BAD_USERNAME_PASSWORD - mqtt_client_mock.on_disconnect(Mock(), None, 0) - mqtt_client_mock.on_connect(Mock(), None, None, return_code) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode(), None) + mqtt_client_mock.on_connect(Mock(), None, None, reason_code) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -1197,7 +1208,9 @@ async def test_handle_mqtt_on_callback( mqtt_client_mock = setup_with_birth_msg_client_mock with patch.object(mqtt_client_mock, "get_mid", return_value=100): # Simulate an ACK for mid == 100, this will call mqtt_mock._async_get_mid_future(mid) - mqtt_client_mock.on_publish(mqtt_client_mock, None, 100) + mqtt_client_mock.on_publish( + mqtt_client_mock, None, 100, MockMqttReasonCode(), None + ) await hass.async_block_till_done() # Make sure the ACK has been received await hass.async_block_till_done() @@ -1219,7 +1232,7 @@ async def test_handle_mqtt_on_callback_after_cancellation( # Simulate the mid future getting a cancellation mqtt_mock()._async_get_mid_future(101).cancel() # Simulate an ACK for mid == 101, being received after the cancellation - mqtt_client_mock.on_publish(mqtt_client_mock, None, 101) + mqtt_client_mock.on_publish(mqtt_client_mock, None, 101, MockMqttReasonCode(), None) await hass.async_block_till_done() assert "No ACK from MQTT server" not in caplog.text assert "InvalidStateError" not in caplog.text @@ -1236,7 +1249,7 @@ async def test_handle_mqtt_on_callback_after_timeout( # Simulate the mid future getting a timeout mqtt_mock()._async_get_mid_future(101).set_exception(asyncio.TimeoutError) # Simulate an ACK for mid == 101, being received after the timeout - mqtt_client_mock.on_publish(mqtt_client_mock, None, 101) + mqtt_client_mock.on_publish(mqtt_client_mock, None, 101, MockMqttReasonCode(), None) await hass.async_block_till_done() assert "No ACK from MQTT server" not in caplog.text assert "InvalidStateError" not in caplog.text @@ -1388,7 +1401,7 @@ async def test_handle_mqtt_timeout_on_callback( mock_client.connect = MagicMock( return_value=0, side_effect=lambda *args, **kwargs: hass.loop.call_soon_threadsafe( - mock_client.on_connect, mock_client, None, 0, 0, 0 + mock_client.on_connect, mock_client, None, 0, MockMqttReasonCode() ), ) @@ -1777,12 +1790,12 @@ async def test_mqtt_subscribes_topics_on_connect( await mqtt.async_subscribe(hass, "still/pending", record_calls, 1) await mock_debouncer.wait() - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) @@ -1837,12 +1850,12 @@ async def test_mqtt_subscribes_wildcard_topics_in_correct_order( # Assert the initial wildcard topic subscription order _assert_subscription_order() - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() # Assert the wildcard topic subscription order after a reconnect @@ -1868,12 +1881,12 @@ async def test_mqtt_discovery_not_subscribes_when_disabled( assert (f"homeassistant/{component}/+/config", 0) not in subscribe_calls assert (f"homeassistant/{component}/+/+/config", 0) not in subscribe_calls - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) @@ -1968,7 +1981,7 @@ async def test_auto_reconnect( mqtt_client_mock.reconnect.reset_mock() mqtt_client_mock.disconnect() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() mqtt_client_mock.reconnect.side_effect = exception("foo") @@ -1989,7 +2002,7 @@ async def test_auto_reconnect( hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) mqtt_client_mock.disconnect() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() async_fire_time_changed( @@ -2031,7 +2044,7 @@ async def test_server_sock_connect_and_disconnect( mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_CONN_LOST mqtt_client_mock.on_socket_unregister_write(mqtt_client_mock, None, client) mqtt_client_mock.on_socket_close(mqtt_client_mock, None, client) - mqtt_client_mock.on_disconnect(mqtt_client_mock, None, client) + mqtt_client_mock.on_disconnect(mqtt_client_mock, None, None, MockMqttReasonCode()) await hass.async_block_till_done() mock_debouncer.clear() unsub() @@ -2169,4 +2182,4 @@ async def test_loop_write_failure( # Final for the disconnect callback await hass.async_block_till_done() - assert "Disconnected from MQTT server test-broker:1883" in caplog.text + assert "Error returned from MQTT server: The connection was lost." in caplog.text diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 1a4ca4bcf19..de70fd32763 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockMqttReasonCode from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ADD_ON_DISCOVERY_INFO = { @@ -143,16 +143,16 @@ def mock_try_connection_success() -> Generator[MqttMockPahoClient]: def loop_start(): """Simulate connect on loop start.""" - mock_client().on_connect(mock_client, None, None, 0) + mock_client().on_connect(mock_client, None, None, MockMqttReasonCode(), None) def _subscribe(topic, qos=0): mid = get_mid() - mock_client().on_subscribe(mock_client, 0, mid) + mock_client().on_subscribe(mock_client, 0, mid, [MockMqttReasonCode()], None) return (0, mid) def _unsubscribe(topic): mid = get_mid() - mock_client().on_unsubscribe(mock_client, 0, mid) + mock_client().on_unsubscribe(mock_client, 0, mid, [MockMqttReasonCode()], None) return (0, mid) with patch( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b2dd3d048ec..af9975de1ea 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -45,6 +45,7 @@ from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockMqttReasonCode, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -1572,6 +1573,7 @@ async def test_subscribe_connection_status( setup_with_birth_msg_client_mock: MqttMockPahoClient, ) -> None: """Test connextion status subscription.""" + mqtt_client_mock = setup_with_birth_msg_client_mock mqtt_connected_calls_callback: list[bool] = [] mqtt_connected_calls_async: list[bool] = [] @@ -1589,7 +1591,7 @@ async def test_subscribe_connection_status( assert mqtt.is_connected(hass) is True # Mock disconnect status - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() assert mqtt.is_connected(hass) is False @@ -1603,12 +1605,12 @@ async def test_subscribe_connection_status( # Mock connect status mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, 0, 0) + mqtt_client_mock.on_connect(None, None, 0, MockMqttReasonCode()) await mock_debouncer.wait() assert mqtt.is_connected(hass) is True # Mock disconnect status - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() assert mqtt.is_connected(hass) is False @@ -1618,7 +1620,7 @@ async def test_subscribe_connection_status( # Mock connect status mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, 0, 0) + mqtt_client_mock.on_connect(None, None, 0, MockMqttReasonCode()) await mock_debouncer.wait() assert mqtt.is_connected(hass) is True diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 4d2c821fff4..674ae316ecc 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -27,7 +27,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import async_fire_mqtt_message +from tests.common import MockMqttReasonCode, async_fire_mqtt_message from tests.typing import MqttMockHAClient, MqttMockPahoClient, WebSocketGenerator DEFAULT_CONFIG = { @@ -165,7 +165,7 @@ async def help_test_availability_when_connection_lost( # Disconnected from MQTT server -> state changed to unavailable mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -174,7 +174,7 @@ async def help_test_availability_when_connection_lost( # Reconnected to MQTT server -> state still unavailable mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -226,7 +226,7 @@ async def help_test_deep_sleep_availability_when_connection_lost( # Disconnected from MQTT server -> state changed to unavailable mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -235,7 +235,7 @@ async def help_test_deep_sleep_availability_when_connection_lost( # Reconnected to MQTT server -> state no longer unavailable mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -478,7 +478,7 @@ async def help_test_availability_poll_state( # Disconnected from MQTT server mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -486,7 +486,7 @@ async def help_test_availability_poll_state( # Reconnected to MQTT server mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index 7905439028c..7d9fa7eda2e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,6 +118,7 @@ from .common import ( # noqa: E402, isort:skip CLIENT_ID, INSTANCES, MockConfigEntry, + MockMqttReasonCode, MockUser, async_fire_mqtt_message, async_test_home_assistant, @@ -969,17 +970,23 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: def _async_fire_mqtt_message(topic, payload, qos, retain): async_fire_mqtt_message(hass, topic, payload or b"", qos, retain) mid = get_mid() - hass.loop.call_soon(mock_client.on_publish, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_publish, Mock(), 0, mid, MockMqttReasonCode(), None + ) return FakeInfo(mid) def _subscribe(topic, qos=0): mid = get_mid() - hass.loop.call_soon(mock_client.on_subscribe, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_subscribe, Mock(), 0, mid, [MockMqttReasonCode()], None + ) return (0, mid) def _unsubscribe(topic): mid = get_mid() - hass.loop.call_soon(mock_client.on_unsubscribe, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_unsubscribe, Mock(), 0, mid, [MockMqttReasonCode()], None + ) return (0, mid) def _connect(*args, **kwargs): @@ -988,7 +995,7 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: # the behavior. mock_client.reconnect() hass.loop.call_soon_threadsafe( - mock_client.on_connect, mock_client, None, 0, 0, 0 + mock_client.on_connect, mock_client, None, 0, MockMqttReasonCode() ) mock_client.on_socket_open( mock_client, None, Mock(fileno=Mock(return_value=-1)) @@ -1065,7 +1072,7 @@ async def _mqtt_mock_entry( # connected set to True to get a more realistic behavior when subscribing mock_mqtt_instance.connected = True - mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, 0, 0) + mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, MockMqttReasonCode()) async_dispatcher_send(hass, mqtt.MQTT_CONNECTION_STATE, True) await hass.async_block_till_done() From 621bcccef7c60428de7f7f1d5b3bca07317ce0ec Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 13 Feb 2025 22:51:14 +0100 Subject: [PATCH 0506/1941] Remove scan interval option from Synology DSM (#138490) remove scan interval option --- homeassistant/components/synology_dsm/__init__.py | 6 +++++- homeassistant/components/synology_dsm/config_flow.py | 9 --------- homeassistant/components/synology_dsm/const.py | 1 - homeassistant/components/synology_dsm/coordinator.py | 11 +---------- homeassistant/components/synology_dsm/strings.json | 2 -- tests/components/synology_dsm/test_config_flow.py | 3 --- tests/components/synology_dsm/test_init.py | 3 +++ 7 files changed, 9 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 0b8b8731f8f..97095f5d299 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -10,7 +10,7 @@ from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL +from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -68,6 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None}, ) + if CONF_SCAN_INTERVAL in entry.options: + current_options = {**entry.options} + current_options.pop(CONF_SCAN_INTERVAL) + hass.config_entries.async_update_entry(entry, options=current_options) # Continue setup api = SynoApi(hass, entry) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index b4453366718..58784862305 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -33,14 +33,12 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, @@ -67,7 +65,6 @@ from .const import ( DEFAULT_BACKUP_PATH, DEFAULT_PORT, DEFAULT_PORT_SSL, - DEFAULT_SCAN_INTERVAL, DEFAULT_SNAPSHOT_QUALITY, DEFAULT_TIMEOUT, DEFAULT_USE_SSL, @@ -458,12 +455,6 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): data_schema = vol.Schema( { - vol.Required( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): cv.positive_int, vol.Required( CONF_SNAPSHOT_QUALITY, default=self.config_entry.options.get( diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index dbee85b99d6..8fb436e8fa6 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -48,7 +48,6 @@ DEFAULT_VERIFY_SSL = False DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 # Options -DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15) DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED DEFAULT_BACKUP_PATH = "ha_backup" diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 30d1260ef32..1b3e21090b8 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -14,14 +14,12 @@ from synology_dsm.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .common import SynoApi, raise_config_entry_auth_error from .const import ( - DEFAULT_SCAN_INTERVAL, SIGNAL_CAMERA_SOURCE_CHANGED, SYNOLOGY_AUTH_FAILED_EXCEPTIONS, SYNOLOGY_CONNECTION_EXCEPTIONS, @@ -122,14 +120,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]): api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for central device.""" - super().__init__( - hass, - entry, - api, - timedelta( - minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ), - ) + super().__init__(hass, entry, api, timedelta(minutes=15)) @async_re_login_on_expired async def _async_update_data(self) -> None: diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index d6d40be3fea..c14f8da1037 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -68,8 +68,6 @@ "step": { "init": { "data": { - "scan_interval": "Minutes between scans", - "timeout": "Timeout (seconds)", "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%]" diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index b63ce6c2e18..b25cf7a81ac 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -27,7 +27,6 @@ from homeassistant.const import ( CONF_MAC, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -681,14 +680,12 @@ async def test_options_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - CONF_SCAN_INTERVAL: 2, CONF_SNAPSHOT_QUALITY: 0, CONF_BACKUP_PATH: "my_nackup_path", CONF_BACKUP_SHARE: "/ha_backup", }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_SCAN_INTERVAL] == 2 assert config_entry.options[CONF_SNAPSHOT_QUALITY] == 0 assert config_entry.options[CONF_BACKUP_PATH] == "my_nackup_path" assert config_entry.options[CONF_BACKUP_SHARE] == "/ha_backup" diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 7eaafc98437..7fe58719aa4 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_MAC, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -108,6 +109,7 @@ async def test_config_entry_migrations( CONF_PASSWORD: PASSWORD, CONF_MAC: MACS[0], }, + options={CONF_SCAN_INTERVAL: 30}, ) entry.add_to_hass(hass) @@ -118,5 +120,6 @@ async def test_config_entry_migrations( assert await hass.config_entries.async_setup(entry.entry_id) assert entry.data[CONF_VERIFY_SSL] == DEFAULT_VERIFY_SSL + assert CONF_SCAN_INTERVAL not in entry.options assert entry.options[CONF_BACKUP_SHARE] is None assert entry.options[CONF_BACKUP_PATH] is None From 00e98954e4ea8c41e043a50420e9e944c2f59e80 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 14 Feb 2025 01:52:33 +0200 Subject: [PATCH 0507/1941] Bump aiowebostv to 0.6.2 (#138488) --- 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 174e8025dd0..5fbcf759ee3 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.1"], + "requirements": ["aiowebostv==0.6.2"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index ba5aeee25df..1bfef744049 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.1 +aiowebostv==0.6.2 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ea50ac1d32..f6080e96729 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.1 +aiowebostv==0.6.2 # homeassistant.components.withings aiowithings==3.1.5 From 099adebcb68b08db715065aad68586c4fb49aa22 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 14 Feb 2025 01:04:39 +0100 Subject: [PATCH 0508/1941] Bump ZHA to 0.0.49 to fix Tuya TRV issues (#138492) Bump ZHA to 0.0.49 --- 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 821159afb22..54de60b8669 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.48"], + "requirements": ["zha==0.0.49"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 1bfef744049..551bc833a43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3137,7 +3137,7 @@ zeroconf==0.144.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.48 +zha==0.0.49 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6080e96729..a9bad901ecb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2526,7 +2526,7 @@ zeroconf==0.144.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.48 +zha==0.0.49 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 6a4f5188b1b4549c7d3a6709b0e0212f70c1e385 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 14 Feb 2025 01:30:53 +0100 Subject: [PATCH 0509/1941] Bump PyViCare to 2.42.1 (#138494) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 489d4accb8a..96935ba4ba7 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.42.0"] + "requirements": ["PyViCare==2.42.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 551bc833a43..b4b190acda3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.0 +PyViCare==2.42.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9bad901ecb..ce23da1ec81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.0 +PyViCare==2.42.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 83f8a4454d042d66e0520bffccd5816660039945 Mon Sep 17 00:00:00 2001 From: "Phill (pssc)" Date: Fri, 14 Feb 2025 09:14:44 +0000 Subject: [PATCH 0510/1941] squeezebox bump pysqueezebox to 0.12.0 (#138205) * bump pysqueezebox to 0.12.0 * python3 -m script.gen_requirements_all --- homeassistant/components/squeezebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 09eaa4026f4..e9b89291749 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.11.1"] + "requirements": ["pysqueezebox==0.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4b190acda3..5b87b3c73c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2337,7 +2337,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.11.1 +pysqueezebox==0.12.0 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce23da1ec81..d27c5a29b51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1909,7 +1909,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.11.1 +pysqueezebox==0.12.0 # homeassistant.components.suez_water pysuezV2==2.0.3 From 51beb21fe461dabca2f5734667a2c8e7905e3a0b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Feb 2025 10:19:00 +0100 Subject: [PATCH 0511/1941] Bump hass-nabucasa from 0.91.0 to 0.92.0 (#138510) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 16d340a480b..4e99d08afb5 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.91.0"], + "requirements": ["hass-nabucasa==0.92.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b49409d9ce7..997a2167654 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index e693b6ec9c5..7b40570015d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.91.0", + "hass-nabucasa==0.92.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 7baea71e608..139f0c168f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5b87b3c73c0..33a78a36da7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1106,7 +1106,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d27c5a29b51..d2dc9851b62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 # homeassistant.components.conversation hassil==2.2.3 From d82dd9e7e6792a1397dccdadf1a1c54d5e40ba99 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Fri, 14 Feb 2025 11:25:04 +0200 Subject: [PATCH 0512/1941] Bump pyseventeentrack to 1.0.2 (#138506) Bump pyseventeentrack version --- homeassistant/components/seventeentrack/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index a130fbe9aee..34019208a14 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyseventeentrack"], - "requirements": ["pyseventeentrack==1.0.1"] + "requirements": ["pyseventeentrack==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 33a78a36da7..209fa514a97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2286,7 +2286,7 @@ pyserial==3.5 pysesame2==1.0.1 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2dc9851b62..1b05c7b2db2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1861,7 +1861,7 @@ pysensibo==1.1.0 pyserial==3.5 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 From b9148d6368bd8323e77cc5097209f34027a6f8f0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Feb 2025 10:37:56 +0100 Subject: [PATCH 0513/1941] Improve descriptions of snooz.transition_xx actions (#138403) The current action descriptions of the snooz integration are easy to misunderstand and result in wrong translations. This commit replaces them with the wording from the online docs, slightly adapted for the UI that already displays the units and ranges. --- homeassistant/components/snooz/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index 94ca434e589..ca252b2117c 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -27,25 +27,25 @@ "services": { "transition_on": { "name": "Transition on", - "description": "Transitions to a target volume level over time.", + "description": "Transitions the volume level over a specified duration. If the device is powered off, the transition will start at the lowest volume level.", "fields": { "duration": { "name": "Transition duration", - "description": "Time it takes to reach the target volume level." + "description": "Time to transition to the target volume." }, "volume": { "name": "Target volume", - "description": "If not specified, the volume level is read from the device." + "description": "Relative volume level. If not specified, the setting on the device is used." } } }, "transition_off": { "name": "Transition off", - "description": "Transitions volume off over time.", + "description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.", "fields": { "duration": { "name": "[%key:component::snooz::services::transition_on::fields::duration::name%]", - "description": "Time it takes to turn off." + "description": "Time to complete the transition." } } } From 9f9aeb4cce3fc59ca0cc3c37a3c8c0106033ffc8 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:10:08 +0100 Subject: [PATCH 0514/1941] Add entity category to non primary entities for motionmount integration (#138436) Add entity category to non primary entities --- homeassistant/components/motionmount/binary_sensor.py | 2 ++ homeassistant/components/motionmount/quality_scale.yaml | 2 +- homeassistant/components/motionmount/sensor.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index c9d76ebb8d5..d0d6825ee40 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -31,6 +32,7 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 2648355c3af..7df450d88f3 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -56,7 +56,7 @@ rules: dynamic-devices: status: exempt comment: Single device per config entry - entity-category: todo + entity-category: done entity-device-class: done entity-disabled-by-default: todo entity-translations: done diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 4950e5d6662..9ca8d2b0731 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -6,6 +6,7 @@ import motionmount from motionmount import MotionMountSystemError from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -47,6 +48,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): "internal", ] _attr_translation_key = "motionmount_error_status" + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry From 4d3a4015edb3edf6d0865fec730ecd48ad34205e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:39:04 +0100 Subject: [PATCH 0515/1941] =?UTF-8?q?Update=20quality=20scale=20to=20plati?= =?UTF-8?q?num=20=F0=9F=8F=86=EF=B8=8F=20for=20Bring!=20integration=20(#13?= =?UTF-8?q?8202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update documentation status in bring quality_scale.yaml * Update quality scale * options flow exempt --- homeassistant/components/bring/manifest.json | 1 + .../components/bring/quality_scale.yaml | 28 +++++++++++-------- script/hassfest/quality_scale.py | 1 - 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index b846cb1c5ca..f292b10f7dc 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["bring_api"], + "quality_scale": "platinum", "requirements": ["bring-api==1.0.2"] } diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 58e67ab0e11..2d7d67be12e 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -10,9 +10,9 @@ rules: config-flow: done dependency-transparency: done docs-actions: done - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: The integration registers no events @@ -26,8 +26,10 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: @@ -46,13 +48,15 @@ rules: discovery: status: exempt comment: Integration is a service and has no devices. - 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 + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: Integration is a service and has no devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index e5eee2f4157..60a5f073538 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1286,7 +1286,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "brottsplatskartan", "browser", "brunt", - "bring", "bryant_evolution", "bsblan", "bt_home_hub_5", From f407dbd35c11f8701947bf6205ee4417ca5720eb Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 14 Feb 2025 12:46:41 +0100 Subject: [PATCH 0516/1941] Disable less used entities by default in MotionMount integration (#138509) * Mark sensors as disabled by default as most users won't need them * Mark entity-disabled-by-default as done * Enable disabled entities during tests --- homeassistant/components/motionmount/binary_sensor.py | 1 + homeassistant/components/motionmount/quality_scale.yaml | 2 +- homeassistant/components/motionmount/sensor.py | 1 + tests/components/motionmount/test_sensor.py | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index d0d6825ee40..4bb880311f9 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -33,6 +33,7 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 7df450d88f3..765cdd7e945 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -58,7 +58,7 @@ rules: comment: Single device per config entry entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: todo diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 9ca8d2b0731..28fe921d9ac 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -49,6 +49,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): ] _attr_translation_key = "motionmount_error_status" _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py index bb68c67ce62..0320e62d640 100644 --- a/tests/components/motionmount/test_sensor.py +++ b/tests/components/motionmount/test_sensor.py @@ -14,6 +14,7 @@ from tests.common import MockConfigEntry MAC = bytes.fromhex("c4dd57f8a55f") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("system_status", "state"), [ From efd7ddeb89f643241ffe78680bfb00b063de4667 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Feb 2025 13:06:07 +0100 Subject: [PATCH 0517/1941] Improve tests of removing and unloading config entries (#138432) * Improve tests of removing and unloading config entries * Fix unnecessary coroutine --- tests/test_config_entries.py | 54 +++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index cf022c42e94..bf2280790fa 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -462,7 +462,15 @@ async def test_remove_entry( assert result return result - mock_remove_entry = AsyncMock(return_value=None) + remove_entry_calls = [] + + async def mock_remove_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Mock removing an entry.""" + # Check that the entry is not yet removed from config entries + assert hass.config_entries.async_get_entry(entry.entry_id) + remove_entry_calls.append(None) entity = MockEntity(unique_id="1234", name="Test Entity") @@ -522,7 +530,7 @@ async def test_remove_entry( assert result == {"require_restart": False} # Check the remove callback was invoked. - assert mock_remove_entry.call_count == 1 + assert len(remove_entry_calls) == 1 # Check that config entry was removed. assert manager.async_entry_ids() == ["test1", "test3"] @@ -2611,29 +2619,49 @@ async def test_entry_setup_invalid_state( assert entry.state is state -async def test_entry_unload_succeed( - hass: HomeAssistant, manager: config_entries.ConfigEntries +@pytest.mark.parametrize( + ("unload_result", "expected_result", "expected_state", "has_runtime_data"), + [ + (True, True, config_entries.ConfigEntryState.NOT_LOADED, False), + (False, False, config_entries.ConfigEntryState.LOADED, True), + ], +) +async def test_entry_unload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + unload_result: bool, + expected_result: bool, + expected_state: config_entries.ConfigEntryState, + has_runtime_data: bool, ) -> None: """Test that we can unload an entry.""" - unloads_called = [] + unload_entry_calls = [] - async def verify_runtime_data(*args): + @callback + def verify_runtime_data() -> None: """Verify runtime data.""" assert entry.runtime_data == 2 - unloads_called.append(args) - return True + + async def async_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unload entry.""" + unload_entry_calls.append(None) + verify_runtime_data() + assert entry.state is config_entries.ConfigEntryState.LOADED + return unload_result entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) entry.async_on_unload(verify_runtime_data) entry.runtime_data = 2 - mock_integration(hass, MockModule("comp", async_unload_entry=verify_runtime_data)) + mock_integration(hass, MockModule("comp", async_unload_entry=async_unload_entry)) - assert await manager.async_unload(entry.entry_id) - assert len(unloads_called) == 2 - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert not hasattr(entry, "runtime_data") + assert await manager.async_unload(entry.entry_id) == expected_result + assert len(unload_entry_calls) == 1 + assert entry.state is expected_state + assert hasattr(entry, "runtime_data") == has_runtime_data @pytest.mark.parametrize( From fa4ebeb6805ee3d215e03d03b1cbea2ef421faf4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:11:32 +0100 Subject: [PATCH 0518/1941] Bump py-synologydsm-api to 2.6.3 (#138516) bump py-synologydsm-api to 2.6.3 --- 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 a083fa5a15f..d076d843c36 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.2"], + "requirements": ["py-synologydsm-api==2.6.3"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index 209fa514a97..fe2127bcab0 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.2 +py-synologydsm-api==2.6.3 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b05c7b2db2..8b9cc8455e7 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.2 +py-synologydsm-api==2.6.3 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From fae68c8ad580b103aea5f2ace77d1cf00a65e417 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:47:36 +0100 Subject: [PATCH 0519/1941] Add icon translation to MotionMount integration (#138520) * Add icon translation for error sensor * Mark icon-translations as done --- homeassistant/components/motionmount/icons.json | 12 ++++++++++++ .../components/motionmount/quality_scale.yaml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/motionmount/icons.json diff --git a/homeassistant/components/motionmount/icons.json b/homeassistant/components/motionmount/icons.json new file mode 100644 index 00000000000..8d6d867f4d0 --- /dev/null +++ b/homeassistant/components/motionmount/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "motionmount_error_status": { + "default": "mdi:alert-circle-outline", + "state": { + "none": "mdi:check-circle-outline" + } + } + } + } +} diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 765cdd7e945..8b210931eaf 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -61,7 +61,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt From 48f58c7d497be2804a75eab178a5a8d833a50e87 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Feb 2025 13:52:22 +0100 Subject: [PATCH 0520/1941] Fix action descriptions in Xiaomi Miio integration (#138476) * Fix action description in Xiaomi Miio integration Correct several missing descriptions, wrong references to completely different actions, resulting duplicates and copy & paste errors. Make the grammar more consistent across all strings. Make one occurrence of "xiaomi miio" consistent by capitalizing. * Apply suggestions from @CFenner review Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> * Change "on a light" to "of a light", remove wrong comma * Change "turn off" to "turning off" according to OED --------- Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> --- .../components/xiaomi_miio/strings.json | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 75563b07559..bd3b3499689 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -331,7 +331,7 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Name of the xiaomi miio entity." + "description": "Name of the Xiaomi Miio entity." } } }, @@ -365,7 +365,7 @@ }, "light_set_delayed_turn_off": { "name": "Light set delayed turn off", - "description": "Delayed turn off.", + "description": "Sets the delayed turning off of a light.", "fields": { "entity_id": { "name": "Entity ID", @@ -373,7 +373,7 @@ }, "time_period": { "name": "Time period", - "description": "Time period for the delayed turn off." + "description": "Time period for the delayed turning off." } } }, @@ -398,8 +398,8 @@ } }, "light_night_light_mode_on": { - "name": "Night light mode on", - "description": "Turns the eyecare mode on (EYECARE SMART LAMP 2 ONLY).", + "name": "Light night light mode on", + "description": "Turns on the night light mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -408,8 +408,8 @@ } }, "light_night_light_mode_off": { - "name": "Night light mode off", - "description": "Turns the eyecare mode fan_set_dry_off (EYECARE SMART LAMP 2 ONLY).", + "name": "Light night light mode off", + "description": "Turns off the night light mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -419,7 +419,7 @@ }, "light_eyecare_mode_on": { "name": "Light eyecare mode on", - "description": "[%key:component::xiaomi_miio::services::light_reminder_on::description%]", + "description": "Turns on the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -429,7 +429,7 @@ }, "light_eyecare_mode_off": { "name": "Light eyecare mode off", - "description": "[%key:component::xiaomi_miio::services::light_reminder_off::description%]", + "description": "Turns off the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -439,7 +439,7 @@ }, "remote_learn_command": { "name": "Remote learn command", - "description": "Learns an IR command, select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.", + "description": "Learns an IR command. Select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.", "fields": { "slot": { "name": "Slot", @@ -447,21 +447,21 @@ }, "timeout": { "name": "Timeout", - "description": "Define the timeout, before which the command must be learned." + "description": "Define the timeout before which the command must be learned." } } }, "remote_set_led_on": { "name": "Remote set LED on", - "description": "Turns on blue LED." + "description": "Turns on the remote’s blue LED." }, "remote_set_led_off": { "name": "Remote set LED off", - "description": "Turns off blue LED." + "description": "Turns off the remote’s blue LED." }, "switch_set_wifi_led_on": { "name": "Switch set Wi-Fi LED on", - "description": "Turns the Wi-Fi LED on.", + "description": "Turns on the Wi-Fi LED of a switch.", "fields": { "entity_id": { "name": "Entity ID", @@ -471,7 +471,7 @@ }, "switch_set_wifi_led_off": { "name": "Switch set Wi-Fi LED off", - "description": "Turns the Wi-Fi LED off.", + "description": "Turns off the Wi-Fi LED of a switch.", "fields": { "entity_id": { "name": "Entity ID", From 371490a4705e1e54e012d8372acf243f69e9f19f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 14 Feb 2025 13:57:27 +0100 Subject: [PATCH 0521/1941] Add sensor platform to LetPot integration (#138491) * Add sensor platform to LetPot integration * Handle support in description supported_fn, use common string * Update homeassistant/components/letpot/switch.py * Update homeassistant/components/letpot/sensor.py * Update homeassistant/components/letpot/sensor.py * Update homeassistant/components/letpot/strings.json * Fix translation key in snapshot * snapshot no quotes --------- Co-authored-by: Josef Zweck --- homeassistant/components/letpot/__init__.py | 2 +- homeassistant/components/letpot/entity.py | 9 ++ homeassistant/components/letpot/icons.json | 5 + .../components/letpot/quality_scale.yaml | 4 +- homeassistant/components/letpot/sensor.py | 110 ++++++++++++++++++ homeassistant/components/letpot/strings.json | 5 + homeassistant/components/letpot/switch.py | 52 ++++----- .../letpot/snapshots/test_sensor.ambr | 104 +++++++++++++++++ tests/components/letpot/test_sensor.py | 28 +++++ 9 files changed, 288 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/letpot/sensor.py create mode 100644 tests/components/letpot/snapshots/test_sensor.ambr create mode 100644 tests/components/letpot/test_sensor.py diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index bc84c22d4a2..dc322d5641b 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -22,7 +22,7 @@ from .const import ( ) from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME] async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index b4d505f4092..5e2c46fee84 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -1,18 +1,27 @@ """Base class for LetPot entities.""" from collections.abc import Callable, Coroutine +from dataclasses import dataclass 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.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import LetPotDeviceCoordinator +@dataclass(frozen=True, kw_only=True) +class LetPotEntityDescription(EntityDescription): + """Description for all LetPot entities.""" + + supported_fn: Callable[[LetPotDeviceCoordinator], bool] = lambda _: True + + class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): """Defines a base LetPot entity.""" diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 2a2b727adcd..60cba78fa1c 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -1,5 +1,10 @@ { "entity": { + "sensor": { + "water_level": { + "default": "mdi:water-percent" + } + }, "switch": { "alarm_sound": { "default": "mdi:bell-ring", diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 70f3bb52b82..0fdaca18717 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -59,8 +59,8 @@ rules: docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo - entity-category: todo - entity-device-class: todo + entity-category: done + entity-device-class: done entity-disabled-by-default: todo entity-translations: done exception-translations: done diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py new file mode 100644 index 00000000000..b0b113eb063 --- /dev/null +++ b/homeassistant/components/letpot/sensor.py @@ -0,0 +1,110 @@ +"""Support for LetPot sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from letpot.models import DeviceFeature, LetPotDeviceStatus, TemperatureUnit + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .entity import LetPotEntity, LetPotEntityDescription + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +LETPOT_TEMPERATURE_UNIT_HA_UNIT = { + TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS, + TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, +} + + +@dataclass(frozen=True, kw_only=True) +class LetPotSensorEntityDescription(LetPotEntityDescription, SensorEntityDescription): + """Describes a LetPot sensor entity.""" + + native_unit_of_measurement_fn: Callable[[LetPotDeviceStatus], str | None] + value_fn: Callable[[LetPotDeviceStatus], StateType] + + +SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( + LetPotSensorEntityDescription( + key="temperature", + value_fn=lambda status: status.temperature_value, + native_unit_of_measurement_fn=( + lambda status: LETPOT_TEMPERATURE_UNIT_HA_UNIT[ + status.temperature_unit or TemperatureUnit.CELSIUS + ] + ), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=( + lambda coordinator: DeviceFeature.TEMPERATURE + in coordinator.device_client.device_features + ), + ), + LetPotSensorEntityDescription( + key="water_level", + translation_key="water_level", + value_fn=lambda status: status.water_level, + native_unit_of_measurement_fn=lambda _: PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=( + lambda coordinator: DeviceFeature.WATER_LEVEL + in coordinator.device_client.device_features + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot sensor entities based on a device features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotSensorEntity(coordinator, description) + for description in SENSORS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotSensorEntity(LetPotEntity, SensorEntity): + """Defines a LetPot sensor entity.""" + + entity_description: LetPotSensorEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotSensorEntityDescription, + ) -> None: + """Initialize LetPot sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of measurement.""" + return self.entity_description.native_unit_of_measurement_fn( + self.coordinator.data + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 12913085644..0cb79ce711c 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -32,6 +32,11 @@ } }, "entity": { + "sensor": { + "water_level": { + "name": "Water level" + } + }, "switch": { "alarm_sound": { "name": "Alarm sound" diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 41150d1b1e9..0b00318c53b 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator -from .entity import LetPotEntity, exception_handler +from .entity import LetPotEntity, LetPotEntityDescription, 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. @@ -21,14 +21,33 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class LetPotSwitchEntityDescription(SwitchEntityDescription): +class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescription): """Describes a LetPot switch entity.""" value_fn: Callable[[LetPotDeviceStatus], bool | None] set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]] -BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( +SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( + LetPotSwitchEntityDescription( + key="alarm_sound", + translation_key="alarm_sound", + value_fn=lambda status: status.system_sound, + set_value_fn=lambda device_client, value: device_client.set_sound(value), + entity_category=EntityCategory.CONFIG, + supported_fn=lambda coordinator: coordinator.data.system_sound is not None, + ), + LetPotSwitchEntityDescription( + key="auto_mode", + translation_key="auto_mode", + value_fn=lambda status: status.water_mode == 1, + set_value_fn=lambda device_client, value: device_client.set_water_mode(value), + entity_category=EntityCategory.CONFIG, + supported_fn=( + lambda coordinator: DeviceFeature.PUMP_AUTO + in coordinator.device_client.device_features + ), + ), LetPotSwitchEntityDescription( key="power", translation_key="power", @@ -44,20 +63,6 @@ BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ), ) -ALARM_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription( - key="alarm_sound", - translation_key="alarm_sound", - value_fn=lambda status: status.system_sound, - set_value_fn=lambda device_client, value: device_client.set_sound(value), - entity_category=EntityCategory.CONFIG, -) -AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription( - key="auto_mode", - translation_key="auto_mode", - value_fn=lambda status: status.water_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_water_mode(value), - entity_category=EntityCategory.CONFIG, -) async def async_setup_entry( @@ -69,19 +74,10 @@ async def async_setup_entry( coordinators = entry.runtime_data entities: list[SwitchEntity] = [ LetPotSwitchEntity(coordinator, description) - for description in BASE_SWITCHES + for description in SWITCHES for coordinator in coordinators + if description.supported_fn(coordinator) ] - entities.extend( - LetPotSwitchEntity(coordinator, ALARM_SWITCH) - for coordinator in coordinators - if coordinator.data.system_sound is not None - ) - entities.extend( - LetPotSwitchEntity(coordinator, AUTO_MODE_SWITCH) - for coordinator in coordinators - if DeviceFeature.PUMP_AUTO in coordinator.device_client.device_features - ) async_add_entities(entities) diff --git a/tests/components/letpot/snapshots/test_sensor.ambr b/tests/components/letpot/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..5d123cf6ce0 --- /dev/null +++ b/tests/components/letpot/snapshots/test_sensor.ambr @@ -0,0 +1,104 @@ +# serializer version: 1 +# name: test_all_entities[sensor.garden_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.garden_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': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Garden Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18', + }) +# --- +# name: test_all_entities[sensor.garden_water_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.garden_water_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': 'Water level', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_water_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.garden_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Water level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garden_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/letpot/test_sensor.py b/tests/components/letpot/test_sensor.py new file mode 100644 index 00000000000..a527d062ca7 --- /dev/null +++ b/tests/components/letpot/test_sensor.py @@ -0,0 +1,28 @@ +"""Test sensor entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 7dd678ccdf13d7074319aec2dc556b43e7719214 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 14 Feb 2025 14:12:49 +0100 Subject: [PATCH 0522/1941] Update frontend to 20250214.0 (#138521) --- 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 912ce508e00..c8506335e16 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==20250210.0"] + "requirements": ["home-assistant-frontend==20250214.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 997a2167654..ed1a1f68621 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.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 fe2127bcab0..b02763bd82b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b9cc8455e7..cdd252d7091 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From 28a18e538d2e740c944cb28b47893f55e35f4550 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 12 Feb 2025 20:42:07 +0000 Subject: [PATCH 0523/1941] Bump python-kasa to 0.10.2 (#138381) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index ff65211c9b3..cdd6ab57c6a 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -301,5 +301,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.10.1"] + "requirements": ["python-kasa[speedups]==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c0852e92e35..49a1778824a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2406,7 +2406,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.1 +python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay python-linkplay==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae91df10624..f89eb3d375a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1945,7 +1945,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.10.1 +python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay python-linkplay==0.1.3 From f191f6ae22395b973eb6e1badfe93c6c2b07ab2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 13 Feb 2025 12:40:55 +0100 Subject: [PATCH 0524/1941] Bump hass-nabucasa from 0.90.0 to 0.91.0 (#138441) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 1335d9b81bf..e503524afab 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.90.0"], + "requirements": ["hass-nabucasa==0.91.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2855d41de04..7f4492b7fc8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 hassil==2.2.3 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index 1bc3c999421..64d32f280c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.90.0", + "hass-nabucasa==0.91.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 26626ca9fcf..58c469c0aa9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 httpx==0.28.1 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 49a1778824a..db05a597cd4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f89eb3d375a..447f6a753b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 # homeassistant.components.conversation hassil==2.2.3 From ccd220ad0fe4a06ac6dee8a1ba5bb0c353ccd9fb Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 14 Feb 2025 01:52:33 +0200 Subject: [PATCH 0525/1941] Bump aiowebostv to 0.6.2 (#138488) --- 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 174e8025dd0..5fbcf759ee3 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.1"], + "requirements": ["aiowebostv==0.6.2"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index db05a597cd4..6ea953ec3e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -419,7 +419,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.1 +aiowebostv==0.6.2 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 447f6a753b0..4e8eb98cdbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.1 +aiowebostv==0.6.2 # homeassistant.components.withings aiowithings==3.1.5 From 72878c18d0c9dfafab0ad05680dfa86f06dd4473 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 14 Feb 2025 01:04:39 +0100 Subject: [PATCH 0526/1941] Bump ZHA to 0.0.49 to fix Tuya TRV issues (#138492) Bump ZHA to 0.0.49 --- 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 821159afb22..54de60b8669 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.48"], + "requirements": ["zha==0.0.49"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 6ea953ec3e2..771b8b5f340 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zeroconf==0.144.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.48 +zha==0.0.49 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e8eb98cdbc..024095a7c3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ zeroconf==0.144.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.48 +zha==0.0.49 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 33d4d1f8e545515fdfe56fd6516bfa40921c3c57 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Fri, 14 Feb 2025 11:25:04 +0200 Subject: [PATCH 0527/1941] Bump pyseventeentrack to 1.0.2 (#138506) Bump pyseventeentrack version --- homeassistant/components/seventeentrack/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index a130fbe9aee..34019208a14 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyseventeentrack"], - "requirements": ["pyseventeentrack==1.0.1"] + "requirements": ["pyseventeentrack==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 771b8b5f340..2298ab81fe0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2280,7 +2280,7 @@ pyserial==3.5 pysesame2==1.0.1 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 024095a7c3c..e01370644cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1855,7 +1855,7 @@ pysensibo==1.1.0 pyserial==3.5 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 From 95f632a13a44f44ed1a693f06d86e436a0e07386 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Feb 2025 10:19:00 +0100 Subject: [PATCH 0528/1941] Bump hass-nabucasa from 0.91.0 to 0.92.0 (#138510) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index e503524afab..156f978fbc0 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.91.0"], + "requirements": ["hass-nabucasa==0.92.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7f4492b7fc8..dbad49b8caa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index 64d32f280c0..4c9327901c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.91.0", + "hass-nabucasa==0.92.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 58c469c0aa9..01f05f94b88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 httpx==0.28.1 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2298ab81fe0..3114f1a10be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e01370644cb..de7be82829a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 # homeassistant.components.conversation hassil==2.2.3 From 21b98a76cc1e53625873413790cf781243783bd9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:11:32 +0100 Subject: [PATCH 0529/1941] Bump py-synologydsm-api to 2.6.3 (#138516) bump py-synologydsm-api to 2.6.3 --- 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 a083fa5a15f..d076d843c36 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.2"], + "requirements": ["py-synologydsm-api==2.6.3"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index 3114f1a10be..6a6e76d7df1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1746,7 +1746,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.2 +py-synologydsm-api==2.6.3 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de7be82829a..0127d0ab5f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1444,7 +1444,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.2 +py-synologydsm-api==2.6.3 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From 5328429b086c2a070849a16b62c6fe409985c107 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 14 Feb 2025 14:12:49 +0100 Subject: [PATCH 0530/1941] Update frontend to 20250214.0 (#138521) --- 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 912ce508e00..c8506335e16 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==20250210.0"] + "requirements": ["home-assistant-frontend==20250214.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dbad49b8caa..1c44651b9ec 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.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 6a6e76d7df1..1a601084b26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0127d0ab5f8..af81f78c6ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From 759cc3303a7937b34e86c1da2fd576547178767f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 14 Feb 2025 13:40:39 +0000 Subject: [PATCH 0531/1941] Bump version to 2025.2.4 --- 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 6d16b877e67..05438c9ce26 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __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 4c9327901c7..ffa6d8cb6bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.3" +version = "2025.2.4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 23d43b23ee0bdb9dfe41eee87ca3cff0577249ad Mon Sep 17 00:00:00 2001 From: Josh Gustafson Date: Fri, 14 Feb 2025 08:03:47 -0700 Subject: [PATCH 0532/1941] Bump arcam-fmj to 1.8.0 (#138422) * arcam_fmj: bump arcam-fmj to 1.8.0 * Revert castings --------- Co-authored-by: Franck Nijhof --- homeassistant/components/arcam_fmj/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 39d289f9cb1..944c70c1217 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.5.2"], + "requirements": ["arcam-fmj==1.8.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/requirements_all.txt b/requirements_all.txt index b02763bd82b..43f850d14ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -497,7 +497,7 @@ aqualogic==2.6 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.5.2 +arcam-fmj==1.8.0 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdd252d7091..f2877dfacfe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -467,7 +467,7 @@ apsystems-ez1==2.4.0 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.5.2 +arcam-fmj==1.8.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From 7bd2c1d710712e5b79877c9e9dae24a6e3c437bd Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:20:19 +0100 Subject: [PATCH 0533/1941] Refactor and add tests to image platform of Habitica (#135897) --- homeassistant/components/habitica/image.py | 14 ++- .../test_image/test_image_platform.1.png | Bin 0 -> 70 bytes .../test_image/test_image_platform.png | Bin 0 -> 70 bytes tests/components/habitica/test_image.py | 99 ++++++++++++++++++ 4 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png create mode 100644 tests/components/habitica/__snapshots__/test_image/test_image_platform.png create mode 100644 tests/components/habitica/test_image.py diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index f1ade2cac44..1669f124bc7 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -43,7 +43,7 @@ class HabiticaImage(HabiticaBase, ImageEntity): translation_key=HabiticaImageEntity.AVATAR, ) _attr_content_type = "image/png" - _current_appearance: Avatar | None = None + _avatar: Avatar | None = None _cache: bytes | None = None def __init__( @@ -55,13 +55,13 @@ class HabiticaImage(HabiticaBase, ImageEntity): super().__init__(coordinator, self.entity_description) ImageEntity.__init__(self, hass) self._attr_image_last_updated = dt_util.utcnow() + self._avatar = extract_avatar(self.coordinator.data.user) def _handle_coordinator_update(self) -> None: """Check if equipped gear and other things have changed since last avatar image generation.""" - new_appearance = extract_avatar(self.coordinator.data.user) - if self._current_appearance != new_appearance: - self._current_appearance = new_appearance + if self._avatar != self.coordinator.data.user: + self._avatar = extract_avatar(self.coordinator.data.user) self._attr_image_last_updated = dt_util.utcnow() self._cache = None @@ -69,8 +69,6 @@ class HabiticaImage(HabiticaBase, ImageEntity): async def async_image(self) -> bytes | None: """Return cached bytes, otherwise generate new avatar.""" - if not self._cache and self._current_appearance: - self._cache = await self.coordinator.generate_avatar( - self._current_appearance - ) + if not self._cache and self._avatar: + self._cache = await self.coordinator.generate_avatar(self._avatar) return self._cache diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png new file mode 100644 index 0000000000000000000000000000000000000000..5bb8c9d9f091c7a448a220a122933a61ded065d1 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8HA{P-`=z|79XV4!w9 Q5h%gn>FVdQ&MBb@0IqossQ>@~ literal 0 HcmV?d00001 diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.png new file mode 100644 index 0000000000000000000000000000000000000000..8e9b046ee05dbf00e565c46dda27eb844c562b4e GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYf8c{W11l>NL;Q)4 Qmw*xsp00i_>zopr0M?)o)c^nh literal 0 HcmV?d00001 diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py new file mode 100644 index 00000000000..17089f57bd7 --- /dev/null +++ b/tests/components/habitica/test_image.py @@ -0,0 +1,99 @@ +"""Tests for the Habitica image platform.""" + +from collections.abc import Generator +from datetime import timedelta +from http import HTTPStatus +from io import BytesIO +import sys +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from habiticalib import HabiticaUserResponse +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.extensions.image import PNGImageSnapshotExtension + +from homeassistant.components.habitica.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def image_only() -> Generator[None]: + """Enable only the image platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.IMAGE], + ): + yield + + +@pytest.mark.skipif( + sys.platform != "linux", reason="linux only" +) # Pillow output on win/mac is different +async def test_image_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test image platform.""" + freezer.move_to("2024-09-20T22:00:00.000") + with patch( + "homeassistant.components.habitica.coordinator.BytesIO", + ) as avatar: + avatar.side_effect = [ + BytesIO( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdac\xfc\xcf\xc0\xf0\x1f\x00\x05\x05\x02\x00_\xc8\xf1\xd2\x00\x00\x00\x00IEND\xaeB`\x82" + ), + BytesIO( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdacd`\xf8\xff\x1f\x00\x03\x07\x02\x000&\xc7a\x00\x00\x00\x00IEND\xaeB`\x82" + ), + ] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.test_user_avatar")) + assert state.state == "2024-09-20T22:00:00+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.test_user_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == snapshot( + extension_class=PNGImageSnapshotExtension + ) + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("rogue_fixture.json", DOMAIN) + ) + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("image.test_user_avatar")) + assert state.state == "2024-09-20T22:01:00+00:00" + + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == snapshot( + extension_class=PNGImageSnapshotExtension + ) From 28ea55aac0ebfa63859e4dea1e3b3ee9328e30d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 09:27:16 -0600 Subject: [PATCH 0534/1941] Bump aiohttp-asyncmdnsresolver to 0.1.1 (#138534) --- 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 ed1a1f68621..b7592bf0f05 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.1.0 aiodiscover==2.6.0 aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp-asyncmdnsresolver==0.1.0 +aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.2 aiohttp==3.11.12 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 7b40570015d..553ced3da43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.12", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.2", - "aiohttp-asyncmdnsresolver==0.1.0", + "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", "astral==2.2", "async-interrupt==1.2.1", diff --git a/requirements.txt b/requirements.txt index 139f0c168f1..2b7290fa042 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.3.0 aiohttp==3.11.12 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.2 -aiohttp-asyncmdnsresolver==0.1.0 +aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 astral==2.2 async-interrupt==1.2.1 From 5dc1689e7c5157f03665563b87e89624eaf0642f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Feb 2025 18:06:17 +0100 Subject: [PATCH 0535/1941] Update action descriptions of weather integration (#138540) --- homeassistant/components/weather/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 85d331f5bd0..31e644b32e3 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -90,17 +90,17 @@ "services": { "get_forecasts": { "name": "Get forecasts", - "description": "Get weather forecasts.", + "description": "Retrieves the forecast from selected weather services.", "fields": { "type": { "name": "Forecast type", - "description": "Forecast type: daily, hourly or twice daily." + "description": "The scope of the weather forecast." } } }, "get_forecast": { "name": "Get forecast", - "description": "Get weather forecast.", + "description": "Retrieves the forecast from a selected weather service.", "fields": { "type": { "name": "[%key:component::weather::services::get_forecasts::fields::type::name%]", @@ -111,12 +111,12 @@ }, "issues": { "deprecated_service_weather_get_forecast": { - "title": "Detected use of deprecated service weather.get_forecast", + "title": "Detected use of deprecated action weather.get_forecast", "fix_flow": { "step": { "confirm": { "title": "[%key:component::weather::issues::deprecated_service_weather_get_forecast::title%]", - "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **Submit** to close this issue." + "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this action and adjust your automations and scripts and select **Submit** to close this issue." } } } From 11aa08cf74c722ed310705bac70140b77b7f50e2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Feb 2025 19:56:32 +0100 Subject: [PATCH 0536/1941] =?UTF-8?q?Set=20quality=20scale=20to=20platinum?= =?UTF-8?q?=20=F0=9F=8F=86=EF=B8=8F=20for=20Habitica=20integration=20(#136?= =?UTF-8?q?076)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/habitica/manifest.json | 1 + homeassistant/components/habitica/quality_scale.yaml | 2 +- script/hassfest/quality_scale.py | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index a58bd1296e0..48b6997239e 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], + "quality_scale": "platinum", "requirements": ["habiticalib==0.3.7"] } diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index 9eadba496f2..1752e67cf46 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: exempt comment: No supportable devices. docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: status: exempt diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 60a5f073538..12b5932695d 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1535,7 +1535,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "gstreamer", "gtfs", "guardian", - "habitica", "harman_kardon_avr", "harmony", "hassio", From d99044572a1ea6a047975bf91b5c29002cb1963d Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Fri, 14 Feb 2025 14:03:21 -0500 Subject: [PATCH 0537/1941] Improved auth failure handling in Nice G.O. (#136607) --- .../components/nice_go/coordinator.py | 4 +- homeassistant/components/nice_go/cover.py | 26 ++---- homeassistant/components/nice_go/light.py | 26 ++---- homeassistant/components/nice_go/switch.py | 26 ++---- homeassistant/components/nice_go/util.py | 66 ++++++++++++++ tests/components/nice_go/test_cover.py | 85 ++++++++++++++++++- tests/components/nice_go/test_light.py | 85 ++++++++++++++++++- tests/components/nice_go/test_switch.py | 85 ++++++++++++++++++- 8 files changed, 335 insertions(+), 68 deletions(-) create mode 100644 homeassistant/components/nice_go/util.py diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index e486263fbe5..ffdd9dbd518 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -153,7 +153,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): ) try: if datetime.now().timestamp() >= expiry_time: - await self._update_refresh_token() + await self.update_refresh_token() else: await self.api.authenticate_refresh( self.refresh_token, async_get_clientsession(self.hass) @@ -178,7 +178,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): else: self.async_set_updated_data(devices) - async def _update_refresh_token(self) -> None: + async def update_refresh_token(self) -> None: """Update the refresh token with Nice G.O. API.""" _LOGGER.debug("Updating the refresh token with Nice G.O. API") try: diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py index 03124971410..b9b39711a01 100644 --- a/homeassistant/components/nice_go/cover.py +++ b/homeassistant/components/nice_go/cover.py @@ -2,21 +2,17 @@ from typing import Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry DEVICE_CLASSES = { "WallStation": CoverDeviceClass.GARAGE, @@ -71,30 +67,18 @@ class NiceGOCoverEntity(NiceGOEntity, CoverEntity): """Return if cover is closing.""" return self.data.barrier_status == "closing" + @retry("close_cover_error") async def async_close_cover(self, **kwargs: Any) -> None: """Close the garage door.""" if self.is_closed: return - try: - await self.coordinator.api.close_barrier(self._device_id) - except (ApiError, ClientError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="close_cover_error", - translation_placeholders={"exception": str(err)}, - ) from err + await self.coordinator.api.close_barrier(self._device_id) + @retry("open_cover_error") async def async_open_cover(self, **kwargs: Any) -> None: """Open the garage door.""" if self.is_opened: return - try: - await self.coordinator.api.open_barrier(self._device_id) - except (ApiError, ClientError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="open_cover_error", - translation_placeholders={"exception": str(err)}, - ) from err + await self.coordinator.api.open_barrier(self._device_id) diff --git a/homeassistant/components/nice_go/light.py b/homeassistant/components/nice_go/light.py index 5b06c02f5db..bf283ed6eff 100644 --- a/homeassistant/components/nice_go/light.py +++ b/homeassistant/components/nice_go/light.py @@ -3,23 +3,19 @@ import logging from typing import TYPE_CHECKING, Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.light import ColorMode, LightEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry _LOGGER = logging.getLogger(__name__) @@ -63,26 +59,14 @@ class NiceGOLightEntity(NiceGOEntity, LightEntity): assert self.data.light_status is not None return self.data.light_status + @retry("light_on_error") async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - try: - await self.coordinator.api.light_on(self._device_id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="light_on_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.light_on(self._device_id) + @retry("light_off_error") async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - try: - await self.coordinator.api.light_off(self._device_id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="light_off_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.light_off(self._device_id) diff --git a/homeassistant/components/nice_go/switch.py b/homeassistant/components/nice_go/switch.py index e81ea489d2f..f043a23eab5 100644 --- a/homeassistant/components/nice_go/switch.py +++ b/homeassistant/components/nice_go/switch.py @@ -5,23 +5,19 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry _LOGGER = logging.getLogger(__name__) @@ -65,26 +61,14 @@ class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity): assert self.data.vacation_mode is not None return self.data.vacation_mode + @retry("switch_on_error") async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - try: - await self.coordinator.api.vacation_mode_on(self.data.id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_on_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.vacation_mode_on(self.data.id) + @retry("switch_off_error") async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - try: - await self.coordinator.api.vacation_mode_off(self.data.id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_off_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.vacation_mode_off(self.data.id) diff --git a/homeassistant/components/nice_go/util.py b/homeassistant/components/nice_go/util.py new file mode 100644 index 00000000000..02dee6b0ac1 --- /dev/null +++ b/homeassistant/components/nice_go/util.py @@ -0,0 +1,66 @@ +"""Utilities for Nice G.O.""" + +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import Any, Protocol, runtime_checkable + +from aiohttp import ClientError +from nice_go import ApiError, AuthFailedError + +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import DOMAIN + + +@runtime_checkable +class _ArgsProtocol(Protocol): + coordinator: Any + hass: Any + + +def retry[_R, **P]( + translation_key: str, +) -> Callable[ + [Callable[P, Coroutine[Any, Any, _R]]], Callable[P, Coroutine[Any, Any, _R]] +]: + """Retry decorator to handle API errors.""" + + def decorator( + func: Callable[P, Coroutine[Any, Any, _R]], + ) -> Callable[P, Coroutine[Any, Any, _R]]: + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs): + instance = args[0] + if not isinstance(instance, _ArgsProtocol): + raise TypeError("First argument must have correct attributes") + try: + return await func(*args, **kwargs) + except (ApiError, ClientError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + except AuthFailedError: + # Try refreshing token and retry + try: + await instance.coordinator.update_refresh_token() + return await func(*args, **kwargs) + except (ApiError, ClientError, UpdateFailed) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + except (AuthFailedError, ConfigEntryAuthFailed) as err: + instance.coordinator.config_entry.async_start_reauth(instance.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + + return wrapper + + return decorator diff --git a/tests/components/nice_go/test_cover.py b/tests/components/nice_go/test_cover.py index f90c2d438b0..542b1717d88 100644 --- a/tests/components/nice_go/test_cover.py +++ b/tests/components/nice_go/test_cover.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from syrupy import SnapshotAssertion @@ -154,3 +154,86 @@ async def test_cover_exceptions( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + def _open_side_effect(*args, **kwargs): + if mock_nice_go.open_barrier.call_count <= 3: + raise AuthFailedError + if mock_nice_go.open_barrier.call_count == 5: + raise AuthFailedError + if mock_nice_go.open_barrier.call_count == 6: + raise ApiError + + def _close_side_effect(*args, **kwargs): + if mock_nice_go.close_barrier.call_count <= 3: + raise AuthFailedError + if mock_nice_go.close_barrier.call_count == 4: + raise ApiError + + mock_nice_go.open_barrier.side_effect = _open_side_effect + mock_nice_go.close_barrier.side_effect = _close_side_effect + + with pytest.raises(HomeAssistantError, match="Error opening the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.open_barrier.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error closing the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.close_barrier.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.open_barrier.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error opening the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error closing the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.open_barrier.call_count == 6 + assert mock_nice_go.close_barrier.call_count == 4 diff --git a/tests/components/nice_go/test_light.py b/tests/components/nice_go/test_light.py index b170a0ee3ab..2bc9de59b2b 100644 --- a/tests/components/nice_go/test_light.py +++ b/tests/components/nice_go/test_light.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from syrupy import SnapshotAssertion @@ -160,3 +160,86 @@ async def test_unsupported_device_type( "Please create an issue with your device model in additional info" in caplog.text ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) + + def _on_side_effect(*args, **kwargs): + if mock_nice_go.light_on.call_count <= 3: + raise AuthFailedError + if mock_nice_go.light_on.call_count == 5: + raise AuthFailedError + if mock_nice_go.light_on.call_count == 6: + raise ApiError + + def _off_side_effect(*args, **kwargs): + if mock_nice_go.light_off.call_count <= 3: + raise AuthFailedError + if mock_nice_go.light_off.call_count == 4: + raise ApiError + + mock_nice_go.light_on.side_effect = _on_side_effect + mock_nice_go.light_off.side_effect = _off_side_effect + + with pytest.raises(HomeAssistantError, match="Error while turning on the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.light_on.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error while turning off the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_garage_2_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.light_off.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.light_on.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error while turning on the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error while turning off the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_garage_2_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.light_on.call_count == 6 + assert mock_nice_go.light_off.call_count == 4 diff --git a/tests/components/nice_go/test_switch.py b/tests/components/nice_go/test_switch.py index d3a2141eb2b..cab009c5b94 100644 --- a/tests/components/nice_go/test_switch.py +++ b/tests/components/nice_go/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from homeassistant.components.switch import ( @@ -88,3 +88,86 @@ async def test_error( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) + + def _on_side_effect(*args, **kwargs): + if mock_nice_go.vacation_mode_on.call_count <= 3: + raise AuthFailedError + if mock_nice_go.vacation_mode_on.call_count == 5: + raise AuthFailedError + if mock_nice_go.vacation_mode_on.call_count == 6: + raise ApiError + + def _off_side_effect(*args, **kwargs): + if mock_nice_go.vacation_mode_off.call_count <= 3: + raise AuthFailedError + if mock_nice_go.vacation_mode_off.call_count == 4: + raise ApiError + + mock_nice_go.vacation_mode_on.side_effect = _on_side_effect + mock_nice_go.vacation_mode_off.side_effect = _off_side_effect + + with pytest.raises(HomeAssistantError, match="Error while turning on the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.vacation_mode_on.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error while turning off the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_garage_2_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.vacation_mode_off.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.vacation_mode_on.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error while turning on the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error while turning off the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_garage_2_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.vacation_mode_on.call_count == 6 + assert mock_nice_go.vacation_mode_off.call_count == 4 From 2bfe96dded803ecd1ed0f65cba729eb2adf40dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 14 Feb 2025 20:21:01 +0100 Subject: [PATCH 0538/1941] Add Home Connect action with recognized programs and options (#130662) * Added recognized options to Home Connect actions * Fix ruff * Fix strings.json * Fix dishwasher typo * Improved test_bsh_key_transformations * Add missing return types * Added descriptions * Remove custom options * Fixes * Merge the 4 services (select, start, set options for active or selected program) And deprecate the original ones * Delete stale snapshots * Clean up logic after service validation * Make deprecated actions issues fixable And delete issue on entry unload * Fixes and improvements Co-authored-by: Martin Hjelmare * Improvements Co-authored-by: Martin Hjelmare * Fix name and descriptions * Add `affects_to` to strings and service.yaml * Add missing periods at strings * Fix Co-authored-by: Norbert Rittel * Add tests to check if the flow removes the deprecated action issue --------- Co-authored-by: Martin Hjelmare Co-authored-by: Norbert Rittel --- .../components/home_connect/__init__.py | 284 +++- .../components/home_connect/const.py | 249 +++- .../components/home_connect/icons.json | 3 + .../components/home_connect/manifest.json | 2 +- .../components/home_connect/select.py | 20 +- .../components/home_connect/services.yaml | 526 ++++++++ .../components/home_connect/strings.json | 1174 ++++++++++++----- tests/components/home_connect/conftest.py | 68 +- .../home_connect/snapshots/test_init.ambr | 79 ++ tests/components/home_connect/test_init.py | 281 +++- 10 files changed, 2331 insertions(+), 355 deletions(-) create mode 100644 tests/components/home_connect/snapshots/test_init.ambr diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index becc78cef90..59a33f01bcb 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,11 +2,20 @@ from __future__ import annotations +from collections.abc import Awaitable +from datetime import timedelta import logging from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey +from aiohomeconnect.model import ( + ArrayOfOptions, + CommandKey, + Option, + OptionKey, + ProgramKey, + SettingKey, +) from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol @@ -19,34 +28,84 @@ from homeassistant.helpers import ( device_registry as dr, ) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth from .const import ( + AFFECTS_TO_ACTIVE_PROGRAM, + AFFECTS_TO_SELECTED_PROGRAM, + ATTR_AFFECTS_TO, ATTR_KEY, ATTR_PROGRAM, ATTR_UNIT, ATTR_VALUE, DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP, + PROGRAM_ENUM_OPTIONS, SERVICE_OPTION_ACTIVE, SERVICE_OPTION_SELECTED, SERVICE_PAUSE_PROGRAM, SERVICE_RESUME_PROGRAM, SERVICE_SELECT_PROGRAM, + SERVICE_SET_PROGRAM_AND_OPTIONS, SERVICE_SETTING, SERVICE_START_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_VALUE, + TRANSLATION_KEYS_PROGRAMS_MAP, ) from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .utils import get_dict_from_home_connect_error +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO: int, + OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, + OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, + OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, + OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + }.items() +} + +TIME_PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.BSH_COMMON_START_IN_RELATIVE: cv.time_period_str, + OptionKey.BSH_COMMON_DURATION: cv.time_period_str, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: cv.time_period_str, + }.items() +} + + SERVICE_SETTING_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): str, @@ -58,6 +117,7 @@ SERVICE_SETTING_SCHEMA = vol.Schema( } ) +# DEPRECATED: Remove in 2025.9.0 SERVICE_OPTION_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): str, @@ -70,6 +130,7 @@ SERVICE_OPTION_SCHEMA = vol.Schema( } ) +# DEPRECATED: Remove in 2025.9.0 SERVICE_PROGRAM_SCHEMA = vol.Any( { vol.Required(ATTR_DEVICE_ID): str, @@ -93,6 +154,51 @@ SERVICE_PROGRAM_SCHEMA = vol.Any( }, ) + +def _require_program_or_at_least_one_option(data: dict) -> dict: + if ATTR_PROGRAM not in data and not any( + option_key in data + for option_key in ( + PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS + ) + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="required_program_or_one_option_at_least", + ) + return data + + +SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_AFFECTS_TO): vol.In( + [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] + ), + vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), + } + ) + .extend( + { + vol.Optional(translation_key): vol.In(allowed_values.keys()) + for translation_key, ( + key, + allowed_values, + ) in PROGRAM_ENUM_OPTIONS.items() + } + ) + .extend( + { + vol.Optional(translation_key): schema + for translation_key, (key, schema) in ( + PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS + ).items() + } + ), + _require_program_or_at_least_one_option, +) + SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) PLATFORMS = [ @@ -144,7 +250,7 @@ async def _get_client_and_ha_id( return entry.runtime_data.client, ha_id -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up Home Connect component.""" async def _async_service_program(call: ServiceCall, start: bool): @@ -165,6 +271,57 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else None ) + async_create_issue( + hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_PROGRAM}: {program}", + *([f" {ATTR_KEY}: {options[0].key}"] if options else []), + *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), + *( + [f" {ATTR_UNIT}: {options[0].unit}"] + if options and options[0].unit + else [] + ), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", + f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", + *( + [ + f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" + ] + if options + else [] + ), + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + try: if start: await client.start_program(ha_id, program_key=program, options=options) @@ -189,6 +346,44 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: unit = call.data.get(ATTR_UNIT) client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + async_create_issue( + hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_KEY}: {option_key}", + f" {ATTR_VALUE}: {value}", + *([f" {ATTR_UNIT}: {unit}"] if unit else []), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", + f" {bsh_key_to_translation_key(option_key)}: {value}", + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) try: if active: await client.set_active_program_option( @@ -272,6 +467,82 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Service for selecting a program.""" await _async_service_program(call, False) + async def async_service_set_program_and_options(call: ServiceCall): + """Service for setting a program and options.""" + data = dict(call.data) + program = data.pop(ATTR_PROGRAM, None) + affects_to = data.pop(ATTR_AFFECTS_TO) + client, ha_id = await _get_client_and_ha_id(hass, data.pop(ATTR_DEVICE_ID)) + + options: list[Option] = [] + + for option, value in data.items(): + if option in PROGRAM_ENUM_OPTIONS: + options.append( + Option( + PROGRAM_ENUM_OPTIONS[option][0], + PROGRAM_ENUM_OPTIONS[option][1][value], + ) + ) + elif option in PROGRAM_OPTIONS: + option_key = PROGRAM_OPTIONS[option][0] + options.append(Option(option_key, value)) + elif option in TIME_PROGRAM_OPTIONS: + options.append( + Option( + TIME_PROGRAM_OPTIONS[option][0], + int(cast(timedelta, value).total_seconds()), + ) + ) + method_call: Awaitable[Any] + exception_translation_key: str + if program: + program = ( + program + if isinstance(program, ProgramKey) + else TRANSLATION_KEYS_PROGRAMS_MAP[program] + ) + + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.start_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "start_program" + elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: + method_call = client.set_selected_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "select_program" + else: + array_of_options = ArrayOfOptions(options) + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.set_active_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_active_program" + else: + # affects_to is AFFECTS_TO_SELECTED_PROGRAM + method_call = client.set_selected_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_selected_program" + + try: + await method_call + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=exception_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **( + {SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program} + if program + else {} + ), + }, + ) from err + async def async_service_start_program(call: ServiceCall): """Service for starting a program.""" await _async_service_program(call, True) @@ -315,6 +586,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_service_start_program, schema=SERVICE_PROGRAM_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_AND_OPTIONS, + async_service_set_program_and_options, + schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, + ) return True @@ -349,6 +626,7 @@ async def async_unload_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: """Unload a config entry.""" + async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 127aa1ffe92..0ec7d3a2629 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -1,6 +1,10 @@ """Constants for the Home Connect integration.""" -from aiohomeconnect.model import EventKey, SettingKey, StatusKey +from typing import cast + +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey + +from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" @@ -52,15 +56,18 @@ SERVICE_OPTION_SELECTED = "set_option_selected" SERVICE_PAUSE_PROGRAM = "pause_program" SERVICE_RESUME_PROGRAM = "resume_program" SERVICE_SELECT_PROGRAM = "select_program" +SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options" SERVICE_SETTING = "change_setting" SERVICE_START_PROGRAM = "start_program" - +ATTR_AFFECTS_TO = "affects_to" ATTR_KEY = "key" ATTR_PROGRAM = "program" ATTR_UNIT = "unit" ATTR_VALUE = "value" +AFFECTS_TO_ACTIVE_PROGRAM = "active_program" +AFFECTS_TO_SELECTED_PROGRAM = "selected_program" SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" @@ -70,6 +77,244 @@ SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" +TRANSLATION_KEYS_PROGRAMS_MAP = { + bsh_key_to_translation_key(program.value): cast(ProgramKey, program) + for program in ProgramKey + if program != ProgramKey.UNKNOWN +} + +PROGRAMS_TRANSLATION_KEYS_MAP = { + value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() +} + +REFERENCE_MAP_ID_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap", + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map1", + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map2", + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map3", + ) +} + +CLEANING_MODE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent", + "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard", + "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power", + ) +} + +BEAN_AMOUNT_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryMild", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Mild", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.MildPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.NormalPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Strong", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.StrongPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrong", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrongPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.ExtraStrong", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShot", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlusPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShot", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.CoffeeGround", + ) +} + +COFFEE_TEMPERATURE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.88C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.90C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.92C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.94C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.95C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.96C", + ) +} + +BEAN_CONTAINER_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Right", + "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Left", + ) +} + +FLOW_RATE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Normal", + "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Intense", + "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.IntensePlus", + ) +} + +HOT_WATER_TEMPERATURE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.WhiteTea", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.GreenTea", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.BlackTea", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.50C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.55C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.60C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.65C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.70C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.75C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.80C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.85C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.90C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.95C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.97C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.122F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.131F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.140F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.149F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.158F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.167F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.176F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.185F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.194F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.203F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.Max", + ) +} + +DRYING_TARGET_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Dryer.EnumType.DryingTarget.IronDry", + "LaundryCare.Dryer.EnumType.DryingTarget.GentleDry", + "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDry", + "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDryPlus", + "LaundryCare.Dryer.EnumType.DryingTarget.ExtraDry", + ) +} + +VENTING_LEVEL_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Hood.EnumType.Stage.FanOff", + "Cooking.Hood.EnumType.Stage.FanStage01", + "Cooking.Hood.EnumType.Stage.FanStage02", + "Cooking.Hood.EnumType.Stage.FanStage03", + "Cooking.Hood.EnumType.Stage.FanStage04", + "Cooking.Hood.EnumType.Stage.FanStage05", + ) +} + +INTENSIVE_LEVEL_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage1", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage2", + ) +} + +WARMING_LEVEL_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Oven.EnumType.WarmingLevel.Low", + "Cooking.Oven.EnumType.WarmingLevel.Medium", + "Cooking.Oven.EnumType.WarmingLevel.High", + ) +} + +TEMPERATURE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Washer.EnumType.Temperature.Cold", + "LaundryCare.Washer.EnumType.Temperature.GC20", + "LaundryCare.Washer.EnumType.Temperature.GC30", + "LaundryCare.Washer.EnumType.Temperature.GC40", + "LaundryCare.Washer.EnumType.Temperature.GC50", + "LaundryCare.Washer.EnumType.Temperature.GC60", + "LaundryCare.Washer.EnumType.Temperature.GC70", + "LaundryCare.Washer.EnumType.Temperature.GC80", + "LaundryCare.Washer.EnumType.Temperature.GC90", + "LaundryCare.Washer.EnumType.Temperature.UlCold", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + "LaundryCare.Washer.EnumType.Temperature.UlHot", + "LaundryCare.Washer.EnumType.Temperature.UlExtraHot", + ) +} + +SPIN_SPEED_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Washer.EnumType.SpinSpeed.Off", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1200", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1400", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1600", + "LaundryCare.Washer.EnumType.SpinSpeed.UlOff", + "LaundryCare.Washer.EnumType.SpinSpeed.UlLow", + "LaundryCare.Washer.EnumType.SpinSpeed.UlMedium", + "LaundryCare.Washer.EnumType.SpinSpeed.UlHigh", + ) +} + +VARIO_PERFECT_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Common.EnumType.VarioPerfect.Off", + "LaundryCare.Common.EnumType.VarioPerfect.EcoPerfect", + "LaundryCare.Common.EnumType.VarioPerfect.SpeedPerfect", + ) +} + + +PROGRAM_ENUM_OPTIONS = { + bsh_key_to_translation_key(option_key): ( + option_key, + options, + ) + for option_key, options in ( + ( + OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, + REFERENCE_MAP_ID_OPTIONS, + ), + ( + OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + CLEANING_MODE_OPTIONS, + ), + (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, + COFFEE_TEMPERATURE_OPTIONS, + ), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, + BEAN_CONTAINER_OPTIONS, + ), + (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, FLOW_RATE_OPTIONS), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, + HOT_WATER_TEMPERATURE_OPTIONS, + ), + (OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, DRYING_TARGET_OPTIONS), + (OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, VENTING_LEVEL_OPTIONS), + (OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, INTENSIVE_LEVEL_OPTIONS), + (OptionKey.COOKING_OVEN_WARMING_LEVEL, WARMING_LEVEL_OPTIONS), + (OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, TEMPERATURE_OPTIONS), + (OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, SPIN_SPEED_OPTIONS), + (OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, VARIO_PERFECT_OPTIONS), + ) +} + + OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { "ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK, "Operation State": StatusKey.BSH_COMMON_OPERATION_STATE, diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 166b2fe2c34..6b604fc004e 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -18,6 +18,9 @@ "set_option_selected": { "service": "mdi:gesture-tap" }, + "set_program_and_options": { + "service": "mdi:form-select" + }, "change_setting": { "service": "mdi:cog" } diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 94085af2fc3..06325afaed8 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -3,7 +3,7 @@ "name": "Home Connect", "codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"], "config_flow": true, - "dependencies": ["application_credentials"], + "dependencies": ["application_credentials", "repairs"], "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 165842abf1c..bc281e3d928 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -15,24 +15,20 @@ 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, SVE_TRANSLATION_PLACEHOLDER_PROGRAM +from .const import ( + APPLIANCES_WITH_PROGRAMS, + DOMAIN, + PROGRAMS_TRANSLATION_KEYS_MAP, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + TRANSLATION_KEYS_PROGRAMS_MAP, +) from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, HomeConnectCoordinator, ) from .entity import HomeConnectEntity -from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error - -TRANSLATION_KEYS_PROGRAMS_MAP = { - bsh_key_to_translation_key(program.value): cast(ProgramKey, program) - for program in ProgramKey - if program != ProgramKey.UNKNOWN -} - -PROGRAMS_TRANSLATION_KEYS_MAP = { - value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() -} +from .utils import get_dict_from_home_connect_error @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 0738b58595a..29ca3da15fc 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -46,6 +46,532 @@ select_program: example: "seconds" selector: text: +set_program_and_options: + fields: + device_id: + required: true + selector: + device: + integration: home_connect + affects_to: + example: active_program + required: true + selector: + select: + translation_key: affects_to + options: + - active_program + - selected_program + program: + example: dishcare_dishwasher_program_auto2 + required: true + selector: + select: + mode: dropdown + custom_value: false + translation_key: programs + options: + - consumer_products_cleaning_robot_program_cleaning_clean_all + - consumer_products_cleaning_robot_program_cleaning_clean_map + - consumer_products_cleaning_robot_program_basic_go_home + - consumer_products_coffee_maker_program_beverage_ristretto + - consumer_products_coffee_maker_program_beverage_espresso + - consumer_products_coffee_maker_program_beverage_espresso_doppio + - consumer_products_coffee_maker_program_beverage_coffee + - consumer_products_coffee_maker_program_beverage_x_l_coffee + - consumer_products_coffee_maker_program_beverage_caffe_grande + - consumer_products_coffee_maker_program_beverage_espresso_macchiato + - consumer_products_coffee_maker_program_beverage_cappuccino + - consumer_products_coffee_maker_program_beverage_latte_macchiato + - consumer_products_coffee_maker_program_beverage_caffe_latte + - consumer_products_coffee_maker_program_beverage_milk_froth + - consumer_products_coffee_maker_program_beverage_warm_milk + - consumer_products_coffee_maker_program_coffee_world_kleiner_brauner + - consumer_products_coffee_maker_program_coffee_world_grosser_brauner + - consumer_products_coffee_maker_program_coffee_world_verlaengerter + - consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun + - consumer_products_coffee_maker_program_coffee_world_wiener_melange + - consumer_products_coffee_maker_program_coffee_world_flat_white + - consumer_products_coffee_maker_program_coffee_world_cortado + - consumer_products_coffee_maker_program_coffee_world_cafe_cortado + - consumer_products_coffee_maker_program_coffee_world_cafe_con_leche + - consumer_products_coffee_maker_program_coffee_world_cafe_au_lait + - consumer_products_coffee_maker_program_coffee_world_doppio + - consumer_products_coffee_maker_program_coffee_world_kaapi + - consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd + - consumer_products_coffee_maker_program_coffee_world_galao + - consumer_products_coffee_maker_program_coffee_world_garoto + - consumer_products_coffee_maker_program_coffee_world_americano + - consumer_products_coffee_maker_program_coffee_world_red_eye + - consumer_products_coffee_maker_program_coffee_world_black_eye + - consumer_products_coffee_maker_program_coffee_world_dead_eye + - consumer_products_coffee_maker_program_beverage_hot_water + - dishcare_dishwasher_program_pre_rinse + - dishcare_dishwasher_program_auto_1 + - dishcare_dishwasher_program_auto_2 + - dishcare_dishwasher_program_auto_3 + - dishcare_dishwasher_program_eco_50 + - dishcare_dishwasher_program_quick_45 + - dishcare_dishwasher_program_intensiv_70 + - dishcare_dishwasher_program_normal_65 + - dishcare_dishwasher_program_glas_40 + - dishcare_dishwasher_program_glass_care + - dishcare_dishwasher_program_night_wash + - dishcare_dishwasher_program_quick_65 + - dishcare_dishwasher_program_normal_45 + - dishcare_dishwasher_program_intensiv_45 + - dishcare_dishwasher_program_auto_half_load + - dishcare_dishwasher_program_intensiv_power + - dishcare_dishwasher_program_magic_daily + - dishcare_dishwasher_program_super_60 + - dishcare_dishwasher_program_kurz_60 + - dishcare_dishwasher_program_express_sparkle_65 + - dishcare_dishwasher_program_machine_care + - dishcare_dishwasher_program_steam_fresh + - dishcare_dishwasher_program_maximum_cleaning + - dishcare_dishwasher_program_mixed_load + - laundry_care_dryer_program_cotton + - laundry_care_dryer_program_synthetic + - laundry_care_dryer_program_mix + - laundry_care_dryer_program_blankets + - laundry_care_dryer_program_business_shirts + - laundry_care_dryer_program_down_feathers + - laundry_care_dryer_program_hygiene + - laundry_care_dryer_program_jeans + - laundry_care_dryer_program_outdoor + - laundry_care_dryer_program_synthetic_refresh + - laundry_care_dryer_program_towels + - laundry_care_dryer_program_delicates + - laundry_care_dryer_program_super_40 + - laundry_care_dryer_program_shirts_15 + - laundry_care_dryer_program_pillow + - laundry_care_dryer_program_anti_shrink + - laundry_care_dryer_program_my_time_my_drying_time + - laundry_care_dryer_program_time_cold + - laundry_care_dryer_program_time_warm + - laundry_care_dryer_program_in_basket + - laundry_care_dryer_program_time_cold_fix_time_cold_20 + - laundry_care_dryer_program_time_cold_fix_time_cold_30 + - laundry_care_dryer_program_time_cold_fix_time_cold_60 + - laundry_care_dryer_program_time_warm_fix_time_warm_30 + - laundry_care_dryer_program_time_warm_fix_time_warm_40 + - laundry_care_dryer_program_time_warm_fix_time_warm_60 + - laundry_care_dryer_program_dessous + - cooking_common_program_hood_automatic + - cooking_common_program_hood_venting + - cooking_common_program_hood_delayed_shut_off + - cooking_oven_program_heating_mode_pre_heating + - cooking_oven_program_heating_mode_hot_air + - cooking_oven_program_heating_mode_hot_air_eco + - cooking_oven_program_heating_mode_hot_air_grilling + - cooking_oven_program_heating_mode_top_bottom_heating + - cooking_oven_program_heating_mode_top_bottom_heating_eco + - cooking_oven_program_heating_mode_bottom_heating + - cooking_oven_program_heating_mode_pizza_setting + - cooking_oven_program_heating_mode_slow_cook + - cooking_oven_program_heating_mode_intensive_heat + - cooking_oven_program_heating_mode_keep_warm + - cooking_oven_program_heating_mode_preheat_ovenware + - cooking_oven_program_heating_mode_frozen_heatup_special + - cooking_oven_program_heating_mode_desiccation + - cooking_oven_program_heating_mode_defrost + - cooking_oven_program_heating_mode_proof + - cooking_oven_program_heating_mode_hot_air_30_steam + - cooking_oven_program_heating_mode_hot_air_60_steam + - cooking_oven_program_heating_mode_hot_air_80_steam + - cooking_oven_program_heating_mode_hot_air_100_steam + - cooking_oven_program_heating_mode_sabbath_programme + - cooking_oven_program_microwave_90_watt + - cooking_oven_program_microwave_180_watt + - cooking_oven_program_microwave_360_watt + - cooking_oven_program_microwave_600_watt + - cooking_oven_program_microwave_900_watt + - cooking_oven_program_microwave_1000_watt + - cooking_oven_program_microwave_max + - cooking_oven_program_heating_mode_warming_drawer + - laundry_care_washer_program_cotton + - laundry_care_washer_program_cotton_cotton_eco + - laundry_care_washer_program_cotton_eco_4060 + - laundry_care_washer_program_cotton_colour + - laundry_care_washer_program_easy_care + - laundry_care_washer_program_mix + - laundry_care_washer_program_mix_night_wash + - laundry_care_washer_program_delicates_silk + - laundry_care_washer_program_wool + - laundry_care_washer_program_sensitive + - laundry_care_washer_program_auto_30 + - laundry_care_washer_program_auto_40 + - laundry_care_washer_program_auto_60 + - laundry_care_washer_program_chiffon + - laundry_care_washer_program_curtains + - laundry_care_washer_program_dark_wash + - laundry_care_washer_program_dessous + - laundry_care_washer_program_monsoon + - laundry_care_washer_program_outdoor + - laundry_care_washer_program_plush_toy + - laundry_care_washer_program_shirts_blouses + - laundry_care_washer_program_sport_fitness + - laundry_care_washer_program_towels + - laundry_care_washer_program_water_proof + - laundry_care_washer_program_power_speed_59 + - laundry_care_washer_program_super_153045_super_15 + - laundry_care_washer_program_super_153045_super_1530 + - laundry_care_washer_program_down_duvet_duvet + - laundry_care_washer_program_rinse_rinse_spin_drain + - laundry_care_washer_program_drum_clean + - laundry_care_washer_dryer_program_cotton + - laundry_care_washer_dryer_program_cotton_eco_4060 + - laundry_care_washer_dryer_program_mix + - laundry_care_washer_dryer_program_easy_care + - laundry_care_washer_dryer_program_wash_and_dry_60 + - laundry_care_washer_dryer_program_wash_and_dry_90 + cleaning_robot_options: + collapsed: true + fields: + consumer_products_cleaning_robot_option_reference_map_id: + example: consumer_products_cleaning_robot_enum_type_available_maps_map1 + required: false + selector: + select: + mode: dropdown + translation_key: available_maps + options: + - consumer_products_cleaning_robot_enum_type_available_maps_temp_map + - consumer_products_cleaning_robot_enum_type_available_maps_map1 + - consumer_products_cleaning_robot_enum_type_available_maps_map2 + - consumer_products_cleaning_robot_enum_type_available_maps_map3 + consumer_products_cleaning_robot_option_cleaning_mode: + example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard + required: false + selector: + select: + mode: dropdown + translation_key: cleaning_mode + options: + - consumer_products_cleaning_robot_enum_type_cleaning_modes_silent + - consumer_products_cleaning_robot_enum_type_cleaning_modes_standard + - consumer_products_cleaning_robot_enum_type_cleaning_modes_power + coffee_maker_options: + collapsed: true + fields: + consumer_products_coffee_maker_option_bean_amount: + example: consumer_products_coffee_maker_enum_type_bean_amount_normal + required: false + selector: + select: + mode: dropdown + translation_key: bean_amount + options: + - consumer_products_coffee_maker_enum_type_bean_amount_very_mild + - consumer_products_coffee_maker_enum_type_bean_amount_mild + - consumer_products_coffee_maker_enum_type_bean_amount_mild_plus + - consumer_products_coffee_maker_enum_type_bean_amount_normal + - consumer_products_coffee_maker_enum_type_bean_amount_normal_plus + - consumer_products_coffee_maker_enum_type_bean_amount_strong + - consumer_products_coffee_maker_enum_type_bean_amount_strong_plus + - consumer_products_coffee_maker_enum_type_bean_amount_very_strong + - consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus + - consumer_products_coffee_maker_enum_type_bean_amount_extra_strong + - consumer_products_coffee_maker_enum_type_bean_amount_double_shot + - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus + - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus + - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot + - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus + - consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground + consumer_products_coffee_maker_option_fill_quantity: + example: 60 + required: false + selector: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: ml + consumer_products_coffee_maker_option_coffee_temperature: + example: consumer_products_coffee_maker_enum_type_coffee_temperature_88_c + required: false + selector: + select: + mode: dropdown + translation_key: coffee_temperature + options: + - consumer_products_coffee_maker_enum_type_coffee_temperature_88_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_90_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_92_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_94_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_95_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_96_c + consumer_products_coffee_maker_option_bean_container: + example: consumer_products_coffee_maker_enum_type_bean_container_selection_right + required: false + selector: + select: + mode: dropdown + translation_key: bean_container + options: + - consumer_products_coffee_maker_enum_type_bean_container_selection_right + - consumer_products_coffee_maker_enum_type_bean_container_selection_left + consumer_products_coffee_maker_option_flow_rate: + example: consumer_products_coffee_maker_enum_type_flow_rate_normal + required: false + selector: + select: + mode: dropdown + translation_key: flow_rate + options: + - consumer_products_coffee_maker_enum_type_flow_rate_normal + - consumer_products_coffee_maker_enum_type_flow_rate_intense + - consumer_products_coffee_maker_enum_type_flow_rate_intense_plus + consumer_products_coffee_maker_option_multiple_beverages: + example: false + required: false + selector: + boolean: + consumer_products_coffee_maker_option_coffee_milk_ratio: + example: 50 + required: false + selector: + number: + unit_of_measurement: "%" + step: 10 + min: 10 + max: 90 + consumer_products_coffee_maker_option_hot_water_temperature: + example: consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c + required: false + selector: + select: + mode: dropdown + translation_key: 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 + - consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea + - consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_max + dish_washer_options: + collapsed: true + fields: + b_s_h_common_option_start_in_relative: + example: "30:00" + required: false + selector: + time: + dishcare_dishwasher_option_intensiv_zone: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_brilliance_dry: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_vario_speed_plus: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_silence_on_demand: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_half_load: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_extra_dry: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_hygiene_plus: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_eco_dry: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_zeolite_dry: + example: false + required: false + selector: + boolean: + dryer_options: + collapsed: true + fields: + laundry_care_dryer_option_drying_target: + example: laundry_care_dryer_enum_type_drying_target_iron_dry + required: false + selector: + select: + mode: dropdown + translation_key: drying_target + options: + - laundry_care_dryer_enum_type_drying_target_iron_dry + - laundry_care_dryer_enum_type_drying_target_gentle_dry + - laundry_care_dryer_enum_type_drying_target_cupboard_dry + - laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus + - laundry_care_dryer_enum_type_drying_target_extra_dry + hood_options: + collapsed: true + fields: + cooking_hood_option_venting_level: + example: cooking_hood_enum_type_stage_fan_stage01 + required: false + selector: + select: + mode: dropdown + translation_key: venting_level + options: + - cooking_hood_enum_type_stage_fan_off + - cooking_hood_enum_type_stage_fan_stage01 + - cooking_hood_enum_type_stage_fan_stage02 + - cooking_hood_enum_type_stage_fan_stage03 + - cooking_hood_enum_type_stage_fan_stage04 + - cooking_hood_enum_type_stage_fan_stage05 + cooking_hood_option_intensive_level: + example: cooking_hood_enum_type_intensive_stage_intensive_stage1 + required: false + selector: + select: + mode: dropdown + translation_key: intensive_level + options: + - cooking_hood_enum_type_intensive_stage_intensive_stage_off + - cooking_hood_enum_type_intensive_stage_intensive_stage1 + - cooking_hood_enum_type_intensive_stage_intensive_stage2 + oven_options: + collapsed: true + fields: + cooking_oven_option_setpoint_temperature: + example: 180 + required: false + selector: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: °C/°F + b_s_h_common_option_duration: + example: "30:00" + required: false + selector: + time: + cooking_oven_option_fast_pre_heat: + example: false + required: false + selector: + boolean: + warming_drawer_options: + collapsed: true + fields: + cooking_oven_option_warming_level: + example: cooking_oven_enum_type_warming_level_medium + required: false + selector: + select: + mode: dropdown + translation_key: warming_level + options: + - cooking_oven_enum_type_warming_level_low + - cooking_oven_enum_type_warming_level_medium + - cooking_oven_enum_type_warming_level_high + washer_options: + collapsed: true + fields: + laundry_care_washer_option_temperature: + example: laundry_care_washer_enum_type_temperature_g_c40 + required: false + selector: + select: + mode: dropdown + translation_key: washer_temperature + options: + - laundry_care_washer_enum_type_temperature_cold + - laundry_care_washer_enum_type_temperature_g_c20 + - laundry_care_washer_enum_type_temperature_g_c30 + - laundry_care_washer_enum_type_temperature_g_c40 + - laundry_care_washer_enum_type_temperature_g_c50 + - laundry_care_washer_enum_type_temperature_g_c60 + - laundry_care_washer_enum_type_temperature_g_c70 + - laundry_care_washer_enum_type_temperature_g_c80 + - laundry_care_washer_enum_type_temperature_g_c90 + - 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 + laundry_care_washer_option_spin_speed: + example: laundry_care_washer_enum_type_spin_speed_r_p_m800 + required: false + selector: + select: + mode: dropdown + translation_key: spin_speed + options: + - laundry_care_washer_enum_type_spin_speed_off + - laundry_care_washer_enum_type_spin_speed_r_p_m400 + - laundry_care_washer_enum_type_spin_speed_r_p_m600 + - laundry_care_washer_enum_type_spin_speed_r_p_m800 + - laundry_care_washer_enum_type_spin_speed_r_p_m1000 + - laundry_care_washer_enum_type_spin_speed_r_p_m1200 + - laundry_care_washer_enum_type_spin_speed_r_p_m1400 + - laundry_care_washer_enum_type_spin_speed_r_p_m1600 + - laundry_care_washer_enum_type_spin_speed_ul_off + - laundry_care_washer_enum_type_spin_speed_ul_low + - laundry_care_washer_enum_type_spin_speed_ul_medium + - laundry_care_washer_enum_type_spin_speed_ul_high + b_s_h_common_option_finish_in_relative: + example: "30:00" + required: false + selector: + time: + laundry_care_washer_option_i_dos1_active: + example: false + required: false + selector: + boolean: + laundry_care_washer_option_i_dos2_active: + example: false + required: false + selector: + boolean: + laundry_care_washer_option_vario_perfect: + example: laundry_care_common_enum_type_vario_perfect_eco_perfect + required: false + selector: + select: + mode: dropdown + translation_key: vario_perfect + options: + - laundry_care_common_enum_type_vario_perfect_off + - laundry_care_common_enum_type_vario_perfect_eco_perfect + - laundry_care_common_enum_type_vario_perfect_speed_perfect pause_program: fields: device_id: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index d07cfcdf854..38fdd6f6ec3 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -95,6 +95,9 @@ }, "fetch_api_error": { "message": "Error obtaining data from the API: {error}" + }, + "required_program_or_one_option_at_least": { + "message": "A program or at least one of the possible options for a program should be specified" } }, "issues": { @@ -105,6 +108,343 @@ "deprecated_program_switch": { "title": "Deprecated program switch detected in some automations or scripts", "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue." + }, + "deprecated_set_program_and_option_actions": { + "title": "The executed action is deprecated", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_set_program_and_option_actions::title%]", + "description": "`start_program`, `select_program`, `set_option_active`, and `set_option_selected` actions are deprecated and will be removed in the {remove_release} release, please use the `{new_action_key}` action instead. For the executed action:\n{deprecated_action_yaml}\nyou can do the following transformation using the recognized options:\n {new_action_yaml}\nIf the option is not in the recognized options, please submit an issue or a pull request requesting the addition of the option at {repo_link}." + } + } + } + } + }, + "selector": { + "affects_to": { + "options": { + "active_program": "Active program", + "selected_program": "Selected program" + } + }, + "programs": { + "options": { + "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map", + "consumer_products_cleaning_robot_program_basic_go_home": "Go home", + "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto", + "consumer_products_coffee_maker_program_beverage_espresso": "Espresso", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio", + "consumer_products_coffee_maker_program_beverage_coffee": "Coffee", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato", + "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", + "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", + "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", + "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait", + "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd", + "consumer_products_coffee_maker_program_coffee_world_galao": "Galao", + "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto", + "consumer_products_coffee_maker_program_coffee_world_americano": "Americano", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye", + "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water", + "dishcare_dishwasher_program_pre_rinse": "Pre_rinse", + "dishcare_dishwasher_program_auto_1": "Auto 1", + "dishcare_dishwasher_program_auto_2": "Auto 2", + "dishcare_dishwasher_program_auto_3": "Auto 3", + "dishcare_dishwasher_program_eco_50": "Eco 50ºC", + "dishcare_dishwasher_program_quick_45": "Quick 45ºC", + "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC", + "dishcare_dishwasher_program_normal_65": "Normal 65ºC", + "dishcare_dishwasher_program_glas_40": "Glass 40ºC", + "dishcare_dishwasher_program_glass_care": "Glass care", + "dishcare_dishwasher_program_night_wash": "Night wash", + "dishcare_dishwasher_program_quick_65": "Quick 65ºC", + "dishcare_dishwasher_program_normal_45": "Normal 45ºC", + "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC", + "dishcare_dishwasher_program_auto_half_load": "Auto half load", + "dishcare_dishwasher_program_intensiv_power": "Intensive power", + "dishcare_dishwasher_program_magic_daily": "Magic daily", + "dishcare_dishwasher_program_super_60": "Super 60ºC", + "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC", + "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC", + "dishcare_dishwasher_program_machine_care": "Machine care", + "dishcare_dishwasher_program_steam_fresh": "Steam fresh", + "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning", + "dishcare_dishwasher_program_mixed_load": "Mixed load", + "laundry_care_dryer_program_cotton": "Cotton", + "laundry_care_dryer_program_synthetic": "Synthetic", + "laundry_care_dryer_program_mix": "Mix", + "laundry_care_dryer_program_blankets": "Blankets", + "laundry_care_dryer_program_business_shirts": "Business shirts", + "laundry_care_dryer_program_down_feathers": "Down feathers", + "laundry_care_dryer_program_hygiene": "Hygiene", + "laundry_care_dryer_program_jeans": "Jeans", + "laundry_care_dryer_program_outdoor": "Outdoor", + "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh", + "laundry_care_dryer_program_towels": "Towels", + "laundry_care_dryer_program_delicates": "Delicates", + "laundry_care_dryer_program_super_40": "Super 40ºC", + "laundry_care_dryer_program_shirts_15": "Shirts 15ºC", + "laundry_care_dryer_program_pillow": "Pillow", + "laundry_care_dryer_program_anti_shrink": "Anti shrink", + "laundry_care_dryer_program_my_time_my_drying_time": "My drying time", + "laundry_care_dryer_program_time_cold": "Cold (variable time)", + "laundry_care_dryer_program_time_warm": "Warm (variable time)", + "laundry_care_dryer_program_in_basket": "In basket", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)", + "laundry_care_dryer_program_dessous": "Dessous", + "cooking_common_program_hood_automatic": "Automatic", + "cooking_common_program_hood_venting": "Venting", + "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", + "cooking_oven_program_heating_mode_pre_heating": "Pre-heating", + "cooking_oven_program_heating_mode_hot_air": "Hot air", + "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco", + "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling", + "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco", + "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", + "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting", + "cooking_oven_program_heating_mode_slow_cook": "Slow cook", + "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", + "cooking_oven_program_heating_mode_keep_warm": "Keep warm", + "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", + "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", + "cooking_oven_program_heating_mode_desiccation": "Desiccation", + "cooking_oven_program_heating_mode_defrost": "Defrost", + "cooking_oven_program_heating_mode_proof": "Proof", + "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", + "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH", + "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH", + "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", + "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme", + "cooking_oven_program_microwave_90_watt": "90 Watt", + "cooking_oven_program_microwave_180_watt": "180 Watt", + "cooking_oven_program_microwave_360_watt": "360 Watt", + "cooking_oven_program_microwave_600_watt": "600 Watt", + "cooking_oven_program_microwave_900_watt": "900 Watt", + "cooking_oven_program_microwave_1000_watt": "1000 Watt", + "cooking_oven_program_microwave_max": "Max", + "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer", + "laundry_care_washer_program_cotton": "Cotton", + "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco", + "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC", + "laundry_care_washer_program_cotton_colour": "Cotton color", + "laundry_care_washer_program_easy_care": "Easy care", + "laundry_care_washer_program_mix": "Mix", + "laundry_care_washer_program_mix_night_wash": "Mix night wash", + "laundry_care_washer_program_delicates_silk": "Delicates silk", + "laundry_care_washer_program_wool": "Wool", + "laundry_care_washer_program_sensitive": "Sensitive", + "laundry_care_washer_program_auto_30": "Auto 30ºC", + "laundry_care_washer_program_auto_40": "Auto 40ºC", + "laundry_care_washer_program_auto_60": "Auto 60ºC", + "laundry_care_washer_program_chiffon": "Chiffon", + "laundry_care_washer_program_curtains": "Curtains", + "laundry_care_washer_program_dark_wash": "Dark wash", + "laundry_care_washer_program_dessous": "Dessous", + "laundry_care_washer_program_monsoon": "Monsoon", + "laundry_care_washer_program_outdoor": "Outdoor", + "laundry_care_washer_program_plush_toy": "Plush toy", + "laundry_care_washer_program_shirts_blouses": "Shirts blouses", + "laundry_care_washer_program_sport_fitness": "Sport fitness", + "laundry_care_washer_program_towels": "Towels", + "laundry_care_washer_program_water_proof": "Water proof", + "laundry_care_washer_program_power_speed_59": "Power speed <59 min", + "laundry_care_washer_program_super_153045_super_15": "Super 15 min", + "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min", + "laundry_care_washer_program_down_duvet_duvet": "Down duvet", + "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain", + "laundry_care_washer_program_drum_clean": "Drum clean", + "laundry_care_washer_dryer_program_cotton": "Cotton", + "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60ºC", + "laundry_care_washer_dryer_program_mix": "Mix", + "laundry_care_washer_dryer_program_easy_care": "Easy care", + "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)", + "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)" + } + }, + "available_maps": { + "options": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3" + } + }, + "cleaning_mode": { + "options": { + "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "Silent", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "Standard", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power" + } + }, + "bean_amount": { + "options": { + "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "Very mild", + "consumer_products_coffee_maker_enum_type_bean_amount_mild": "Mild", + "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "Mild +", + "consumer_products_coffee_maker_enum_type_bean_amount_normal": "Normal", + "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "Normal +", + "consumer_products_coffee_maker_enum_type_bean_amount_strong": "Strong", + "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "Strong +", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "Very strong", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "Very strong +", + "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "Extra strong", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "Double shot", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "Double shot +", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "Double shot ++", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "Triple shot", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "Triple shot +", + "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "Coffee ground" + } + }, + "coffee_temperature": { + "options": { + "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "88ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "90ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "92ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "94ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "95ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "96ºC" + } + }, + "bean_container": { + "options": { + "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "Right", + "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "Left" + } + }, + "flow_rate": { + "options": { + "consumer_products_coffee_maker_enum_type_flow_rate_normal": "Normal", + "consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense", + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus" + } + }, + "hot_water_temperature": { + "options": { + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": ".WhiteTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": ".GreenTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": ".BlackTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "50ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "55ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "60ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "65ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "70ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "75ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "80ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "85ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "90ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "95ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "97ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "122ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "131ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "140ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "149ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "158ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "167ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "176ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "185ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "194ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "203ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "Max" + } + }, + "drying_target": { + "options": { + "laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry", + "laundry_care_dryer_enum_type_drying_target_gentle_dry": "Gentle dry", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "Cupboard dry", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry plus", + "laundry_care_dryer_enum_type_drying_target_extra_dry": "Extra dry" + } + }, + "venting_level": { + "options": { + "cooking_hood_enum_type_stage_fan_off": "Fan off", + "cooking_hood_enum_type_stage_fan_stage01": "Fan stage 1", + "cooking_hood_enum_type_stage_fan_stage02": "Fan stage 2", + "cooking_hood_enum_type_stage_fan_stage03": "Fan stage 3", + "cooking_hood_enum_type_stage_fan_stage04": "Fan stage 4", + "cooking_hood_enum_type_stage_fan_stage05": "Fan stage 5" + } + }, + "intensive_level": { + "options": { + "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off", + "cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1", + "cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2" + } + }, + "warming_level": { + "options": { + "cooking_oven_enum_type_warming_level_low": "Low", + "cooking_oven_enum_type_warming_level_medium": "Medium", + "cooking_oven_enum_type_warming_level_high": "High" + } + }, + "washer_temperature": { + "options": { + "laundry_care_washer_enum_type_temperature_cold": "Cold", + "laundry_care_washer_enum_type_temperature_g_c20": "20ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c30": "30ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c40": "40ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c50": "50ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c60": "60ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c70": "70ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c80": "80ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c90": "90ºC clothes", + "laundry_care_washer_enum_type_temperature_ul_cold": "Cold", + "laundry_care_washer_enum_type_temperature_ul_warm": "Warm", + "laundry_care_washer_enum_type_temperature_ul_hot": "Hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot": "Extra hot" + } + }, + "spin_speed": { + "options": { + "laundry_care_washer_enum_type_spin_speed_off": "Off", + "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "1600 rpm", + "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", + "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", + "laundry_care_washer_enum_type_spin_speed_ul_high": "High" + } + }, + "vario_perfect": { + "options": { + "laundry_care_common_enum_type_vario_perfect_off": "Off", + "laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect", + "laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect" + } } }, "services": { @@ -113,8 +453,8 @@ "description": "Selects a program and starts it.", "fields": { "device_id": { - "name": "Device ID", - "description": "ID of the device." + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "program": { "name": "Program", "description": "Program to select." }, "key": { "name": "Option key", "description": "Key of the option." }, @@ -130,8 +470,8 @@ "description": "Selects a program without starting it.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "program": { "name": "[%key:component::home_connect::services::start_program::fields::program::name%]", @@ -151,13 +491,197 @@ } } }, + "set_program_and_options": { + "name": "Set program and options", + "description": "Starts or selects a program with options or sets the options for the active or the selected program.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "ID of the device." + }, + "affects_to": { + "name": "Affects to", + "description": "Selects if the program affected by the action should be the active or the selected program." + }, + "program": { + "name": "Program", + "description": "Program to select" + }, + "consumer_products_cleaning_robot_option_reference_map_id": { + "name": "Reference map ID", + "description": "Defines the used reference map." + }, + "consumer_products_cleaning_robot_option_cleaning_mode": { + "name": "Cleaning mode", + "description": "Defines the favoured cleaning mode." + }, + "consumer_products_coffee_maker_option_bean_amount": { + "name": "Bean amount", + "description": "Describes the bean amount of a coffee machine program." + }, + "consumer_products_coffee_maker_option_fill_quantity": { + "name": "Fill quantity", + "description": "Describes the fill quantity (in ml) of a coffee machine program." + }, + "consumer_products_coffee_maker_option_coffee_temperature": { + "name": "Coffee Temperature", + "description": "Describes the coffee temperature of a coffee machine program." + }, + "consumer_products_coffee_maker_option_bean_container": { + "name": "Bean container", + "description": "Defines the preferred bean container." + }, + "consumer_products_coffee_maker_option_flow_rate": { + "name": "Flow rate", + "description": "Defines the water-coffee contact time. The duration extends to coffee intensity." + }, + "consumer_products_coffee_maker_option_multiple_beverages": { + "name": "Multiple beverages", + "description": "Defines if double dispensing is enabled." + }, + "consumer_products_coffee_maker_option_coffee_milk_ratio": { + "name": "Coffee milk ratio", + "description": "Defines the milk amount." + }, + "consumer_products_coffee_maker_option_hot_water_temperature": { + "name": "Hot water temperature", + "description": "Defines the temperature suitable for the type of tea." + }, + "b_s_h_common_option_start_in_relative": { + "name": "Start in relative", + "description": "Defines in how many time the program should start." + }, + "dishcare_dishwasher_option_intensiv_zone": { + "name": "Intensive zone", + "description": "Defines if the cleaning is done with higher spray pressure on the lower basket for very dirty pots and pans." + }, + "dishcare_dishwasher_option_brilliance_dry": { + "name": "Brilliance dry", + "description": "Defines if the program sequence is optimized with special drying cycle ensures more shine on glasses and plastic items." + }, + "dishcare_dishwasher_option_vario_speed_plus": { + "name": "Vario speed plus", + "description": "Defines if the program run time is reduced by up to 66% with the usual optimum cleaning and drying." + }, + "dishcare_dishwasher_option_silence_on_demand": { + "name": "Silence on demand", + "description": "Defines if the extra silent mode is activated for a selected period of time." + }, + "dishcare_dishwasher_option_half_load": { + "name": "Half load", + "description": "Defines if economical cleaning is enabled for smaller loads which reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets." + }, + "dishcare_dishwasher_option_extra_dry": { + "name": "Extra dry", + "description": "Defines if improved drying for glasses and plasticware is enabled." + }, + "dishcare_dishwasher_option_hygiene_plus": { + "name": "Hygiene plus", + "description": "Defines if the cleaning is done with increased temperatures which ensures maximum hygienic cleanliness for regular use." + }, + "dishcare_dishwasher_option_eco_dry": { + "name": "Eco dry", + "description": "Defines if the door is opened automatically for extra energy efficient and effective drying." + }, + "dishcare_dishwasher_option_zeolite_dry": { + "name": "Zeolite dry", + "description": "Defines if the program sequence is optimized with special drying cycle ensures improved drying for glasses, plates and plasticware." + }, + "laundry_care_dryer_option_drying_target": { + "name": "Drying target", + "description": "Describes the drying target for a dryer program." + }, + "cooking_hood_option_venting_level": { + "name": "Venting level", + "description": "Defines the required fan setting." + }, + "cooking_hood_option_intensive_level": { + "name": "Intensive level", + "description": "Defines the intensive setting." + }, + "cooking_oven_option_setpoint_temperature": { + "name": "Setpoint temperature", + "description": "Defines the target cavity temperature, which will be hold by the oven." + }, + "b_s_h_common_option_duration": { + "name": "Duration", + "description": "Defines the run-time of the program. Afterwards the appliance is stopped." + }, + "cooking_oven_option_fast_pre_heat": { + "name": "Fast pre-heat", + "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal or higher than 100 °C or 212 °F otherwise the fast pre-heat option is not activated." + }, + "cooking_oven_option_warming_level": { + "name": "Warming level", + "description": "Defines the level of the warming drawer." + }, + "laundry_care_washer_option_temperature": { + "name": "Temperature", + "description": "Defines the temperature of the washing program." + }, + "laundry_care_washer_option_spin_speed": { + "name": "Spin speed", + "description": "Defines the spin speed of a washer program." + }, + "b_s_h_common_option_finish_in_relative": { + "name": "Finish in relative", + "description": "Defines when the program should end in seconds." + }, + "laundry_care_washer_option_i_dos1_active": { + "name": "i-Dos 1 Active", + "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)" + }, + "laundry_care_washer_option_i_dos2_active": { + "name": "i-Dos 2 Active", + "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)" + }, + "laundry_care_washer_option_vario_perfect": { + "name": "Vario perfect", + "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect)." + } + }, + "sections": { + "cleaning_robot_options": { + "name": "Cleaning robot options", + "description": "Options for cleaning robots." + }, + "coffee_maker_options": { + "name": "Coffee maker options", + "description": "Options for coffee makers." + }, + "dish_washer_options": { + "name": "Dishwasher options", + "description": "Options for dishwashers." + }, + "dryer_options": { + "name": "Dryer options", + "description": "Options for dryers (and washer dryers)." + }, + "hood_options": { + "name": "Hood options", + "description": "Options for hoods." + }, + "oven_options": { + "name": "Oven options", + "description": "Options for ovens." + }, + "warming_drawer_options": { + "name": "Warming drawer options", + "description": "Options for warming drawers." + }, + "washer_options": { + "name": "Washer options", + "description": "Options for washers (and washer dryers)." + } + } + }, "pause_program": { "name": "Pause program", "description": "Pauses the current running program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" } } }, @@ -166,8 +690,8 @@ "description": "Resumes a paused program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" } } }, @@ -176,8 +700,8 @@ "description": "Sets an option for the active program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "key": { "name": "Key", @@ -191,18 +715,18 @@ }, "set_option_selected": { "name": "Set selected program option", - "description": "Sets an option for the selected program.", + "description": "Sets options for the selected program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "key": { - "name": "Key", + "name": "[%key:component::home_connect::services::start_program::fields::key::name%]", "description": "[%key:component::home_connect::services::start_program::fields::key::description%]" }, "value": { - "name": "Value", + "name": "[%key:component::home_connect::services::start_program::fields::value::name%]", "description": "[%key:component::home_connect::services::start_program::fields::value::description%]" } } @@ -212,8 +736,8 @@ "description": "Changes a setting.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "key": { "name": "Key", "description": "Key of the setting." }, "value": { "name": "Value", "description": "Value of the setting." } @@ -307,319 +831,319 @@ "selected_program": { "name": "Selected program", "state": { - "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all", - "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map", - "consumer_products_cleaning_robot_program_basic_go_home": "Go home", - "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto", - "consumer_products_coffee_maker_program_beverage_espresso": "Espresso", - "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio", - "consumer_products_coffee_maker_program_beverage_coffee": "Coffee", - "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee", - "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande", - "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato", - "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino", - "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato", - "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", - "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", - "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", - "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", - "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", - "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", - "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", - "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", - "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", - "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche", - "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait", - "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio", - "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi", - "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd", - "consumer_products_coffee_maker_program_coffee_world_galao": "Galao", - "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto", - "consumer_products_coffee_maker_program_coffee_world_americano": "Americano", - "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye", - "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye", - "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye", - "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water", - "dishcare_dishwasher_program_pre_rinse": "Pre_rinse", - "dishcare_dishwasher_program_auto_1": "Auto 1", - "dishcare_dishwasher_program_auto_2": "Auto 2", - "dishcare_dishwasher_program_auto_3": "Auto 3", - "dishcare_dishwasher_program_eco_50": "Eco 50ºC", - "dishcare_dishwasher_program_quick_45": "Quick 45ºC", - "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC", - "dishcare_dishwasher_program_normal_65": "Normal 65ºC", - "dishcare_dishwasher_program_glas_40": "Glass 40ºC", - "dishcare_dishwasher_program_glass_care": "Glass care", - "dishcare_dishwasher_program_night_wash": "Night wash", - "dishcare_dishwasher_program_quick_65": "Quick 65ºC", - "dishcare_dishwasher_program_normal_45": "Normal 45ºC", - "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC", - "dishcare_dishwasher_program_auto_half_load": "Auto half load", - "dishcare_dishwasher_program_intensiv_power": "Intensive power", - "dishcare_dishwasher_program_magic_daily": "Magic daily", - "dishcare_dishwasher_program_super_60": "Super 60ºC", - "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC", - "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC", - "dishcare_dishwasher_program_machine_care": "Machine care", - "dishcare_dishwasher_program_steam_fresh": "Steam fresh", - "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning", - "dishcare_dishwasher_program_mixed_load": "Mixed load", - "laundry_care_dryer_program_cotton": "Cotton", - "laundry_care_dryer_program_synthetic": "Synthetic", - "laundry_care_dryer_program_mix": "Mix", - "laundry_care_dryer_program_blankets": "Blankets", - "laundry_care_dryer_program_business_shirts": "Business shirts", - "laundry_care_dryer_program_down_feathers": "Down feathers", - "laundry_care_dryer_program_hygiene": "Hygiene", - "laundry_care_dryer_program_jeans": "Jeans", - "laundry_care_dryer_program_outdoor": "Outdoor", - "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh", - "laundry_care_dryer_program_towels": "Towels", - "laundry_care_dryer_program_delicates": "Delicates", - "laundry_care_dryer_program_super_40": "Super 40ºC", - "laundry_care_dryer_program_shirts_15": "Shirts 15ºC", - "laundry_care_dryer_program_pillow": "Pillow", - "laundry_care_dryer_program_anti_shrink": "Anti shrink", - "laundry_care_dryer_program_my_time_my_drying_time": "My drying time", - "laundry_care_dryer_program_time_cold": "Cold (variable time)", - "laundry_care_dryer_program_time_warm": "Warm (variable time)", - "laundry_care_dryer_program_in_basket": "In basket", - "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)", - "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)", - "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)", - "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)", - "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)", - "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)", - "laundry_care_dryer_program_dessous": "Dessous", - "cooking_common_program_hood_automatic": "Automatic", - "cooking_common_program_hood_venting": "Venting", - "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", - "cooking_oven_program_heating_mode_pre_heating": "Pre-heating", - "cooking_oven_program_heating_mode_hot_air": "Hot air", - "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco", - "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling", - "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating", - "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco", - "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", - "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting", - "cooking_oven_program_heating_mode_slow_cook": "Slow cook", - "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", - "cooking_oven_program_heating_mode_keep_warm": "Keep warm", - "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", - "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", - "cooking_oven_program_heating_mode_desiccation": "Desiccation", - "cooking_oven_program_heating_mode_defrost": "Defrost", - "cooking_oven_program_heating_mode_proof": "Proof", - "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", - "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH", - "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH", - "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", - "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme", - "cooking_oven_program_microwave_90_watt": "90 Watt", - "cooking_oven_program_microwave_180_watt": "180 Watt", - "cooking_oven_program_microwave_360_watt": "360 Watt", - "cooking_oven_program_microwave_600_watt": "600 Watt", - "cooking_oven_program_microwave_900_watt": "900 Watt", - "cooking_oven_program_microwave_1000_watt": "1000 Watt", - "cooking_oven_program_microwave_max": "Max", - "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer", - "laundry_care_washer_program_cotton": "Cotton", - "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco", - "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC", - "laundry_care_washer_program_cotton_colour": "Cotton color", - "laundry_care_washer_program_easy_care": "Easy care", - "laundry_care_washer_program_mix": "Mix", - "laundry_care_washer_program_mix_night_wash": "Mix night wash", - "laundry_care_washer_program_delicates_silk": "Delicates silk", - "laundry_care_washer_program_wool": "Wool", - "laundry_care_washer_program_sensitive": "Sensitive", - "laundry_care_washer_program_auto_30": "Auto 30ºC", - "laundry_care_washer_program_auto_40": "Auto 40ºC", - "laundry_care_washer_program_auto_60": "Auto 60ºC", - "laundry_care_washer_program_chiffon": "Chiffon", - "laundry_care_washer_program_curtains": "Curtains", - "laundry_care_washer_program_dark_wash": "Dark wash", - "laundry_care_washer_program_dessous": "Dessous", - "laundry_care_washer_program_monsoon": "Monsoon", - "laundry_care_washer_program_outdoor": "Outdoor", - "laundry_care_washer_program_plush_toy": "Plush toy", - "laundry_care_washer_program_shirts_blouses": "Shirts blouses", - "laundry_care_washer_program_sport_fitness": "Sport fitness", - "laundry_care_washer_program_towels": "Towels", - "laundry_care_washer_program_water_proof": "Water proof", - "laundry_care_washer_program_power_speed_59": "Power speed <60 min", - "laundry_care_washer_program_super_153045_super_15": "Super 15 min", - "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min", - "laundry_care_washer_program_down_duvet_duvet": "Down duvet", - "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain", - "laundry_care_washer_program_drum_clean": "Drum clean", - "laundry_care_washer_dryer_program_cotton": "Cotton", - "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60 ºC", - "laundry_care_washer_dryer_program_mix": "Mix", - "laundry_care_washer_dryer_program_easy_care": "Easy care", - "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)", - "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)" + "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]", + "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]", + "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]", + "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", + "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", + "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]", + "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]", + "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]", + "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", + "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", + "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]", + "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]", + "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", + "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]", + "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]", + "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]", + "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]", + "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]", + "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]", + "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]", + "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]", + "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]", + "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]", + "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]", + "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]", + "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]", + "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]", + "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]", + "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]", + "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]", + "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]", + "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]", + "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]", + "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]", + "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]", + "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]", + "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]", + "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]", + "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]", + "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]", + "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]", + "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]", + "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]", + "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]", + "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]", + "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]", + "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]", + "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]", + "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]", + "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]", + "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]", + "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]", + "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]", + "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]", + "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]", + "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]", + "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]", + "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", + "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]", + "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", + "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", + "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", + "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]", + "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", + "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]", + "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]", + "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", + "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]", + "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]", + "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]", + "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]", + "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]", + "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", + "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]", + "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", + "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]", + "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]", + "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", + "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]", + "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]", + "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]", + "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]", + "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]", + "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]", + "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]", + "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]", + "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]", + "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]", + "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]", + "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]", + "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]", + "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]", + "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]", + "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]", + "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]", + "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]", + "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]", + "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]", + "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]", + "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]", + "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]", + "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]", + "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]", + "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]", + "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]", + "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]", + "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]", + "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]", + "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]", + "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]", + "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]", + "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]", + "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]", + "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]", + "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]", + "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]", + "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]", + "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]", + "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]", + "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]", + "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]", + "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%]" } }, "active_program": { "name": "Active program", "state": { - "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_all%]", - "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_map%]", - "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_basic_go_home%]", - "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_ristretto%]", - "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso%]", - "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", - "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_coffee%]", - "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", - "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_grande%]", - "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", - "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_cappuccino%]", - "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", - "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_latte%]", - "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_milk_froth%]", - "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_warm_milk%]", - "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", - "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", - "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", - "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_flat_white%]", - "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cortado%]", - "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", - "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", - "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", - "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_doppio%]", - "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kaapi%]", - "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", - "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_galao%]", - "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_garoto%]", - "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_americano%]", - "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_red_eye%]", - "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_black_eye%]", - "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", - "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_hot_water%]", - "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_pre_rinse%]", - "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_1%]", - "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_2%]", - "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_3%]", - "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_eco_50%]", - "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_45%]", - "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_70%]", - "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_65%]", - "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glas_40%]", - "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glass_care%]", - "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_night_wash%]", - "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_65%]", - "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_45%]", - "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_45%]", - "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_half_load%]", - "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_power%]", - "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_magic_daily%]", - "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_super_60%]", - "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_kurz_60%]", - "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_express_sparkle_65%]", - "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_machine_care%]", - "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_steam_fresh%]", - "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_maximum_cleaning%]", - "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_mixed_load%]", - "laundry_care_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_cotton%]", - "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic%]", - "laundry_care_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_mix%]", - "laundry_care_dryer_program_blankets": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_blankets%]", - "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_business_shirts%]", - "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_down_feathers%]", - "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_hygiene%]", - "laundry_care_dryer_program_jeans": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_jeans%]", - "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_outdoor%]", - "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic_refresh%]", - "laundry_care_dryer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_towels%]", - "laundry_care_dryer_program_delicates": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_delicates%]", - "laundry_care_dryer_program_super_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_super_40%]", - "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_shirts_15%]", - "laundry_care_dryer_program_pillow": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_pillow%]", - "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_anti_shrink%]", - "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_my_time_my_drying_time%]", - "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold%]", - "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm%]", - "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_in_basket%]", - "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", - "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", - "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", - "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", - "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", - "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", - "laundry_care_dryer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_dessous%]", - "cooking_common_program_hood_automatic": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_automatic%]", - "cooking_common_program_hood_venting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_venting%]", - "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_delayed_shut_off%]", - "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pre_heating%]", - "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air%]", - "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_eco%]", - "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_grilling%]", - "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating%]", - "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", - "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_bottom_heating%]", - "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pizza_setting%]", - "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_slow_cook%]", - "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_intensive_heat%]", - "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_keep_warm%]", - "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_preheat_ovenware%]", - "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_frozen_heatup_special%]", - "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_desiccation%]", - "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_defrost%]", - "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_proof%]", - "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_30_steam%]", - "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_60_steam%]", - "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_80_steam%]", - "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_100_steam%]", - "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_sabbath_programme%]", - "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_90_watt%]", - "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_180_watt%]", - "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_360_watt%]", - "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_600_watt%]", - "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_900_watt%]", - "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_1000_watt%]", - "cooking_oven_program_microwave_max": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_max%]", - "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_warming_drawer%]", - "laundry_care_washer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton%]", - "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_cotton_eco%]", - "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_eco_4060%]", - "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_colour%]", - "laundry_care_washer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_easy_care%]", - "laundry_care_washer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix%]", - "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix_night_wash%]", - "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_delicates_silk%]", - "laundry_care_washer_program_wool": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_wool%]", - "laundry_care_washer_program_sensitive": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sensitive%]", - "laundry_care_washer_program_auto_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_30%]", - "laundry_care_washer_program_auto_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_40%]", - "laundry_care_washer_program_auto_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_60%]", - "laundry_care_washer_program_chiffon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_chiffon%]", - "laundry_care_washer_program_curtains": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_curtains%]", - "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dark_wash%]", - "laundry_care_washer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dessous%]", - "laundry_care_washer_program_monsoon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_monsoon%]", - "laundry_care_washer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_outdoor%]", - "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_plush_toy%]", - "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_shirts_blouses%]", - "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sport_fitness%]", - "laundry_care_washer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_towels%]", - "laundry_care_washer_program_water_proof": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_water_proof%]", - "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_power_speed_59%]", - "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_15%]", - "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_1530%]", - "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_down_duvet_duvet%]", - "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_rinse_rinse_spin_drain%]", - "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_drum_clean%]", - "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton%]", - "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton_eco_4060%]", - "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_mix%]", - "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_easy_care%]", - "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_60%]", - "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_90%]" + "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]", + "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]", + "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]", + "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", + "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", + "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]", + "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]", + "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]", + "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", + "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", + "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]", + "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]", + "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", + "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]", + "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]", + "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]", + "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]", + "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]", + "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]", + "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]", + "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]", + "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]", + "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]", + "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]", + "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]", + "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]", + "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]", + "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]", + "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]", + "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]", + "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]", + "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]", + "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]", + "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]", + "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]", + "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]", + "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]", + "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]", + "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]", + "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]", + "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]", + "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]", + "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]", + "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]", + "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]", + "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]", + "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]", + "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]", + "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]", + "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]", + "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]", + "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]", + "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]", + "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]", + "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]", + "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]", + "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]", + "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", + "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]", + "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", + "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", + "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", + "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]", + "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", + "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]", + "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]", + "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", + "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]", + "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]", + "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]", + "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]", + "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]", + "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", + "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]", + "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", + "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]", + "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]", + "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", + "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]", + "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]", + "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]", + "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]", + "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]", + "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]", + "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]", + "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]", + "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]", + "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]", + "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]", + "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]", + "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]", + "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]", + "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]", + "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]", + "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]", + "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]", + "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]", + "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]", + "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]", + "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]", + "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]", + "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]", + "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]", + "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]", + "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]", + "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]", + "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]", + "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]", + "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]", + "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]", + "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]", + "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]", + "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]", + "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]", + "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]", + "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]", + "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]", + "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]", + "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]", + "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]", + "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]", + "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%]" } } }, diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 4061d5ed863..7b74c2290c3 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,6 +11,7 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfHomeAppliances, + ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, ArrayOfStatus, @@ -199,13 +200,13 @@ def _get_set_program_side_effect( return set_program_side_effect -def _get_set_key_value_side_effect( - event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str +def _get_set_setting_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], ): - """Set program options side effect.""" + """Set settings side effect.""" - async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None: - event_key = EventKey(kwargs[parameter_key]) + async def set_settings_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["setting_key"]) await event_queue.put( [ EventMessage( @@ -227,7 +228,48 @@ def _get_set_key_value_side_effect( ] ) - return set_key_value_side_effect + return set_settings_side_effect + + +def _get_set_program_options_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], +): + """Set programs side effect.""" + + async def set_program_options_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey(option.key), + raw_key=option.key.value, + timestamp=0, + level="", + handling="", + value=option.value, + ) + for option in ( + cast(ArrayOfOptions, kwargs["array_of_options"]).options + if "array_of_options" in kwargs + else [ + Option( + kwargs["option_key"], + kwargs["value"], + unit=kwargs["unit"], + ) + ] + ) + ] + ), + ), + ] + ) + + return set_program_options_side_effect async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: @@ -319,13 +361,19 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ), ) mock.set_active_program_option = AsyncMock( - side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_active_program_options = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), ) mock.set_selected_program_option = AsyncMock( - side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_selected_program_options = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), ) mock.set_setting = AsyncMock( - side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"), + side_effect=_get_set_setting_side_effect(event_queue), ) mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect) @@ -363,7 +411,9 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.stop_program = AsyncMock(side_effect=exception) mock.set_selected_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) + mock.set_selected_program_options = 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) diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_init.ambr new file mode 100644 index 00000000000..581eca66cb8 --- /dev/null +++ b/tests/components/home_connect/snapshots/test_init.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_set_program_and_options[service_call0-set_selected_program] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 1800, + }), + ]), + 'program_key': , + }), + ) +# --- +# name: test_set_program_and_options[service_call1-start_program] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 'ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal', + }), + ]), + 'program_key': , + }), + ) +# --- +# name: test_set_program_and_options[service_call2-set_active_program_options] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'array_of_options': dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 60, + }), + ]), + }), + }), + ) +# --- +# name: test_set_program_and_options[service_call3-set_selected_program_options] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'array_of_options': dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 35, + }), + ]), + }), + }), + ) +# --- diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 009c40b662d..e7380d0e255 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -1,6 +1,7 @@ """Test the integration init functionality.""" from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, patch @@ -10,6 +11,7 @@ from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError import pytest import requests_mock import respx +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.home_connect.const import DOMAIN @@ -22,6 +24,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.helpers.issue_registry as ir from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( @@ -34,8 +37,9 @@ from .conftest import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator -SERVICE_KV_CALL_PARAMS = [ +DEPRECATED_SERVICE_KV_CALL_PARAMS = [ { "domain": DOMAIN, "service": "set_option_active", @@ -57,6 +61,10 @@ SERVICE_KV_CALL_PARAMS = [ }, "blocking": True, }, +] + +SERVICE_KV_CALL_PARAMS = [ + *DEPRECATED_SERVICE_KV_CALL_PARAMS, { "domain": DOMAIN, "service": "change_setting", @@ -125,6 +133,62 @@ SERVICE_APPLIANCE_METHOD_MAPPING = { "start_program": "start_program", } +SERVICE_VALIDATION_ERROR_MAPPING = { + "set_option_active": r"Error.*setting.*options.*active.*program.*", + "set_option_selected": r"Error.*setting.*options.*selected.*program.*", + "change_setting": r"Error.*assigning.*value.*setting.*", + "pause_program": r"Error.*executing.*command.*", + "resume_program": r"Error.*executing.*command.*", + "select_program": r"Error.*selecting.*program.*", + "start_program": r"Error.*starting.*program.*", +} + + +SERVICES_SET_PROGRAM_AND_OPTIONS = [ + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "program": "dishcare_dishwasher_program_eco_50", + "b_s_h_common_option_start_in_relative": "00:30:00", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "program": "consumer_products_coffee_maker_program_beverage_coffee", + "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "consumer_products_coffee_maker_option_coffee_milk_ratio": 60, + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "consumer_products_coffee_maker_option_fill_quantity": 35, + }, + "blocking": True, + }, +] + async def test_entry_setup( hass: HomeAssistant, @@ -244,7 +308,7 @@ async def test_client_error( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -async def test_services( +async def test_key_value_services( service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -273,11 +337,188 @@ async def test_services( ) +@pytest.mark.parametrize( + "service_call", + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_programs_and_options_actions_deprecation( + service_call: dict[str, Any], + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + issue_registry: ir.IssueRegistry, + 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 + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # 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( + ("service_call", "called_method"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + "set_selected_program", + "start_program", + "set_active_program_options", + "set_selected_program_options", + ], + strict=True, + ), +) +async def test_set_program_and_options( + service_call: dict[str, Any], + called_method: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + snapshot: SnapshotAssertion, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + method_mock: MagicMock = getattr(client, called_method) + assert method_mock.call_count == 1 + assert method_mock.call_args == snapshot + + +@pytest.mark.parametrize( + ("service_call", "error_regex"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + r"Error.*selecting.*program.*", + r"Error.*starting.*program.*", + r"Error.*setting.*options.*active.*program.*", + r"Error.*setting.*options.*selected.*program.*", + ], + strict=True, + ), +) +async def test_set_program_and_options_exceptions( + service_call: dict[str, Any], + error_regex: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(HomeAssistantError, match=error_regex): + await hass.services.async_call(**service_call) + + +async def test_required_program_or_at_least_an_option( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + "Test that the set_program_and_options does raise an exception if no program nor options are set." + + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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_ha_id)}, + ) + + with pytest.raises( + ServiceValidationError, + ): + await hass.services.async_call( + DOMAIN, + "set_program_and_options", + { + "device_id": device_entry.id, + "affects_to": "selected_program", + }, + True, + ) + + @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -async def test_services_exception( +async def test_services_exception_device_id( service_call: dict[str, Any], hass: HomeAssistant, config_entry: MockConfigEntry, @@ -348,6 +589,40 @@ async def test_services_appliance_not_found( await hass.services.async_call(**service_call) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ValueError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + service_name = service_call["service"] + with pytest.raises( + HomeAssistantError, + match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], + ): + await hass.services.async_call(**service_call) + + async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From c090fbfbadff4e01411722320113c6ddb1ba0c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 14 Feb 2025 20:21:30 +0100 Subject: [PATCH 0539/1941] Add binary sensor platform to LetPot integration (#138554) --- homeassistant/components/letpot/__init__.py | 7 +- .../components/letpot/binary_sensor.py | 122 +++++++ homeassistant/components/letpot/icons.json | 20 ++ .../components/letpot/quality_scale.yaml | 2 +- homeassistant/components/letpot/strings.json | 17 + tests/components/letpot/__init__.py | 18 +- tests/components/letpot/conftest.py | 63 +++- .../letpot/snapshots/test_binary_sensor.ambr | 337 ++++++++++++++++++ tests/components/letpot/test_binary_sensor.py | 32 ++ 9 files changed, 596 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/letpot/binary_sensor.py create mode 100644 tests/components/letpot/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/letpot/test_binary_sensor.py diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index dc322d5641b..50c73f949a3 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -22,7 +22,12 @@ from .const import ( ) from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, +] async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py new file mode 100644 index 00000000000..bfc7a5ab4a7 --- /dev/null +++ b/homeassistant/components/letpot/binary_sensor.py @@ -0,0 +1,122 @@ +"""Support for LetPot binary sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from letpot.models import DeviceFeature, LetPotDeviceStatus + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .entity import LetPotEntity, LetPotEntityDescription + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class LetPotBinarySensorEntityDescription( + LetPotEntityDescription, BinarySensorEntityDescription +): + """Describes a LetPot binary sensor entity.""" + + is_on_fn: Callable[[LetPotDeviceStatus], bool] + + +BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( + LetPotBinarySensorEntityDescription( + key="low_nutrients", + translation_key="low_nutrients", + is_on_fn=lambda status: bool(status.errors.low_nutrients), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=( + lambda coordinator: coordinator.data.errors.low_nutrients is not None + ), + ), + LetPotBinarySensorEntityDescription( + key="low_water", + translation_key="low_water", + is_on_fn=lambda status: bool(status.errors.low_water), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=lambda coordinator: coordinator.data.errors.low_water is not None, + ), + LetPotBinarySensorEntityDescription( + key="pump", + translation_key="pump", + is_on_fn=lambda status: status.pump_status == 1, + device_class=BinarySensorDeviceClass.RUNNING, + supported_fn=( + lambda coordinator: DeviceFeature.PUMP_STATUS + in coordinator.device_client.device_features + ), + ), + LetPotBinarySensorEntityDescription( + key="pump_error", + translation_key="pump_error", + is_on_fn=lambda status: bool(status.errors.pump_malfunction), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=( + lambda coordinator: coordinator.data.errors.pump_malfunction is not None + ), + ), + LetPotBinarySensorEntityDescription( + key="refill_error", + translation_key="refill_error", + is_on_fn=lambda status: bool(status.errors.refill_error), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=( + lambda coordinator: coordinator.data.errors.refill_error is not None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot binary sensor entities based on a config entry and device status/features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotBinarySensorEntity(coordinator, description) + for description in BINARY_SENSORS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotBinarySensorEntity(LetPotEntity, BinarySensorEntity): + """Defines a LetPot binary sensor entity.""" + + entity_description: LetPotBinarySensorEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotBinarySensorEntityDescription, + ) -> None: + """Initialize LetPot binary sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def is_on(self) -> bool: + """Return if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 60cba78fa1c..43541b57150 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -1,5 +1,25 @@ { "entity": { + "binary_sensor": { + "low_nutrients": { + "default": "mdi:beaker-alert", + "state": { + "off": "mdi:beaker" + } + }, + "low_water": { + "default": "mdi:water-percent-alert", + "state": { + "off": "mdi:water-percent" + } + }, + "pump": { + "default": "mdi:pump", + "state": { + "off": "mdi:pump-off" + } + } + }, "sensor": { "water_level": { "default": "mdi:water-percent" diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 0fdaca18717..9804a5ec3a4 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -61,7 +61,7 @@ rules: dynamic-devices: todo entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 0cb79ce711c..cdc5a36a15f 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -32,6 +32,23 @@ } }, "entity": { + "binary_sensor": { + "low_nutrients": { + "name": "Low nutrients" + }, + "low_water": { + "name": "Low water" + }, + "pump": { + "name": "Pump" + }, + "pump_error": { + "name": "Pump error" + }, + "refill_error": { + "name": "Refill error" + } + }, "sensor": { "water_level": { "name": "Water level" diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index d4570ce44be..6e73bb430cf 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -30,7 +30,7 @@ AUTHENTICATION = AuthenticationInfo( email="email@example.com", ) -STATUS = LetPotDeviceStatus( +MAX_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), light_brightness=500, light_mode=1, @@ -49,3 +49,19 @@ STATUS = LetPotDeviceStatus( water_mode=1, water_level=100, ) + +SE_STATUS = LetPotDeviceStatus( + errors=LetPotDeviceErrors(low_water=True, pump_malfunction=True), + light_brightness=500, + light_mode=1, + light_schedule_end=datetime.time(18, 0), + light_schedule_start=datetime.time(8, 0), + online=True, + plant_days=1, + pump_mode=1, + pump_nutrient=None, + pump_status=0, + raw=[], # Not used by integration, and it requires a real device to get + system_on=True, + system_sound=False, +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 454d4e235db..25974b2d78a 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from letpot.models import DeviceFeature, LetPotDevice +from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus import pytest from homeassistant.components.letpot.const import ( @@ -15,11 +15,42 @@ from homeassistant.components.letpot.const import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL -from . import AUTHENTICATION, STATUS +from . import AUTHENTICATION, MAX_STATUS, SE_STATUS from tests.common import MockConfigEntry +@pytest.fixture +def device_type() -> str: + """Return the device type to use for mock data.""" + return "LPH63" + + +def _mock_device_features(device_type: str) -> DeviceFeature: + """Return mock device feature support for the given type.""" + if device_type == "LPH31": + return DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH | DeviceFeature.PUMP_STATUS + if device_type == "LPH63": + return ( + DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + | DeviceFeature.NUTRIENT_BUTTON + | DeviceFeature.PUMP_AUTO + | DeviceFeature.PUMP_STATUS + | DeviceFeature.TEMPERATURE + | DeviceFeature.WATER_LEVEL + ) + raise ValueError(f"No mock data for device type {device_type}") + + +def _mock_device_status(device_type: str) -> LetPotDeviceStatus: + """Return mock device status for the given type.""" + if device_type == "LPH31": + return SE_STATUS + if device_type == "LPH63": + return MAX_STATUS + raise ValueError(f"No mock data for device type {device_type}") + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -30,7 +61,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_client() -> Generator[AsyncMock]: +def mock_client(device_type: str) -> Generator[AsyncMock]: """Mock a LetPotClient.""" with ( patch( @@ -47,9 +78,9 @@ def mock_client() -> Generator[AsyncMock]: client.refresh_token.return_value = AUTHENTICATION client.get_devices.return_value = [ LetPotDevice( - serial_number="LPH63ABCD", + serial_number=f"{device_type}ABCD", name="Garden", - device_type="LPH63", + device_type=device_type, is_online=True, is_remote=False, ) @@ -58,23 +89,17 @@ def mock_client() -> Generator[AsyncMock]: @pytest.fixture -def mock_device_client() -> Generator[AsyncMock]: +def mock_device_client(device_type: str) -> Generator[AsyncMock]: """Mock a LetPotDeviceClient.""" with patch( "homeassistant.components.letpot.coordinator.LetPotDeviceClient", autospec=True, ) as mock_device_client: device_client = mock_device_client.return_value - device_client.device_features = ( - DeviceFeature.LIGHT_BRIGHTNESS_LEVELS - | DeviceFeature.NUTRIENT_BUTTON - | DeviceFeature.PUMP_AUTO - | DeviceFeature.PUMP_STATUS - | DeviceFeature.TEMPERATURE - | DeviceFeature.WATER_LEVEL - ) - device_client.device_model_code = "LPH63" - device_client.device_model_name = "LetPot Max" + device_client.device_features = _mock_device_features(device_type) + device_client.device_model_code = device_type + device_client.device_model_name = f"LetPot {device_type}" + device_status = _mock_device_status(device_type) subscribe_callbacks: list[Callable] = [] @@ -84,11 +109,11 @@ def mock_device_client() -> Generator[AsyncMock]: def status_side_effect() -> None: # Deliver a status update to any subscribers, like the real client for callback in subscribe_callbacks: - callback(STATUS) + callback(device_status) device_client.get_current_status.side_effect = status_side_effect - device_client.get_current_status.return_value = STATUS - device_client.last_status.return_value = STATUS + device_client.get_current_status.return_value = device_status + device_client.last_status.return_value = device_status device_client.request_status_update.side_effect = status_side_effect device_client.subscribe.side_effect = subscribe_side_effect diff --git a/tests/components/letpot/snapshots/test_binary_sensor.ambr b/tests/components/letpot/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..121cf4e3f82 --- /dev/null +++ b/tests/components/letpot/snapshots/test_binary_sensor.ambr @@ -0,0 +1,337 @@ +# serializer version: 1 +# name: test_all_entities[LPH31][binary_sensor.garden_low_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': , + 'entity_id': 'binary_sensor.garden_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_water', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_low_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_low_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump-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.garden_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Garden Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-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': , + 'entity_id': 'binary_sensor.garden_pump_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump error', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump_error', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Pump error', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-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': , + 'entity_id': 'binary_sensor.garden_low_nutrients', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low nutrients', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_nutrients', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_nutrients', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low nutrients', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_nutrients', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_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': , + 'entity_id': 'binary_sensor.garden_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_water', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_pump-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.garden_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Garden Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-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': , + 'entity_id': 'binary_sensor.garden_refill_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refill error', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'refill_error', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_refill_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Refill error', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_refill_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/letpot/test_binary_sensor.py b/tests/components/letpot/test_binary_sensor.py new file mode 100644 index 00000000000..03ce1bee1a5 --- /dev/null +++ b/tests/components/letpot/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Test binary sensor entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("device_type", ["LPH63", "LPH31"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_type: str, +) -> None: + """Test binary sensor entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 58797a14e7c54be566eb869dd59cc5410923155c Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Fri, 14 Feb 2025 20:48:19 +0100 Subject: [PATCH 0540/1941] Add 6 new sensors to qBittorrent integration (#138446) Co-authored-by: Josef Zweck --- .../components/qbittorrent/sensor.py | 108 +++++++++++++++++- .../components/qbittorrent/strings.json | 23 ++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 9f4610cff64..23ec485fcd4 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_IDLE, UnitOfDataRate +from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,8 +27,14 @@ from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" +SENSOR_TYPE_CONNECTION_STATUS = "connection_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" +SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT = "download_speed_limit" +SENSOR_TYPE_UPLOAD_SPEED_LIMIT = "upload_speed_limit" +SENSOR_TYPE_ALLTIME_DOWNLOAD = "alltime_download" +SENSOR_TYPE_ALLTIME_UPLOAD = "alltime_upload" +SENSOR_TYPE_GLOBAL_RATIO = "global_ratio" SENSOR_TYPE_ALL_TORRENTS = "all_torrents" SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents" SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" @@ -50,18 +56,54 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str: return STATE_IDLE -def get_dl(coordinator: QBittorrentDataCoordinator) -> int: +def get_connection_status(coordinator: QBittorrentDataCoordinator) -> str: + """Get current download/upload state.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(str, server_state.get("connection_status")) + + +def get_download_speed(coordinator: QBittorrentDataCoordinator) -> int: """Get current download speed.""" server_state = cast(Mapping, coordinator.data.get("server_state")) return cast(int, server_state.get("dl_info_speed")) -def get_up(coordinator: QBittorrentDataCoordinator) -> int: +def get_upload_speed(coordinator: QBittorrentDataCoordinator) -> int: """Get current upload speed.""" server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) return cast(int, server_state.get("up_info_speed")) +def get_download_speed_limit(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("dl_rate_limit")) + + +def get_upload_speed_limit(coordinator: QBittorrentDataCoordinator) -> int: + """Get current upload speed.""" + server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) + return cast(int, server_state.get("up_rate_limit")) + + +def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("alltime_dl")) + + +def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("alltime_ul")) + + +def get_global_ratio(coordinator: QBittorrentDataCoordinator) -> float: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(float, server_state.get("global_ratio")) + + @dataclass(frozen=True, kw_only=True) class QBittorrentSensorEntityDescription(SensorEntityDescription): """Entity description class for qBittorent sensors.""" @@ -77,6 +119,13 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], value_fn=get_state, ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_CONNECTION_STATUS, + translation_key="connection_status", + device_class=SensorDeviceClass.ENUM, + options=["connected", "firewalled", "disconnected"], + value_fn=get_connection_status, + ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, translation_key="download_speed", @@ -85,7 +134,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=get_dl, + value_fn=get_download_speed, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, @@ -95,7 +144,56 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=get_up, + value_fn=get_upload_speed, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT, + translation_key="download_speed_limit", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=get_download_speed_limit, + entity_registry_enabled_default=False, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_UPLOAD_SPEED_LIMIT, + translation_key="upload_speed_limit", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=get_upload_speed_limit, + entity_registry_enabled_default=False, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALLTIME_DOWNLOAD, + translation_key="alltime_download", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfInformation.TEBIBYTES, + value_fn=get_alltime_download, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALLTIME_UPLOAD, + translation_key="alltime_upload", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement="B", + suggested_display_precision=2, + suggested_unit_of_measurement="TiB", + value_fn=get_alltime_upload, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_GLOBAL_RATIO, + translation_key="global_ratio", + state_class=SensorStateClass.MEASUREMENT, + value_fn=get_global_ratio, + entity_registry_enabled_default=False, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ALL_TORRENTS, diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 9c9ee371737..83d93766ee4 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -26,6 +26,21 @@ "upload_speed": { "name": "Upload speed" }, + "download_speed_limit": { + "name": "Download speed limit" + }, + "upload_speed_limit": { + "name": "Upload speed limit" + }, + "alltime_download": { + "name": "Alltime download" + }, + "alltime_upload": { + "name": "Alltime upload" + }, + "global_ratio": { + "name": "Global ratio" + }, "current_status": { "name": "Status", "state": { @@ -35,6 +50,14 @@ "downloading": "Downloading" } }, + "connection_status": { + "name": "Connection status", + "state": { + "connected": "Conencted", + "firewalled": "Firewalled", + "disconnected": "Disconnected" + } + }, "active_torrents": { "name": "Active torrents", "unit_of_measurement": "torrents" From b916fbe1fc50b375233677079da97a573aebadf1 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 14 Feb 2025 12:50:51 -0700 Subject: [PATCH 0541/1941] Add time entity to balboa (#138248) --- homeassistant/components/balboa/__init__.py | 1 + homeassistant/components/balboa/strings.json | 8 + homeassistant/components/balboa/time.py | 56 ++++++ tests/components/balboa/conftest.py | 5 + .../balboa/snapshots/test_time.ambr | 189 ++++++++++++++++++ tests/components/balboa/test_time.py | 72 +++++++ 6 files changed, 331 insertions(+) create mode 100644 homeassistant/components/balboa/time.py create mode 100644 tests/components/balboa/snapshots/test_time.ambr create mode 100644 tests/components/balboa/test_time.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index c982d59d513..78bf6f7cda7 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -24,6 +24,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.SELECT, + Platform.TIME, ] diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index c00567a6052..0262f26f4bd 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -78,6 +78,14 @@ "high": "High" } } + }, + "time": { + "filter_cycle_start": { + "name": "Filter cycle {index} start" + }, + "filter_cycle_end": { + "name": "Filter cycle {index} end" + } } } } diff --git a/homeassistant/components/balboa/time.py b/homeassistant/components/balboa/time.py new file mode 100644 index 00000000000..83467de8777 --- /dev/null +++ b/homeassistant/components/balboa/time.py @@ -0,0 +1,56 @@ +"""Support for Balboa times.""" + +from __future__ import annotations + +from datetime import time +import itertools +from typing import Any + +from pybalboa import SpaClient + +from homeassistant.components.time import TimeEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BalboaConfigEntry +from .entity import BalboaEntity + +FILTER_CYCLE = "filter_cycle_" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's times.""" + spa = entry.runtime_data + async_add_entities( + BalboaTimeEntity(spa, index, period) + for index, period in itertools.product((1, 2), ("start", "end")) + ) + + +class BalboaTimeEntity(BalboaEntity, TimeEntity): + """Representation of a Balboa time entity.""" + + entity_category = EntityCategory.CONFIG + + def __init__(self, spa: SpaClient, index: int, period: str) -> None: + """Initialize a Balboa time entity.""" + super().__init__(spa, f"{FILTER_CYCLE}{index}_{period}") + self.index = index + self.period = period + self._attr_translation_key = f"{FILTER_CYCLE}{period}" + self._attr_translation_placeholders = {"index": str(index)} + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return getattr(self._client, f"{FILTER_CYCLE}{self.index}_{self.period}") + + async def async_set_value(self, value: time) -> None: + """Change the time.""" + args: dict[str, Any] = {self.period: value} + await self._client.configure_filter_cycle(self.index, **args) diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 0bb8b2cd468..3a3561f15cf 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Generator +from datetime import time from unittest.mock import AsyncMock, MagicMock, patch from pybalboa.enums import HeatMode, LowHighRange @@ -48,7 +49,11 @@ def client_fixture() -> Generator[MagicMock]: client.blowers = [] client.circulation_pump.state = 0 client.filter_cycle_1_running = False + client.filter_cycle_1_start = time(8, 0) + client.filter_cycle_1_end = time(9, 0) client.filter_cycle_2_running = False + client.filter_cycle_2_start = time(19, 0) + client.filter_cycle_2_end = time(21, 30) client.temperature_unit = 1 client.temperature = 10 client.temperature_minimum = 10 diff --git a/tests/components/balboa/snapshots/test_time.ambr b/tests/components/balboa/snapshots/test_time.ambr new file mode 100644 index 00000000000..6b27717e2d3 --- /dev/null +++ b/tests/components/balboa/snapshots/test_time.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_times[time.fakespa_filter_cycle_1_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_1_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 1 end', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_end', + 'unique_id': 'FakeSpa-filter_cycle_1_end-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 1 end', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_1_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '09:00:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_1_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 1 start', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_start', + 'unique_id': 'FakeSpa-filter_cycle_1_start-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 1 start', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_1_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_2_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 end', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_end', + 'unique_id': 'FakeSpa-filter_cycle_2_end-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 end', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_2_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21:30:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_2_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 start', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_start', + 'unique_id': 'FakeSpa-filter_cycle_2_start-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 start', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_2_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19:00:00', + }) +# --- diff --git a/tests/components/balboa/test_time.py b/tests/components/balboa/test_time.py new file mode 100644 index 00000000000..21778d08e2d --- /dev/null +++ b/tests/components/balboa/test_time.py @@ -0,0 +1,72 @@ +"""Tests of the times of the balboa integration.""" + +from __future__ import annotations + +from datetime import time +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_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 init_integration + +from tests.common import snapshot_platform + +ENTITY_TIME = "time.fakespa_" + + +async def test_times( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa times.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.TIME]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("filter_cycle", "period", "value"), + [ + (1, "start", "08:00:00"), + (1, "end", "09:00:00"), + (2, "start", "19:00:00"), + (2, "end", "21:30:00"), + ], +) +async def test_time( + hass: HomeAssistant, client: MagicMock, filter_cycle: int, period: str, value: str +) -> None: + """Test spa filter cycle time.""" + await init_integration(hass) + + time_entity = f"{ENTITY_TIME}filter_cycle_{filter_cycle}_{period}" + + # check the expected state of the time entity + state = hass.states.get(time_entity) + assert state.state == value + + new_time = time(hour=7, minute=0) + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_TIME: new_time}, + blocking=True, + target={ATTR_ENTITY_ID: time_entity}, + ) + + # check we made a call with the right parameters + client.configure_filter_cycle.assert_called_with(filter_cycle, **{period: new_time}) From 28dd44504e614eccf9bb2bf7d84ba5060910c426 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 14:42:36 -0600 Subject: [PATCH 0542/1941] Bump aioesphomeapi to 29.0.2 (#138549) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.0.0...v29.0.2 --- 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 185f9ea5cf0..8f9f06e6967 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.0.0", + "aioesphomeapi==29.0.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.7.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 43f850d14ce..447166213c3 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.0.0 +aioesphomeapi==29.0.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2877dfacfe..daf8ff556c8 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.0.0 +aioesphomeapi==29.0.2 # homeassistant.components.flo aioflo==2021.11.0 From e16343ed727ac1e105105bae4445ac8178daa5c2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 14 Feb 2025 15:41:45 -0600 Subject: [PATCH 0543/1941] Prevent voice wizard from crashing for wyoming/voip (#138547) * Prevent voice wizard from crashing for wyoming/voip * Use stub configuration in websocket API --- .../assist_satellite/websocket_api.py | 12 ++++++- .../assist_satellite/test_websocket_api.py | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 6cd7af2bbdb..4fc1708b866 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -19,6 +19,7 @@ from .const import ( DOMAIN, AssistSatelliteEntityFeature, ) +from .entity import AssistSatelliteConfiguration CONNECTION_TEST_TIMEOUT = 30 @@ -91,7 +92,16 @@ def websocket_get_configuration( ) return - config_dict = asdict(satellite.async_get_configuration()) + try: + config_dict = asdict(satellite.async_get_configuration()) + except NotImplementedError: + # Stub configuration + config_dict = asdict( + AssistSatelliteConfiguration( + available_wake_words=[], active_wake_words=[], max_active_wake_words=1 + ) + ) + config_dict["pipeline_entity_id"] = satellite.pipeline_entity_id config_dict["vad_entity_id"] = satellite.vad_sensitivity_entity_id diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index 257961a5b32..f0a8f02fc50 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -313,6 +313,37 @@ async def test_get_configuration( } +async def test_get_configuration_not_implemented( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test getting stub satellite configuration when the entity doesn't implement the method.""" + ws_client = await hass_ws_client(hass) + + with patch.object( + entity, "async_get_configuration", side_effect=NotImplementedError() + ): + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/get_configuration", + "entity_id": ENTITY_ID, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # Stub configuration + assert msg["result"] == { + "active_wake_words": [], + "available_wake_words": [], + "max_active_wake_words": 1, + "pipeline_entity_id": None, + "vad_entity_id": None, + } + + async def test_set_wake_words( hass: HomeAssistant, init_components: ConfigEntry, From 4a4c2ff55211498f2344860d6e88472c391b5acd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 16:17:35 -0800 Subject: [PATCH 0544/1941] Bump zeroconf to 0.144.3 (#138553) --- 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 ddc74fba8bf..7a17c0dc5c3 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.144.1"] + "requirements": ["zeroconf==0.144.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b7592bf0f05..7aa76de2620 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.144.1 +zeroconf==0.144.3 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 553ced3da43..66b25b75f92 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.144.1" + "zeroconf==0.144.3" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 2b7290fa042..2cbd3780eae 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.144.1 +zeroconf==0.144.3 diff --git a/requirements_all.txt b/requirements_all.txt index 447166213c3..ccc401d4f4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.144.1 +zeroconf==0.144.3 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daf8ff556c8..4831ca47990 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.144.1 +zeroconf==0.144.3 # homeassistant.components.zeversolar zeversolar==0.3.2 From 30a6a6ad4bf9ff4ea7780e33aeff2cd23c31889b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 14 Feb 2025 19:51:53 -0600 Subject: [PATCH 0545/1941] Use language util to match intent language (#138560) --- .../components/conversation/default_agent.py | 15 +++----- .../conversation/test_default_agent.py | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 23c201d7579..e8bd38f5adf 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -53,6 +53,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_added_domain +from homeassistant.util import language as language_util from homeassistant.util.json import JsonObjectType, json_loads_object from .chat_log import AssistantContent, async_get_chat_log @@ -914,26 +915,20 @@ class DefaultAgent(ConversationEntity): def _load_intents(self, language: str) -> LanguageIntents | None: """Load all intents for language (run inside executor).""" intents_dict: dict[str, Any] = {} - language_variant: str | None = None supported_langs = set(get_languages()) # Choose a language variant upfront and commit to it for custom # sentences, etc. - all_language_variants = {lang.lower(): lang for lang in supported_langs} + lang_matches = language_util.matches(language, supported_langs) - # en-US, en_US, en, ... - for maybe_variant in _get_language_variations(language): - matching_variant = all_language_variants.get(maybe_variant.lower()) - if matching_variant: - language_variant = matching_variant - break - - if not language_variant: + if not lang_matches: _LOGGER.warning( "Unable to find supported language variant for %s", language ) return None + language_variant = lang_matches[0] + # Load intents for this language variant lang_variant_intents = get_intents(language_variant, json_load=json_load) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 54aa30b3fcf..d9f9917b9e0 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -3178,3 +3178,39 @@ async def test_state_names_are_not_translated( mock_async_render.call_args.args[0]["state"].state == weather.ATTR_CONDITION_PARTLYCLOUDY ) + + +async def test_language_with_alternative_code( + hass: HomeAssistant, init_components +) -> None: + """Test different codes for the same language.""" + entity_ids: dict[str, str] = {} + for i, (lang_code, sentence, name) in enumerate( + ( + ("no", "slå på lampen", "lampen"), # nb + ("no-NO", "slå på lampen", "lampen"), # nb + ("iw", "הדליקי את המנורה", "מנורה"), # he + ) + ): + if not (entity_id := entity_ids.get(name)): + # Reuse entity id for the same name + entity_id = f"light.test{i}" + entity_ids[name] = entity_id + + hass.states.async_set(entity_id, "off", attributes={ATTR_FRIENDLY_NAME: name}) + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + await hass.services.async_call( + "conversation", + "process", + { + conversation.ATTR_TEXT: sentence, + conversation.ATTR_LANGUAGE: lang_code, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 1, f"Failed for {lang_code}, {sentence}" + call = calls[0] + assert call.domain == LIGHT_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": [entity_id]} From 7a23348b1da8b7f4ba025a61f81e26d88f2c5749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 15 Feb 2025 11:29:40 +0100 Subject: [PATCH 0546/1941] Fix and improve Home Connect strings (#138583) * Fix `hot_water_temperature` strings for tea options * Improve `deprecated_program_switch` issue description Co-authored-by: Norbert Rittel * Improve option descriptions strings Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --------- Co-authored-by: Norbert Rittel Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --- .../components/home_connect/strings.json | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 38fdd6f6ec3..8bee37796ad 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -107,7 +107,7 @@ }, "deprecated_program_switch": { "title": "Deprecated program switch detected in some automations or scripts", - "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue." + "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." }, "deprecated_set_program_and_option_actions": { "title": "The executed action is deprecated", @@ -346,9 +346,9 @@ }, "hot_water_temperature": { "options": { - "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": ".WhiteTea", - "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": ".GreenTea", - "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": ".BlackTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "White tea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "Green tea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "Black tea", "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "50ºC", "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "55ºC", "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "60ºC", @@ -509,7 +509,7 @@ }, "consumer_products_cleaning_robot_option_reference_map_id": { "name": "Reference map ID", - "description": "Defines the used reference map." + "description": "Defines which reference map is to be used." }, "consumer_products_cleaning_robot_option_cleaning_mode": { "name": "Cleaning mode", @@ -517,15 +517,15 @@ }, "consumer_products_coffee_maker_option_bean_amount": { "name": "Bean amount", - "description": "Describes the bean amount of a coffee machine program." + "description": "Describes the amount of coffee beans used in a coffee machine program." }, "consumer_products_coffee_maker_option_fill_quantity": { "name": "Fill quantity", - "description": "Describes the fill quantity (in ml) of a coffee machine program." + "description": "Describes the amount of water (in ml) used in a coffee machine program." }, "consumer_products_coffee_maker_option_coffee_temperature": { "name": "Coffee Temperature", - "description": "Describes the coffee temperature of a coffee machine program." + "description": "Describes the coffee temperature used in a coffee machine program." }, "consumer_products_coffee_maker_option_bean_container": { "name": "Bean container", @@ -541,7 +541,7 @@ }, "consumer_products_coffee_maker_option_coffee_milk_ratio": { "name": "Coffee milk ratio", - "description": "Defines the milk amount." + "description": "Defines the amount of milk." }, "consumer_products_coffee_maker_option_hot_water_temperature": { "name": "Hot water temperature", @@ -557,7 +557,7 @@ }, "dishcare_dishwasher_option_brilliance_dry": { "name": "Brilliance dry", - "description": "Defines if the program sequence is optimized with special drying cycle ensures more shine on glasses and plastic items." + "description": "Defines if the program sequence is optimized with a special drying cycle to ensure more shine on glasses and plastic items." }, "dishcare_dishwasher_option_vario_speed_plus": { "name": "Vario speed plus", @@ -569,7 +569,7 @@ }, "dishcare_dishwasher_option_half_load": { "name": "Half load", - "description": "Defines if economical cleaning is enabled for smaller loads which reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets." + "description": "Defines if economical cleaning is enabled for smaller loads. This reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets." }, "dishcare_dishwasher_option_extra_dry": { "name": "Extra dry", @@ -577,7 +577,7 @@ }, "dishcare_dishwasher_option_hygiene_plus": { "name": "Hygiene plus", - "description": "Defines if the cleaning is done with increased temperatures which ensures maximum hygienic cleanliness for regular use." + "description": "Defines if the cleaning is done with increased temperature. This ensures maximum hygienic cleanliness for regular use." }, "dishcare_dishwasher_option_eco_dry": { "name": "Eco dry", @@ -605,11 +605,11 @@ }, "b_s_h_common_option_duration": { "name": "Duration", - "description": "Defines the run-time of the program. Afterwards the appliance is stopped." + "description": "Defines the run-time of the program. Afterwards, the appliance is stopped." }, "cooking_oven_option_fast_pre_heat": { "name": "Fast pre-heat", - "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal or higher than 100 °C or 212 °F otherwise the fast pre-heat option is not activated." + "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal to or higher than 100 °C or 212 °F. Otherwise, the fast pre-heat option is not activated." }, "cooking_oven_option_warming_level": { "name": "Warming level", From 91ba9b22398459b2fc9b70019d5ce29483191da8 Mon Sep 17 00:00:00 2001 From: Khole <29937485+KJonline@users.noreply.github.com> Date: Sat, 15 Feb 2025 13:13:16 +0000 Subject: [PATCH 0547/1941] Bump pyhive-integration to 1.0.2 (#138569) --- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index f68478516ab..712ccf09cae 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhive-integration==1.0.1"] + "requirements": ["pyhive-integration==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ccc401d4f4c..e284e9ca51f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1993,7 +1993,7 @@ pyhaversion==22.8.0 pyheos==1.0.2 # homeassistant.components.hive -pyhive-integration==1.0.1 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4831ca47990..dd71eb6a8e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1622,7 +1622,7 @@ pyhaversion==22.8.0 pyheos==1.0.2 # homeassistant.components.hive -pyhive-integration==1.0.1 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 From 798d2326ed608da5a4f7d88ec3e4f4077f1f69fa Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 16 Feb 2025 01:20:51 +1000 Subject: [PATCH 0548/1941] Bump tesla-fleet-api to v0.9.10 (#138575) bump --- 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 330745316d7..bb8f6041771 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.8"] + "requirements": ["tesla-fleet-api==0.9.10"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index bfa0d831a16..dfe6d7cb3f9 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.8", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.10", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index ef4d366c779..d777cf5051e 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.8"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index e284e9ca51f..958b94e1065 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2860,7 +2860,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.8 +tesla-fleet-api==0.9.10 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd71eb6a8e5..03f3ea60307 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2300,7 +2300,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.8 +tesla-fleet-api==0.9.10 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From cbb0dee911b04dc30d121d5c80d4758b0af04c5d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 15 Feb 2025 08:22:04 -0700 Subject: [PATCH 0549/1941] Bump pybalboa to 1.1.3 (#138557) --- homeassistant/components/balboa/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index 61cb5bbbf69..38d32adc4af 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], - "requirements": ["pybalboa==1.1.2"] + "requirements": ["pybalboa==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 958b94e1065..d3146e55fef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1825,7 +1825,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.1.2 +pybalboa==1.1.3 # homeassistant.components.bbox pybbox==0.0.5-alpha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03f3ea60307..ac941a94b8a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1505,7 +1505,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.1.2 +pybalboa==1.1.3 # homeassistant.components.blackbird pyblackbird==0.6 From 08f6e9cd12232dda3273a9b6eed986abc3ffcf70 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:24:43 +0100 Subject: [PATCH 0550/1941] Bump PyViCare to 2.43.0 (#138564) * Bump PyViCare to 2.42.1 * Bump PyViCare to 2.43.0 --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 96935ba4ba7..a5718962f55 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.42.1"] + "requirements": ["PyViCare==2.43.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d3146e55fef..d48ed91eaae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.1 +PyViCare==2.43.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac941a94b8a..0c9f65ab481 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.1 +PyViCare==2.43.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From c89d8edb3cf9ed5928af2b9c84a16a2c70bc9c68 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 16 Feb 2025 01:27:29 +1000 Subject: [PATCH 0551/1941] Remove dynamic rate limits from Tesla Fleet (#138576) * remove * TEsts --- .../components/tesla_fleet/coordinator.py | 22 +++++-------------- tests/components/tesla_fleet/test_init.py | 18 ++++++--------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 129f460ff90..128c15068f6 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -17,7 +17,6 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, VehicleOffline, ) -from tesla_fleet_api.ratecalculator import RateCalculator from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -66,7 +65,6 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): updated_once: bool pre2021: bool last_active: datetime - rate: RateCalculator def __init__( self, @@ -87,44 +85,36 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.data = flatten(product) self.updated_once = False self.last_active = datetime.now() - self.rate = RateCalculator(100, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5) async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using TeslaFleet API.""" try: - # Check if the vehicle is awake using a non-rate limited API call - if self.data["state"] != TeslaFleetState.ONLINE: - response = await self.api.vehicle() - self.data["state"] = response["response"]["state"] + # Check if the vehicle is awake using a free API call + response = await self.api.vehicle() + self.data["state"] = response["response"]["state"] if self.data["state"] != TeslaFleetState.ONLINE: return self.data - # This is a rated limited API call - self.rate.consume() response = await self.api.vehicle_data(endpoints=ENDPOINTS) data = response["response"] except VehicleOffline: self.data["state"] = TeslaFleetState.ASLEEP return self.data - except RateLimited as e: + except RateLimited: LOGGER.warning( - "%s rate limited, will retry in %s seconds", + "%s rate limited, will skip refresh", self.name, - e.data.get("after"), ) - if "after" in e.data: - self.update_interval = timedelta(seconds=int(e.data["after"])) return self.data except (InvalidToken, OAuthExpired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - # Calculate ideal refresh interval - self.update_interval = timedelta(seconds=self.rate.calculate()) + self.update_interval = VEHICLE_INTERVAL self.updated_once = True diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 2162226efb0..ff103ce03c2 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -156,14 +156,15 @@ async def test_vehicle_refresh_offline( mock_vehicle_state.reset_mock() mock_vehicle_data.reset_mock() - # Then the vehicle goes offline + # Then the vehicle goes offline despite saying its online mock_vehicle_data.side_effect = VehicleOffline freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - mock_vehicle_state.assert_not_called() + mock_vehicle_state.assert_called_once() mock_vehicle_data.assert_called_once() + mock_vehicle_state.reset_mock() mock_vehicle_data.reset_mock() # And stays offline @@ -212,20 +213,15 @@ async def test_vehicle_refresh_ratelimited( assert (state := hass.states.get("sensor.test_battery_level")) assert state.state == "unknown" - assert mock_vehicle_data.call_count == 1 + + mock_vehicle_data.reset_mock() freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - # Should not call for another 10 seconds - assert mock_vehicle_data.call_count == 1 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert mock_vehicle_data.call_count == 2 + assert (state := hass.states.get("sensor.test_battery_level")) + assert state.state == "unknown" async def test_vehicle_sleep( From 05696b5528f2d42e4dbdd696a07081eac2509675 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sat, 15 Feb 2025 16:28:10 +0100 Subject: [PATCH 0552/1941] Add Event entity states to diagnostics for Bang & Olufsen (#135859) Add diagnostics for event buttons --- .../components/bang_olufsen/diagnostics.py | 18 ++++++++++++++++-- .../snapshots/test_diagnostics.ambr | 16 ++++++++++++++++ .../bang_olufsen/test_diagnostics.py | 8 ++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py index bf7b06e694a..3835de7c551 100644 --- a/homeassistant/components/bang_olufsen/diagnostics.py +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -4,12 +4,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import BangOlufsenConfigEntry -from .const import DOMAIN +from .const import DEVICE_BUTTONS, DOMAIN async def async_get_config_entry_diagnostics( @@ -25,8 +26,9 @@ async def async_get_config_entry_diagnostics( if TYPE_CHECKING: assert config_entry.unique_id - # Add media_player entity's state entity_registry = er.async_get(hass) + + # Add media_player entity's state if entity_id := entity_registry.async_get_entity_id( MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id ): @@ -37,4 +39,16 @@ async def async_get_config_entry_diagnostics( state_dict.pop("context") data["media_player"] = state_dict + # Add button Event entity states (if enabled) + for device_button in DEVICE_BUTTONS: + if entity_id := entity_registry.async_get_entity_id( + EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}" + ): + if state := hass.states.get(entity_id): + state_dict = dict(state.as_dict()) + + # Remove context as it is not relevant + state_dict.pop("context") + data[f"{device_button}_event"] = state_dict + return data diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index d7f9a045921..bc51f89f96d 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -1,6 +1,22 @@ # serializer version: 1 # name: test_async_get_config_entry_diagnostics dict({ + 'PlayPause_event': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'short_press_release', + 'long_press_timeout', + 'long_press_release', + 'very_long_press_timeout', + 'very_long_press_release', + ]), + 'friendly_name': 'Living room Balance Play / Pause', + }), + 'entity_id': 'event.beosound_balance_11111111_play_pause', + 'state': 'unknown', + }), 'config_entry': dict({ 'data': dict({ 'host': '192.168.0.1', diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py index 7c99648ace4..a9415a222a8 100644 --- a/tests/components/bang_olufsen/test_diagnostics.py +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -6,6 +6,9 @@ from syrupy import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from .const import TEST_BUTTON_EVENT_ENTITY_ID from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -14,6 +17,7 @@ from tests.typing import ClientSessionGenerator async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, + entity_registry: EntityRegistry, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, mock_mozart_client: AsyncMock, @@ -23,6 +27,10 @@ async def test_async_get_config_entry_diagnostics( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + # Enable an Event entity + entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None) + hass.config_entries.async_schedule_reload(mock_config_entry.entry_id) + result = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry ) From 482df7408a047954a559996029f8ec768a160cd9 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:29:09 +0100 Subject: [PATCH 0553/1941] Provide part of uuid when requesting token for HomeWizard v2 API (#138586) --- homeassistant/components/homewizard/config_flow.py | 14 ++++++++++---- homeassistant/components/homewizard/repairs.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 6bcc51f939e..68dc54aef0e 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -23,8 +23,10 @@ import voluptuous as vol from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import instance_id from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -88,7 +90,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): # Tell device we want a token, user must now press the button within 30 seconds # The first attempt will always fail, but this opens the window to press the button - token = await async_request_token(self.ip_address) + token = await async_request_token(self.hass, self.ip_address) errors: dict[str, str] | None = None if token is None: @@ -250,7 +252,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None - token = await async_request_token(self.ip_address) + token = await async_request_token(self.hass, self.ip_address) if user_input is not None: if token is None: @@ -353,7 +355,7 @@ async def async_try_connect(ip_address: str, token: str | None = None) -> Device await energy_api.close() -async def async_request_token(ip_address: str) -> str | None: +async def async_request_token(hass: HomeAssistant, ip_address: str) -> str | None: """Try to request a token from the device. This method is used to request a token from the device, @@ -362,8 +364,12 @@ async def async_request_token(ip_address: str) -> str | None: api = HomeWizardEnergyV2(ip_address) + # Get a part of the unique id to make the token unique + # This is to prevent token conflicts when multiple HA instances are used + uuid = await instance_id.async_get(hass) + try: - return await api.get_token("home-assistant") + return await api.get_token(f"home-assistant#{uuid[:6]}") except DisabledError: return None finally: diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py index 4c9a03b493f..60790202032 100644 --- a/homeassistant/components/homewizard/repairs.py +++ b/homeassistant/components/homewizard/repairs.py @@ -47,7 +47,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow): # Tell device we want a token, user must now press the button within 30 seconds # The first attempt will always fail, but this opens the window to press the button - token = await async_request_token(ip_address) + token = await async_request_token(self.hass, ip_address) errors: dict[str, str] | None = None if token is None: From 78c4d815cea9ff532c9de71cf9bec163d4aad0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 15 Feb 2025 20:10:27 +0100 Subject: [PATCH 0554/1941] Fix home connect coffe-milk ratio option (#138593) * Fix home connect milk ratio option * Use enumeration instead of number selector for coffee-milk ratio --- .../components/home_connect/__init__.py | 1 - .../components/home_connect/const.py | 25 ++++++++++++++++++ .../components/home_connect/services.yaml | 26 ++++++++++++++----- .../components/home_connect/strings.json | 19 ++++++++++++++ .../home_connect/snapshots/test_init.ambr | 2 +- tests/components/home_connect/test_init.py | 2 +- 6 files changed, 66 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 59a33f01bcb..01eb6e8fbea 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -76,7 +76,6 @@ PROGRAM_OPTIONS = { for key, value in { OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO: int, OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 0ec7d3a2629..3a22297ebee 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -157,6 +157,27 @@ FLOW_RATE_OPTIONS = { ) } +COFFEE_MILK_RATIO_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.10Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.20Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.25Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.30Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.40Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.55Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.60Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.65Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.67Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.70Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.75Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.80Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.85Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.90Percent", + ) +} + HOT_WATER_TEMPERATURE_OPTIONS = { bsh_key_to_translation_key(option): option for option in ( @@ -300,6 +321,10 @@ PROGRAM_ENUM_OPTIONS = { BEAN_CONTAINER_OPTIONS, ), (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, FLOW_RATE_OPTIONS), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, + COFFEE_MILK_RATIO_OPTIONS, + ), ( OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, HOT_WATER_TEMPERATURE_OPTIONS, diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 29ca3da15fc..50e50afd598 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -328,14 +328,28 @@ set_program_and_options: selector: boolean: consumer_products_coffee_maker_option_coffee_milk_ratio: - example: 50 + example: consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent required: false selector: - number: - unit_of_measurement: "%" - step: 10 - min: 10 - max: 90 + select: + mode: dropdown + translation_key: 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 + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent consumer_products_coffee_maker_option_hot_water_temperature: example: consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c required: false diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8bee37796ad..3ffd84e61b2 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -344,6 +344,25 @@ "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus" } }, + "coffee_milk_ratio": { + "options": { + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "10%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "20%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "25%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "30%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "40%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "50%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "55%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "60%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "65%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "67%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "70%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "75%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "80%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "85%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "90%" + } + }, "hot_water_temperature": { "options": { "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "White tea", diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_init.ambr index 581eca66cb8..709621aaefb 100644 --- a/tests/components/home_connect/snapshots/test_init.ambr +++ b/tests/components/home_connect/snapshots/test_init.ambr @@ -50,7 +50,7 @@ 'key': , 'name': None, 'unit': None, - 'value': 60, + 'value': 'ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent', }), ]), }), diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index e7380d0e255..9e514824147 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -173,7 +173,7 @@ SERVICES_SET_PROGRAM_AND_OPTIONS = [ "service_data": { "device_id": "DEVICE_ID", "affects_to": "active_program", - "consumer_products_coffee_maker_option_coffee_milk_ratio": 60, + "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", }, "blocking": True, }, From 78337a6846eaeb042d80b89150dd3827943d921e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 15 Feb 2025 20:16:07 +0100 Subject: [PATCH 0555/1941] Disable zwave_js testing resetting the controller (#138595) * Improve zwave_js test of resetting the controller * Disable the test --- tests/components/zwave_js/test_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index a3f70e92dcf..6f341f8f77b 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -4930,6 +4930,9 @@ async def test_subscribe_node_statistics( assert msg["error"]["code"] == ERR_NOT_LOADED +@pytest.mark.skip( + reason="The test needs to be updated to reflect what happens when resetting the controller" +) async def test_hard_reset_controller( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 0a78f2725d2ad99d9280cc8bb0308281768ece43 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 15 Feb 2025 12:20:33 -0700 Subject: [PATCH 0556/1941] Add switch to toggle filter cycle 2 on balboa spas (#138605) --- homeassistant/components/balboa/__init__.py | 1 + homeassistant/components/balboa/strings.json | 5 ++ homeassistant/components/balboa/switch.py | 48 ++++++++++++++++ tests/components/balboa/conftest.py | 1 + .../balboa/snapshots/test_switch.ambr | 48 ++++++++++++++++ tests/components/balboa/test_switch.py | 55 +++++++++++++++++++ 6 files changed, 158 insertions(+) create mode 100644 homeassistant/components/balboa/switch.py create mode 100644 tests/components/balboa/snapshots/test_switch.ambr create mode 100644 tests/components/balboa/test_switch.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 78bf6f7cda7..207826d136e 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -24,6 +24,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.SELECT, + Platform.SWITCH, Platform.TIME, ] diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 0262f26f4bd..9779984b182 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -79,6 +79,11 @@ } } }, + "switch": { + "filter_cycle_2_enabled": { + "name": "Filter cycle 2 enabled" + } + }, "time": { "filter_cycle_start": { "name": "Filter cycle {index} start" diff --git a/homeassistant/components/balboa/switch.py b/homeassistant/components/balboa/switch.py new file mode 100644 index 00000000000..c8c947f499d --- /dev/null +++ b/homeassistant/components/balboa/switch.py @@ -0,0 +1,48 @@ +"""Support for Balboa switches.""" + +from __future__ import annotations + +from typing import Any + +from pybalboa import SpaClient + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BalboaConfigEntry +from .entity import BalboaEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's switches.""" + spa = entry.runtime_data + async_add_entities([BalboaSwitchEntity(spa)]) + + +class BalboaSwitchEntity(BalboaEntity, SwitchEntity): + """Representation of a Balboa switch entity.""" + + def __init__(self, spa: SpaClient) -> None: + """Initialize a Balboa switch entity.""" + super().__init__(spa, "filter_cycle_2_enabled") + self._attr_entity_category = EntityCategory.CONFIG + self._attr_translation_key = "filter_cycle_2_enabled" + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._client.filter_cycle_2_enabled + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._client.configure_filter_cycle(2, enabled=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._client.configure_filter_cycle(2, enabled=False) diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 3a3561f15cf..90f8fdc3d6e 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -52,6 +52,7 @@ def client_fixture() -> Generator[MagicMock]: client.filter_cycle_1_start = time(8, 0) client.filter_cycle_1_end = time(9, 0) client.filter_cycle_2_running = False + client.filter_cycle_2_enabled = True client.filter_cycle_2_start = time(19, 0) client.filter_cycle_2_end = time(21, 30) client.temperature_unit = 1 diff --git a/tests/components/balboa/snapshots/test_switch.ambr b/tests/components/balboa/snapshots/test_switch.ambr new file mode 100644 index 00000000000..ad63fcdf387 --- /dev/null +++ b/tests/components/balboa/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_switches[switch.fakespa_filter_cycle_2_enabled-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': , + 'entity_id': 'switch.fakespa_filter_cycle_2_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 enabled', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_2_enabled', + 'unique_id': 'FakeSpa-filter_cycle_2_enabled-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.fakespa_filter_cycle_2_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 enabled', + }), + 'context': , + 'entity_id': 'switch.fakespa_filter_cycle_2_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/balboa/test_switch.py b/tests/components/balboa/test_switch.py new file mode 100644 index 00000000000..4b6bae172f4 --- /dev/null +++ b/tests/components/balboa/test_switch.py @@ -0,0 +1,55 @@ +"""Tests of the switches of the balboa integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform +from tests.components.switch import common + +ENTITY_SWITCH = "switch.fakespa_filter_cycle_2_enabled" + + +async def test_switches( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa switches.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.SWITCH]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_switch(hass: HomeAssistant, client: MagicMock) -> None: + """Test spa filter cycle enabled switch.""" + await init_integration(hass) + + # check if the initial state is on + state = hass.states.get(ENTITY_SWITCH) + assert state.state == STATE_ON + + # test calling turn off + await common.async_turn_off(hass, ENTITY_SWITCH) + client.configure_filter_cycle.assert_called_with(2, enabled=False) + + setattr(client, "filter_cycle_2_enabled", False) + client.emit("") + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_SWITCH) + assert state.state == STATE_OFF + + # test calling turn on + await common.async_turn_on(hass, ENTITY_SWITCH) + client.configure_filter_cycle.assert_called_with(2, enabled=True) From 827865a1b9a7fbdf7315fa60fb81a2e8f3877b5a Mon Sep 17 00:00:00 2001 From: CodingSquirrel <13072675+CodingSquirrel@users.noreply.github.com> Date: Sat, 15 Feb 2025 15:36:54 -0500 Subject: [PATCH 0557/1941] Bump pyeconet to 0.1.28 (#138610) --- homeassistant/components/econet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 86e3b3527f0..bc7505740d7 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.27"] + "requirements": ["pyeconet==0.1.28"] } diff --git a/requirements_all.txt b/requirements_all.txt index d48ed91eaae..236eb447c8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1912,7 +1912,7 @@ pyebox==1.1.4 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.27 +pyeconet==0.1.28 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c9f65ab481..a87d322f6a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1559,7 +1559,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.27 +pyeconet==0.1.28 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 From 6059446ae362311878b7663fcb53329f77d401c0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 15 Feb 2025 21:39:06 +0100 Subject: [PATCH 0558/1941] Bump plugwise to v1.7.2 (#138613) --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 983ff10b0a6..87878980f2d 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.1"], + "requirements": ["plugwise==1.7.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 236eb447c8f..b7081812b44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1663,7 +1663,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.1 +plugwise==1.7.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a87d322f6a1..4c995a6bead 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1376,7 +1376,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.1 +plugwise==1.7.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From fdaa640c8ec41589eacf292bfd7335bbdd12d61e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 15 Feb 2025 21:44:59 +0100 Subject: [PATCH 0559/1941] Add issues for data cap to onedrive (#138411) * Add issues for data cap to onedrive * brackets * Fix double space Co-authored-by: Daniel O'Connor --------- Co-authored-by: Daniel O'Connor --- .../components/onedrive/coordinator.py | 25 ++++++++++++ .../components/onedrive/strings.json | 10 +++++ tests/components/onedrive/test_init.py | 39 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index cc759437c07..7b2dbaab87a 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -8,12 +8,14 @@ from datetime import timedelta import logging from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.const import DriveState from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException from onedrive_personal_sdk.models.items import Drive from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -67,4 +69,27 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed" ) from err + + # create an issue if the drive is almost full + if drive.quota and (state := drive.quota.state) in ( + DriveState.CRITICAL, + DriveState.EXCEEDED, + ): + key = "drive_full" if state is DriveState.EXCEEDED else "drive_almost_full" + ir.async_create_issue( + self.hass, + DOMAIN, + key, + is_fixable=False, + severity=( + ir.IssueSeverity.ERROR + if state is DriveState.EXCEEDED + else ir.IssueSeverity.WARNING + ), + translation_key=key, + translation_placeholders={ + "total": str(drive.quota.total), + "used": str(drive.quota.used), + }, + ) return drive diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index c3087d435b8..3a9f6d06594 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -29,6 +29,16 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "issues": { + "drive_full": { + "title": "OneDrive data cap exceeded", + "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + }, + "drive_almost_full": { + "title": "OneDrive near data cap", + "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + } + }, "exceptions": { "authentication_failed": { "message": "Authentication failed" diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 65c3e62629c..b4ec138ebf4 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -1,9 +1,11 @@ """Test the OneDrive setup.""" +from copy import deepcopy 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 import pytest from syrupy import SnapshotAssertion @@ -11,7 +13,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.onedrive.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +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 @@ -131,3 +133,38 @@ async def test_device( device = device_registry.async_get_device({(DOMAIN, MOCK_DRIVE.id)}) assert device assert device == snapshot + + +@pytest.mark.parametrize( + ( + "drive_state", + "issue_key", + "issue_exists", + ), + [ + (DriveState.NORMAL, "drive_full", False), + (DriveState.NORMAL, "drive_almost_full", False), + (DriveState.CRITICAL, "drive_almost_full", True), + (DriveState.CRITICAL, "drive_full", False), + (DriveState.EXCEEDED, "drive_almost_full", False), + (DriveState.EXCEEDED, "drive_full", True), + ], +) +async def test_data_cap_issues( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + 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 From a3eb73cfcc8ada5175a042f12e647b7bf8f50124 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 15 Feb 2025 21:46:00 +0100 Subject: [PATCH 0560/1941] Replace alarm action descriptions with wording from online docs (#138608) --- .../components/alarm_control_panel/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 5f718280566..ed02b2d0ee8 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -90,7 +90,7 @@ }, "alarm_arm_home": { "name": "Arm home", - "description": "Sets the alarm to: _armed, but someone is home_.", + "description": "Arms the alarm in the home mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -100,7 +100,7 @@ }, "alarm_arm_away": { "name": "Arm away", - "description": "Sets the alarm to: _armed, no one home_.", + "description": "Arms the alarm in the away mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -110,7 +110,7 @@ }, "alarm_arm_night": { "name": "Arm night", - "description": "Sets the alarm to: _armed for the night_.", + "description": "Arms the alarm in the night mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -120,7 +120,7 @@ }, "alarm_arm_vacation": { "name": "Arm vacation", - "description": "Sets the alarm to: _armed for vacation_.", + "description": "Arms the alarm in the vacation mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -130,7 +130,7 @@ }, "alarm_trigger": { "name": "Trigger", - "description": "Trigger the alarm manually.", + "description": "Triggers the alarm manually.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", From d435f7be0924c12685d3030829c61cf160f986e5 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 15 Feb 2025 15:50:52 -0500 Subject: [PATCH 0561/1941] Update integrations screenshot in README (#138555) --- .github/assets/screenshot-integrations.png | Bin 66219 -> 101607 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png index 8d71bf538d6c775dc4c09447b265497d8cf83a47..abbc0f76ff0288adc024528eb3ae0ef2bae41156 100644 GIT binary patch literal 101607 zcmeFY^;cX^@HU7BcL~AWU4z@;E`t*!xVsOQK+xd9-3NEK;O=h0-JL;}&v*AdyT9yv z_7B*5&UBx9yU)2jRb5?GPdztWRapiNnGhKY3JOh57N8CV1y=$E1*3!r^YJ8Yf5rQw zLvoVUb%laL@B8luoy3Gr1O-J7B?l1K^vXEx@Jun(c7J#`w9R&fXu1zex%4)}!G3xO zR^l-Bkv-gfxQ3NLp`pEvO~FUpMf=$oh$Bf~L_oy8^vQG7rD-peM&BjQ&fWUC;vl1A z0Wv7^Fd?heV9Cwpc`x%Y&DFZMR^hvN+R^396R|@;8Z)?a6##xdKaya$qBEz~ZhdA?uHCR+MkeKc#{f25@#a}y@3jrI+C6`G8DH&5)S**y^N zA{1xhMVkhfmW;^*%gHQZ@r!L1oBe+*BV3)QWRbyq;MAYlMM67~|1}eJ++Yx$I| z#s0>|1_*>PSzpxEWdztCPv<+=zQ^a`y29W4VnjQU_b=L}XuFtU^6|f>`DQH2#xZ2G z(SB8PUVnb9psOo8Y~Z1KM1M|kK>uTjsfhpcrs)6sOQ^DN42ag^EDRajS>qX|Fr8Uw*MNz$?lSS`NyQuJ@wLCMVUQo1o=Ys z76a0%GVjQ1ugH&CJGLKU+51l*?f)Z{W$pYGrY-HV`Rk__E|U0SlA(i}20i=s%6*oR zo&Q7-RVCF!lr*TKk##lj(KuTgMcY*UG>WJ0l%7JRL+y2D#ytIhpE|xxBpwk z|Nny@ra_l-3au!De0Y!iRoyPqTEP&)g2r1u&BS(5W`a})zc@N1~Y1DTN?sDeGg|a#wmzlfU5V$ zPyZ?FBOySpB7^JXMT$~)+<2TvetqpoI}`5*C;*pf3Ag$^fta*}+D&$fzAC544gUK^ zNhuev$(W>+&Rb>h6gGq=Zj8^I5^y^`3F<+~^6cCC#SeeDmwj-g6GEXUY}R#;_=%@J zIuxb}ZBP4+|9Q+czPAmgjD5q*LMTr)WNZI#qB2#fc}bBizOTlAtB?G1{394m^HptO zgIzKtTM%LfgY^lg)%WMcmjxa?BxK!&YG-C=MJpZdwVOe(s0?hq6c-4QG%U8+VIK zAR0ge+G-ib`UV-dEAM7+{u0KiUffxmNK`EWZ7YM`A(iN|%UsYv>mx)u$t}jbwA3l% ze9+XwVI1S{>-5M34R!qe!edeq*FSFr0B85xX*RaSR`o>Om@1B1J`@LYO{={{n9lfA zD>E&oWB@IlJI4mk_Beztq-upQdrHaD8nv@f20&Q`ChF$(U=E;Ws^FZxv@BO2L_U+S&31zRRgD?5do$c{5 z*tyLVbw=NYvmaI*i?$cO_Cj4=O1GYIfG!!9r>c70_s9`Ar{J*Ncb$yc zgfEPJuCN9CHV)MfRG5WHCvl~rVTd4)Jk5J2L7MS^o2G!mg{GPI$NWC&{&)JPHBWO4D|$T`Cr8j>!CmvIhyPD%&P)l#U(Cl)m$!wU-6&F! zbqosuwvlLYh4pZ?ZDq&S^=K7+a+x!6i*MHX+A74h(Z62n*DsmM*5l(sh2)NN_k+&U zLN*hNs#gon@4(GVg89V=>Cc#h(I#GJe3CUVy>Q0MjL+*Jof=Zw1iUgEDsnab;{9zN z0}L&@VUc#U&zo8S8{#j|2cwY~6i|5^cL!HqMbwPs_`sF}-(@_}vVQDdOm=MR#r$L~ z1k&Q!&hA2QRg~)$$AdBQtpj|nPv@iq7tdap;`v%gCF10(YuOhBp-pusr}S{?mIYc# zvIPbVOoll4=U;O9#{TRGsLR9{Nv~qibuUz`UM6I}_{|^7Bv3o2dQIwFp9QudV(Ho< zApBzOeAM>b@P269cv^tP5bCz=N*z82lBH=xFZy%gGufnMErCC?79#LjXyU3TNw4_? z{}vvdgL9?~1dCMX-gbWyc6NTrnl%&WYhhRW@cbVK2mF@`w8Ppe+@=i?VnL%Q_ZCpE zn(A7s(Ay5eDML0N$~Sge*r_@au02VtK%}^T#p#c~^y~C(nP@m?6<{TMe-i60mim3(a55eaVH zW;Hzn&&OoG7gn)#JZV{wZt`6#g6>P6iCNBMoWFmKW2@Q)N`_OM+Em`uqvf$HE>=$6 z_?wfi#m<*R6@UYS4Z@R2oRSRZlJb9#5(3Y4(x3X+##zex*vpOTg#z)CN(J1*Oh=gvFDb;0%f?MJm0|kn}9H?@O*QVtJ325cgLViyywSM z-X5Aetm(-B|KaS)PF6YW&;I<=4SwZ_unl@=d6E9L=cVqBzNorsf&n}dlCwY7g)=Z)Wcrn;oz3ZyJMRa_HnoTjNG;np9YgceLecca zO>0(rFJDnZKCvG6o7XGfdkNu0z`$*r-H|y#x+uZPSlHoVu_Zs@Xu12q1hkRe(sF~> z0af=E`w5fZ6PAI+2zYIo>zL!17$y)DQMxA5vhBZ0Jz?e*+8Hk@wt5$_b20_swXriC z_|(9ZQg4?WQy#gLe{W&uFUS?@q#gy=fX>H&#c2pWHNu|X*g^du5)0N-70R3M(=}v9 zI04ba8K3?4#mPn(6tLyKl?a0HN%XSmf@8qzZ1Mgy;p0D_@#SNNm>9Sezdm)qN0+H$ zooFQ8kLTQA&$(Mh8JqQvM$Nj~L|#@yt|+`8UD#$xY|omyTzh^5 ze4|)sJoY%}!*z1}&v|aXO~8}AwqZPexWfG}B_h8*j1>-xeP?0*dPnfu%$5;6xg9u7 z!a-3P@DOUORwe9%^<;R5+6OxN{Ef)yRaFhxr`(aLki?5>nOq_N_WX0eC^O?qYbC3M zhF5QyQel$qj*ZL#vm3645(ZBANl|5jhSk#NIs<9jexAC<4iVgomazFO=F$&2E6wIY z$Vz;6P1DMglWcs^O=uP5qbtEbNZ*j8y9;Q`wzAwhxPB(B+D1tKt4BqVE(9vuy;td* znEjk4p~chy+xr+s$ufNMQhvlaRNV@S#{-P~K;oSvQkgr%n!b}WMqX}Kw^S<7MfCke z#Q{wZj1^osUyZM0mESn!^OBs|Ii5$>f`Y z%kO7M#r7fDFsOFXSFdPA`1(tL#;VFAnBBxMAUv_hZls)_sm%3X`2FsT-`30Q$XpUu z)5dD}Y{%;GS$3BslGB+Se1Fxs)lH0xp?Q%dh?5u~uW0-2KMDj@$!_@1sUIivR|*y9N%sE+J{NvE&aUzfG4*ajC^-mE8i!Tkn-4hk0T>QyDFaFvyYnQxKL;lPkfHf!j-_3V0kE1l|od}SiA;gS_-1q7DD0DsG&4NlnR_vuRD+n8#i*BiZ3;Q@crE( zCsu$jYK9t?=k#N!Ky{veQ}OLO!R=Y!db1hTsfAJo`^|B!K{r>+zdgGAUaS+TQhewu zCp&hEK?@^-Xmx6sgwa7cw?B#r$wUc4S69kc(6Kl|lQ;ZE8y45;DZlHTDbYF*EE;(3 zh$E1Atyt6fb{PqBu>Oybh$`I!vt-(joso))pE%5HULNjF#*! z2Ftrz!Cj7(z)0j4&BCwQN|V?n(yohA(V3m@ZOqRBZBukJB7gaZmEE zE4a@?awaIT^uy{lE@8CeWUPk4 zJdpH0VQ5g4P3XKY4Mu`Bf1R(Wcv9^#X(@tw(g9n?=vBvUxwoEXE5Kr)$-vRVHfSYb z-R}2RGS}-GkW|}X?T1B6^90A&LC-5%PK45K=*{*8hXG{>X_L$2MLRdMZmsJcnVZDv zavCa;b0`Jvu27BXa*oIZ~N4~<0OA}W4x$(euo6m4_i`CpU(-2UM? zH4u97Yaab`Pl)LAAe-L^N*Ay3+W&~B(X2W`kW_<63TK*0D~bEJqmRypGqz=~r3Sln zsuw+>ZtGDNS_6xr$x$Ewr2bk>$(ba$h~|4ICZz>_$Z+U&+D`G5M~x$Zt%b$;twXDT zto=BBp*a&Tb+=Uihklo7-dk>p97~QV-WQy)2}_sU2(asUvtCq)@dn)u)X2Q=-_h^Dvi4YeV%G!+A~;J^{9cFi`&yrL<`YcKJi;C?nf zuVSdI(bSYpGU3i=j8H~hVsm|?+)RcSP?l22FrigU8E%t3Br>1E)O z>Leq(sY#NEDADtb>*mK{+p3POa2}OoTn!NV;x=eJtzpqHU{+yLWER0s*_KxJj=%lh zR`gHRynOtR1v8l7wek}2IqgxAT85`c*rU`;O~%BqKaToONeG=y0EHhT->DMem8NY?WQaj9M4(Cd{oUfY1dH%au|>T2io{W)`^psf8sLXfZN2Oh+-Q z3QI{|)3;|6-vJSHA4w)!P)Y-+ViuIXt_Yhpuk(f}_rI3LjHH%x6AH5I$cgKW!I9~i z#jySbUw<9PRV43b{C&{}!I!5cisew5Y*vlsK87{9UlD0Sx>2+aD>0}v@LArY^gsrS z{JMb{`W!iq?$tg#DjCsgeq@#_G1~)9lplFiStzz-wZ>e%uhAL3CPBTfG>bXiL!1?ugbueHm%Ti|ax1K^cB*+_i z-Gkn2U%TTdS`Sk}bKd-2)9cb#tvsBt1gJ*e%!D(+qS{^UfPjI;B>qS6z=;+k$t_FW zoVUo9_nHO>Z2(S+r#u}_TjpmcN5+Fcu5qb16h;rOb$uO&T9JQjkm5n-rQDTdIVL9>#6{ec5&-RQ z?(Q8MUOou}r9W`|?|j_$lC<02t`meQCq?SWH2*tjJhsE_RRx2LGShDs?BKJekxIp& zUi{C{<`r?N6H6Kx@&A$|`acdi4!Bp7##pve;G3QPBkVYKyis|xZa&wdr@##J@?bq3 z#~K2Drw{|iGPxYsp~G~ylCD*bpg_SW)*$qgLPNQC3hBW0+p$-LMFll`_%n|0z(_pn z6%MPItc%tsojYwV@qp6IIISu}zQ+V3{*yRItBcQ9NNGN#Z3+?S)R;W_{X@Mc75hR} zYmQcp)>TsUU#7nXp7yM{m)CCVi9b8wMutKS_YpWtW>-I>1_wn+cV$m9;#O&Pl?EoqKzn6i^(1Nxuhg-X}r*Zmc9?3 zrX(x^GwmwXMya!pq%H(a^Iz~YK#>;l;BzT~jccZBG9F$D40G%)%vmRTW6Y%nJS^3p za{@8-($T}$BhMi*Rc2BW?r$?FssA$YB{BP3T%FhnGuYVAmhe>r3s!U%=z!Mw7dahH z@{UL2O#n6S>_6>T8beiOf0E~#ScXv+H8mN8I9-ImFf{OU$I1KODYl`M$i~F_vkV z`{GZX2{Xk-;_=-y94zsl;he75MRZK2KzKMc6qjhm5HYh_RclE1)_tnSy2ySdMRc7m zU@!A$AwfdYV+9J*)azvjc2HmwOW)$6JOFjwd3lby(mnB&8aDCN{_A@q#6+Lw5Y~DfV zf&vjaj5!Q0poMMh7;BSbYg?bP>1I-Pt5QTA|7&2&#y~ow%y(=NdTrAJtEx% zXk)zhfrG#%bL&g>?qP_bjLKAr@ML^nFc+;dvau;nB9Y{WHK`D73Ea*9a$eQGN&oX3 zz-Vl>y-#pHoOVvkpqbcQdd{gkJ1vR()g1w|51GS#Y}*bx5bn#Dqv8T_VklKSN?wvW zJ9mIhucBgKo`hHt9hs%#nzlI;I~+V$srrJ|%)5@N9%9XzAyo>*Zmmj%`+nAd{hrz%m~S^4Mg|3md!Y9P16nXVIiY1R`>aTt z)MUZ7rqL!<^Hb#0NZQt?wr-fkyy;u~V0A=jgOW%v*eVxyS8#o$1fWPAUoxyJ!-k+6 z2H#CPSJ|TeMJ!*@tYmp^8dsnnPVbA;XHEF>(0 z+S%;PC{6rTL1MZS{1wl-Tl2iLE#Vhwn8>6 zk!sX{VQEuSFY4_L5qbsWn7S0DKn2f$4&F<8l6MZ?pa>d=UZbFUEPp7{%D@{6y11yj zJ1i{jAxjlT-iS}NWP%25`~K@eojI_cbFPi*z+~j?1#C)ng_akvji36#?T28IvAnJN zdvWIabCa5RI$0JC>klNEw4NROH8MKNw~my*qG|MkftXpj1a-*pgj%VZ?t^?E0N-=>2`1*ZJ*u}Xp zj@k^7Qq=+&8lX8zQbXfbAQiqIy@dhR;)DL-1HLN$S~n5nTzO&5Nq)wTME7wLsJ{Yspa-UWV1q0TfpMtqgQCn))>&l|il= z?LlEpCk-i}sTqXX`+oL2{*5wR|;VuwWZvwmFZ>WF|zxGcZAf-hZG6NJgbe)7; z`S89y`{eIi=Q(muQ;NTU!^0X{u?mSfJw&;1#J+sXJkA-!h}HAxO8;!(D-?^Z$d#&W zUkGBCPIV>if*$vS96HR`bVJ;}$l6st%_X+xqwAcgF^hFVvpDX7-%6Sc@x=usF}Ovd zF`{pPc*KE*l+Ir7r}73$?9i>R03xnA@J0Y8MX4%ho{B`7%G^XVEy8LEDTB$>4 z1NT;Gd1+j@k(@Q^KGzKjaYqfCZaa@OFDGq;$m)wvHZQ7yNLuu=#~(HYrA5Iz?H+Kt z)b1Zku+TWc;TZ^pS4uQ1$f^1IYQEg`PKIal9oL#qllC6$$S7IA^O zWO{L`r%$C&<2T)c(D0a`pOGN{>c*_(cfqdWbkcqKLYI48OaIe+GVtQbN6lsw<@}A} zqbkB;1!S10>iR#gdnn;5kkBREXYADKlR4cITRcm0m?@VI{%9c z0Ovg1lXm~;#Vjv&;egPwXsSc196wBrmz`2-TJHyjU#4h7@2?*1!?|+ZUZ2tO1URSo1>*p-ho3b^9_Me=a{H6nkcRag=1vTYsrFqK0bzHTXTI+oc$y$f(p_{{ z>kLcgqzaKzY@n%B8GN-(xL{$CPKsD+jxEf>W;-tI{d~!RkSe)PNmQiLxb6Bb$ExQ;)zr~mR(t+U#A}PM!Z>(fK{fBg7NWO|U1?k|1a5BhZCwS=ZjvvL zCif0lZT3~3r29UoA9hv~9INZF|L%ombAXrzA-~<;D7D85lm3vJh3WKr!%NAP*8Qq` zsu$(0!*RtNU2abillc?}=#!Sxg%h{nHpBx&)R(27tSMW!W}DRBWXaqZRU3&W59fe> zZmLo%D>mSZc%m2Dxx`ima>$X2CVNhQ>6Wz%Z$8ovRmg)1^ZuF|73b|SD(ejZmX23<`akAY2_2ZP3$LqGf{rn?4S3ku6GzRV6GxMNV2B)MXt%rd1V6pP{#Z2h?3Ki* zU`~>cmZn1N!t-=u9uGVYtICJM)^W6Lq zA|Vp}gsWy;V34(@GI`^M_}HJ;EP7aA(*@E3t8j(3NmdT%Dby3F4z+dQ=2B5hYRb9k z7eHUMEH};iYnkNP7&RWm)5*N;PmOUAmRp))NiqEL%#?U{cP+tQzQXZWghStO^@&F! zhdH-~7z*cm5p^BOSd!d5=X>9K)Eo%UCLT?e#wdCXC^Z+{ipLu5}7M7V6- zs1tOnr{TonwifH(yJzUb6faGUp8DYQXr{)0AVZpAC3VSMy3MXn7A!K9>SFzA7%V;1`56VDjaJ^nfws$gma4#w-q#Tm|18OC*q-z*PqR!wSmpL%&3wMk|_x zZ>L6ZRJ^7YCIntTF!o~m)#p5Vk-NDB(W|SNm#0Y#;p?H}AD_u??*g7?FamC8joz+j zLQoC@uE<2whkrJ$d&pphUU&`aPl!Oqs{HndBqe*H!Vc@Q`5oV0S{_6%52PaT-)=-f z=Zjr$<1qm)&aBBOu&!@UM)XM$d~4P9b!9hXB2P7&ujd%=kex`^76X~moid|WQ(P%N zwl?>THE*Mr!O53H8BxgDg})JF5&#gMQ?f{u@cQKKVP-2?wM?%ilTo$%<$aaky$tNw4Wh`C<|z1zI&Y3?h`jL+_3t*_=#N{_kdVo zl(clMtHp}YyzHf3x)BmZQD}EH_hXcf6h9)Ep9k^$JW26Seki+1%moFqgi^uOJerp- zdk1-lvxuIp&*jS!lla$@Z*I31*Qectl2xS1QG161aPm}T!gUQk(&AeE< z$UZQNeV<_Jze76l&!}FQ<6Ku8x8*X}lEt9QQco?!6^GyyIiO>wkLxQfUT8W z`tUR~$$#&(xE}c3UWhz)T)a+Jv9^?}(!V?oehxU%H-h{_*znz;6ZIUTh!nlUAbX#V z@qgaQei2F^A8__t(6xk;wYz@Eew59hew_LxN;+5*_I6v`^?2^tb=QN@aiNj@EK#AQ zU%UIS?c&?peAO!xmW-O){j#P1o9lbEX@BqHrqgV(=xg}I#P5gBi{5DIxiG>e=C(7U z&ByCZ&vn;H;Rn!l8CjVoBiY-KH$by&{=DPjO$Bs}bFiB2bU{6>8KG@SMN z8^Sy33%tC_UTj(kma_>rb#z-+W-BD86)$pAm3gINLutU9K8h&3o8UwsP61tqr%61_ z9fzX+nlf83_~s>cf}CX?WHJuV+h|1%9pY_4#XH?>p#!B2#7fhIF5ifMpK-h$%Rg;X zDV~6Vl#5Poe6Uy67{7J&{$PDVXOyR&fId6_1^ylC`5T{)97snFUGOezly`38xwXHq zzeP$H6`?d1RpBN08Z2V3Hf`x#BC^44@20Jo8swApiA%ezK|@8@+pNHEcE()c5A(NX zCx1uB$%}x1J&r-r_33HnfiJ}ls|yb&3IT$i=cniAmHe(2o6duV3dtKTbFMK$&xiS4 z4?iNI#?p9fCo-0Md#OHud(2Nz5ir?kZvk)%30==d226ikv$MjE8*4<4?cRGMG7|zP zw|*i4&(_7?JeDt_I1q@d4`XQ*v_#sE!|4_>{AQ304KoMH-d~IiuhW13p2?F!!O*RJ zJa4-=yCD1eRPcGx#(lFM#OQj!TP7f4PqEHX3R%XKkPt)k1i8jov;f~MH&xTuYQ_ii zRn)@B^VZ@0t{PU7B6)@oCg*mF#k%EFXWC77BCCr1XgmBP8px5Ag_+t`(PpfknDSs; zon}fChtJh$e2Neq(7dlqdOSBWUm%-iU2nhtQ z8av&9ZyKL>-gp++Psf&oeY0qgldFm=N09$fmd_w)kkx^2=CK%L4aM>68zI~Rf`#Il zrSEk1)5)E1M|+uY#2~rnrlRCxHTR{B`j|nssX21J(C*Vt?PwNe_LkqP04!ZOls4|s z$z}slN;6P)dFd}C7@~e;9zdXnrzc)yNWklWb}%_q4xb!E;-xV=ainH{Z zjC)=xD_OrkDt)+3+iy2Eo)cM1X7?G(V;1+Px`5lP_ZwE*3PF|X1^nwL1Cd2$lQPv2 zvvyaDk($mT#vKj+eBEulJRjA#?8WcM&}S}fd%?GewoIJFwnLEpTO4?rX@RcP^uNv2 zW&N&?OpdWt261+kiHe$P>#J#yxziN{sO9bW^TV5i;|%mgZ11s~DCk(w{7EQl7qy_! zlI)K{kAjv~wNLa2scR>jFo7?rbM=&{Q#epkX&A&c-5EaAP^el??<)f=F^AWKXz)_{ zWENU#lwx^3 zoY03KDFrkaL#o`3vXoa-(gbF`&TPx0wUF6rZFYWzOwjnA&g4+c=9@!WAjiEmpCBn= zR0|997du7Vtjms9#BYmT@$c;-tso_}gp@+)DbhWo)*ViMi<@{!q6<+C6kT}@h=&*) zN#=6Q3v9riE7Dvm_L0g18z+uhoNB_1(*y#nzv5%89Qd8P2#_OO)Eo zRh#vcniEzASRu$*JaD&NO>XE~;ES)qmrwp#!sN)?b9BX}302k}rM?q1&!xe$t*k7` z8(DHYrip!fcP(ok?&u=U8&WhhFbq7F;!tN@UmUYrnwbiUKHKA}Ey{=eim#8@e;e#L zQiZXVD2bq_78dffQIQf@<&SY|RcFKtLmUIAjqW+LolWr9S(3R;L3^&dPg2E{w_bE2 zjO@v4dltj+ZFt>4N8)$ho+jI^)PJ0uwdSJ>At;|Soc>-hX_fogtUr5poqSNOK~7F3 z0mkHV@&Daoym|M35FYJJeSLku-K1$dEZ5qb6|{fgwfVmVuHh#GHfqhAl18aUFFM}G zxK#ApJkCA{XZ0Trj!9dD(^jjB!=g7->4P2WvtnH=bUg$X6|LXS{iRj@?R6=G-!+e3 zzL?*FlGQzt7IJfQ`qPl_AjZt%LK8!aU)zlo`ttEZ}et?a9b0X$xsJIhq49aJZ>)bD~jyT)*%et@wskgO$5m#ZX_Cf!JkS2 zM?TR=}E3 zx!&sswC4Klz16jr^Tt~WwGOYjj8f+_N;01 zuENnD@^T~kzW)BW;J&yyG{zRNtlMvTbz}6XjtQ4(NCFG{gD%_cZ_OYv=wW^IfGy?W zc`?&>{f>{dZLe{;&NAQ~@)`7MG|_d5u-S19E=%ak@;H|JaKLY;2XEF+PKTnMkd7+Q zT{c_uzs;(S`%knYkW;R~^%`hRq^lr>-Svy{Oee55Xf`=B zOWk>;Sp{0a4?5tA?dq2=p=b*~j=s5dSUjE-=u(BLRiR}>Um3}-;`1b{TA&P5+aY>! z(hBpb+{{Z4kN;8yoLy^adgbw-O`lWOnF-9J8-sW<#7brs+R~d<|ALRaF@qUKF0WpV z)S#Ia3bWfQPx*t{V;}zZiUi&LJ*gKrfs7%8w*O;q_J-5Wj329lVzS|VTZ3)3-VyOqWERs+_61JO>; z58~wQ{k_orV_HnW_N=4N&dE3LiP7>T$hq%j?dD6RUd4x({n#L7^T)|X{{0`U&_?Z! zW4t?`SIB19;mV1_kQwKg5I48qN5BjDFWW5p_T++D@nND50Z!8&OjG&7?NBr#(W{4S z|C{v9%az4Wy>ie8DE{Aia^Jk1I;UXRD{A0*}ynX*{>l zJJ+tR`L5a8Ap0o&hjJ?s$phj@*WdjKuk9|9JJaK>CSr_e5f!1(?q=?4Y-A!azYWA9 zeKMq`HbM_LB&omNe|_DD_jx#*Hf$QOC6(!rUd|x)V(+^A`LDm&iFOCLd0VJHxKA+- zM@YzM#*i&Fb!u4zu4L>wS@%3gd7ARRcGS-4(%UVdl&Ab>bvWZIu_=gtBk113s%%`=!10fhQb&8E30WiM&5&YfS4n?z%EmZa%Z zszFtFD;>l8R2|VrxO&QYsP}*y+5eFp_(RS-Lrh|+k+51QSt;r9BR?{H&x1@)1QV-- zpJ9>x!C|{MEgv5NI0hU9v!iPA$l2BCAoj8&2Cq7HX#hA=$2{L}AmYXb2%Y=zH0k4W zHg^}G_dIgMFw8+Yk?cu}x`h=3R=%%aZ_gatY(E(JrZt#wL8C&NKrG$%$A5B=e?mA> zkUx>oG{IIXPx^T_czN=DX}onL2@)698f#jsPx|P`{o);7{T%g?gqFJ_-2@xS1 z@H?9Ensoxdmg7m^AuOg#?hkLb2!;1(WtA#0UZtu1{eE*g)DZ0k`jRoAQT&(@=z2FU zoxX6Gq5$R$lC5W;0s@gx@hro@mD_ZMsU`fTPX?xpc&hpec=IQn9gUPWky1kKK1WIb{<5E*Peg&jed*K3TSq@)RpGN>YRZ9>8jRA2u;yREkE zoitq<3aC?4w6sPm9Mv1;NjIwKM9QDg{17!M(}pg^S96A4D~HefZczh4gtSa4i{tON z7SDr5H0i%?66fIHt*Z8h#8}pulh$kjr`HbfI;lH8czvr!Zf{0p@E*iIWTCOpC*gJo~jt{W`EqU+W5Z^y~4fU6p8gm z{d}Q(fxGDRq2#%~gOdJtqnYx*7cdwzna$jhuXX|Agb>w}kT&n3SNka4W~;5W7T5bh z%iUDeloFG>r;h!KNJ*DpiEaGK+L*b5KL9*}MFDXi)K`R}OlCj94p%Z&Xbxwnz<7V| z1#xgrhQ()!46b|r$!%ewgi$L!WUMbRST<-NQ?BPw=P6=FUIiJVP`u1_x$FQc(7tIY zISNtu-=*W$xO1aTE&r3!T(;|@W#|yAI@)|D*^TlJB>J-EQxSDMbsBXn8cH48UyZVu zMYgLtjxIi6Ik3bXW1aKCH5icl_jSvoc)7z+xF3*O+uESc|xhD>s@fSY+>` zUa?7%?s{Mq_i*xgR1A1nDIRW5l_@d%iK_CnGuLP5@~>ib{Sg8enfsh8pTim_??2a5 zbl+d=_m!k8N3+sI)2Hk357()HKD*xn$FiHl9nsDx^KBTzLREuvIO|e;4Y+(?7AmOo z;CpHBv?2V1H+GnqWq_iK#kB_}l5fgt*+>*f+^6D41gUqlsdrj%G#@21k_xNlKJe&? zLL_q$@UG{li=nniVdTN}>BKE?;-aZKS@f9^lC+79gQBU)K=^dF{u2M)&7bcnP-Rkw zz;5={>*jQZ|KQ)YD162}qq5*@5ELd(KTou6TMH*NU`?^YMQN7Wouuf5rtpk7$7)}^ zfX2)M>Wqu3%=DWy#+ZEUeE1)+Bl36 z`vZMy+gal8c!n=4^*elpd;u)o-1W?L+hb1azDj$93Af?u@dRr7X5E^O=zRXn2F{Gn zl~5dW>8oyk?K3%pCXRC^V9a5##Z5KD4@uoOx(4}1LxZOKr`fT~*uM6)-^2gm%-ppJ zkf+pqd>Y5{OANB~tp^5cAhF7Yx{cYRPFYB)&;%}BW;y({keaM_{=nAZuotEO7UH$q zYB0Ifud_6Og#2L${`Hai+r$FR5xTO1<846Jrpiat-l?yeYYBl7{qC!C^0#iH`QTt@ z2|hk1Or@7VA;^%)FA`42jSLq3#&gv@a~lc3n2_grh9tVK(DL^yMe+(&6PN>n>C!zRl4NA@X}H!Z3Q4H%1kUDUX0Bi8Mw@AVFYg!I5;NAf}H_Vx4g8>YyU2vMCp z6m@>cU789EGp@7UlDfsJWnPZLLD%Y_pRxvOK2^+bA(q43XxLV7St!m7Y5!1Y;TE6^ z#Y3Xr)4@>a?ZoUjUlr<5t4)o(_*H~2V*uoOij3b|53`M2@!(LhPE3OExKtH}DJZ30 zZJu4Sj}_jDO=)QUTvO~AFUrGGTYj;-zKE`rvAEDi%8b@7eMROG8Z*c2cK2IrO`4qa zcKrl@VG#`!cz*E544!_z_M{r!7%d_^>ejHpAM|u;u<-aRA~uo$r{$k*p-54b%(Smt zZqG->FK-yyT4il0^w|Ptbz4Wp*~r}okK@D!0qX0Kr$0;m-L_JXF}IaomJp<5nZuX^ z4N1bV03ihy!F2B;M*_Mxzj}Ssidn#fpWLR1j~P-2`D}5^w%k^bP%Do9J<&pumaUa-44U$x=cyJk_oMRzI<1 z9?EpmZn4>LxEfq5k&**IHm^qEwDDZ&LDp+GLo(>jN|0 z#KOGM*hH*-9S(B_J1)YTmg;V#{4mH|%yNu*8GZ|^zl!1;pA%V}MI`YSvc%GI6cZ-2 zpG~3mvPmaxG_I!>#o2I@*x!5`QpM}GQlFpjC9TT#u6ORB5hnFS-3`_jn=4n%`mYK@rWUV~ z^3_FUP53#=h&NBySQB46BrW%~wpKxm4VrW}*jjZm64=-*FHTI{S~fNwdFhTeN5TWvKC|Ge2*h|s}^$objoD(R9l1gDr6rHi3pd?b)R;{53R}-X88q%)-aM3 z^-!?~0XexSGWxkD*lAwd_mV512%*|GLakfx)R}m;2k>Te@|XL+L6kllz(LP?7$rma zKhJETSnmlmXr<;N_Y#yC7li&6f+C=W8YHvv;iLjNTVdj0;W!wXPYU^L!&C^(qH4<~ z82k{fcbO~Qo0%;lr7p6_X`cD!NkypfkHh{c89yutW+bq=K%!VEZXq@m>3-RP2$1~r zRI9s!$M-dTeb^&Rwj!GFDuif!oysgslVYbS)l=g9H$`hpn!*TP)N*j!?`ya{JC8>D zb~F6?*UrF3NuWaEO!~&7K+}BI>DDQ6^7_L7UuUHzqmq*K@fVpCdi-br3N#)XM=bqm zrDJ$rhLw?|fMi;BzRsMAU=~P{1qyY;j`PRD^@e?W+PYg3KT+>eb<|RhqL8YwB3;y7{PXNh?Imf^GPRm4Y`8E3K0@; zTI3M7Reym0eEOdfTW7_I2AOddPX0*e8kfpPi`l2em!SKz1@$CA9(bYE0s{b7tr5~X z*gMOcS0Uv8DUIjpe{liOfx85bx`8OX@ELu1t4he*jBOEs=*K*zWu-WfdzCq*wL8)W z;|l*{CpCLT)~FfIIK(rcTF#gMY+@CY#SD1n-!rkm;y)?p`m^o>!HKC#4F-M-3rk66 z7CAhvo46Fu;(}8%xYfDUA|iB=yKGCBNpX^mlb}^=XkB%2*_%PMFi_$ zq9Er529v-S{Gc>sn2FnfmBqq@nL*`AH%rLmKX`MFr=X>vtrn|&>WG=_NY7kOmAl^= z^DDL~=np4CcfP}CA{>J`dtYJc{snXD)*DBR1(s;q4&e$f|09Spzd&DFDUw0`@hM}7 zZH?TYLjpD4C};m-c>|MWqQ_E9D$2I`!!a7jQQ?c1Xcxbb+3@%Ks6+)!oG6qYT*?U& zzMsSK?F$g*(^w9i5aSj6ovh z)sIjQN5L0o&@8LvrvaqUC_5{E^9vKe^taxMxW{#U$~Xve-v(QT-PbmX3L>a?|a2H=e(}D=32X+U%oz#*`~AF z4=*RdJiQB*fs2|VO0Cl=FxF^K$eX&{dZMj_23_(XeGe5|`<%Lh{b_&_K}S2yT!6UD zM;hPbQ%M)a?n^$m`&{t~v8^P^cgye{w(MMZEGs zOv|pmnRjyh-%{DIAJkH^24QT0QngdEN{bv6f0t9!Svz^w0#R|dW?M#J`-jv=;<$^R zvS_w^$rA9w2!7R&M#W_r#(Gt9$a}lQ_ z_fiJv8rGvRQ`PWDsG2|ZkGq&FlCe}xy|VU;iS&u6oIqsw>uVnD#fAFNq_9FuIy_l? z@XU94sfFBVzu01MHP6xBNBgd^43`Ay!*56yomPv?WGoL?-op@4rWne|sK;frt45LG z^`LVfzS~jl>4?pkji+XzNg19@*ne^K-kioqteqe=i^w0!56=SS%wQpt{;LMmZ7MJ0dgmHj=AHa7S)*)+9h$v2y^Irpns!zncx z8m+XbbuMu_O-KrrS=5A)--9|AbZ2JrpG7jdeAx0a)CaaJ9wq0u8vdd zQOl6YFWkjj)hZzNy1%DbtEkw#p4zl>xG9OQs%B3mAUr-zB|i#JxE^0foi0eFWV!k@ zHR0Ry;}Z1!-mh~l{xas z?R6cyFkpm5n^kOl%GdK4Y0Ah$BM0%Zy%4Cop|E=aSr$x`(U!IOVCDO%#k~yxN^`V_w{t!!tF`N7Z3LSX*wsr z^^O+SKkB${QCIJnPi+puq+YMt3wzdY_PXcC2uGVTN&)M$T&lXm5i}chLdyG9@^-HW zE7okyL({(*tLOCN*(ug87nitgGH;H2f1==5$k>U*o9s2KOrX^-hlfjP+DvAgw@E)T zkBu2CO;H-SF3^B5?EdG?zmwN4@ts@kNcfCw*^zq*UF2t&!nPeg{L;$OYvqgv6xgD# z`U{LGwBu!(SB>!)(NkrqSxT}5^hI751W)0MOSj&a2(xxfvR~e-TwVwz%a=P;8I`HP z`E9PdYCa%zsX-iDX4ZWgtHs)>=M=Pwlksi%tzXDP6+%7NU(KZ(D9O^%(Xv8Hy?ZJB z2!X6;ld{Fy(SFlIVt<3@?<_M(Dkt1wIm+C<8A+?%TuETaI6!V}ZnD=OH%-eQE31EhHftLRqmHj90ok zl@`u68Of0=s462`P}Hu6r*FooOzM`kPu@DKmhbil1#M)`Mvftor7Ul@&yBw6qkyu6 zcXNQOa@qb;m{C=pfO9X^V~f|nob;5NtDN0MAE=w^(@?6!9<9iT#u%CXexW)%C*3HT zM;jb8I056*(1(yiolLIU)}ULYu%+~nY;wT z(&6@$t%dpFc=IHAc^X@W+^fp1u2PvY);%|4A!9Q+iM{~xaD3y@?ye&_3{8S6p&^Q0 zFVPT%Og{#D>AVhyC{Vxl*pbWeWiXs{Ev*Qi05 zNR%4Qp@h3%`NY!%wvTYQr|wd)5@7bIr>P5QuSi4?u;q6ujYrkVmxues8_DqvL=^5H z&Xq|WsD8<6WS`Lbt<3gO+v^b3J4JAmsZ?^=&Yf~EcX{QayS?b;1D4H!NsWm5~Ud3l6hMao`$ce*x>XQ+8vy@eRRFGpP{ zp&61Q?Hn7Z`+he})H@{RXhrWvYs6_tU0Fu|Z_-*N0cPc8;$h+H=lX*lE0Z2vwA~N#tTjWf2%TUEki?NRzPKn3DaQ$=7boI;0mnzPnnWynt z7|CXM&Vf=zG-F+TT`QIeEbBclUcaW#Tp~MXd5Hdg%T24^ZUQP!8f|7eSDyJqMn*a| z#F@yr+!)`O^mg%br}n!vop21*ycw?lD8OFTI!)L8z1*?dj(W!TI9XR^W)r#k1e4ON z3b90sLJ2D3)iVUsg|&PD0=21>FNoMGIDv>f&ftEd<6{IbP$3`kG1 zP0{$smtXs7ZJE&1`gT7>o6z%-yP>>FC-mXUy*lV62oXp)m9APJDp5Kh*B_65+nT@q zQ$5%cmwCaV^O-1XAT`T2T>x7~bVv^NWUuc8S*RHkTkLvh)u6~1DM!k&)gNlTU3y1e zM;@);hZ0I2V+tf&FB(TYx0(KIHt?kF(^UQItAf)2M7=c$;r=bNckIR3qO1i)C6;xb zf@jM&XY|N|PG@OOLl2z>4FbVpXcMfDQR^CdutZk-O z%v29?+KkShyygF5JLi0Ti3F=lzmwzcRXyVOE658G4Ov!T3Cr1N)Tp=nNW1a7ShM0w zb~af)=ix^O&J#;KGne{B{B<-vNp z^Nze(;C`yQ0S8hEV_P6nN
Pns2Ssk8m^1g%#ATaj9ghsOD`!V+T1EC#%et6K)X znJxcLI^f{0=~j|WCmPE%2{D@Z9?PTsqXB#j3?&}M8nMvDjxgNE51*M3W7B8TzK*E> z;K!B!A#&&AmT5$@{Ak`!D!LjrdNva|<}GI1cl64p-kS+&`+Qj&T2KpgA#9@5(!DdGL#Evu1iZXAcVB-{?A`m%yZ;Ygwldwm z3kUpZl#W@}c6N0MZ&)?p+qPYO8M7o2_VG$P>53%Mth1SsbdZi2IXykSJm0K{K4+M} zlg4Ho^qgwET#qWJzP`S0&ZkMY!J)Oa6^@D`<*}Pje5T2MY53yB3o)^FO#^T0fEfOO z;9%dYqj3v`IB37bS9M{(7`|8z1^^j1LXgW|_MY5zx0m`3`T}&NoWon@2}SOxiVX zqDcj#NcdfZ$1#YAi16`Ug(bUH&H7U?@$izaub__6=ifJE^lkRxx%C3Y?48Zc3#j?P z`nA{*{z+qVeWY-upegaG#ZKYt*RP>&OUUc=-;4sE)8C)}{{3ru%cS(->;6jL?%qky z9S565Zr|(6?(Xh5YFTFe=2K+@*)JApTvor!vIIQ`Q`mmEDcyV5yzazz=gu9m(NfKd z=T^VHpx4Ogq#0aq{}ihhLTh7VV#tMYF)$3b+KjuR?%ccIe6iC+=6ktEb)`N(OTz1r zXEu<=@3I9u6#kHW%WxZzwHffdpQfCVcvg2z1`dphZrh18*#dN*Bh=_=T z6%Dm?tJj&4iuGg_H97eQ_k-2(3WFiLwcnp~+Rj$f(f?|?Ez3op-`%l1KU}Y9-j{K5 zIy{;4ovXDPm-ObHJm_O-o7g^8o#0q@~~BV7l6$N+lWP^#&h(CoUyr0>&5Q ze2?U-GqagSXXx%PK&ZwiZCobp!(aNZpNteL+Rn9l!`YZ{@TJh}?});}ZJzuJ0QqZ2 zvfv-~w#bT1%*;`aJ~v%^h7HsYu(7dW9wzdn<1I!C=w%!(+T#_4fP*Xud7l#tdW@P? zo1UaJmJ>N!#t_*L20teH^5$2Lda1@(xt>m|7T_E<^$}i}sKmWu#A}*iHkjHzu4p}H z00>Rh+1gH4nQqK*%B!l^!ex(%xV#YB#G+ze>RcZ0%n*@~%oNC{@60sqPFB;(U$jO? z55WXUMi5|}}Q3%=#aJ{&=NM_P;*-o#juf+nS|p=M;fPQuyq-Q>AKCqqjb>FECU&}Pf2z9u9hnridC z`TY6w{QNu^6_m@Z3J)co(R4F4H#c8iU7c)CnK#F?7_`DOM$5DgY{)9LKcy9zUg1>z zrl+H$azU@%nu3(L^eJ> z#ACc2UvQGC?4~d?*k@*Pa&Uf|-piLS{r&xQzF1pK*QP(WItB+lx9z{w8Ck9U2^>fl zaO2mt+F&_yu5I>x^(}S_qR{j(Jf2BNlI?)O3emon>d)jqX%EHD1v>+8 zqhs4*nsHcK(aHy32N%Gs*Em{jKD3tYEA(Tq9iLejjwQ-1TiyDV+XZ%h1DJZ`JFxI0 zSTTcVujOQx-+h0%+5O;mg~42%tpVi|K@TUGJkv=CXCJ`_`|eCPynp{5EjSmv_E@PV zTr>xvixDr}dJ7D5eK;TNTo;ZO&dtf;vW3N-)HF^D%J7C*DoLNc4*YBg;Sex|VUqy& zoR{PE<(Zh%!uP=LZYf*aU6?px0k_W(mf!xV(W( zGmVL74eXPM4G)a1!9hU`1Pj}Sh?=25i>Y3&+hDiweckmp=iANSpR&D=wV*ZCZkOOz zYvH3fEmQsS*X-q{@vc*4*sz1_>a|Xyo5c%r?Z~BiA*}br>6KB7&iJ- zIlxJe6@_ruO+Z+H3A}gj-bnKhf#-UDGH7Y>R6P9DxvU^G5~^QT7yur?{)F?55=2m#CYRk=reLC518CZ9rJ{5$fB!cxuoYk%_G2aLYeU&&d`@qm zy^wmTHR*}Nrd7cB^{3X#*vN=RA>(gS8*TH^ZylI4BDfh0%<5of6HG!9lg^X%>G$`L zp@_8_{FgzaQ?cvr?DkZhFC6_kRcnP$LIU=@1Cu46$(I-s7Z;btZQBZmQiIK30%UKo zF6dD+{S?f+31-$DcA8X~$^!g2n&TeH#x+SxNx4Cw0y8;mySY+qjZaD8v7Y$sI_G@? zWl};yLdCA6K+zx|(aNRR0k&`9_;3Cj`*6r#m~QzrPI{nc;A30umn78HyCMjUy;#>~ z`Si5x_LrsCUgBV58`nV)o@4ga1($1RI1i&1L87MqcCNK$Z%LM=MNAAtON{XKUKg3z zVHcS%Pv#)Z_!UfOXBIeq4*;5Kw$M#xH>vIQU_KIe9ow^j-sDDF9GB0jK+PYLCo(`wztH;+pG%dp289L)qX;88Gb|XG^__&sv(B0a@&4 z#*M#v;%Td~n0Q%prt{T`RRF8pKF!XX8t?BSnGI&pxAlV26QwWTiyT?s0=Fh6CbqHr z1_o}WH!;3KUELcjB0^rXLjN%;Du76ZX06vtbtev=QYK2~rTB4 zmjD|goo5jLVfdH2fQQg9GRn0Mitnv zJ_g1^NXF}VTvk_ii+Q*W;N<14vQA2efGH)G$>+@OvNKv7e`yaq_-ucr$!@`~(gJ)7 z5U~_55~%uJ(@KTVb{?K!Dk>)zamgkx9k=R=A}t~omP5>AM}8z?Hk9>t%T}PM?`zV> zXFaJgCWdXNi{XF~#jEK2TZaBjGpT}X^%IAVNFsLEDX_bt1-ot;5=z*N0!dmZaCZlJ z6X~s8XY;oCcz?e{IXCFn+EibktWQ{o_7`h+2ob=_Z&l*y-V*GBkePXV0&$28j0^M^ z5FIHo__Rd3))F?wRK5gP!@CBg%<5MT5+-1vb0V*#q{E1t6hz{qI@;tyZc44ABfhPcBBrGNq5AbG|p;>0EDnlUamK zx-lAFf-!(HfkVX40Voi3twC;xi z)YPAsM1>Rq2>`lCo&#j0_RTL*7lxQLuD5)qy$Rq@F3!)flsERWK^+5$Mn)s;gpR4Rc>`P1s{S!vV}0-l4A4Cn_11x4{q zJ~9##Xf$aJPeSku*PTSR7{9>4qDlB+(_}VbI*vHVvON&-X3eS<`hrWf$;p}^4OMUG z!j;<7n-K5c&*FDwKH>+#lVm&$3@$MtLF_i(O=m&SFW-Fpgp-wPtVo#(mhz$B{BHjY zH{Rz0?UTI{S%;XS17xMqxm4Nnlf|FSiZ*7?kuf-a9Yz4c$F0DE-G*ZyUm{QX9Uf2F z3PU)+Wl(zqAqR#V1|@Cvv+ISSN9tU?YS*U$vv^;=?l7(4ut2{Bk49B*1wJ&p!|1F+_|dd_S~@=FScQQ>>qL_)3nnB# z<2wr=$)f@K^kioSVmnn1gx1rGi$3{%9Vx+~_~hi|%U zTN_Z^;+slNi(!+&4Bmul+e}`z*D0VvzCu?X5g0Zf06rDcxtN?6lMCp%j{MauK_d-- z^^U;t=}(?pI&DqZw})VM765RacW1pJd`SfL5Xg>v)IIj+ZXM;M9ry`BSe|AC98`p4 z4NwnB2?^L@)Nwj)lo1u;vNKK3q3*9-((7vr?h#mmLpI#%cCcvTi@|RIskEN1uZ+e1 z?oZQJvD8hYV%!ECA^=wXOkq-@SbN)Kjg1UV7Oun3KSGrpYz=`CicBrmq|@ zWlGA$U@{-p*|U{o1Bu8dD|S4~TMt03ld&1KL-cC|YH~8`d8?SgsKCX8m|6ziz-2Ri ztNGy@`9gm7%ZFrgzL}}h_4InvqQ7Caz09Bu+|PmSY_mn)4IcTf*->T`;?R_ok_$Ik? z^VLVT#1WMMvplVBDtX4E?Wytc@jB4T5IE)MqT+B}np!|b?>mE|5xP1Y0Y~8t6cE%w zjIG%##n=T$y$yve*YL9pug!jYGnwpxS;Rm+RIf!SR@__oOBIVpab+%+r zo}|qPKbWreI@<^JPQk#?d)n&f?++3&H6Z~96Z0FdqTpR02)ho<^MKVGP@=`*p}=tR z9aeffw^WLC42_J9!6J2XB_ibp6MG7RkCG8il+>NU(5vpnQ7|(06A0T0n`Cr{K7KlQ z22!f3DTHouKo9P=wz~_CI);-&(P1!hV_PmPT+T)@kwM)k;*&w!?8L2ne`H=)s@HT3 z!Ur^Qq%C}sX+imO#bF>z@B(s-GTsj%<`@F6`yTi(^bPd(U~MRwBoroS26nes8h7=k zzJ~@vkNLrI-$O=Y)UKgBY8Q9C_%NMEC$lkkZo|FPpU?XsFImU$GfW_e!6(3#(>62M zO~az26(MB<8VPXz8A_6XhSYXig@FJXLPAJ*22^o(s&2@ds}J=7p?C-ecid1X z*cRJt0}K&Zc9myxF?0clkh=PF(M3S&dKW2V4D3Ns0YA6~F>enCf9nLko3T+GsY%Jw zr7#;QkniMga@~!Jj7+Qmo!<wXG+jE%W zYV2fK+pf>83P;xMc!<%03oL-W^g}jawp_0X5T@Vc@)klO zj0!QEk=RDm=7vS07%yr+683xI5J z{CCeW@jB?9EFSyCR1S0S{No^vnr3FfQveVlPxKyodF;x^oLma>9Dar4HvYoX+%AEl zbVd7u6M{6)ZOqXbyv(`(8izI%?~2}pr2g8bRfeLMZiY+O4B5j9-G+UTcDzRS12bsr z5I=SpX?-^Q2H1ecudnnalR@$T2pQzDh=hGE!4GY_G_|z}8#D6prG>yVB%(>>>4$DJ zOdE56*Q<-Ar6q7baLOgMyPdD^4dh!O7{SkG%rGTdVPIkHZ$MfD=;bYL!Fz#Z2dSu8 zs=>s|#jptsP#s7Re7-&1z(GS}1_%S9qsXyWHVVTE!{7yy%lrJW$Ifq)lUmvm=w}oT zJUb2M88XkR=<~fm^#UQpa@oy)g$ILDfP{<3*LQa$I<6q+?6oyf35_I!Bn$KfHevpO ztVYDp>V%H@pv)!cCotw1A$!Uk;NOE;g8WSl`#^P|+DEzBZSOYM0=h@9(J3}j9kLRs zeJdr9mAb|OzoMz`4D4G`U{9$l4VkY;h`^)@J0$p;k<1I7XzZU;V2zA3-W zV?j!&o41N1LkovA05I`#8g@Nes*5)AnicZL$Cl-nYY4XeEctsO~ zH zT})h@trHZ(Ooc(4qVQD>@aLP0xf?uYT^{HVWYii~^1z(*Ama(?dtloJz(@;68bE&X zX7TD^*zRCqVpf8^LKaR@=qwM$BQ`b`OwRV_hi|vwd^DembDN221$={zqCwLk9}G!} zOF#~kft+{kK-w2LJ0Ee>{l`y0-sSc0booxYW1bHG87%hMt9uX0&WZ`KE;62mWf(?k? zpgzCD*%?$+x4Fw4F_be#PLIRC5MNz3MvDQbK+(2ln(ByK`Ux(8Zh-q#DSO41TTHk! z;j(%m(YI>^7sa@>Tz{(0&{=;OFtd?1zRHSgYRX1=^Y7@jc$86BkKwAQ8M6B9C?Ug zzno{cX56O_y;@8@u(TTE98p;}>ro1cqhNG0NZfRSwcf4-)>x0>B5NSpj)MkO-tl;v%zt z zQ{`xq`;I=aAUUun_nES%PCIjo;gIKiZJ8!g4)|~vM|IwYuHfr-6_J{j%%wG#j;QGO z7JV!?zkmS5^num#T#6hx$#rLCHiQxp_e08E*OzQaHpw#|dQ}$9>!wK1x^6GN8~o#S z-PFwg+nviaN(aoEz>Br$<1X94GDqE{HLQ*6A&ZHv*oV;BvD8W8P!#P8KzsCW+p|Ma zRK?dI3z~v=E##BHUx<_$2SD4JPCkbe2|R=*9lfIr!%%Z42&1=Sfp58-;Hfd{MUEzYzzljf#Y7<}rbhZcb8%*&BB?9+4aO&~l@l6+x zN~~%BFJQ(r8QyXI4;~Tz&njs6-_&Ge&^M9U@nxa~Q&CWejMO08i^RPA@9~1={(FL$ zlnl`?#NK-f)&P9ev)&t_PCtY}oAJJn3ulOF&GFOF&`?l(v#Fc1=Bl3bLSOTr{=VWs z{q!j?<@A9h@I1&)cw%a^{~2_i+d*$e{GWsV)CZWNG7dE>D{Ir*JKgR7pafPw>#w>) zikeYwQ8ND@l;HjklxWFc!sMo3BXI(#95?L9* z4CQwKGY4BSOg~g;N7KiYyLEjwN~ZLCBb<*N5m)TZswX3{5vl(7w)y?;&VK>$|5PV= zTktU5x!HWAZ1CM%nsL!afIDdp@rIOoLI_Y5qmH#*o+H-sxzl0eH^svwdD-v?Z!mAD`PAnRbGB_ocKkE4==6 zVe{Wx?52G}Bj>3apL8JYDOhEIGA;Swqt$s$)QG~3hc;~{W}nCJWG_T4v6{^by|cb$ z-^;TuszxzHyvHW(JZoYJiH8*qo4sqtS^TG0BAHH^PAfb9amx+I@>8ycd++rfJw7^P z&I?oQH9eyU0p&F)ourn*A&%)M^T4)blA1Q#t#RK(Q47xwF&@EF& zR0=vCXPg~H^etkE(rBFcIbIKi`uce8)M>R`mC2^xor|lyY5nZ8I{8$b99nI6v$^HuZ4^eO~y3u|y}V(vPGXQBV#e(d&zj z5Qg&0`@-n+W?S2yz1_Ao@V@Q<1($d$kobskO}Ac%;e}I^vnsWkMW9gn`ufKAh>5Xl zMnpvk*)IkZSU5OkPg=LyUwJ@Ks=T*s*)>jvUxFcO7wMP@|1tI5toPi+l9$^JG@gkicqHAhW6(kNjN8pzJYAV9%jOra6Mzo zXnb|@i+DILN2*+g=)BCB&k?!9<}5U=E438Fnrw)ACv# zx$)*(3*=3X*3%eWKHRk#|4@WF?yj`=;Z7;C*kKf_1x*2~N?de?my7+H@Sr=FXWrdq zWvR>S4ubDpNEW(m7yI(GeMv<89bE}HNxKpqmGPPq>TGLEjfNStC&%rd9)nTPh$}}R zMmX-gt$yt09>azijv>0{wVA|ZL38#94aLk=NlNNLzTBp$*p$yrCL2vOIqf@l`>(R# zQl;3b56}AZGwL#Fl0!30hUH2MN*YV-0(mfFV=>r|4ctG(Q2j&1w2#Dw4mHdpuacx3 zw&&sIYP8RUsHz1XG;v+^e4?o?UP>m>@fsEVg-b)AI@#vXmVjSmiqK8@)W`Ly>#j+j zV<$oCw%7i7;S+`H&*q;VPwD&EE`3Eeu&6H^8{du4n*42o9VXg1NI)f8r-*>iExqjc zxxfN)5A57jP}s|}Hju_$q1I5J`WxZzr*r;Pk5S$iAN4M_NgPz~{lchI zZ?^C*?v94=GZgl9nrbtR=ZW?-0rl~S!+Pv5CMml@RPudM_E6Gkt+Uk3hg$tr%hAD#9yB@tb!h_m+rw;3d#t-@&25HrW z3;jFsjpH)~{8AW1Q!cN?Duwy`2`YC*PcLnR&v_H*pF^ z7oTaie@PQrQsOX(+G-FtI&yjj8hX^uRE7_Dn!kF2;POxnO9He?VJGm}cC zEw||~ARUGKx)sxrZ*{-24|JjS@xy^}63|6QWnk1utLiDE~vecSOq zCw$k8!sO$6{|H5dS)R@F;GpiRRQ}TKM-KzCMDuXVAeUxY4y#J4-HgT`jTv93<)202 z9nT`Oo%XXas?l972=+nu@P?Fc{s?-c)#OoIzUhyQ)@XO#ymdg2{RP%O1SX4Oe@RG+ zrE|Zfq%rz@NlBw~UlM(`yawz` zgPir5>BKxmmENB6CE>jO1Lg=`ZA5&ryfk&s&n;48lw*I?t+-0G=ES|{cuqUJ1*xWd zX1|C|p&ny2m6Xy9h-ECNyvRwwYZx`~-1sewk016^{yerrl4dNk#&$vIteCSU@NtRs z3LlGlo7zp{)j{C0@e^a3q~@>Wln=@I_dOPqeU#?TmtaviQ{}7A=HJbir2%?&yg2dS z38^}}^BgoYRA`oaV`G2x;x{WT>hf*1* zL3(#5GGF(<7{+7B;>yg|S0|fP`l>kMi~_=*&B%Q%ao0=$&=nNT|Ud5pN-{#XqZ& z!ej?3En{tuFMkegjb`-!?SXwFrMikHvteJFZ)k0wYmKg^|Jp5oV_t)cdO?-rNupx)RH%oxm;na@zsiK+d0=2BrRBKB{qpC7aa$l|Y6qgKP zG(HBw8}?L_aEMyRCnvPvfgoe&xiL!b&OGQYHlXdC?Iv0PTZ;1X@-%~69AGVei6_Gm z%V?~kE$jS1(YT0WS%O_b#oV7A9Y@y<*$sZyIe+TV#+KME_lkQveEeDY1Mx96th^MQ z9H(^ynUQRA3QiH!bsF!X8o_Mt^UM9vKK5^Y|DG%td=7hkm2^~nHi=XIbm+)WLiinH zUsGuIR(4N_E}!avsBzf=x_=PMCU4z1Em>W9AwS`GPaZ0T6!SDKz8i+0nz~(P4dU?* zhcL^!@T!09wvHkR>5zO$vfVl=RoSgIMF*8zBs1&hB_vjbN|k@9Gd)PMC-b43T8t>> z0X7wjQBuO*$@O+Zihi^D;_U2fVmmB{RebMwZ|~qF8F{YOv5>ffR<-`y%U~s$FU54d z!N#4$QDJc{(kmZ%)~?^bpOeql&$m>6ba24Rj&6dAP1#^oB-qxXFvRCS{@BO;s!u(u z!l)`mO&(X2j)vNOKaq2tjGFt(KGVu~cU%j2O%a6J@|If0Zvk zb|oo+Cg@Q4%Jk9MJ%o3XVQgl5`{x@|a5OKE5_1zRHg9UmK&GJi&K55_8MY`QGV;mB z)m>`tnZFk&+o0nciI3Knmayk{>2p&h|GO4IQIwV9?&TG%-Y@U1h@!FYT^Rhh?=VO} z5l~Vh%%ppIpct+u{>wR2rK&Y9T96CQyybJ=B;PzRjG%UXoJdHHndp^cazfUKJ|}d! zw-c?yh^MY9{qd28k(9-`aeICECe4if4BMxf8hHcjI4fQgH@?)1L>r~^v)ycSK2>#f z(mQwV$Kbj%?=^80MaK`OPA1>Dl!*q~tBQJ8ka>H*S^3$fXW!$0K5XJ0XMG-DoT;@? z&XaH5o%o{yy;SM1K+vU##si8?M3pNtgm)JYDDJlkyy?ZyMn=5vpr@F55qa_N$3Qw4 zVS~Z=&J1jIpbKEt>W5VaV5O9l@*`^MM}Puu0Rv%ScSNXgHk|Wmh=Hzkv`7P{3~Oxv zH1(xrWa8vGt4htA&`4TS`Nrnvgq=&N@|~yyGvv_LjOnEc{5l1)wd#f00@(uX2a)i_ zOYqjOjCW_lJ%cqKJ-49AaeBC?Tw;^$AWBi)y0XOd@IE8!VAeH`L)K6MRhq`Ob|P#>q;{XEkZlGR)sr;5n}rvTAdzy6Ou~)W=U21=Ddiq zHKQS{`S_yrDgyIjFnDO2k2+fuOOcYA#q76xTE?cp3Uyk#j{{F)^MbNyzX_wjH z3*#AK8XXsxOZQii10P)i42%z(9;N14sT@|vp~hVym9VTu%a^II@eDK4!@Q>4B4M~P zo^92R=kDEmu!Jy3(8BrV!>=a?fBr4t-(2ggCS^DVR=)mWusu`9k}r2eFG*BVvf2kV$8Sycvg)U0D4M*b)(^$fc2*1zd zUT@DcwkG4abZm+>S;1_f(3+*<`8$1%q1l6>segxjMfv&(_7uHZ{VN`qsw~@TTAKk- zI|;fA>DmKs`H}O3mS+5wlyY;tOA^mnrG=9&y;oa;}^h!3^O%t^? z8Eub#V>h4SM&>SItV8nA+dJe#aaxnpla6t5SWPaw%=i{D>tt)wX4O$7H_8qBD>Po0 zp}Gd|*<3($e`G<=I{xt}HTp3j*I_Pt*B9TNh5|)F(=V@5%-kIU_1f&qO71xa{8HsPE{?yL-Tpjgb(^hn} zA8yYHANlRa*bv7 z-Wf8dxDnw@9qS~#-7iHyo4?;GUMy#E4vQ+uHx6}*v6h*xB8{^6W|+@~E%636gmN!t zast`Rj@jE-PXhN6yQ~zpMJ}Izz1opWiDkzXQfUpQL#Ocx>*!r0D{|y4_&Mf!j(Eza zht`p2#=jXK@z*q>pTU(kf&H5%THCeE9-G17q~^($dD7Dv4K?beJH7LFOfIr|aRPCS z+L5BeuB_HR8S~o`3p;I&BAG{grpsd=MzR-4i=n#2iU0hd+&(r>)yQu%$Pk~TcW?7a zrtDLf^m;Q>?ND+ZG37arh3nezvm~@VuDBPO52@9Wl@Yp^2#jF~_S|cK8MaX2_GupO zOclC^<8`*Dvy*N9&XrteH=XskiiCx{zG>UJ*_PdA!Sf&X#(@~IQ2bG*-q+Xn?C7;B zcQpZ$0_-iQm~9D_a+$w$Ug<5K^P-|fT4}u96{1)uR}LbKSdq?AxO3wD)kWLoWq9UP z-9t*=F1bQxy)BoEmzPu`T&#&~M&(cO*Yi58_B5AlGwLy0sMA7t>^pSml?q;j@=^B+ zXwfv=)GL07-m2g`BAOnr!yi*FWY$P!6IZ z6-@xS`T~-F`TdCukGmZCPexs%gH3u$FH_aBmetD64i_nW`>4YSURF2`wZlyG91F_- z<+mZ#adti_unJaa*E7_>UiZ`GTWZf}G!&x=RFmKe48g?z-fqjkxb&Q|I#LP)c0yxi zIN{qz#@eCUZ^?YGwE9zakDHOQSUaV~J#fCT@4f~~b>AbUwYz#RD8dnE4n8XfHS9a^ zr0O#1G>$IVr3|cMcST1N3mFZTO+v}2XO%LQ@w$NCxYby>pzDGkw`A0Gts}m1zcp9I zt1o3xGNae%yprMR?%rcPaN5XmIcsXJz zjmA|;{iuS&y!GllJ+V&{1BJ%C*9=OOt09TDoxN#TgDM*JLkev;e!%{TMb93&RQQds z=NbvEc!s1Rx#=(qx;o5pCQa^dv9VEs65IWvx zQiJZH>8_KR6M9F@zN7utV&1q;liR!ILK9OT3(O13$_)g21X|&T27|WLD?T~oH?m)t zyAaP-u6#{$NhU!^K-$G$&Q2I!t%=Owt5P19$eukbUf_OlkG*uvQ6-PJ6bi!fNwU5>CZfPEg9etaKfJe$3W(MF|Pryr}DB8+VazD)Ux}(?OQK*1F`vNEws1eAb(NFrs)J zjDXou>poJzZ`1Ng=VXU_#=#NFniAF9StCYAN9$&NF3zAb+jdoMgmtRs?MSKOuGGiC zxtwna8TSw5f;t>RPZv^cP7VxQ_rkP2*pQmUk zu}Zi_=(r=aGLDL59?9%~V*GR5`LP0j`4c{$%2z~`uLKi3=%15z?vog$IfuQSWiF8x zloZV&U0Oc2Oe-}QXdQmnP5+pb3DsIiqjATsr@%PBsY=jqlU$y@W8-ChX*99dWs^S@ zfBy8JQhkXWY=nTPHXYYm8FwiLjwH>I#iZXX;`J0LSy-1;k=;pEAgo*~l&mYZo5%^O zgk2(#Vlb?KM;fs9+iJW_tI16M<{@*v{cs){2|sJ6TEyJTXaV@aiPam}NtHNm0IM0N z-nhpJOl;4eJMWJc&kbIe6yQjzZ;`xq3Y~$BU7}rCj{M39pO%-VvWYs>!nQ^q!@kdwjb6{n9r*+(U z_w-Nfwq9l1>E>Jaz7yOt5#*6|0}~R%|HjBM~jEd(H(@f%PIGR+t3HKV=+(51FiiT zW?587n4dqdl^rkV(sAy4jrTmed-VSM5Gf1YGL4J(h@o*57lzZwC*{rD4O$MhSHGX+4$_DU zCm}uv7o@~6OPzg*XfihTT|+QH-MWyGCiMk5s~BFYuDSp!mMFH<%BE(k)$dqlwf{xa zSp~(_b=|sg4elnwAuCbt= zyVkhnEpEXGax_?w`Ud`3NDJ9YZjH;~KAQin-~9#=%JXqhQu=58xQiFSf{ROm2!c(? z>1CT&(eH3NAAov5KlG6`rn#)ExaaOgQFp*bJ;++C5Jg^~w<>{ac{dyD3Cvp76nJ9PC z?Q37m3yo`JE@9Q?;MIdA^Pj|oZ&tTS$%uiJ&2sJ{o)RTO-V5)(wr{ECvb?#7l9T#a zp#d!a@*5Z~+7vnA%?4mn;}Y@kkmWTLim=o$)!~SZ+g^{-9!x7QbZ5T_dK^u+DdL0v zGby%{2k8CseJYP5j){o|sU!Jm&$D45`f^&1x7_Tnx!#QJ(5dhn&f#FF&ol;Zyg$g2 z2>=t8F$>MBP@A{E1h#YScTav37c(u6fbT)83JFx1DmPI4F{rwlS<39QvQcGqNHFIj z1ymq64OR9k>OR7O2&dzWWlnfEf2UK%z}^_!p1QkrP9N`8O4Q6prIiOr*T^y(x1|&o z*e$Dr!{Z#rMOK;g4#mF|&yuhgcAS9#wblsi{sS(#(@Erqvg3Lsi@2wX)JSo!*|{J_ z8S@XvzX7UEMS_{7f8JjE<<-x&^FGiFUz#DODW<}!ea$3BQFUOu&UogSbGS%7w8`1s z3`}ynX)lBv=U!?`Awg5-b66@tgP~F76=NFk_ttgoJjoV$>>aikFOewN^Il&o-5+=e zr-X_OAGwEnkBn4=l9v6XDWYq4UZO~|;oN0aIbtd=zvcozk+NQV zNedS4C`zPei-|$6)jtU^Lw)?cDcQ)?^G9y3sSAIl&KHSCxxXJo1*aIINC(8@3<
  • 8Ml((_4P zT8yF&=4O=TI~`_6s%v`DqYx)@?ZMkex-ULzXEMpVLmsIl?M^GBLSv@9VknCMzDaoA z?jO(Ha}73y*~SZ3zDVRB4y{p%9lKR8>x?EvUvHE7)V;yL(hq-kd7g;l)5l5H7)9n; zX>{k-StLUd+M~xcySwbwZ`6(QZ1k&yq+=5j$l1Sb1_r@ISuKnk{&y@lIq&T&=O`#D ztMjpY4kbQVb}h6kpS8)D+*A-AyY2VEItYLE1wTW1VfSb zq%lFODw(ZCEDow{-w~yBFRr#vK5o&tEcyQA7)+}R#Q1}OA(06RB$0q;Qr$?xn8JaI z7*INjxC{#JREQGi9eu`S?De^|5(v#)iQtr*^XeoXw;_R^Gki-#zn$6TMSEO zcC~G4)0`98(ixw-QG(RNN)M^`>*Z#({N%r3H!H~d>~&DP;71@`fZF~PrcPSm=w%9H z>HKt?-z`4JbEg?cJ2$T&ikqb2@5BnpQq2BfllAQYa82Q5GON{}vfpO6UxiGu_*ndf zJkvE+Qm10O?Bxm8l=mNm7~^zHy1;si2a&dpa!8 zdG&4n`TLH30&zb)j74zO_Po5t5H|wuE;(h@qOdr-tLZ|gq2{wH;Y-ir$2_9EYLbFf zPDT!!-U{sU+q`X-YX#Q1y|iRs1y&fZAl36Hv)=DW)dlam#o%Z538(#!7tr?n7+#JzQiT&R1EY7SDWqwG_N&QP<7Q>+~_}5fR$h#M)7|%oW=2@un6%f3$+JtfweCejGL)O$pNGPxH|( zdzxg!unyt^Q&AoT|9jkP$#XI9Sj?=fl-_5$CNdRW{AH%Pb@4ca*Oc}Rj)xxQs^+j( z`^=bswPdWR zUuqLX>m>iHX+}qs+soi=|&JD6eQ0KCw|Vm(YKWv9l{`4| zwcL)#vE#Rgt93qyPYpQ+T%HQwW48QS!y}YZAoP?6ThnRihFvHZzJ!k}70)l`cZZ8`T z9rn)LQqap*6Ky=UmtFJm*|+O&X=!NaW9ja0YHBJlTmrs!cXtw|f*}>jE-v3s5dV2fnxwh>>Zd=Z;$DPpa zXDu7Na1Lt@;Gme?x<*^4x`@AU|DM<8zEEsv;QSR<$$XhDLljxETnsif+>GcZ-P6)Q z!1OkD%J8FG)%^LOK!#*<*U!66I(W9nLFj1)l5B%=a9ST7XRgot>OU0DGlv@1p(Uj7 zc_cfX1Ox^s%v2_RDl8ZVuN$bZYsgvMT)xhw|N7XFxM05wVX7-oB*OZtkMmN-235tK z4FzjB4^S<*z-ms*DBH71H-x=}wB#Lp4fsAZrYk{;;|~kVsI6VsDB@6fZLdItd%43_2fdbe!2PX9?kDTNM8?&!U?cO-%&mxqlA z|NgM&D}_i^w7;C~aI#Wjy90km)fc_|xy5?4D?~TkM1g@P&UmrK!`r4l@h=8>PRm8k z8VxHuxDZw;syRToS-Duhjwt{66dgfqNv~8sB#|&VpD=w&_=#J{RrggQG!YaSnbylD za~HD_8L7=iNyVV;FWe+<Dww^I8@FJZivI z-sALe3h;NmzWS|62S}O zwt(STnjEo>e}VGK!LZ3VoRG8`|3C`mC=!}s-5ral!b5jMJ`-f^(_g|PWS&zn_-DXR zCSFBK!xZ7d&nb2<8CoyRy!<`iB23 zZ%h7Eh?%97YOSCzOPJ8GlppFSAr@86FSD3Xn`*;O$}X-kiZasr{jn#x)SyzCn??S! zM|nc_o`E2nmzi2LWHAa*xJIHgT>Py#4<^E6z~49Rd^2_M&n)>kqKl)Z3xVTI3*X%FKTdb{>3ug(6v7oXc?Z@E}n$m0?@ znrJgULM(_Pcn$I|@NWfxv;r$QO1`qKlqwRf_G! zDjW*Ve}`#FQ%t54^&&R~xdGpnB(RmrIkabksC0w+j0(Ys{Bo>fU00@KMQ&ZyE_ZE4 z1Xxpte6nIQdxVZ%I`z>#{vdJjWK;*{y!yt$I}xlL@VqyZ@{eE+&=+-%_>2Q+Ovgzz z7PEA1hc84FHZ0y-e&k=Zm_eW!5a>+3S%M3SMo<$9G_t4rr4mZK@2~%k4^O={2WXQ= zabx9XDyonf3Eoclc}e6X$)I;KtVmH4Kb#izu1=X~dH5?dY@LLAoV>aJ_uoL<4!^!1 zD)rG&R5QVuSeP4}hBEvT_o#(0)e&jiD=lJ^qP1MV=yD34XY0DeaQ0 zY?2`0?hn2y3FPw)HVn&Qp)7X9#>(kP zpfFd=OAWYM8}#PZI}a95;c_qvrDAS-e(Q_yxg=5ka@#3A33r2h9ZoN3CN(D*xh@h+ zW@Uho6fh0n;@OU-_mixeKIAa-F54{(r!1`#Uve2%09WZ8zV@t-e$RQa%G?-RL!t-a zZd2YkF6i}q7swjHY5v_7RH15122|ax8oGNrP?ASRkp>PAgg;9D5SZqo^i^gL!j1IP zEj(jnw~J>%r%H>nj5|(!m59;i`s4>msDpV5O3aEM8LJwx7W?{Fpunw<*(10ExNvBn zx!F{4)ACsS9i9>WvVGg+;?)Rzs%;aSB9rF{cLuJ6rt}PmA)GCLUpF0J|J6iI$&er=5olDl8q58FAO~0L8qh(Hmku7> z!=J(E*_BYRH}=L)d_t5fb4(g>LY7mg;&|KdseAX@l^R1wBygP$IifzdcQ31c#Bcw( zicOzvsR|)Pg^fShbzEtxuph<4Jd2=w*hIFJJlv8UOEx5rbr7%{KuZRPPJWn>UHN&{ z>Gv`@C-r|?fOn#4R8YRY=OwwA*-5W#pv0^($?_MCI`|}wQaqmMDwj{x9*qtJJVzr& zdqTsp6AR}P_sQ&mPTD_^-C9P+X^p0Knp8glQJSaDas|8`4hSHntft!;^B2%&+6%%< z$k%IGCUuVon-tHrpS3sXjs*>_Vp;2vQ7Tk|(3e(*;wh+4@CEBDh#$;BfI)6tG|3PEsp;t*-+9X`MDI2sCph33 zWdhwzpnQ`VLm;FobiOzh=&~GzVmVkB@mOf-Ky|2$u*zDo1i!?9LP>^D&%@eteZ3x+b)fWlq5oJ zEP)%f`0;Mp*XV94G!{EuOm_BINa6L;4n$I2xz>;oo@W~WZfc*5IWD|};VhI<7+=pvn3K;wO0^aIPBj_EHad8@xNLWSG^ zgjffv_HuUxm+T`!vDR3nh6fBt>U}RQPc~1r8U>FIcjHO;?jsEJy0mc&!%6?F|qeRfvr{*ir5CGZ-ET*$GOx8>g~5UE9+iDS8_qM9Ya4x7wMDoD&&b- zjIjCy3<6$|YB6Jy&p*5L;VlHSVbu~9^zk2Y^kOP!I}lphaEDtnlJ*ODbgXf3Z~&|T zpiZf>t{RpfX0Wbe7-qNvgp){7Kndf}@)A34{q?JwZ7tYPJ8i(WbZUdgQR{~Jfq0EHPR|xr0$JdAxao96?P8I0Z&c$ zW9;8eN^#JT^gyL=yl_kdiW#Ex)=&3(4#-*`3YowyD>^6%M}1jjI74{)HG8A@IWm%i z3^@iv!&$HK>)c)l8!{;i3fd3&aW%Q@$&$BxgNsqyXjevD$->)pc3WWT5QO+=_x>Pa zw)@Oi@ovtl6f7b6bohxAC5N^Q+?FxcI(jx)1~#6Y6@dQZCe<-+*cis~cGYqsYqV(n zR!tg40|j!o-nh>m@RQI~um&Q*&Pi9^=hgl0$%l#WHie*XQ93MyT^7vS*B*ux9Z8tD z&viSP`0wNvQlzP9l5>*6S8Q?K~UF}N)?6jQ~-}jX6ENJYalLh=W6$0?+Nn8 z0?DQyk>f*d7HO3s19xIecL4_DK?>XI$eo~A2S zIz)F2X-iyGngS|2r%lt`0T0*y3kQolkvL+YG&B_E)n*i2B>gaIlZ3eV0bPr3fzNKv zDU6h6&fyR$Lz6S9et2IeLAYtDtq?Fjk>J-3;%?&}p9S?K5-6leuWd8SK;G-hyBx-@ z&bQzm&T(b6-xuV7)l(m3MlLy zWJ_*6K@VMhDzycTFotgS{tA%5icpUWW8c@GXE9H|H2$owp&8O8B7!6fC#~mShJOqC z@EBq_*}~Rr88{!6rQK^?6YCJS*HUj2ez=4QOb~mf=cR4de{euD8J3j}H^IO(Rl>b8 zjb+fyOigwD=s2+Lar4wyaH0D%V(_{$wDK=))RFx(v8!Dou+5X$t|M#N|89p{jNva8on{-)jvx}f6MAvNd>%0Q?HdgoMUFB$__T`Mp)ADqg|BFBm^S<7I}RBt;k^@j-n0|$nugu z!6slg(|9?FfSLq)2J^f#I&IW;nyQ|z-#_5l+u{1iJ;`g5>hf30^s2tSThOEaCA{T) zqQQ>u*OlZqfDdk*5%ztL=Od&-f*vQ>e!4oN$d!210mi`GBCNFSemUu*^WR}W3}qV^ zEA1|hklWdhcPE%rb|0`+{RbatRyFwO+b;nfwaQVd48pAHW~RmZ{%#bfxOX_fmF5{`U=l^i*ALaiEz{Y5L5kks-~I6HOors4LM0-K&N z_x5U$9!VUv&?!1A?FZ@b)ILk>oWXRW0Za3In9O7YP8uwKvQ(Mx+dl;b$A?OSj(i;pV?*$oKA;Q00HY{Gg2ph%pHcyrsX3x5sLBDA9VHCC;u zyPnUJSX?3l*MSU;vH9nr*9gSk6Kju=GsRT`p*hnxoE^bE2{uj*h~+Ygwh+l#dmS5d zMDuCjuG3nAFb=+`SF-nIp!+Qy2Y&)4o7$G^(q=BptZys*F0}tJvn%HhEeo%1P75O5 zTq=dOl+qY~@#kTLIeUha1R!BZq>n~o#>2BRMTaB)2?X1U29dr=?(V@Y#BGnAPnS$U z?O!6)yOVmJsr7`%6Pl)XOxvJrNvu&ww;A_AgU(K2O2b4J-b|ANtv4AE{)@~uk{krvpLVA zpU$e={&lH7PspR1^_TZvwL}QpC#ZpnJ+joSJ;=j4w&Wsdd~t6tAUyeQ&;<13Kd3CITRs?_E66}ra%wnfXK5i z4Odc6y_ie);;H)Ak%{s6uaqV9=hJ_qKjnrB0b1$P9YrS1+r#t-3yYH(mRWTl#QDX) z?rh_?Fq6-5`wy_8G%Hsnv60B{GJH`BlL^I!H?0|u9h{rg2gn5m;s>K6`% z&ewPZ0llpW%yC_GkwK|7dJjCT{f6?F-mmF--r zrFXtNoFWFuH9e=c$rozJ08VaS(Y||!2nM239_LtquLrS5w z$_BdwDr`DcEIHrJf`F)!{7K3JTV3pbclUPEbRN=0sBiB#KXcfpXhc}+rBVuKxV4J0 zG^cpu!-8Sqm9YA4o~g6!9jzAa4Bv*&!rnC4k|s&Af4hAzQLHXe&&6}zsy^EntyJzs zFFnXeFy*i0KTe&&uVzmV&BZEIXzEfpNEK_i7J2fsuxi@YxgfWla#UxcNIn(|1Vv8w ze;l~i(`==VIgm*76hGe{bXdKf&K(bUH!mSiyXoG}ML(R`xp_jNInYojm}qc2I6tIs znK+5jx~z%TaOGMc>ww%y84!UeGU26BVWEx&hWX~R#**vL4_Ah!{dtI>!@oWh<=VcB zqKFsow^k~aEKRIaAQ6o8e!akb=$3^=49zxu{dv!}>eH5(?858*c|jH5UPgNJbtH(i z#K3(fHG3csM9BGxgHbg1iy>i9S*cTo`-{cX{hogOe%g@7hVo1sDx!FvS(N+m;_6>5 zO#*|RAxzZn^yQzo$5U{Q-FYM1a@6IvzWw14a=(r)snKV?xWlImKR3Jz+TsTlfMPq7 zAjYcSemclwdwp{QCAn?fzW^7~BTkW+n8@z?_wrir3cagC?;cBX)%S!2ADvLxborL8 z`>~fD6||XKQz8v8tr|V2iu+^mD8E{a{;M^dAm;Y`R^cK9nL>AT`WU5TfDsu9OG&}b zo<+!v%<6A(HPH}2c0c=5rBA4bVY?)C!ZIgj+2H=w+<5H<_h@9PtiF#9Lyorl^(DhT zqPeN#ER9H=v}z_TZy(~bKHNtqbtFRlWvIDi>4*HRt|a{r6am(bR~$Naka)z({QLQ2 zej+ni8Vww|VqXim|RlYIS5Y`}(Ve&$|3DPwAu{CI?vFN{)=3N6IRgKx#ONo7OJ7ilZkH zB(7i~M}}h{=b-R0Fc*m!-`(NRfFcl_TIw6q!)?~kSZVvhjZTF1OXWM&ET?2GP*Mfj zowkSivo*Et4Hg>$5%;UR+N)jF_3My^ldV~-rFrPHrj5l_}Zx*JP&-M6vx-Yzpu5w6@!j=5%|RE zKpipi^><{u0!fC?do3LI1i%08Ofb;PM<*~|eMTa%rEhC%L+O714_!tau)cZaNs;<7 zvkEGLi?%FPL(p)%rmk&290@x)xjL%!*-zS)^k9{Hj8-{Y_^N=ej*#p5%zCCfk54u1 zMyC3nfh*oR8xZ!bb^ty*hlbD%U8&EORi(^kSQMGrSpn?tkg1D{Hm3>P3FD%byXpZ! z#24RtK!jIBj&A>E)lft@ok~!ZPU8Luc#zP2GCJ#bwq3vuTg@KJzdKl=immKw*D}Ys0g(ieJ_sY&o!xDdwpEo^Zpb>{ zvsh;P>Z23fU2)8IUYUc)mj$IbBoH^vO^&zqpZtp$(c>sFgUnVYF?&aUJrxs9&k_me zNti+eC{SR~;Ndh2LRK1FhHP5tNhtC8 zZtU&pF;w7%KMR$zvW@IG%~SRLBk1Q@=tByr2A{Bo!oX#?fDb+`|MEgi`llh|xZRzK z!O&(cdLa{=U}8|YsHeGl6&u!%^ zlx`x953Y<>8Y&R$HEw$QK$uHKPEIvCI+yUwx%2sONJzGz+u>y@r3{F08nHT$iRS7o z`{z(mNp}AUNhN%?E02vvkRB{5oMbr3yKJk`&B|h+k|Sots}NK79K~z{jc|VvaJ-?a zz(AAays2faB~J4fKNu8_M(jPq!pG;h4h=%>10a24$2wZ6HG@Ui%CtGDZlX-#kUlZ-?B)wd?R2R48a9H?1 zn-$h#q#NG8s}Vcf$NkzhkXdQJ-P9z61rlcA&xj<9OnlH3>{)HD$J3&GcU9RXGEGZo z?G9qX4=_)1+CgjqirrY&j_M_m{~}cPzL7B;{JB%ekS>0};E8^{-{M4#j5IOC&T`AB z%MAq))u=U4`r4Cbmo89l;WLb85}eUTr&tXZF2JqYEpXe4-~52daXicQXBR04xgsy2 zA&I9G?=!XqaDrelD15y?Ic>L_D&wEqc6j)NL=!tmqW>+8Xdr9s^jJ8B$1J3zxHZZ_vNm%Z?*j({v^2TyvTaGUO$~R z3R765H=?2;_3_A_f%N*BpNsee<*qCW)b-uQYqcK^IwVBOd zC#6Cdd9bRE7LJQQ33V?rv|@md6Ec|oM*bDGsG>lscj&+YZi(%!eI4|KbJo?N zBzbwIP5Ta`HG|FqYP!Pf5JdvKC_fz(jP~*mhif4&wRqj5B`RF7PT|IOQdr)!3fd)l^!jby~oZxn3QH^8P|7d$<<`l zjU*K2Rsl`+3Jd{m71;t+Qso%QV2MK-2468PlLYH%1(_{o8zM%5gTx}KGK?hgueiM# zSy@l7ifsQf1sVVatA@f1H;*!PaljjdoUQwkYNpW(V3ohHv}Bi8jn~bZRLjty!GYYa zx5H2#E>`MJ1WoOp^=BN~tTp_N@@r2xnI(K4j<{|@9*na&+W-)y4f;VYsFp~8aXPYJ zSH2WSs1OmkD5e(@R`HGG4;u5xipo0anlJjkT{>{aU`>njHT}8N0KEdlzh;hQQe<#O6h`;88PYLba>wWgQ zZG2v2hv__jNM`E4fjEzF)sEtuXQpvoF_*O`zOi;*dRKGv#_);;%&)-wn_?$)E0M zB%M^W4!>_Hf;vHoc(AT{KmP4o0tPJt;baM&TfEQXWJV4n^{LKi2vUa%4zL~&%g-S zQPB5qB;ia2LWl%SS+b_afNCcD^|3n8KrLS3QD$Of^xo`xng4DO7Mqj`N?fWh!-`Jo z!u-{%eH|Wc_iY>|2*7yK6%*4bF9QZPj}8BrSjz2d^U(nVzp*W z_Om=O1>B+Ohm3iATUC??cGo`KCY!|&d(ro!;0gcZf}JRdzz4qjH`lYk1U`@Mqnj)8 z9|VE{svU2HD{3JZ_l@rsej98pt`j#OpOo`Jf8Yzw4qunDW4hKCZP(Ks3hD^CZC%hQ zCog6O?sV_ZGc!^T*xziN9Ab!~h)Eq>|5`cl39e>0B>T2-L4L=ho7)_5j8_t@k=hDI z4|}|%>nJFbhsQ*GY|CvgZ!=U;Hf7Cdc->=Mtn)ib|1hJu>d-P{;<0GYS|PO)>x2f{ zIAbJum~uetErV(ipmUAR5~gaB2fJD0yv(N&PO(Oej=PPoPT%xQJgKrSzE}9l z16rH_8XH^&9+tc0QC67Z!^6WXY&zfjJ$%zT@`G>uT5ZDSmMa~W6K*Ag-;LUk`!GFV z*g7w3ea=5sUqk?yR;RNBHT4~BXDJLx5>a2zw>mEK3Zz ztS-qnqYZ1_jDf*mnp~Hj#IL8KYkpXnlUWp@u$I>9!ek;@-}u=K6Ikt%+#iE2goXn> zD`Y(i8ru}qFTIBc2VC`F$TAzwgxAhRDwgi4>2_{VG|GQmc(^>s@G58aFY}yvkf7S# z!^3l+>ct#bQj6uiS1(ggO2!QEB(l) zBS<+K*N$jm(+4@H<$Nd$W1|hz!miz_`oa|Q~h1&88}DFbPbjc>Ew&0B?TMzC!-AMruE9T zIf<(e^jCW9`TQD(okfAS<~Q4vgijbafw(YAD-By<%>}JOy@~TTu@HR7u3$P-g0jN9 zXbDTQ^3dGzkYhUuRVER!<>3_^pPDIYu{dvGe#)QT>}yFEqFzhazIee2#d~hc=O~NN zh>a-+T~NdY9z};R)AZw27**Xr!iuy{t2f1;@809+8`QJ#_Ty!k#aM63)i=~LCbicd zwL8qXqV-r`Ti7643^?`;8FmD^!j8W9t@j9P=(#bTn%ZF(#$3~po!glWjSfWp>ILQ2XK z`X%=F%{^+}plQPrpx4guqtb7B0>tv9-dCGNA*}vyO9!Ao?6JW7vH5J7bKEVbYwMr@ z|KGdHOyd{!%;aRjr#T&fq4%`vT`->I(zocOtE;QvE4lbfb8X%D$xk;IsTcRDG_l;` z?Q#}9f{(10cTrVAm&u3k|Fi&vX*hG{t{K%I#iU`OcZX1$k8YIJ`t2&))f(2vCgZ?z zG*Q1Ql`GMtq&32gq+v0Z<4LHfbe&3Gixe^ACsG~ zNpkhIH2SUmvg|Ng+r1^7lJ4{b(XTW1p3&5d=*mdS%uDjU%~>N&5>Mcwt2=*#bh)c^ zP?qs<=A~g^R$W0=lT)+jLiW&U|5V;rUlK}INmT3iF79_m?7oS&ME2W|W_?;}E0!;{1GHN#9AlkNr3FK@nEO*|&2jj`& zI{SeGC=6png&#nNLI(xbo|IcR+b*1osfsDrfA^>0Y_{$j;3%mzYWIxt)|?A&|1AdT z_{Dz{n&N_$$i4f1{$$)_$w2(2F6wtFtGpWA9-hjTq?~^D67p7Sm%`t<7)9Hhfhhg9 z8yoT@Il=)+Hc%pLlD!)urfz@Sb;O8qd}K9mtzcZ(%*(=_dZj~~Hx{K7A;$7$7P>O) z)8@bp9tUYT>eFUcj0JY!I4tOU=A)xHwB)$;s0cbJ5KIdmClEEnpaMxzf#NmfMC;e| z;zLPj-F11(`)nc{3gOAR&-yPSMDF{|{W>yBNfubfB_($x>FqD8RA2hwLaRvw-?~dQ zNXh;J4-+&Zpvs6ZRIu^z5J7Ip{w`!5sadUx>~-e0@S)>Y!~6s$kk2@v@8rrV+alWn z$SwlX^&)`DCCEgMEB} z25;{0YwLqto@t6J0yN1m(l_`jq~>C*hQCZi<;yqG9}0|l6&ft@VZ#ca8PGH*r*g#` zWBO%I=#WwCjM>ALr_U7dEbw7(qQ^=s@_6U~SZZ5bI+m-5LlW%|6v?J5 zv8)1C`F8@>^WAr0UPn}RO;aqyAG@solgtL7G3qM1yWfX8@%i!vh;nMLt1^mBj2jAx zJr4CT9-$lvGcldqw7JL|hAq~3u+7O?TOSRl&{Vet2SXQ3RP3KvA03^ITTc*}#zJl8 z)OD*H7?i%@NGf$64Q%Pet04onLyRE;Q@i6)whh07yozdF8}*t$iLcb97jG|lICHZu zI%v6uAKfLYKhIKXMC8H1e|sN+sP(UCPPN*a%42<(%hroXgawH?+;F#954L8Y8D2lW zy`i59rsESpPJ?U<1cnGyo~T-ueh9rnwIAe_ke)BId}!b5@5_Mrh=F$TiL+WRe4iR} zds99R3c1I7?{8|q3rz0alM11~7-#wv55jbiXZ!C4@x>!bz`89nRoCjiQfM8@rL1m2 zj=t3tpfxK;O`I*b%D%^*tr4Bu=YOMG-rGTg{s=N*3s!qqb2FuyGerc8V;~Dp`CQL@ zvTSv070Fq}* zV^0ZtrVsbavpT?rkZcc{EU{FbQ!$Y!+PHuLLDRD^Ft9K<3FehsAGfaqbAvmvA1NBp zk|riSD>LOAKJO4pm*Ug}k+(;HzP(VZ+b6)Mj18I<0fK}6Fs`f7QGj3>z#Jn>$qhFV zdG_#4RR(c+l%U40$~%JtgX@~PtHdo8H^sg37LWe*+LDe>^@mlqCrveUt%=&l*?PHW z6qF#0WL>j$0pTVe6rGtG-hfM~pwa$>1L74l&jcKzP&vRCP!Nd`A`OK*j_^f}Gm4;Z zmFlL4N%Nu=yySEgZoRSlef5apdU&w5W}!AJ#ig)agriQXHzAThii+_LAxsvz2c!b6 zrc!G<{_EW3(=DiD1J`g7EZSo2>#)<>Ia|4HrXwG_b}$=4lmhyKc+PX$4`o?Pw=XS! z8dSV16()~>oDw(v8Bs70=_j1{C;~Qw7DjcFn0=0sVa_C{J`7m>1@f*+N(Kf7Mn+_z z9|CQbA*(TLz_YKs1Z@3)&u@Ps2fu20;iq~@`VT#ZqtCz}S%sRaU_wXjb7pRGG0~CB z+Y{UG%VI%fP=jbLkMnHh^9Q0z6hc!r6@RaPl{M&O{20-^I6xWNcM)*eQysR(Y{L=D zLAx~4qSH*M)mHia7#=AO>$pJkeQ23FI0_l}2@w!AIGOcfR%)E88|EzXb1>3W?|LHr zJPz4q8ULge`hjY3DaiC~r%ht=r=jV=$XdQ>*gmNviKu~9X=@wBB*8KP70A+RXNcgK z$T>Whg92KJY!uzUy&eQTTNQQ^E+Nm?ojmvc%P&MorrS%u?~(oteef{1f0lA9B2qfp z<)tIV+xrzucL*+pvPzj!SItVIM|n8REb_0q0+9E;=C zT{&0^n(9bVSPDvP(>w_&P79F}U?XQ{9_KFtRo~vWZ1`Ee-tnDow5Tq)0YgpU%S$Q4 z2_d%RJvdC`Ji$3s*=LRc0OSrh{X=>R$1FyNhfRi3bLcT~@o>aF$Jf4l-QZMP0);C-7dZVBbcT>4|<5_RcU7N7Q9(Mk~Ml3&Y<=TssGCsHAJ53Me}vX z2;I^OewZ957zAcs+8<`)wG^H<``ps{TNYw0Jb$`DPG0c39`(Yec(neJFU+hnethgC z`;vO#F^n{)K<`LX=6zfbZE9{HP>LS)uu>wkCq@BD&sP1KDdPCD$lbqQ(w4&UPpRR~dLQlV{wsTtL%O<#CGUg3^*;Z%URhK3SOC-cK*zxz*jU&%Be&$#yp#`Hmh_<8lLg1;97maZIR;|@FiOa^(XWOo zLRXys+q+Bbr0@P9_XgkBKc<@mnI($LtOM`t7$Mqp0^5z z1?Cej02J@Ty%nO5tlgcC!GiW!&iRlz8+Fp2lyF4kDf6_I@ z>jdQYiSt7}KPenw69RM`9O`LM zuz{zixz}W_UM4XG$zk@(%|PJG8*orivAnjrpC$JzKHptbEbG{QKX5%PtgR;twj})Q zu;Z~ZLsjHWoFM(Iy(B~dEp7){tW2$(HZ9X)coGN$8?N^V&uNr}&o%8`q<u%tMSw;Ev7Wfg^yjpn?FRjG~lwe}gOd3ID~*_vFB(2d5$m5{Hphm{V;tZI|fb=dAp9c+S^xj$z zO*&OH_>iQwRIIFkYuRRg`^ti=1W)yFsk>|R{fymIEuZK$^Mo4wieh;t5;Zu=$mj&T zf@&|1;+#N98Pf0lVaw2IaQZU7dyJ>*#L`+O)VbxY)9N&rfRzP|sh)NG!X#>-+=J!+ zzY+jiNE~)#EipQL)mGQ424O94X+?HMD>&hNLs` z;wG3;MoI+WBrD`x8u3#BX|C9qgiuny>Q~Mbs(?Vr*(&`EWDOqMcX&oQA~^N=tDPb^ z-tX`B(~a^P8X6WoHU$8Cvi+vEYr)Wiblxcn+HGZi?S7882&HP&<@6R?M`}Kpl{Z`3sAQZR|Pg zE~qO2aUFct%dSf;iaETF-iirid~TT~72J%fiWnuuX#`n)%zE{QlXF{$Ng19gC$y4fMeyx;eXws-0dKEcYc$|H#x#-f9qx?K;uhX(*IF@(~m5cVj zjD@mDp5N8ZX^b8Z7XcpcZ=agh^@=qSw;o-^ks{V&ZpFRK% z`{nt%a%%;Kq&pexs^N4Py=Ib0&fo=R-T>SA|Hs~2hE>&d|Nf|;gd!zKDBax+vfW5X zBb_2CAfPk?-bjNWB@Ke4Y>@5{C8SF_1?dv$K4ag{d4BKy|JUa_*Wr~HviDkR&N06u z)|}&$t5+t~(>pJwpcQlCJ04`Ok@yg_on2*c%z}YYQS{uY+ z=V`K?H!v7sZ#3Bn=^Uogx9iYx83QBurTp2y|G>zkLQgXMykQAyx&80IXgdGfqyGQQ zKMMz#qi_o|YK=*iNY-9cVpK}cIXLO%{ zcZS9!0?9QEu)uiU=tfjmMG#HsDbf>EXHp*>@KVN}`Q^*Ya zOG?nBsKYOK%cH0Ah80;PjT7J@zG5tB1{UK7X2T5Z3(s^Xq$IX=`+Ha&mk;Srj|*0Bn8= zQrK3N5fc|%&Nt%1zky0lt8p1fOY$tJUNrQufbUys8jh#T@ z@_||lGXn##*zEyc6wuh(+Y4mD&pfQ=+V6#)?B zKW_oMNCCKDIMWRb^L4xZs$LMyu1Kx5_Ap%0&&8w z&yeHAK7c9#y;Ss0m>D(7Eh@VFK#L2O|KF0z{v9`Dq4ZKM;I91;!K93OSwO^%o%=FA|k@(^oX2cqoaceFCJc@#19tVc<<1X=C8p zMW5CIDUve2OVF}A0opxt7*wJFvKXKOr;jq&8JL-g)nSo2pJ(}!vRbU9z^I`yn!wfs zq}Dj_-tUQ70d}=&+I14@j&;tfe*vXQ`h4#*Ec_K*;H5S0v{8z?tO)><35Z_xV7|V` z#wjXRD;dBRHgOr&0-H2$eC+`^z{l>}j#t2m)&pwP_=_7f;)aI!w|(<~&IeSi3m{Db zmO50b1V#ak<2w2k1l^~-_J?(-0UY?_JuR>x!G5-8UA7&4;L2c?t|NUpHl7k`*v5vi zBi-ybVCc~E1P>VYOq9>TW{^o)s_xyp8w>ntos7f4I2 zuI7rD7w3IlMjy=LA4JXq+e#8Z+-Q84+K8JN@a}nFF2=^j&`S!YE~=I>cZhsuYH{Ba zZ6BZ*0Chv-eSv9qY+{@Nqd#5iB0h}qGa5J$tQG?YLe=tUY&=jDx#Dd*q8S59rT)d% zzkK-@wy>-0?|E(2C4KlEseTF_=Rk_cz-w4I{rCCHDFsB4gv@4`E%80Cp1KSLoLk z+rF2SwFvmbS@(r^;B9~{S`Fg_#AB0scPAX-wr;SRBF)_1{6Ij{Pn079a5Gez9?%rq zjh6zD&3S7|^z}*$V5WcuW_d0FCIuSB4EI)`z1e(&x*LEI9UZI&l^fyY{D|e{r6uq? z0RC!X%uGp10ls)W*ddTy;a0d@A7IAX0iY^CzuBQ?k#yYwn8$7qjV>-O>Rl@Uf*#)b z99(FtezzC85HLRe&@Xyp7``@wO7t&$Wl*UU?4+mXilQPew>1XT3{K-0C70Kd!QwlxJy zH+6xtW_k;htjM6M2#hY{E%;6@AoGBdg{-7`ZHe}DD#N1gGc(GY6Pq zRwsr>ly*QcfdB)*;Anuh^&gFDkbgjdfjKw;z%94-N43)ecufsW&7r};8Q}QsdNetz z036S|{hr$%05XAY26(^JqIKbEXH8?{84wG&GxDUJg4iU$0lvL0b@yUQugnsF^Qpn4 z+{P(sX%`~~4&;FYAU2@cs-WNiWCo(2CumYSEP{T`i{11~JJ?kXt_uihXs9+YV#%eL z%H9Jktq{Hp7GUmkCNc#0GRD}WLgR*sRc9f9m;qf7P~E)BF~Rl5+u-+5PHCVUjh7dv za^ZJjkRs4JR=3^g1(O1#s*{6FO5=B@07(W9IR<#=rrB9=S#V=$QX0%6l_0Qc)jzdG zQv14I{#5%p@JX()CVutbpN#tnXmbW4D=;BXmErNXWT5#^CoBgtiZ%oixQ*lE<0EIk zuUxxM1)$=AOj%&zt;{r>8o5o!rKTz)HFK1X0o@1lTMV#3n4QDTNjI1)=e64^1Gi;y z$b#?|VFvYTomXMz5pv-8Bpm0y{(0pCfg5l{?|2s#t;6=YGa#_`9V&mrYVU?CxX*_$ zQwYB>Z18khpMwyElv5vk4;onL0@1;5hmy=d{CjA?DfFQOQY5{o=rPc?%N5@;r@4Py z*a4uj6U+zT=+;4MONQ!tC!{Zr`v6$C-b!XN1%n7y33oIIv4phW=%u*P&yZQr0_YF9 z3BE&LR7)MmrEFfMe>(SIA-$;IX@iL%LjMw6*I@^9dP*zM>dkdG^E*H&SXt3%#T=DX z3W`hxB=`lVydSVmW?{!cBM`Zp>g^O26@7estZ}^t4$Ugw=-&o8%^lJMe*+p0vy=`F zNJ(B^B<%3&@+2VZyuq1RrFrD|6Kd-ixeNni`_I_3NC?KijKOBt=%R)LeY#qR6Lx|c z6F?jO0+Q7S{1@Q-G@x%SU`f3s(jYc~puGmfIKz1bvJ-?R5L-XlcxMl5 z|M!n5pmOR1QBkq_5+0J%q;V>Vryc@G5Lj$lh_u!ZPYY3K*d`c}_O=DIsA~x!-mYGV z038J9I^#NNKfmR`%tFM9| z1TV;U2B)ZR-{tuTiVxhYskdzW{AGqUFBxYQAibIKy*!5`B0DDsy#Tv=dMcP!!MwT` zUuqTV4==uil*SFZENgeCuf5gWd#L`c^%nx*uWf>(hWr+tB-@P|*&7K*TU9!%Q@utQGWs%CUjSH5=%@0C%R8^epdN79XzC zExCUC{#1buH*m;eWB0(Vuw>^bCqF{M!UOq)5a+RqiVwtqAV@|{vnDGwvIXM+WEV*# zlLRa;Liy`Xr03@!V?7}|31Ru^0(s8iMp>WK!Pqk^C$#F%fV)Nq9%yM^!$%+{b>l>+;Rp|J@5Pg0g{U%p;)B1m?0jYXu+y zVaXB=07#m`Q+`OGZrXT5|I0rQOd)#`o^t$t?Y4yJJw32)5QOi2dJxgWR6pwxI)fnO z1;oDrcw}hKE%G;{dKI=+kO)BN;o9tnr7a|n1fPP&J_E=SBBEt*IGjecKc8Pdhwp}$ z#>}8)psnbEtD5ym=C{Hpgc7Kfuy7UF!mmq_+6 zP(tAGl6_9h%E`Ge^}2d&`M#Jliv$gA=W8IBLkntVIxxDVBzQ3Mqn^!Wno31;&cJlZDo%o$iFEwr?&z7YDomzSwHBw}B8oot+p;B9f{;EjH16?4?Hl3`o+v@A6Yjole zGYQy^Y``n|^5?^}a5Ube$p->bcYyc;!&n%SDX4mqirwJ4K0>|tDN>3pR#Pt(O(2EX zWeuhpTuKd?!H~^Q5(LTfnLz>0Jp_{EIWyb%WA27>-%B6%(^BZrX${Z!YZ+|uZ7?J? zU|nP!x@th22AAYNBM&SusiV2K0POVup0wQ{%7@n_8qEr>PHk_c%8geLyf zuptd(Ct1SdB}o4k*b!vPQ{cz$i8&v&OJ5M4M1y#2z?IQs1rt^eyk0Q1HLzG)Z&tJl zV)Li3%{yM%hd>CnzCp}Yw^h3?1z`I>T=%;6M@^(1pu}Su!Ai>v zj2xe!7GLlA&ZDTIQ(+Wc0%62mrH1=`-Hn^nMrZh!#o&jLDYDq060 zm(*hkqx%;|5WUcMeMcSJ<`)*gzc+!4?Zkqpa=9m&jN&Qayx&7EqLM6B2u=;=gxwRX z;L-i0(0)i`<>@O9Cl%urh+j+)&LO<(Z#tp#DPgUqA*Di=Lcy2f)s8T za>v4eN1UgJ+uY@Z_-Bl;{&$ZN!>RBsklTsw<;9l%&gWRZ{{&+4BHfaCD2U;4QlEe% zwzZ;B=P$sypp!tjb7MdX!`B6UaXW_vdj`}!$YjsaE5Ya7Gr0#Hu|`Xa1W1uE8d7n6 zgya&WNfn40-K8>fGCcy4ULheNfxB1c!u=g&VpCHaK)xZffvg%{ z94##^9c+y60v3K66jH~;TPv?{mpe{vP{?7%aC9H+*guqGNB|9^kN@P(r4s*OUJeN! zNgo0ih-%SsBhcLYcizhCKEwkz#Rq9T1g<2ty&#|9&{Go=JzT4Hy7~L+Pq*qKCH~4= zH_t;fJz-g;gkae zU2anXDSort-1-wo=NCH09u`2f2lqbSOf z(W7lTk)Ig^SzNph3>d=VO;BbCV8N53OFZCyBzIbfb7xU2u5}J|o0Sl^m0J(cooPS5 z`)nMtgY(nFNOFFr*BlR_!b2fscl3ik5@e|Pm!s~Dn3xzylTZ`K@O>c61fNqU?J8ls zzCaBb1{?RCW8>ln1E?4M1dj%~#VRT34buj7pyc6c%vLsvXK-o@LW~a@5%(oeVB`T; zy$UaytVuBlhf#X`($c;V5TJLixnlgTxe+)00;Hx7R_sK+W-ci63vdx29_TXg<@xL- zaKL94U&gU&KEmDs6(UlH{0!2WJepVHuaEvfK!Mhcv$Hd}BKX3wBre@=H*))fGX2B8 z2M9u@0pA!w&Y!a~n2L0Uzl@}ozJQ9L_uhaUOtvp%6R_~XWjcUL1KoTUAom@qoEWX> zybl755*S`6TtU=0s&B*r5i|HPs0FV;!5Df30&hMemaUf2v_p}_rYnwJ*sLWO#vV#! z>kzj=9jXC>Yt@QJ-&?l_<fLc?^S_(SKg8Lfj zyK(zIm6-DiR4s`cY2=`e1k)>J`W3?#3%{CXaA*kani1JOU=;vp7kIVQ4I>MU){8;x zpj@0N>E&uoU<60dA?MZ9&_L%*X58%lM^MzZ>PgZ9wL>rHp>p_?vDan=6esib%9daz zq15H8CpbN{2hpdMxjBbPV|{<&!TRX-6IE8I0YJ_L)}?7p-~}cEtQUx*gYW75Qez$(K`xlyKoA@&mcXZ321&f%mjK$!NGw7ru2JcVG;KL@~`@>LZgmUUn z&}0*^d56ZQ!B?rFY6Jr2*&{fLE+a@uT|lN+@c~wJ@|1pouV_y;gr)Pj!V0^ z#6Pje8xEq^CAuyH-21hWLUC9ma8KetuQ_)R=9b-?g3wHTLxhn<>)+8f?yq@>X2{bF z(%nS^zv&RXSW$syYpB-NR&Q8P0TV3$^z!6!CEowf>og1z@&9?nHr3jgdhOpAO|h0^ zU-{|!p{FGJhhXu)q;;g|e}U}k{{^x?i2W~+{r~;hzoYdaR=lu9b#(s$jboz&7MJBe z4<3x}%NyMygr+*ds2`7jLXOc&f}{v#4X)xbm98cdt(cIvN#Kcu+v3vM(SzN)vY>Ncy1bT#MnsuBfOAbw` znvzUQs;pY-);t$fNEBIHv{_>l6Z2yg6%;}?EC|)JN#P>v60&gnio7J61bXnH`ChKP z7V;ikWmVNfcL}(&Rp$x2O8)wX)v=gYuSB*lxYnhrEFZIc<_OLkyhex>wSS0)uqzV( z&*ypbdO3FJ>AsAvL)(M?EhQsa*yBJZ*<+IZu)*?J5Tto(?iFo{{M^;7qI7y zO|btzhbayl^J=cIE9@_5Ux3$=(0R(TEVXsLIhzhnNJ`wVn%s020}s$UR6vudcpJ=a zWR@6|gH0JM!aO6vFIc?&quAS)?H^I}8lBLemEZeTb(V#tC0ll9JU8_YJAI}iGx}4< zVQ5>@{vTU1QcHa?AD@=i+a3IQkd>cb6Ps|njScz4CNS9VhZr@Q?mmdNYf|(=D8v3d zq(9ZVzV9hZR-6d3VckLW-GXEJ`-E)_Ss{NPJPM{a{zbWm(0`LJRD~cz%aYy8V z{|NdiiYE7vHe>k_s=tAI4z>FapIs*F-%%O*`0f9Cqr>yQMn=IOMVE2s?D+|Q(OeGZO2otW63;~Y6hD~-th>w>tBHuioz2k*y>10Z+_U{t}9rXS= z(+o}28&Yu=s^0Y$zioMT(H>QJ8nGdjP;)82RDzPIg5H>zhANXw9hV%*ce}c;&iZAO z^0Uh7|49zJcIDaf{mbpi9?8?IH}DIdk+ZSG$qBs@0;I+d_xKM_9({N`P@q*g{P}5t ziE4rRCh{yHH0wcjsG`e!dpFAeXRv_K`OI7C#mk+-s7n26xAwIe-43aTOUL7zxo2;9 zZlz+4Z+0&JP;tB;dAJ?2@6#?f_KB)-Y=Ul)!XP_&Kh{=$A|!Z>AsYM3paLr?oo>2z zcTnlOY4S!%^2YL%2anSFY~ObvaXIK@LluIn?@k6Z+E#x|2uaLhP}Vg5QsPr~7$kVXIr*-^ zS4e18o~J|S?gcJ;lET{q9Qv`>o* z_P5wXD0RZ~ldOpu5M)6smTV*DJd2ZQQM*GK;qTf@@fSVzt940RI?abng+g>$HL+#< zI-Xk+ApP>y7ODxlyOC0~S93aX5`2GqYA<<)pVcd>$bI_F$Eg{;#!!ul=T8(w-o-#- z^Ru@mF%=p&Rv6swHawp68#bsq_lOmsNd&)#dCxh~~=IEZPhVI3{cM_fXrcT5*!qmxtVS(O@WW{)%6Nzr3_D*m(2LCY<7eTDt_hSZ#* z+qz(&x)K&0oi@w&OylA+LL{2z3fV9@` z&L=*a#|;Qh!{H4gaUkXjthEw zEA6x;=cGUr@<7Kr1#D8R@4k#qEL51hWpT&qJn~@pN+hBDmO|G$1^k*=T|`W&B-8jW z`=i8mitrb^DpeFJS1GRE#Gw1Owm2jZwRzBcv^Cm+H}d7!n_7XA^@OG_092>UVnwnt0=m1>2m=0~ zZy_P|eiAK6lCoFa+Y|A}<95r1sVP;{bj0%^iVkl*R~64t5mFkK2Z1Ev7*r&#FVG*`kXDP_|k%TpnpHvD=l1aB3 zspl*8eK?*@zRyi|s&cWWD*KMGbmA?8fb+SmaQ4{R;^b={-DRVf@ z+dNz~{v}I>dUw3;{2|XV;oa=bk!`o@GqtdEJOUvi0FU%NqZAglvC5Os8 z>6qwPWsBP*6BF<7$K1S&Wn~&+lK*u78|#<8-D8f~J<$V+$c%0(Wg=o$MimAxW_{d* zkQHP>bBFSpU!oo>yXc<6Uz`!X$f4idrH#izu5+mo24c|!*=6fr1Q>llzR%>UsE!Xj zbb7geYo)(%^J$cs^-vmf`Bh%RAE+DE(aEzI!h}g=NZ^FU%ao9yRa_<7_NWB*wj11@ z6!M7@+4LHfw14H$#5C>ld%XVBtMaZ{lAT)v+hm0#F|qU3FHDzmHe7B<`uXF!EAZ&u z!fIjmw%|?w{4o1twDBL?;?|g34rv7C>Cc$${u-vPZ5vJ|66LaU)!W@PtVR7wP4t)v zp*$#3=wcM=xLHiY_2ZBM$5I1@iC6j|f`}oOrkMka5RdUk(w^x)^$m7)EaJ1 z1?jho3A0^Fr<(_dmlE}2{B^Syab2xqyc@(Ay|HmM3apZiUxQwa)StI@mUD0?`WVqy zw#|`ucFvT!u0U++W8JQ3r;Q{KI-6!hH)sx+p%y+IYjDxt6SDf_Fc$QuH_|!DAB$Q-lK27>TuRxHQ!$S>-LHkyTNq$@^-3` zzoP6snVkR;pY3<%_9kD^fhD~<>7^#==eCEoyh19W6(h-+Z)~*Qqx=Fxeuwz067egL z2CQu=`Q2fcMFi`L*O?vlDIHH)DZKKD@X8$Bv860LaM-XOc`d7b-)>rNbwfKsv6JG9w(68(~=7!v66wS~!A;M1dev_@eN|jjeQe5h?t3|c0 zuHNjI8~kmB<1yI`UXoNPHF6C8@3VAMp*>C10wJ-%LwtGtC>nV}JbW&8lFoC)wWqjd zjHtrcjNW192fszxJ}`bpa|=SL{^cU2Wq<7 zR$2vq?W_X&in@s=?q7NDV20<9F}xgO;d*3$G9)xNjr;J$OR1NSllbkcN6WN+dcO5X zx=4!YKHI$+lUt~K>0|PB?-a`mkEp_QcA7knflg#%v4t4X8oMt-NPaBplkZzGG!>sZ zx%)Dn3CnLaIwwJpb>z&+d?V89rdh_-PB!S9IiG7rN_V7VsX@mvOUxjuo4`WXjJQr8 zoS69SrL^ro@pIPu^A2zEk{)UK7)Sh^nQ6E*PH%b`7y9EIW6oKZ|0Lp`oAmjwZ#IvG zh}e_OHPya8K|KO^G)B1X*Q1%O*Q0_5g`wijpzs3}+I2m#Dy6 z73=95vE3>>+<5MEI3t^FW9U@Q?4#Y&zIQKaS50Xm`jf}W`eXaYZXK96>dgF|%*OtN zu$2!Fk%f~N#OPtJ&t`-Qy&A*DB&0I6kjzoC{IH|K7bkkK-qPc>>3}b#F>_(EJ@R%w zw1dG+(O?L%S5hif)%W_cuH0x`>~vSYgorsbafm|-@zxkO4S_W8aZ@AFeshN>ks&!p zcsstkrTg8Mk4|grgyQItzx6jIjo^E7r85^kIuBSoIuDT+>6k@kn0pTBX5on0SgQElO-7U!2dipGp| zJ)V>=AFl`;?ap4jn7gXh&R3d#=~iQO8bR2~LvEL(L0qEZD)z(>C9g=LU@^A(-l>F- z>w}pYQYk#VH8a40(U5{Skg76d;1)?2aH<%t`!|Ee^u^l=tTEZ&wf5P=z5Kt z|5)YpY@j-}ueoVyy>>Ay`;{OD}$E%;?1k=BXpF;*C9%@-qXq%&QwNc)KdG zl!$Vk65z205Ga-qC?SeDtSxX4_M-$-Be6gpRa*6gfUHot(ph##b6n>_+yZ<|@+o#8@ zlOZ?@smCP4)Dl)L@+*{>mlqM)-KjQbGVY`c4D2G$Y z`M-U^VP2$$dngIcQLDz9-~GQ{clZbog>a;)ev05w+57fh&`57=qqgV8QG-)08}*mY zn(mi7mB^qTi+Gh}=q;uH&Txe-P^10tLWI{7EHeE^+>#^3ehSiC-8ZdCe&~Q?X)^`2 zx#BYF>espRu*!T(lI--uWN(fZka^+oQ8wE^6fwZAU+?#pZ2f4FIYfkCTS63f@OKz-(G(sS~(?hz9XZo)_kM+VMQmh2xs z9{a!Bx9x^<&xc);RggLP{sbeX>A!T!0;w?BExUhQe~d-zAM12D?J_euY))Fs)XC7v z7gEW--g>Y`F;>HTS*91k$nLFJqNb{(6Qd7~rW&E}%>0=7z4^{x)D?H6R7^uoxxzd#`7`$$s$+dv z_oS8V*f?i8$nS5?RI&M-+#LNZ-Cniq^jVbDOTsW3^Z2HqWye3?kCXl`@)Eu(?${Mb zr(R1X_7m{p;MWOWEMV{K>SEMUEN&g|`&ga-!F}oQHrbVeA$K|bgV5>37N&G-`+2_x z>wvh0JK@Xa2g}4GDtIHt@1v1L-2El4am@O|RT_(m{z zPTE^K3jZGJJV(tv>5fgW;_uymI8n0BGe?Y}V#&Gqaz*zbmD}fJxAMCXZbGvQu>vkF z7I_w9b;{+&q@`}3?R<(W9iOrm%dBQ-AvdyM_Des9Td?KGb9RmBoI97yI*K0Nzjw>p zzZ-LVoOhiY?zSTRt>dI}KeI@sEdxs!9gqoeUVu3Pe zr_v@!a^eTpM8<1EE31bA+8Z}7(rKdo{$3NP*8BKy^Ff5Fyn6LyV4{GG#}(d}HXBZn zA@P&&#-r^~bgk?reR(e>3>i^`YTqVYwe`fyZU~>Nji{;0GRWhQGB6P1Xe0fy+N7F3t26nyhp}f7 ztRByVG;K||Gic(}@@027=nNNagf?7uPE7K@@{vwkEVoe%QQMMJ@*Ur1MoH3!f7Lm~ zuasA=l-t*#!AK#r^e6aT|xzJOJoQj6H_sG!k_q?h4KYh-ddtC~0219zL zELK*#vurdv=jz?lXGI)2mcN#uuE~`})=84F-D z@Q&TCyO4!Bg^irnqZ|)`>F&;_k@1rE|DGtNn`kPMX%Hibn-$BnZg3wYcj%{nH)o>b zE~wT?%a{;Ej)f5svdeb&CchxqDXO$FM6^ObGsvIr)_QCek6bVwPfEWdJUyJL_a^a z&G>!^4;vV}Y4j%eTOi^N>X=pKXLm?&Y`%HQ_c4!_i@w`zD(;r)OWuP@b_%`Mb${<_ zJmvgaJvSP@Z}V->lAkbYc+}0)SB5s$j8y@bu4k#d%XmL^blCZhp~T(jr~H#4dL*lf zQafHU!F29iyxQC24yXK8_xFp*i-okTlr@^)tI)pfip62n#io6`iG}j3Nf+E6qr`SD zFc5q+BynY7ky_}BYL~q8n0)_WL(sue^=y5Hog!B_SF_~kfeC=3ag%dP7M-Qyqobc-K{aIB_X z+NO5<$H3PC=iaV(Dc|te+V^|{g8%69vRYawOEB}P{>*zQox;YG;NS1xAK=`06x*P@ zp81&VI`d!hz)xD5&(us#to89r)D)u}n-0>>7pi2(vsjG7>2ZmrnMgY&j(Uc6YL^!T zBeLr%_*90>J3k?MVqg4P8(j59pu92p>sYiO%e=XSK{(xIfUSZ9438>E=5| zOx5`W%bSxUblnvvIkwd=M42&q{_11l_J%`?0K&KUdH22_tvQ60Ti-%in<;Nj#{W8h z`Rqf*;wQRjL`sJnk-}FTIoBoOAfV_zLpE!a%Fc6);d5gqwtYJfnv7MIY3IYxJuS<%`0eWT z(u`-l?Bpcane`gVNV}xF#cIoX`lG&eh3a|;7Hc|9CZ{je@yp|-FTPWy7JY<+b}gY} z4BCVGS7R|_*kbO6-J1Gz=k!dJJ5qVf?T0O97ER1JLnm`4@ot{(`wA_6U9DPEZYlNR z>z@R<=Ubw2x)Io|irD!$gIMBeUO&fk1QVH3pZA9rm1#Itxv8dCF)aOw!iY*|`Q@(C zJ9hcOV^L3=P5(Rh8j*Cie{7;HbInVk^OrxQY*L+DSt_+(;S~Mwn4eO!dY8{K&>ZGA zUewfWt<~drnmtU;g0%K#Bl1&jKCIfaQ!G2anbF~A6*f1=VH@M7?ksp|8W5qx7_;!s zX_mprSIfAJ)b_OvwMJ_Y&)n{Odkf0?+wCCb!STHmW&ZbO849%iOpD%}oj+M9pYhoQ zOp^Jb2quy(vX+xQq#o*r1zbOzXJRZD>+sBWm-ICFlu63}m^Q-POK7V{AdZcNBiO&f zFc*8#9TA1ycaEuUotJBFS-LuyZ-aDZ8Ku`_iMdlkV4j4acWT)rM2NUdb7gazt{@zX zWtEBrOUr2v=}B8%_1WnZ%uinXuATI5qSU`YLiqtZO1#sM^4UH#P*mXVd5Qy z44e#J@ibqKgPcl63-1wI!YKMSk7ty;E`cV+JW5ruz1ySiI%g}^UiRhqN8zFg9GTG5hfZ>(yV>iAwXqO=ix)jx2=>YkQ@)e{*t5G#D&c)}LjR#FEf7 zaxn7b$zB++8(O5WjSaRkR~nWj(c)?!atC+!zgM$lziM*KvAM(yW!#hf_PhvE z6xK6N!7Q6V?(GiS;-W6bjwdL{Z12g%ExosF9G+%QsQ!3mXrhKzgzQNafnA|sHxeuV zqbXyS>6h8={Tsn~k1`+U`!SjZ3uL~@_{`kG`zr0J`?VZXOIsc%>q|E}G0{Wz+S?&4 z@|rF|uUt-cVD{AUuCesQnkmN|HXJP0-m@90mnvwI9W{SrH?2RMDE3!HF1$xTi)Fn` zLyEO^!v4l1)f3m9K5+$XzVlipJ?vKVq}OI0n6v`Q*hJB&ZAk%sSu)FLjeC+y=gy@3 znqSS$6r#gW2KOhPuI#bv`vnk%%3C8Q63o)5NGD4+SBnO|+0~U6Reh9;N{l0@S&()r zf1%*d{bS5Yet z<$;*N)j3_LEMtL%kF$=csbz$!-yTRg39<8yIpS5T*Q`=v? zeC^XiQDu5dt&&t}SuL3ak86y%v^v<5?)&<5Y?M8pEw;#;!OqST&1tNWDAOLF=0cV-ILlyGbX$z&1otj~hY z(vIXdIE9B+zjU+uqjYBqx=pjwzMxxPP;E{Nve_LAvfKmB3wTo zraYKKtX`}^T&Zie<8P`N;(s@7w{Elcd0X6K&)J)Pmk$(=-vyYn>OF61(a^bF)phT$ zO=-oe!;aK}k?Tgk48%!l9)Bpo-(6@`nEuk`+@{ROe%s0*V7{*W7ou#WT(?rNtEa^7 zH|C+U#P>TM33@SzGI3j`d_I-UCw-NHgZDajsCN{F_}+qK~ z;!;NRJ5@RK4M=={;-^c9qt}1@DAUyEp`#eJ!X3jvC0@MJgTt~BBXwj+t(03=dg_{X zKEd|uzZ2I5qYZ8GbNHP;;dXda{tO=$q-~kHcPfZ46_9TlLMFqZx_0=fpG!y<5tAr7 zC;fTvn#b|5krUbc>`e?V9$T4shtVx6NsC0TSaLfRW}{Aw)qj^->6aV@+v^|qNi0VM zYBu7hBkLutMl(3_ZyH70lnSmsBG6k-=73t%*wfrDwhx7RtTl=?eEAmhos95?Ui@i< z$|XyS0WqnZ11 zhAf1!?>Phyk}_qqF29a=Y!}M2P+Cz?rJV&ZovHtd zXE9}S9rIXzdYVG5vDJ9~UfMnybs`WY24!SNS_K)Yhn&ZfLwg22ii*|~i7JxCilq7L zf?e0iP;3`<;USodN3k!;C@pC?>`?X*@^01ovHV2$O8ps0OB6R`U%2T(P0W1NiPA5y1 zi}%7-x>!-e5k(4wriH_CaXAsSL!I-;F$zjV@Tm0pmI*btrM8g5^|Gf#cbs<;bNF=W zGrQOr{!>HLSLXhrQDI1aFS$~V-I9pT_ux!7om&oX2bjgr|0q>x}cmy`H_Wv=&wQcgnsaU-&nS zjf<66DD>{+s2Sy%_NI#PW(c5O*q`U=woI&!(ob}A9$e|E7rinf_<`a^?y(IAqfPX5 zVD)nC6t!#XS(;^Jg#n&6mXl+t^4N1^Yx$?JyU9t7e7|~=BgV?}Ef?G6A3go_ExR`< zq{=5EK0eb|?n2`XhP#~&=y_M9>FPNp7_LhlbxrbvYKPz1aBL1Ix z%IC3KSj2L8Wqy=4v#s{a85|j_g9MNnOO-BdV;$-fLVb=9mz2-d#}h6$o#hchk*R7 z;d+I+4_RYjp zEi*p`oG4SHs?Yg__ItN($A`MXj0O9+b>&*;opXLBvKse`aF zvpJP+t?;41R0*bhfNF zs~nGj@BKIA1XhdG;y`E~J;vmu#-sspgzOc$lAUpLQY8K9&m{WFiZaRbq4@Zl9AOt5 z{F>}^%~qp!4GZrU-w-%)lyUN0wXn##iJU7{sWT+UIS9qH&wMmGog`l@udBC#SMo9m zn-FFN57B185CTe3=*;1#sp!=lN4_>O}RgH(d)LmHZE?&wqWEw z^suj?VJ0xVw{nH{jqD2F)vq4y5eK@^esd4g)_AVfVOt;l_u{ zzP(@1PCm5x;bRE24LLYW9gkw!K2M&Ym+5-49d1>DyZ*N;(_EuEauay29Y`w18%?=uj^CMI zC4C{296n$A3O{)D7ctEAfmmz5PNE&J(A{v@92(6$&I zt>r6!DZDu>WI#)KxAbQ(D#0y$t+xpmI}`)!-g#T-`%zQT3?0KA46lB-0nK6*S5YCs zn(Iyg+YQY_p7Ra)^0JPgzPm&p@+^EN_TNtjE~58v1;9A@F1H}cy?rwZR<3&T_) zA3E%piJLuN7H6*%3Vchtym%+EHoeB{I)`k|XC-rboa8qT%&*hD;(th_WLe+X8bz&= zKE_Ve>8&_c%A_5u!opqS)jGi}{PU50a#@lpf&jTwFXX=zZb0KRP2O#{^rU>-m4xu= ztg@4D@;%J%G~Te=QyZVo_pV-0@?_V~O7Aaqa`uo~+w+ZwNT@NZP#nG6%~>v!8ru0= zLoFF?;XT0-!^RMarDo*yOMSNC=;+NeUj7G9G0v#pE?ayks{p#VcWR%q zT+ic9AwT`g*m#N1Iru2ZHtf1>vf}V+;!1)b2G6xD;o}1#wpI58n z_w0Qq(O{b@;4oH=_dJ=w^vWw0v-hJ*%vbe{oS2uDH-7){!|uL|^TQkOThQ&EC-LrS ziNdTqms$vhn`-(SMsC{Qb$m>&;a__el)v2gH1i@s)O0Avv*M+<<3@4Zdwr}7-phb8 zjjuu9N^4yae2Jwuv^KB(5ZW&pF)$F=7|rR{tqd>1|D3&lp9mX|pXh3^rJ^lx;^G}&NzTgn)$`O#YgQ)wa0hL%@M>4->aY5bo|P!1IQp0rXazf-|57+K%=!fBJM zE0aEOv^zgYsvltquQRSLWrIVqw7m8W;!l=63u3gY^m=ODAZB?)AIsEGy9|41@^WkQ~e~BuX~t-qeMQ* zeWp!ay@&7phg$DzJ3hZmU%JBbPdCwo5mq`4k0JWPiit@(1=ro9)o;1u zDV{E({ho1JO_BH&hVH=Q#r-%6+xZBS@b9UGS+p{4eYY=v*MD#&Q!2I&>SG2tm!xd2 zl8v~P!fyw>&`z_7T>{hF`BvHjaXKED7!1E-mpSyQ?|I3KS8-*5U|5>|bE{5cg8o-; z_QExPn?JjJ`4f*)okh*Jw{ctPWUo*dlk1L~tv;b#zO6Kp^Q(G+{KvUsoAQS%<;gP> zIe5o3(*%V$qeDzGvEk!Pd{_O}*Wkp|n(k-GMW4f{su}${dWur4#dj6^l&XssnPc}BTiZaRy)Ea8d#oI$Zg<7vg)0oUMz1&O53jR-_>ml$5j3l;Y&%1kUOyC-BIy zR#3L@N~p40aOoK(rY};R!Qq}JIF_E7nMp|h2u_@Ty}vPVRu8{%oAJoa$+7H=WsQ<= zX0AN__UG7Zxn6HJsq$;AERxXE+Q@5Q;H_7DvZHHRw#HukGTAhA4zT-EZ`YDew}-ry z$Lg^Wbjklhp8FxbjY;57(-{WK4yn<*>Fl&*FYieWJH{84S7Z_VgSBevoOX45O8SAu zXY79Q-KB$f7-AcF`~22=S^UQuhPCE$?!Nn+?`K~kfsEv{qRoPINf`;-5YNeCM}Im2B1Wk0 z3^H$|LvZ1a8*a*9uZB=KeAh{ikFC%!1>Rn7Ho9DPSQaLMpb*4V#z5fh=5)NVgaSzs z%7)sN*j=}xeGu9nCz?I2ym;NUA0NUgeS^VIKmnX~xu40QJmE84`YaRS8b8uZ(xVqI zMxj(fPZ)DGZvUdyP$KkKG9I6*6ca^*w^690<~8UCfehmZ@!1+C7#$r{5>2+UR&;$< zv-=MWjxGB5514&uIhO3SXr4ss)DJ02|2hJs(8+bpIFn+4d78ePZ`YE6d@Yct>3rN~ z{SO}lB2#Sud*Pm@T%=Yq%F<>BgofZ&k~uqTKvc;UfWIs=Tr~tTChYC5g)Ar1ag~Lf zfXl`*-D!Y*@ExE9#4}d4*LHS_yv5&mKLB7Y6F{^N$gmlF9j5;8-GRU7KkneM1AnSQ zOw#`BQdqgue~FWqP~w-^2OE<$zEirF+Gz7ikisdsDaje|5nYr8 z8}|+v!*}hH{<}lrzZ~K|K6LdX^e68k5EDKN8y|$i_CTHuy00%`ZTy}}KPT}RAD&TUY2XQQKq6pb4XV9B$1=b7;H5T&fEZCFr$^el{PHn)cqe5iq z>toJo3dT(0&-iEx&TaUhYe^SELaUEFEbNw3HXo1Y!Syg@@Nm6MHmH6u+=zv{muWhi z^wlD~xv+AP#(alYqBt66e$}`mhscmCE_i&PPq!ia|7QWh2XkNrIB}Vs2l-Bx;qRlD zJrp=7$*FTdpoPGqs3YF`_Prm31T;dEHFTR_g*Jb_!vOJcH+f}dBkOSDcy=S?H;miE ziE7GCM*pOITt=e#jrJ36Kwf!8>$ZWuz)R+JR0B{Vh#&|su7aDFcM`}H2jVJe0jWwL z#t@PI6;Pglf?Kc4R~RG-jj`g=F*NKl8;%8tgpL2`5dfV*T@2OZBJlx2R|G72w?GIL zKqagy#xEiwB7pe6fxdpmx_@*9+A0vU0}O;RkHX?&x|EMV8gkpc>tWRy$c^4^MgghK z0Xu@IK_F7s;Igk)rwxr+0OEYE^ZHFd#CByFV^4%_Z3(xEX>}>NIyxea7J^>-^0e|Q z2WC&gO)+{vqoZ;(-Z&csx4$HjHhRl&jH<*1pH|wjaVb<_IJCXfTCA>N)&o;_=*% zD`3oSAY5F==557V$Gf(>_QFIitv$`l=>9LF!Q`2_+Jiiz9cO+$)8<$kF1=hYkMAK=km2BJ@)%In7__7tLYw}+Wcff2rIk{ zYi}fVtsTc4X7R<0f$S_QmPq`pS{4tWhW2)bzCjR=7K^24O_;99zGT4i%A~=pw!#qy zS?p;NAj3+v_TJJ)d70WjrQRpD&TaiJiWD_{1ZmppmJn(>_;_mD>nlirY(hWKMjUw! z?DgIUn7gigZmOS4IZ0Wb(O)IpKjyDJk&8IK`0gjHC}!Rm3FKQa3AQ7R|l7 z%ljCkaC2c#ESfbc$$yKev2ixG@T980MWWMH2Gut7my-l2K@ z2cZ7}x(KkQ?Nqj%cmgikdmyb0=!t7z`mlaLZ7LWU8G+cOCqO-k0xTX7W*#x)h}_gl zEQk-4&fC#3<9XhE|6*me3`l)`09^#=AFUl7J^%~zAL|BW(}i86ZCZ z3=eo?-^b1OSuQhj_V)HbLNRNTZ|#1rN#uVku~G8ElX90e&4DZoO!8}}vtakVs7TQ% zH{y}MMp-uR27Y}WZxt%#rKDDN-PvxC~*KUy{3AAF#B2>4Iq#2U-~nF^31dt2?1w$(b-XDsiFpqYT}#$9DfprD&u zN(G18MD4G8BmG0a?%%w88+f|%vF?987yMx6vlzwGWVF82TKWE85m-3**0_*5#=jXg z^gKSzB3f1HPfJx!tRcbJD68~YvV8C3t>Ajx0Cf~e9K5Yq1vZu%Hwi)z=Ulm6yl>JB zQX|1X)>yx|c=t7Lj5ZOR_syjdmY931oi&EFmaAW&-2bqSx_m#QL-Uy&s&9bb;fL7*5_d}^7?ipTi@ z`vMA7gjA0GS{0*#zn$Rf7U53e%&&NN#IQ~X2RZ?j#wwnxtE;375F7pP0s~C}+2A-O zf#G(uj~Zm&|9Swe4@d^eka=tYP7yb&A^7?KDg#t@UzT;Xa;2>8X zzL^Fr1!ey2cmQ%R0n)pLOnD7+OV}2l&%g2qGwDkM!Oj(6<^8bZ6MjRoNUgo@$RMoJMufB=8~U;zNdMQ zwVKHhF|v|kC2zXN*k?l>+DF_Sjz%=AxET_>cM2|9Plm~@=IBgj$VHNUL#<0e)(-m{ z2!1+K&WRsgT7}moW{}yg8Scn376*tp|6G?|kJ+BbwcE*E#ASb9&Z1W`o6^vBmO+vf z#}iYfO2Nb0OX|Eo(jPze>X_PQ{hDli+9p{TyO2pfC2VhW1?vB_)bG1>kB;My^U5FX z=B~R#7&O&xwpit8hYEXQ@}z*lFIj3I22_yyEXGU12E*y-(4cZnY^tYjZ1!oLnktRs z*dUd^A@g%klh00ja|)4sU)8JNrc#I##Gg=nPt)neb*VZ2Tqy9&bN@uhcGSLXj_E)U zhm32hJ^V|v9qIM2w%0~OC%4kZU;jHhYV+QpenO23N{fAnTk|XQe#gD>f7&n_T2~)tt~!^0ve5^mv8s|&oyWiA`K1-16Q-IProYl+SX`&uF)450br}N;dx)# z&l3n(vufA&3zanduv^`E|8k28$pEy9K{BuM!`2-e3%Zpk-^;v_kr7Gg8NeP%Xf!50 z0j~`ia$Q{&`Hz8GZgPy0gaQ>K>u4a$79~P zEf{C+R_L{y|MyzpvRqzT8dLh(I}L8d9&378=t^Rvlj?K^4yA@K?ve_UfiMp_Z6?>A zFRqTq>A@Bd9F;&aElcJ19{a?>Ct+79yoH>9u5N_f$L3Ex9uv$RXiNjjRMOHi=5o0o zuz5%^bH$eT8tWU@1l`x`t5)vP1`UJDot6f@Jkj}xWQBUHynWi_1;T@flMzbPKe9RZ z$1g-^Qb62NuKd-`iVQ#gxwj=0=gcE|k|zv{?1eHx&m;+>?q{VuNR&4kHefB~V5_?S$T= z8t5T_K9q(zJUl{fe6JQny|0>2GdT-t>8PMxzB^BE-}_vIJ{Y!~tRpKkuOyFsgD0O| z;$rYh+UaTuF?Z8E2Q&=+jO28`+nG~G0sV-V8tp@T>OnI}LO zdoI z`jDWndKvK4rC9fu%`3Zw0Bh@>^W=-Lstg1VW9&P&^9d~12wHvDQ&uwFzZNrz&7i+F zYir2ejwO?KS}AQ+a~qx?WqF#%D~{)%Pwp)RjClElkD9wuxlpIm~3d7AzpItj`^O~1@--Y-~p{pR*ZrS|PkSEiTD(yC>|lGZN$n?au7>HPpfM|y2y@A;Q{un0o(Ha(3DvZ)!W zT@bbS5pG|OIa_Ba&D`I70~?cwRILEq+Ncku7i0-WWSa~|-qhjrgh8NQJDER=?}DEU zK;u(HpYUNWdi~*vELemuELzRjZVEWy&}UY-QB zCzg;*^em97?af^N$yJ%n8JK{D?RVP;2;Mshng%TZxwAW&^#YiP>7EBAfc$1bhj##| zlEC@^fK%>0$b?(HbBYg!3JRmWK^RhI3)RNa`aB%Ydp~~sAO=Lf48a>fmstalBA_i1 zcYJr*Q3w!w4A3%-iqfj8s?t&ui%*A~SVkBGOk({2c9fKql;&<-0a%m07%a_y0fp&5 zBnMy!dw}MfJ|K<&Ce(xEq#=S=&*Kdbn+{K{g}4|NU#{cg7Q}~KVLt&{ao1C{Ce5Bw z0|${H7+PFeH# z8)WERLPfxE`QU0v4zXJGxbJXB1Bvt8>Xblx#%^PNHt8wnOd-^C{_^`H|8lFj^sNOg zfZUjHG@|8$F@l7+im<)!ALqhg1{|zqoLaV`-($W%GtvzNgx4wC>Z7|-)@Dr71Ezle ztC-aZ>q~c<_EQr>8V?}O068x2tX$uWXa%NRxbM7wwR4%V*kk$4ijFCUEg;mV-ZBq{ z9fd2M@JA?b{)!VNU9jb=+KW|{3GIdo3(5?<>*fJOa8?fz8;A=g;Hieg_CD5D>Nhr; z8FdkgaoWB&z*lzzLE6egpmF$+TCb&oQq=AmJQ7F3Pd9OPtJvW?pF$;+X!;yk)}9y2 zY+vrOTu&=jk-^ijpE_bW2hLNvcez zv<>Ug{#dl?o#03Rl07w9Pb9Q)4?|F7^~Zp?`Dgl_z&>H~(=7e4PL>mqSW zZGZdaF(JTi2cAKF0XeB!#_7KX7b7dHqqTJlpnXU5&K~tRr2|g`Yrxer<_%CeBt})|3 zEg<{q$~g*sVJ`EE_u`{2)aOrxU!|i>Q-sj3_8(vniEO~ZQXj{G^1NyAWh_>k#o9UV zUTI)8Oc{kJ498-e`5J?xEEklmu1+p&p*79S`;P6<>*=`v#=)7hk`_#k#6OyiC}^{} z533nM=xXrF1NOe1Li=7zGELc~@147V7thm(!dfGF&21fH{R(=IK4KzFL9Ie&$3Dak zM#(kyhfKM&6OEuk>B4ma%!if9DoY(z5!&K#gUv)~Y(0=kBZAQ~eJCIti5e(q{Xw0wQy4BU;n9#`wu~a z2?<6~5Sb+B`jluvQWV60fLFM`ioJwy0bU}!nI*#&<1!))PS1@dbz^$-OGm7q3sonO z`D`q-)dGaJI7f_&T3?_ChSt?n!N3IK3PR2sLn1V@j;oc|Kd$2=kdCUf*z4E=nPWL}C5GYwr0QC%L6|6NoUr%oZbGXpaBZ#+}vEM#M;qf-G3JoVmI&r_q)l_@WUi#5GPdxoFion z(3StQ<$!*^O@;mPghw9$eE)Yhgq|0PK3M}56TlzKQN6*!!tw(KF-!_wj13G70L8qc z??qbS>%K8ZfR+M&{R`kmadUB%1NS?CPDF$O9INubU-;FURB9QWWW$2E^0%H&t-Ad|}0k(ks>3Q)$zK%~c>J~JWM|zoVbmCH?{`g=rXU7p)qeavpi$Zht&3tmX=@PW-sm`9V_CF3F;#e4oR-D2djnH7l@Z$C(v9+b~Y3KY*4W}%X z(nb!HvbR*xS#gQm+F-HjG-3Tlv|!(vwkn>X9cFR~9n&i4sV!|IH7A@Ly4TcQo$^{v z+EflAdZ9DCi4iKFceLOBPV9wKV0qp`h7J0Sgi)a$eD_R@7cPa3orHR3vRn9RLh}%hKbtXTxYdl3AX|w2GAQIC zI$AOWw&75buxMaJ(NZ|buf}1Z3k+Db6}z~fMF~2>I5-0oK+XWOB(xWBvNIg_zWyc7 z*u*XRU|>&WrA!*~ z(eN{7H`FXNA_)KL1itok;tnu}oS+r2(~cUg@|+w5(0@Sbv=m_q7BpsP?fJS1=z2LU zRIdZmPFjGWA0J_D9#?f)Pe)k1D@K8-AadyO@v)BW?1ZH@0q4#B>~4x>CLn{)0aH%K zE#&DfI?qQKH7j(kfIF)Yz@qs*EUy8i+WV+M_oD>_P(eY#f0tcZSy{w+U1Ou$e-j+O zJpzrj%JOnMM@MCvIM5eha)2+ms2uiWa-8_1LQq#_;db6SQA<*hY?`pvTo-DR=-1If zqJ;rQV+bM>sEZ`CT`_c{^xaAE2%CF4ySrhUT3T>HCx<~Vr#k;G)c8G~AnOaY>obS+ zv9uwwzv>46wXRU{y(~(aOc!wjdSu`K)_Hv|Whic(Ko*bh&>`L_T>Eh)g>?B({HJJJ zh>5E4KqXyfy>!9MX-8J`GeL%I3mV;#jQBe~DqVsPVr_m|0Sh18r6)^n`L{p3qkanY zN4RJ!61aadx{*$3-^dc{6yRzj&Y_z5_S5N@`=>wx1zUNPDp5)daYHaq&jiZDpkEcH zKue1%Eh=%KQa|}FpFBL(S|tL5V!az4b*_i^PqjP#==%Om*b76Qo$YXY6Sat+;-T|s zYNr0&VXSvf$w0;G2+x)*2w^p0*xV{wInX%0+9>43+Pv%ZMp^++W;6pqXP_iFb4eEX)Bkxa&4f-N(1aRc=A9OfNo9# zHQZia6iqI$e?=2F3`qQ00E26IH=-#L6Q|MbTWvBM8IBrA*DVVhA!E;OEnGR3W{*pc zSWeTJI{{sPL`|?DIL`1HYwkF~fuw8~iVDr=RGWy2zKtHqolx%ls^c*E;O`XQa?h)5 zZ!b$XW>AKNLlv|B{^iV2e^Cx03$5I@Bf=jWIMquEiRi`cw)FYa-6_@Nmn9$CHgx}} zv-ZHaF~63(j7xS`kRD)AO@%}N0!|2$Ox_V=sKtl zW$hhkZC zhlC(c+pK=WFZi|ZSP8=Z+ZDR=_qCWbeB2-ex>o=Bu+9Luoujfh^#Pld%L>QrP$=4- z^uJ}!@Y{M2fDZ$RYI$?>xBJU|V4(}T|H#`Ps(2+Em>u&90D}%eNPba&|Br9!2CFhDLA$2)X<2k@?i-4KvSJU(Yl-#!_xNwn;AVJI5BP9Hcd6zZiijm*jT31m z>gWi$QFb;YqXlMx+CHOM&AILPEEsv^fQd|}r`^hx3R0jd5t6@;xquax6y)iAWA8&1 zev;(kZ1daoQD6C@^<6&@2m{$(*h%Hx8y>9WM?(Q-Wbb-5MbAkld+&oMji`6W)^i`z z2{O)TwMqN25u}jdU*{^xq4E$Y9c?w^304`}(P`azStQawacz!X33O&!EnKC=Qnri( zH@eV;MDTo^_pA{h9hz|+F}6u=@pdKePQO>BB8UI2&DZzYG+cY!kjt}FB9ldDmgFS<{Ce$f5drv-;j8}TKwGY zP}iTf=Z#I#jalH#0fN`qkcO`6fcf{w;wU}Zcw--9gw>+fTT9-O(ojTGpR&0IzXzF_ zxsx}!bK!;XZ~wv)dc)4;t}Ryd7syuu(y z%iTXh#9@;F|MT$^oDo6Bm76!^`5}Qh)747%xA?Peny;|s*~RH^GSSM@1z+>+X>usn zPfo^0?r~k!o9DTK`l4WU!kFGG!DZQ;oV3IOiAX52T_nsWk`}a=^q>L?M3BRY=p?m^ z37vW-phv$>w?GSnS>7xnasnA^Ne(a|k^gJvKJXdhHS8UU^|&hrjdT_>nhztY9==42(InBG%A%)s9R zzvHNlkc$4u6(0XxhpLD(jDD3yAYYX^LzsBd9Sy;J_-)X&)wujlP!~R0g$dMHvDaLR z2kJgqJO6(c;BbN@%OM0;yO+fIlzkK}6IDv;Us;htqJz_jsZJehB9vWVXXVNf+mTQV zhay^ifZtBNjp0U(kF*BW2O^C3qvbcZ<#S6uu_~HW-I>58a{9x!`I9@ZK$s+XE}iDn zs^CXex)b!>ZJN`Ie`CKt^(!=BGl!8)WodK|23Rf7Gom*Z%xrmtQ}v>mn5BUJI*THa zXFbs8t1lt;e)ypd{FmwAy{f0-IIUc}EBG`RzW3?;uti~qwMgj+c$i$Q&5%GefFsBG%~%;d*TyB*_} zjU4OGK8}j3j%0rNay$=JeHg@`KyHxdDDPZVl`YXs(chRqPhwl)@9itf6wwh8DMDl? z#--pA;MUsm+v~LQ&JGxH>(?A3>4KGr2K)IZ9FnwIcz3uMd2dE)`|C#|MW#epHWE#g z=-!iy?j>EPD%bhe&bD7VPq&v@jhyGY&<)9@Ev?6@_oAC1NPKKL8u?x9D7JDg%tG>f z*EIZDkjs=RpRn?k$gRApQ?lU3lCh(j@)(Ecus|3XJy+^6Wmqv9<;Z8i?<^nhAEbva zgTT?6mbQPN$N9HJ+`AcmDl<_rNfZ={83U((0Sa{X)(&tlol8_3fRv~8W;@&>gG_77!)nrg6N6w_jGg?7p%GMljb|S_U{ihYiAoj ziQD%^lW6s&50VAdd{&gkwj-o^kTN*RD5Zbf%bMyPVWIhQs_(-=DE^gVO!j!2v#2D2 z_?c}K97)VfGpM{9)w%|qE*nM$oBKvG&3YXIH!X|RLOYk$7ue)(q#0{%sV0yN%QyV| z>Zm5tx_Qyd_Ar_WOkiKG@4Xx_C+XR9UP0<91R*bj?u@01(7bGhKm6kLyHeJyONdAw zR^B8)5fg`f8_{id)Ahe~RYr;uX@5E^ir|qy7pOsErvoLJ1J&Q!!TccJnaDTyF|jTC z65F=>hvqs8>-&2U+Ij8DYjpTfFPbblK%jGuE0dRnvo&`4Qy@6kpPLQQ)+9nF&&p-?^1@Pq6SK2T z-EHt0svg{0KieBX&&q%tf)_~|og&aUu7Dl10C`!xQ0tZfOMioi3d8q*2!4B#MHAz)XHyfkBh- z^Sia?i|@n?>&o>E?`ir=P*%s~ZjuIq7@v{8@4rSAPxI#_hyXCQh;fEweglRD5(f;~2a9}ls zF&hRJdCf(uN64gEj>$-vKKG5`ua;3M7Zik}Z0-j9pqe9>BUjHwk9oS|21E(sYNY8u zJ(k>CK^$OjB@Q&8dNCzE2P@~w9CMkM-)PUFPObMi z{To~~Mg@~OTMdLv;aYwYmOThT0-56iI~>;?gP6b`{l1-DZ*~wh!2Fn(&u3ds2P7KJOTtI7yK1D7I$e7g{LO1VpsG*kb}$pvrVtNLDNCY| z$COvmVyJNNmvddqWwn6O>HcnZyAqO`)6BrLwR0(G-&Ipor4nMGIyBz~x(skiv!=E% z5xA9iOc*19;`T9J4BFyXy1=qxa7^{j@0yRxEVtZ$rY#>fbY(23e_=MZ58T(b#&_ef z=W6FldH(Br3v7YnAc$EF3=6*Z?6r<+Gdd3x^KX`#?%rD`pOKmimTb+bxmrs5ooN3` z;+Ft#(aXO&GLE^$d=ryT7TIjlGmc2fsz>;;;$`Ln!CyJMPtQ%9qD;G42i(~ zn;%~{+0vw~Qf#h}G9dy)@{O1z#z``vt)i)_EMS9rTZ&t{ z5#5KWpux~EH(G zE9+$+5aal7SQ&j;%S}j&E&n8`{qj-N3&rPzcCS^a-H#Xq6i$;Y3t9xP=#U6%;Gq)N zE68t^MD2{GYJOZZ7__v|Z)xce5&zsm2sP6aF6OY%x!qw}!mC#oi?N`|J^%9MPiRF} zhCqHR9anDcZ2`WniX9|wXWsY>OowSik-K>rrIoP$y}%Ly`7x^-yYt(@GcR4_4~NIc z5dFmj3a`d(LQHo*{O}ljC`{UQd7G0#0m`pTc+s~quPJnwmGGKFA$ZiZv3qt=1{)Mo zE)l8P6qu6rNlta^X!38?`$-&1PEa#=YHG{AtjY7U-(QYaW1p3oCC~Aj-#^BkB&SGf z(I*2QHWcI9*we&8C^TZ5ePkVbOyuC5C+&Ye0@HQ3C=if3nXh&L3%^IRU_(|vrgOiiOIvx53(%_P06CX?4*#Bf{ag=%rQ%kBMkRbry!S56WRJkF?; zkJX%4r8=fRoH7n^R$AER))dQfL=f}8^3f54k8zmlEtbbLnhP@Ea2s;0i}Y301&N5^ z)qWUpvv^$)CyjT}nf6Z$hO=94RiP*#kik~l?Zb_G-y~r30H0Lv?+aYBv8!7q#bK0AE+}YS1Kp`utEJ5<(iak|2mb3#ZS-@r$gr`5z@C1-!-at9Xz?zW zqDYC}(7}b;SjRLu?e(za7zQV&4Ji)(`VJg8TYdmT8095=7;}g7Z5g2;8DpXS*TpQM zLGL72rsLINz28a4eWBlx7J!ZiD9p;r)d{-4B#BfEpCPUidUK@zPP+fcHC|mdIa?kY zy8PVS&#iCl7_@Q0SMT+R_Mi_22z`kD9tvB%196zhC443? z=WjVFCh>mdD?VTRHt#4KEqYy*!qtA0Z`?tPb;#{xHEvA#ppF-e%;p}9zj--KekAjn zU`nwkT&gB_l;t(lNvVzz$Ios8wOth{gfG*oKAR!hkHF zQy@m)w}&J4iVtSooood8Aj!%2`LRJ^U2OG1M0k&1P5HefRF7wS<7zZ373p*$ljL#S zPhCzn?Mqa7?C=^-Qf+p8ec@g|#R+?Wi!D0+{Am)Fg(ZqbxYn1ov~^M^hS#+`g`ccS zJr+VfpZqxs9=I`toZ8iN`8aIm4=e8X8?zlTBFF3&LEzos0LFkdp-PKSrk=+3-X0l$ zmL!O)6OPH%H26WFa?asc@`&Qz52%UZ6|cSg5U!fvW||XYjAQS`PYZ*n|S*Q26X&Hd8#}uF)8(w5>5xx1uZ=Nc^GE)BUp#bo&Q* zE@_hFrOUlzZYsUje#EMh3B%86fJn^BPJvl>4W~ZAN*u`u4-CQ?7a!HppRe`M^y&)T z+l?gY{PwA@P~To$#sk_|(dj)_Ty1K-_sjn9Q<=h7sPfQ`u&?nz{HO(4fw8Gq2ncepsh9bF7B3L7m_2=DAREbc zC`U1aCgMFm_h|%=p4kO=y)pi5li?|~s8wNgU%S1Oiv1Aw^+3ws} z_-A5zV2=*f{^h)e_K@=}E)?qE;FhKJN9S!5Uj|pL*yRUGH&GDM>{1b0H#%Wd81=6PGX*7>GQHP01GwxP_G_mVdMc} zEky#s*tXRx^6wJoVx-)KlfZ6QBJ25B&%x1k50m8W@!G`d3{H~@SHS5NR3en<7c6y# zJ-|k!v8xgm04sSUFuZ9KL6wUg}D;r3j`uAhwi1VrN*B&*$jl1abI)2q!nW1soTik~kzlO>5TnaPe(sz+< zWnyBcS8qPvbEjnKcaEbUy6oHIkm%Cg_>>yQ zI}TWcVv0_M)$#6x$gR7_*B*tp!c{XmeRKBl>l-tT` zZN^cbi{DQa=+8ghcFjwEXtaWGG3SGSJW$v~TXlP$b&PXUud`-wwYKtkFP`OHtcm#j zZd`k|weHs#1EI~-Sdf!Tz>#E?z8Jx2Zc1?I_k=KjP6XnQ7Qd@1mK3hs)5B37Tq}#X zI21HG7{%cIc+xBfXIp0g_zd z>m#A*_w!+$H3i+)6>{fGwf#c{`;1a(KWpxcEuI(<Ox|U(ThWIUshjsdJSDi|``}t1el2dnlSFi*D5`~>pr2SVqO$2IzJPyOBcnU} zR-ujg+PycCv3Bq=-HA{rv3XPu35W0M%yTw7zh%?9iiz zS38GoTVw2NEt||#k|*z^!=v$UzMD@gpZeKOkK7Z_qGBI>I^ttUxSn&c_Bj9TTE+C| z-Rq^r8z3A`8SO|#jfwfjsa$kYx1qN3F5Nmn=9>N$GL!P?j=udQ)8gYwtd7)6SI0wS zRIb$~=y~kq_m(Ux6$Jt~2gx6n^A;@wM+Yeo^n*qU4Xh>^L>?+l4=sk%>En5qd=H#L zg7{CR;q6mXQRU>*y0=|C*CcIX64B>u-TD}cvbJ;pUn^$rnJJbQNUB_uym|G;$-xB- zVe(&M&092&L%(-#^*Wm%fP`?UISXV)b=s8lC>_*NTjB+?<)*?z?DQJaWqdp3@Pcwl zCPVmkX@3r;1(1J{tF>LEdg?7uQ_w63W8g>$ z4-fRt+YdW+w14WW)Rj@pATOL>^9iBj2v!S+zYEFiUZMKxiLyEztv37XcUkO+bRU$F z=iyZf`B&|0o2T{P^84Vi=A9sr5s9FB`x#fMyjUW3iZEtR0(e}IQJG}s0 zeM59Ei$Y%JmhE8*UONe7C(p{p@wW|v16{Gsk+i$|tqE0gi)rW7J;_S_=p<=lqw{|RYnADnz@{J7 z_thm%N!%(uA#$12aB?>79Ry#ciUSXaMZd6cZMF3)^D*Kqu&HN-&E^Eg^0mQN=Z5+H zU;Hft@q&wQcCXz?CKC9yw8VQ_(+*1debbx8%LKmA+;uO$J&6?X5>)JzlOG=%=T5=& z#A2rz-6?ozHX?i7L(KyJ_?Kfg(PqosaCKBlTkTx?F%y*Ck1G`eZw;JFZnsi6SB&d6 zOL6i~4hf4eJojZSC5BjYVXH$jYF!@A6s#FysV4ZHXxLbAahr#bLPA^_y|_ya2;}yu z!MIYf=E44|O`m4o@jm&=6hPxbaLpm}?*&twFU@`=1-H6kDK^M|cekGYsr}`vk(+0s zPyFYg=3z-`-0kh*6m2zWg2}P?Q?q}9`{&1kYD>&_(hRGert%#HtsV+b(%7!!HjNou zCDn`FQSf+v%@LRTgQZgc&-*v~RWrAIXD*?C^FQq{Csni^{DNAR8wH6Ls?TBUaPt=7 zX(gaKD9zNnkf!S9jP=R!TYe0=cd#sB^xW~g(NGI2C2sj@t6_fT!*HQ8lWgX@C12Q~ zw5=^~UIb}i=+S#%sjozZK;rnfNwx)B)N~T}4>BGZo6cF+MwCYrf4QM7_O~!8v=FDS zWyES}v;8wXF_5>Qw`8bLqHZ@(+}$mf{@vbh9B!5((||4H=B8qStPhlU8YWou`lr0*<>3EAg-w^UqN^`{VH_aBqYnD^)csakO z4EsYy}0^4nsdqZ`f=-`gGYxZvT9w@$ltbbYztWK*bwm=BGF!27nIwCbyu0; zAZ@JdudeUY3_C4XF6S11WXb3wg3RkFYy7n5v|mV_-1Z(o5+to_(&QJ6%^aeSuLCfMZ>{i)p(-R@_g)m*UKlD)wKhstp%x8&JqQ+h4E9VRpX`qcwf z!xh5L?1hkuYxj0n&+KCvLDy1dyeZU@l(;_EfO(JmyAm@&K6>28c-wgAh_8*R6Jv38 z=PlXOp$u4%xbSf2#rU|&BSziWzy#u_Q_kYklMe~qNEIF8@p+}o39f3RM1G&a^q<7{ zT@O@M8)(MT&(vmXT|G`5+EO_gc_iQANtM3cwr_t5+QK2MwFT{RPU2c9b*(OpO-Y&S2K!Z}{P>P^d|GzPAM#o1+0rw9^}83zLHhiqAT8 zuc|W%@iP6=;jg}b7%O(E7+^nXav6yKX+x|kVtUs;y!)H?J}T;04JZ1v$nR-= z;i+EdUZag3rJnqGE(l^y)5Skq zOxw7KukC$B2$8gR^vF}_6*#n%n=PLLTwe>(E~*JLCWndRC5O~;oLn1Xp2Ddn-wdws z_9gc_7mczI!9V0P z#W9X@;38M3#=4R`CFitiKSjPfI&s%yetT>; z!tRtXBuSBAcc=~?57cT{O`*&V$d zD#p9MoeAMwzxOi^`Wi^m9}Dog@0ThEdKwg37R#pQHD~Apo(#~xyjaP8@o*IWw8;22 z!7|~WGR_!dYuY^ewtNvOZOdQO2|0_Mvgq1sMA`}d&3-fK+B?gQe)DbA0sfdVKpFVB zsBQMqDBSvZ_2}1WQ?Xi6dK(qx0U~T8hst=ac#qrCC9of90Lh<-mc=>N{2BI=QY4+% zrkd5CqLg=MEaC1>>%}0nOBRo`v&7SM`nKd@k92x*x;q5PAIY5B-g#R#k)P5;Zzp!l zJj5(S?oC|63t!y!_m`w(CU!Zf_C{?#wRTEDob%#JYb93!he)bcAe~Epz72y2ERFYuTMDp0J3yCQbI>ST{0nA zcEw3aIXKF*g;~W!RFpM%_`@M{)Qi{Cgm;SMfOGt_nA*uFNh+Kq3=T1VOXR-0WEHlw zqK|so`H4d_r$sC3hxPi+#6c?F zNaE2{ExH$}yt8%EzNCZAOz0nDPkDj~xr>Sl+>4FKBjxeJIjp%;vqa$Kh80Y)LJB+s zvWZtYSickguez^{s%j0lwiOf+L{Sh_q(MrQ21Pvh%L9=Dv$Hso&j%FEiEc8wx9 zZ$}9Go}97m3(1cIEA~F&mf|rklnQa;UGV9)r3-3Ht>%K!vt1`2mBn2cy2}$7a;nsw%&BJJon-e<_Su*@Vr#oYN7Zfj zku;bM{J2^xFw!ZH2@T2G+1TtKKQ5$6Y6ceagrDpmnn)ARm$Ck-d*$_WX09jP z=Te1rCMe4~lI~6V2GaYH=15QrH|%+0s5z)pq&Bf$7@1#t@U@V(OwC}RvZ23Ejd=iN z_a&v@F#_b?{I8gg3=1mkI=7`VsInVB!Rt6fw>GRl%jr$YGILJf{d-~Eo~GHsozGZl zrcP}rxXlgIa`qCF4oDxpSX^(mq;ov$7x~lxHytA}J+U?eS8eOsuA}tfhWd;?^x@q4 zwp4}gk*T_s8*kfk#?@qGHKEmzD0lCO=`|FBc{8Tvsjik#K$%pV`RxfGHC>*|!{$ zkJn_TgAH|?8b-Ta^R@+JkC%_nJyEsFQXbG|RHZfe)ogXW!<8Mn(=>%-;geYM`I_*m&GoxFPU2TxvYG5M=HfpZ^Nc2ooihQXo+`;F+;m5c$yM|1JGX29(9}2;pBc-gaSt?-E((p0 zkXp3rcwlJ8M@1fF)TbF0ltNb@9vCPcI;vggafUcBqpl&08!&g2yV|fJ{Hu?8X3FF* zg)Y`$?z;-p&S!;u^)d*&Ib5KS-DfF=<7_{T*`lJ@du`F&w^>8lCW9N1ni~@&v+`*} z)6{)!LVZ7MLLcZ0tjhC{k@?W&w^;ALSSVQI%JH_Qrz)*S;E9nxM}1E9weYdWhn6`a zaztc?uS*QQ5M81RO{09Sq8=)MBKwHQ%2cQF&UQ$~Q@QZrmOaWhe92xvU(GS33nUJ8 zIrAjkY{dW7ZQb%KN7Sf7G?=&&eq~r4R;!iE7@WM@$h_$rb?1=B^`m>=ve#0MOjC_a zN7xD(Z|kwFEBxZO=04A+cpmfo<0_>$y;1n1SF`RHCBmCe>Auve`;i#r@X0;V-4}eC z+(@&TxN45JNO0%Wqusx13#fwar02MH2=A4hpwv#PThiQZob}gYTa6COsKZwVx*+Bw z$Jl+6Chk9&)~Bzgf8`sd^r`EfjbVS8xrYx*L4s&xLY>PQ8TaTO4j%Dd7IwF;(@2T* z!?YKU1bJHt@R5ao5qK-#eedMzJrT+MO!aLNg8Kf$q zcpluKkUtVa7Mt*?wy@_w^6#^S9ZQBff6>uTW@QN6DAxYSJR|I5G^`x@D#zcyHtK{A zE6+%Qhwh~jBPW{IKEvMyJF|Y8zkX&q-R_wGjNZiCyFh|IE^%7&99I%mz1-;2w>y&} z_65tz@@+*&*fBCp*PORyiyW+Gz0!I>R(I!XAF`to)-} zShY|M4<`$mqkPi5$b_#g6db+9w_w)Y$SHDRT3o-XlP#Z<;jXS?wQO-vP{>sWDIX$M z>(XntzwR-jI#E@pYDXUA<87_U##5^B(6ZH$f3(K&=ZzVwjNg+ZLvgA-uPANKB|hF+ z(Q?+;o!vTGV^g7_(e=+G*PmAQl@`(gQKg~ADlx-0c2Um@H7pv_$M&o(nrM!Ld%^;6ML1dbG^WOc1twiLda%}tg0!EykB#JuT*)2wAG;~N=li{fkRjHl< z{`IN2{v6#1Ht1xLP7*S%oJfCBbxYyxA(?t(e>Ky*IPRD2EFES3)$bF2<~{qF^mgRt z-~<0}4EhIFb+v1VFUWHGSEPLKDEi4m7sj&W-)6EltG_KbnI!ja+8ZcG=pZKbxN z7cw%Uoa=c79IBjy-uT}(-gQsST_OGW_mqy*Xd05kwyaDZ?Qu6K$2dYmG_Rg?e6{oU zy$i$nayi98o40hRO52i}-p|Vv2k41D9INphzn7Yy>mT?=9dIwV>Qub%#;o~_N~dhu zkXc+{j}O~x^9!VvBH)oqXH>d)g^3<^JO-mIry& zCYP>BUwyTU_1bWW97S01mB9!01!8V;1#8n7?|V#CQW&Ihw!a- zpNO_ixf{!z*J;K?ThlA5HZLd~)z;z7;)p29qg~@4q50@ue){j{Xz!r*sv!OQT}9Q| znSY*3{?78i#y|gg|J1I3A7K9f@5g?%9Nd6gT3l}5#DsRTLNpwUtm}P78pO6+M{UBx z!vj`1U~#4TA~rPCb~^V*Bwxp#_CVarTdz znh35t;%#Z_G^b9ns;BYVj5Okj&0Zoq?L{Yl73RWoi1zQ-b80@_JkyXt7AL25JjPyl zVyA(D0rjQ(DI2GbAHVeASAo9J4SRhKouXFk1;fkOcsSysnL@PSMKQ5$netS6nbg!& ziG{(}dShFXy=5+=v7Tu!KYZYgR>;5o$(_mN<4nfyeR~4%Yx$mn>AXSglJH!@F8k++ zi3agnzZ&DOJ`(JHdp(kPQuE_|&7`UQ49a9leV?XTB!7j`#+H|Fyyj9ZUO%O6J7b!iej4!84U+x(lRnK?*51v93IZrEp^=9+6-d6zP7bKO|Z0M z+AO;sc}XY7kcez?va7=izZHMZ=kDQgo>XjmV?KdY-`JRvn)((d_rC_kV5~KDIAa68 zC>$Idm{dDUOO_ADwV~&IOu9arw^R4`_sfN_9;2i*F6HLn$ikfYzh`opn|Vs1%=%E# zVJkIeD3ZwicYnWu!_6ew5X>+336##)3stU5DJv@3-N^75@5Y;;-4SK1Tx_ zW*$F&{OX*v=`8mN+==98))zww1=R;(Q z`8P>-wPTNqYO(Dkd|?#-{x+Ck!7@!sN=mfrs7LZBLbb$oa}8q}{PrvHF;;#dq5OL- zcjpGGA3S(axn&zFW++!%^yxO&i9?~WZKipPMjuTaOSe8{-&N|Ps3kd|YgqsHE7YsM z>iJPg%Riqzm#6MoSe&M!D!TIp`=14wZ@oAz784Nx3qc|4p*rl8W~LSxhOywr+IV*C zM&Xwr6^Yaek-^nv|8FaJynRr$&GZtiy=2Ao841B{N7rHRj-Q~zvzY0&eV-yqvBO^EC#XWKV4b6wK zq30*^!NS7A&TbjWpq22~6B84Y!+u)8>T3?x2MiGlvi8T3^Dylx zv>t9xQU21>lB=GsVcZbYUFu|q9VjIwCAcTUX!2a7);*jNPOKn> zkGx{9l@+kFwavPb-IHCuwN_BB%FMX!u>0pn^IHgM9UUDkZ#XTCAeQzTIW>7c@gAxR zS54PAkGVmF`qK!t T3nj)8jZy6)D6=H-}KZvpkTM}$Pua?Hv{b^yrCP_RnH1yKL-xU}dme((^TjxkqVw>%6 z&BA=On*r7W_A|F{-VDR$#O&;8>^P(3)V*K0!rY#%rS9fA zZ*No6d%!vF!s%r)Z4F!}a36oLJ92F7XNKm5@p@ChHI0z1KV0ST>DtA;n$FAk&2>-P3LxmI$QX`wpE+KrkXA=7$?#Y{7H(Y&=2qB9EJge?IV{(PYL#@ zUyl`a*;ttsvYmL>^Mv~nGFwq}l^%lU;#eCXN{S10Rq`9lB2E>FD}Kni6qVrBpRc#)n_eGD+ul=p7V( z3X}@r)DvZ9#s)o6LF=JIWCB7$d?t+*IbX2BdC_qbhLG6VRV|SJL5N~Aq}!}DMH$u$ zIUhu$kU0Q-Y@c=xJbjo}?Ku@lUm~(u1kNBj#kQ8%aJx-mX1?;%j zEpzVd?k<%HVmv41zXEFqIGk=x=J!fTOS8Gn!H|oV>vjdq2pggVa^Z7|X}y_m`Xl_p zHxu5epLuMIjCU7Cnown3U0wUX1t?y>o>#VeGm?;Cz(5Qg_8_hU+r9;ycI!PTuB)qy zGMt#0$f;LW0_U!$PoKgpfKBoF32yzK5(jK15_8>Hc}jHj8R_W<_vUVv*v|sHPf=3Z z4c3Ifa#cb~N-N*8;)2=&3H$~)=;#t~XI`YIdp?>+>SntQhTO=~Eln%==>2&HmG2vN zcz^vGCFE$O-@XBry$o^SXh97@bDZ3Re;Ie)UU$% z?|C%#YrY)EV6tJ+nPXIFHMoV_N5#HQQ5hh*_TAA7H>nuTV}u>RI5&c30i`cQsu~gb z(Y$L4po_!Ywm0qM2`|FlP(RnPc+2r?qjB7Wjw~Hi>kJD& zfKviFxGK}Ov`rKpOG`@`85!J!PLU0?UD`6iWnufYCwf|15oSC0@Am=~v%|0`37knl zsnkeQ8$~9;k}4-4paJg)ya(n&N0b4rn2a6_3wR$BJ$+py-*j*DSEuEvQvFKeS_?NM zB--Ns+^RVyT|`7=ye)lsbGesb9RrMlx0;yU)Gyy-Y*awcGUrwBnHN+S z$I7P3Pn;;QpY0=Hc2UtYFE8Ah({xX%=aK_j=SvUx4{}s3P3;~gNI*aU zS5iKGCGG?u$-KL0F&A|Km+9f|PIc*i!TnC2ahkFA%yn4It*)kHQjksj4Rb(^jv7eOul}5VGyW5}4vP*}ChBR{T{y@pTsVAS4 zn+sysg7_R=bj;Jrd}H^stBd;3z9eGx52$tUIuPLHZTRnD+Clh3liRS$jq@R}-1=Uckx`$@e3Lvn8daW^HLntuc56#qvaF z3ko5fY1hX|TxN*NC0qv)xuEUD3J5383runWu$(-35>cKYojF<2a>FJy?4p?lfB;2W zC(pDEp@an2NL8tH+#gi^;eKb1)N?8V1p%EWSVp{6jOCDNCb$B|ik2cP;AZ4#Z!aMs zQ39TXai&Y4i^&NM3**Nfcepts*w}^SqXpY~d$$RkKqbi+z78x%_|as7LZ40(34C%e*2j?_S#a% z#hem3)GvWy)MsIv5hV;kM$k#EJkv+LcJ}~n$;rbA$zC|UB6kr19f8qCKfs((ig1M5 znmL9dIXEmq>>f}lTpMhU{QdOC1A82KUdIO=& zODj!ySqVg@2;>t-p!x$ZD~sCRfeHjzJ04@xSeTp35oT9U3vS+LnSDdop>i%)H~WU^ zr1XnZujpBj7ISJ3AHGAGnX@i;-Be>$zkK;J-e_cLYvw=bhTW)GeW&iek{%IHLe1;O6P->6fsldUJg_-?UAZ zWnL*E6T`ncH;6oY_hFQoX!tm`;@2$A%sgb6>B=#ZyL~&Z`SQu*$3L2NWcmC1V@gj6 z+FbM5=VMawcqgh0+T=;p1CSN|;Tp;5CR3rA;O<+j!~{lWVv_PGz>jD5OG!Wu$l`g@ zNIna+7>+OnMwK@-G;Gb#Y?;#mDU+3DJxlW|5S|>Q(X1kEufSr{!t?>wB?WqjJpRCzh zlH6-FiGKXw$Fs}J&@sc$X?x}iDX7KV{5;!--DH!Uxyg;@)!p5jJ@hGVavHGEs7E;i zvq||t0tWE5@h~bRQW$o3{A5J#N00(|QBet^YGMmIVln>lk58Y@^6*%}I59px9$k*w zi&SUd>9X~##v`I~5hoMMm?T06-y3{8e1a9IYDO!VsB z#K{RG_3hg>+A3rd`s?y(i8D&>^b8CTI_?Ad7RNjC@1Nqj{n&_{GN*|DDY76&So5BF z{vLZIzLm9gSJ5LgM@K?5ggiT2?|}IJXwm!cl|XxV0}uO}tpJU#C&{B_NqxH8wjBl2 zxuMVf-`9k&QEDBL>n!w6&bXKN6Ws=dFMr1Ao@F{Ijn`kr`KI(U>P4&@`gcv?8pLcT z!t(R;8J_jz8VB%HRGaXkt470XgQ&my?9h9EiSld|K9RF$|DfdtSzzVOJV@4-sw$nN zf@bdX97iBMECyu~o4{225gUlC&}iJhV{d*Kzhxi6WEfNgy-8zZqj;saw}i64kI(xU z>Sx~0&dw;IHW14=r<$F@tX54g{?N-3&m>qc|mbvgM9!(gPjEZvpUGd}@3H8w6Af7F7 z_T0I1=p!IpcpawEdav)f*vg|sPI8K0>7?@g(xx9jG9yCJsT(;dDZK1H8#+{LSjeNzbc~ltQ|fZ4@6K1FP(g13RYtQnIe68HT><^U z=Gvp$FPXRU5jAToE3Mgj+X#gOX05Vuwi{W@9)JAo$Kvra1RD#NfA@uHrA)Ez< zbUz7|4d{Z?(IjDEVW}6V1fch!2Egyh0AvaCs?Ng&CiQ!GxC4j-=}Zs2AA~kPhN&vT z&(Cj;ynZ!6Y|9>8LtOnm5)J5^2F8U}OALYl=xf>5+Bp2=@-X`&ijH~&js|E&xU4Tt znvJGxfPzD!TwfiI)dLKmE~5#&S>`MtxEABiqWYFyyWk;CPT;f;|9r;fdy!BdKXvMU zw5pw6z*5NKbniC41+;q#2srC;h=9Twu-HDKzC@w3E(iM+= zG{W{=LPF~^f4<;(6P$f?`xI51_~HPUh`PIIWMv;TS3KE`L!|>~br!fqFsOiJ^!mux z4B+C8NlKq zo1|JPCKILENE60CQDOU-W z4S7Pqq%n?QP$S}G3;$(weEB_}>g%UqMA-#=tn{OV!5|;xEqoV|dm~)P&tI0G)ecX`6#&1OegrAYlXh~TT z{04P-5mFJVc&@|zP)*&9n^cP|DbS0i%eP%|C{*0Nd-q~avt>hA(d4f+P>TM%@z!Q% zo=H>R6~_6;$yJq=M(DL5UeH`L1?71NEFnL7w7;)!>%tc203-x`|Gj$%ueva91884p z)FmgjVLNr#$cUx0GGLjA_B+tK8+{Zytn!6sIk1sf5vPv|$*TJ0VPRq3-rnE8U*qJ= zM(>X_C`ZI8{t5mqcFbiraX$RMGCD zyr7^Kg#P*S&m`2qLPkbLAe({uN9Z}vO5VSp!Q=KsHfas=78=MSXf}=<;ereVMT@O} zHvtXpot&njatH|t(bLm&am`??bn+iW=k$zTW$`fz)HG(^j09t$pgOakGY)X{8oI{K z!=B`e2VT$!X+XP!5JJ(BKT{Vk5ke6)f7R=-dZt!ev%4+27--#y1}&A6nEjPE6ApM1 zdmnhs!u)`<-iQ_1#mCnH?JUO#H3Lg5Vh|B72(v{lD0z-KOw8_n?1Xe-V`s0sn;^=3 z<%9ttVi4WHUd+ZLh)zYy|K`u2-1n3q!$7ML0F-o+01Ad zm&6KI+@5PPGOGYc*v|^H;Z~Lx7oU53GwYQ(^EY`IAUcP(@$^Hi*KIyeC zS|VMf1JHlldX3q>IcCi2@QxXiU)|jy;kS3j5J6|a*FaSgb8BbXwPOdvRf0hI;of{k z!>+BJBCh`|l3hCdIJlcjxyaM{KWXczh@Q>#iT-Ho`lsP8W_^8H1tRunmGeL(Q0=P zUrY1zLXXCRjcOrvL91J8RruZcS@54Po*7Y5Q9;3d2sw+u9B;n6jrb;A`CM05$KfB8#<&xU4oD5J7M{2 z*Bbv`KYRcFyj&9aBy&Rv=c|57?3GL;0*{MCA(tZEOftWY-FVJXd?O!JS_j%PHqrm5FLYR8%yX3o*7yn$)Ye-3F8{u{9A6d56~~n$!!b$qOW-%s!5G1{Qi!bP2bCzdRwYOhE!%FC|f$cyt2)6@}dGaT?oR%hla-a&Rw&O_m z(Vo(ERodv4m48F7;@lcOXwhBNR_5YF*KC>xMiL8OHn%d_ObsY79-nSwZH4Q^#KZ*L zA>ohZ2Ld1^A%P_iNak)xZIDOJV%yBQK&(SykXBY!xT;{Vd!(h!Z2t8hc_3(uANdeA=o(KJcfa(Y#0zmGQ0)R#rU!zL({F!&guu+p~Ud=5h zN5?XlvO;z0#%)8r?ZE1dL?C^tn&e(lFenmYVk!I~2@enCx7w!~Jb5Jd;U94sJhL@Z<5qU$4dycxd!ATlZD3`ahR!!2AYVB0zo@!s zE6XORBV!Qc6&=Xi)6GeKNo*<(LUcJLiFt^g=`Q>_g`qNsfrCSVLUJ(U{_cO5`|T;P zws7O^qxWy+ofGPu2(kkf?NCItL7|~Isxn%>pIKf8z{~0D_W{oS&m>Nj=ltoY^mH>R zsVaQVhnbsu7HfrX-ETJl%R4(A&;X)KWp7>nEB>n2Ydu%YN=6}*4 zdPB4M-(M#@_WB5_gsNqOBO7h8-h|1)nFGA>COyO zAwG0SZRlAz+z0;_ru|WXae71wTA0K)+M)%>Ha0Q?EYl*tQZw4)>ME*FtfO3`W7GL~ zzZ3cQbxl2id26ng69@-l6^Tu6F+ z*ug;Tdt)|~Sv-E1_S?9EKRkOYr|ukV+r8J-u~d|aX;@j2=>8Q2g%`TnoLPzS5<6un zD6;#`D?b(Ai+i>FVnpoQ{pEPte&UTo`>x)-JCqnh{+D}QoxH~nIx?#mmT7-?c^FdEZYR5P#jU~+yhOxa~#~j(6fEuFN`$Gx#c{=-$Sq z&3-0aTH-O*C8b1hz`wPX6>t)oTzVU+!!xD86VI!#gl0My1GcK`qY literal 66219 zcmdSBbyQqWwaXDl~G15YF*{Qk#ZL~O;kKajgbwX1~upuzlvhM5rY;)N`xyRWbB z_m=kdcDt>knymSNhaktDtm>n3nu8qc?Ja1Kb!mHr;eJkqZ&yW4*M8qSoEMm3bij{n zWI9NS5KW3Of|(HUAJ+(<5M`Ajb|-P9k&@*5ELau3wWsqv#n%#+ULPn*R0OBI{AHp_ zfu*2`uoj7?6XJG=;w~md12G+fOU}Z6d@Z7;)}oPevz&>?(nI`W|Fhca8AjMsPB5K7 z$e|t>9T{QozTaO&|3lS|Rpnc|(7DA-LJ}R&QC70&d%fM0-V&@*Q&}157kim*`*M6Q zWW#EJ3q_K|404MRT3l6(!~%#SI}hyNV;Q^x>bVjezv#2ikDtM6D5n$5`wSim=;;lC z3nlcR0!FDJgNJ;tqPH)o3!yXb2Hzp1AUPs0e_3Sz^u$(w(BpfNPKbwPid9NaJ)QVN z{)Nbl_$T!qu~w|Z%ag5!;D?~)&bNYx%Z)08$s~DL_A;c|a5LZ`(tI!c2GnGjWEH|F z^Sz#pykbFo#aa@pn~J9rF2Ek#otx8c^MZe(W9E28JcZ7OQoUDTbr&6pCMYWtrl(5L zXSYCjfEgW+Cb;T_cDJ|ATm+q=9XDkhUza&N<`;J>`RthaN6zHur1fqD#AO-Y&$NzQNJrgvYNQl)I z-B@ ztn-|9Vsb=y7y@iqjR~`VueS0|eTM_vrbq;=Rj6(rxqwFOo?1Bj;>A~L_$68`uoAVe*rsCBLA^Lr*vq+P z`d#*%XU?Du~3hV%Iz87o5|rPW5wGoN0n zFf%fS>sA}eIWE=|17e&h87y(QR2Tl~(xday zu8}W&c6YHcJC!d3udMAV;Oe>zizJ{`f2o|DC%6%ibbSVwdCsbx2g(ijwA|nzR#M_lB_KveJja^o5Na+|_&5U-f4jF-XC-wLT22+xky!Ha zMUD|vn8^O!IdL3xiFJqzQ{sgwvyVv<9{ZI z@OmK}PQheaD^X#u>*lCXfi^H1XholsZ}B-eI3#nK zb>)451>Zv(JB?dC_OFG+8LZ{rKdUR|FmCzG%1`&Ov4`^I?w;~$Tf-VJHLmw;YQ}l!B3s(`lT)<{V zRmVG0bC`C_u?FKEZUnz_jzZ@5=5Z-1F0Mof2i-=@hdP%GsH9GxZnHVu#8WT$?uoqw zffT%ITz0UaT&Aieymk{62MSkgdexd2PcASP3)6^M_7b>^6$|Q2oRv)QjS9qJl+s2_efTOX{TPnLW$$Gj5QyHVTu zmqMpXUsEp0a(l(KyEvX&bUA+LcVke)Y5-#VtbOAyzNYK?&Gm8cgG%$dzhj7zOY6I! z^GdVnjfZ>R6q;S&_5IrZE<3{84r;V+jyn+Q*B9LnYTftewJ8~Wf9PilIFDL)X|7i` z?&bANyi-`iNAD(s?`66l?vCfVG4Lpg&cgPdGdKirDiS1Fw_Om3pAp_|5(I0&HG&X3 z#GLMeWCVn8-eq&Q2T+wvT;4GG; zPCCOgOE=;*xm& zpvv20XPd3t;2P~5`8XQL&ELb1OR9apsU2|Wb{eI`$+v@!C7O`hE)cni`UCcZy0eizM^pfhD75-uUsdxg=-SY zS3$BLO#R_6Z6d)@`_bOHzPXChuQh0OsT{T7gSG&6a@>?>g zGrXa2?${z14{X2 zo0_-`mAHtlH&gOxgN7lIvg@nrRL<)%q2wGbZY1Y@k<=;h*k+q0co_^>P4(K(D8wB>wMZFrYH+8Y6Kg;IiL`)u*UTdnIM>`=rYl+S4{ zcENQ?vD~6SVEOvtb>{IqRF_`Y!CF|_(KYQHrqa-LqGXOwk-@Pa?~vw zu#D?G4L43VZl({HLy{;v4!@V`&UPgq4DdRI3jxPU+M+#Bq5EB93KAsawqz@gjjTM zI6rqrO9p2qHAyti0g2{ynQ3Pm1yf{+d4YLYrEaxyqnf=ya+~D0N znV?HH&n~5a6Zq(3WGE`eOavBj>`l5%6uyC^ydK$w98TnzCd?rhA272cuMrKuWet0T z_?Nu6i`-{2{F(U^Y8M8Jq6;AQx2YT^@{Exm^Q5q_8r*tOykQ-*aZ{QHi%SC^NFDmG z&-vbbRitnzUV{?^7fP8ldc4G=6;XBGu=2LeCjGruA4S4ptk@rkTTGuyY1U?4-p0}q zJi0U95VY1tcjl20G;q4j=k(IBh1i*;Q{mHMf|M{u5v;s^$1rF?(G}k^ZVvz zLz1xbfvY>8NzG`6Q$c(`Z(`aY_#%)cWp`A1<8^rCxc%Pe#C<dP5M+5%&(l8Mg^ zwgMt$3b?^P*H55=E;BQ4ZkZ}J`Byhy0{*IOdW&6ai8-O9&D;4v(8+^8a44R(biMNi zgTWR2v9oGYXi=wfq2k;3Dg)0HXoS0TCZJbJGLiA0m(#|MYlyjau!y?|j5c1nj3UKG zWI?4R>?u6+regANSGjhBO##DsoW!J75JL>An7q7*q;$y}qIs)ok_IEWpjiBfujINa zlL5lLxZ4rz9`-;)9A9}FeGMdxeH%;dUlC$h=CCPq^^v=-3_#ruExdR2+P{acjC zs8Hc71etpRv;SJxQy}-jxUi#4CS6r^0poJTS-KMo2#mr7KHhxM5WyC-9y)_%NvBmE zD6M5G+N}tddgd5vF2_4J?2aM(TU@3k2cI=J99Okk4c1xcXYx4|@Rc9;^Fx}hQ38#` zdOM@%#WqD>H@led7v-3rE0pj}gPoW84DHXmO9F8l!ZC#MUe4az+b>iZENvmrkvcM~ zjBDLEXK0psmok2&qwg@VlCfPECzalawLVa}?QK8StR&%ZsCZdZo>Z!l@u_(4u6YA1 zotV!wh4o%_;~A$k-u^-%`E04y#(F1weDQSl=fDTJ%rDnivjQhS=Q_ifP*F=O!i= z#9)w#{v1mwspLI2(J(yB(f}RRePd2eUBqGhOLX2KcYy)V-;fMjRw{uc(rIi7OLxeJ z?%Py>DcD$OgomYAIZbaT-#ZI`Uo@{Yw72IE&4^4y@k5AIgVhjcVTP=i&Hb&McA>3! z3XRP|^`cPT6KVm7V)!NpS?PO8y?Tn|45@y*7PG10>vPI7hx!F%)OVOOKL)%eA?k`uTv63z>H93R&jG4u$LzvtpEh60lWHHh> z%pB&x%gIm#iS0t24(J03SEEUqdesir#ybMZTMcnF46Q%si)LDd@^N$4?nzK-*pr2i zU|D}K2rH?CyBRB|3n9t1GmQ1uDxP@f<)&B~r^%ZtL295QP5OJsn$}2~;9Ga2wqYVep$<#H91I_^PKdPEV1a>;pI#XPP%%L^VPIZv9SC6x01qeaB!(CezePt)we87(3y zGX%d&3th!z6wKbfcK@A2%G}=uf|J9O-G`0SS~m6?h&1O`QWamBHXq|e+ejY08y0)V z8r6GOW&Hhm!f243h)*l){pn(@S#8jzXL}SPP|qneyWrhNhkISW+xmbhah=y^M9RH+(7 zf^f0&898;@!roU1&6?w@7n=oWvdSj!jAhWCs-S>lC^VCf#IL8C4n>{3Bb2uBCBRP>A~)E*Dp=$Q)`{FwXy`L z=fxLiU%WIa>P$)#?M3pBM^ z_1zOyvuD|xqHVzw5Ng8#u=K$~^}Gk_UKv>P_Pbp!9uGwEs9QrUVVGq`Q{#H47tl{;>-yKZpMP#Bd0%2f0jJk z9UN@rpAIS-qGC@c6inE#hPeeT;cpF>;GVxLy-C3EEN-a}zhX1%>flihz8W>t=0oxP zLU z;S>MpF7^d@zuTvJt-C@)KfUHGwHx!jG zr5$~dU;V0qP^9OoC&EhT>24e?6B$hQ&I(2v$>nZLMQ|Q>%iJs1ZE2+yO zXx|xojU_`rh{yArYo<++szaaV&zob1g5d0D&ozeMFy}acU+C7TX5<|LE~gl_){f!4 zcvYfEBw~X#)=FK#YLS6U^Ym*ctNssSWi_m>c=1&F>ozx{b3qCeSr^qD7ye31L8G`< z&$rBk=gND>ydgkTpJx#URxK^X;IGit>QB0b9;8;EU4e2d8a7DpQYQ=+3rYn;?Nc2@ zqnoy5ensx`@H;OklQU|Gfa9w$%Vfejuix>j<91y}8*b9r zx~S-(|MUz_#u#MCMz+w{oh_Nf9lPsov`l{uu@Zy~=%9^Rr>bR31nOp}{~Gw}gqsAv z`A8#9LMBmVT+DR1M7|t?WGo6h>Tjhpi;6eVG3vRQ7P5-|h)2$#aee23I^FV@x~)tM z{5JiSi!x~?RJ!y%=$pCWpKp>q#!pp$`DB8;m@hVhgL}Ymy8Sxm6@g&ojo`#gP&z#s z4;kg3vsnn`uI`T@`u%gnq-5prTJ&CzY_IB%j&tUx=70&jTrQ6yx?AqV_Gj683K`ivk{GW&(;l^AlzI!gz{SUzTM*_Y#+? zKr{VvV#WPV-wJ23iXe9^lV4>G$IDN#{|5~N%DaP)FRsym)QFq-YTCP*>FHT=?L4(T zD5Nm;X8F?7WCcB|)YdzEWUg0?((8T-)CCU3 z!x3ufH}n1EVt+cxG!Sl+qP`LDephYO1b7Opg+K>|z$$dAX_jPXl~N+-p~czi8mnwNq` zTheD*EAOI48!3C|Z1 zQ;H6VCV?XwKWn2fC@ftB*3s%{t4~tCcbK)C7L*ItfO3U1 zHGeOb&z??D>W&g!K}S*WKw1tB)9fKck>K^;Izsb~^C8K>33_Sehdqckr~(<>3ysH&W#8oN6%bqhhkx6(_h!B^VEf5@WUD#}THC*_i2X2V_ zT^@~>?3Owqb7Mf`4YP(#mn zZO1T+27FNyDOY)A|9+-r`{CJ+MI7M_)|$dp#V@GKrd@R5fn3(mwMEfpe8V6P3j{l7 zr%Cyz)47zo(SV(A$*1K4B3mPcE#rQQVp>HU3^l?f?Z1oVA6lYDIff1j#|?{Zw!hoj zy20v?v41{+XVWhx9IXGUo{XgH=T7<9{tKHROY){!P~49gDN6AaD$`~Q_8<@I2-W3~Hmn`*iqn1OHQTKapUmPM1Anq%?oW7x*dVN5O&otzFJd<+$_ef?m z6>YM*M|i#o?J9p>=zQ2-%D$1t?@<_$qf%7_#>a>4^R;Iv%;ZBnNI@;L?5PV?%d_`6LUp8{sb4p-L9D z*#}-xSorwjh|rtRCIl`u7t2rd0V=Jq!w12m;KNJ@8HXoLO5CX8HSceyhX8e$>2#3o z-$A|#;ai3j+637(yO*<9AO89f7tyXlN*y1h?e=ww8#*_G^EQ{jFUV#*n)-2@`em(J zu=^bqKOz=pd!Cu6<4Zm>m-q6FzXj7S#WT~K%G~$|B_HxZ$lAg-rJrFzEs8!iw1{md!SSXN!&?Or(Y8K>}RAi_vqD1iq`2KO=I`kWlTI z72CXtcpI}tR}m?!I`x_vfi{b#4 ze6z%fGo}R`>g@Z3HvB5pr)#^uWRw!6iaxXkyvs9t+oAxLyB0tn{QUufQ_*e34@v)h z*E#=<{%MhGtzLWlwAkit7pu0S_+csFp+T2!YAxuhev6d%9yTL0`}4hs80UT7FN6F}2^ZPc?3#2YBEeZshP;ocpM67oQcgBrSw!PuYT7g`u zaOt|hA@y@UijkgQE`hXJATo)u`I!qjlp|iQ=Nj!kGRyU#di+tnvZ^L4h9K#_guI(Q zbS6LFg9SFi#=d};E=BIOSHrTha~h;2JgPu`M17wB)KWiTQVXPuh(O$IM$u|VA)#uQ zzkWHdQOGEe0U~=!Mgsz)Ogwa?gU$mRmY-JL;_-$K(%@K-aM1+Z?Gc*4GEWAwvJ^`} z!sEm>DU6Ukva>TJYBp5@5$JlV4q*B~xPpB`Ki?jpN#eF5aGwCxwwM7iIW(pyTB=v% zQRqg&3M3qZ;HS8uE6d7cufu{gUG@Jw4?gJ6L86x2IcmuO+0*un(E&HZXiNGWVCIG= zf@8y}97eGvs_ z=C8g$fAW1JATGHm6_PL7Q4)OXk|(yHk(ybmpT?^1CB|zuj<=b{+B1W!Q=L`o+CP5x z_n@}^tJw30zoMA}1I={Q;mSj^K>TkznW9ZCMYqSUHFAMadf)cIhA?pH}__@EoP z#8$QyHzb!kW~y@{i~j$LQjZYcz@E{C$4BL>ZE``9w19~Q#+ zwnS|62ajfC5Vb0B)YwJ03R4k5=IX3?VN~ z(_GH()?AJ?yGg&`ax~hZ0t>p~lg&tDd<3;mPnwH1;1Gvl5+30+ZJG9a(N*+aSl%7_Fk5?y&vaHTVm2?kPz z3`-eYlJ+P4A-E1orwNGqY_@?<9BxmV1)3w?k%v2&3*~bqfAIYEb|+iro#WSE*NS7r zB&7rA@EcX25}eP@4^eW^#ps$4^up-l_=KfiYCARQ$V)oB>dqN3)D*Pl?|)p~>~Qui zaew;0tJ7a=z+-zp$xo5FF%mZ_FdtWRlocK9EZ`E)&}0dd<=51c3xPtxHWm-p5i zxKMVmay;n|E(;anUk@pLVLgko94J@nmw%(iESv7;EMDa(Pc37tuPc=+hJSLtw_qYJ zoSvE~uT`p=&tOJUphV16Vexvbx$h|+SU-D6sezgnA~N{-^*d~xGLIBPO|8Z^&gTuJ z6SRx*jp;2`LvNN{x(gVdB$6EcA?oa{GzP3p=-WgG80*qsIFOUCS`<5_ih$GaXgG}LT6*cN^K z3>rw?V-G+jY+zn2zgjjKlX%KPf65@=)6!fOY!*d2{MDDB9|Gp^^QD=|+vo~wq zOl#f<5_RjI{??D#Cw2!M2`ZAjXZa!pr-*(U|M8-jIq2T=28%@vp9#@0xgL1NfES18E_x7MV4s1k9k4n9qxP`#QxC3MC_TCKwE-af^k7{ zG3@}G3jVRA_`wS(?(Jr;;v+)0xzC)DyrUd5EYHcKU-Yu~{xRPpAW|Na>vLlpiD>#v z-5()j7uY9sgm?P$o(}KS0+ZkZjp>OD^fJgobkd{`%&_CVDRdbxZwUhzggMFy3pK&y zF@RFPO*s@Lipy3*Y3RT}AV;nAL!_`q>1&>{Pa95~N}Vv#q<;AvGO}p-b4rav3jUOs z*L@ixaX!bVar|32HvVn6X4kFpSpGX*syQm_!w_thsEd8;4C zFzg9pN2^;RaNZr*mqBR1b{{i6#k-g-LXjg-Ec33-XV+x!V9g6=yZ98E<1t6;K;hjn zSmED(MPURtklDF?MzQnM;*eHVKK@C7fdQw0@QlUW{`dI1D(@Z|+awe^; znRh71@%`AF%#3rNsUt_v0uw`S#yr0ONoj|-mWjK`n@d`y<6uC5h3pz}jG5NAh9Ez^ zkAMt1zZOjK2amTUKLn{en~2C_O0h56{6ux^U7w*k+2B_9N$tJhU*xf*nTJ7e)(Yc} z5EmJqtfsl!pJw88Gyw<_B8QN=&*%40;Z-*N?kFTV|FC!K0ZhzH>6r*di}uMJo#`du zAG`VKDdkV5>_Y5Ch!^fIEt32(KIUSxB^?rm=$mzomfgJ7q$!ZLKM>+rfc`{;(??uM zi6Zxh>rMU0bdzY2NZ0TZ=wKDeWlVkb>I`i+Mq%o>?SQk9_R8Iu44f|D_1k4Sn0gND z05m$THFHF_Rg@lxYNGt%H;ZwIB6a~Qm>9eSMs@P{BD{;LizE*v@9Uhu*t!YxAk2}K zRo=h=%VH9LxQ$*g$3jjAHDrKAIxd-y*kZw6_@0$nLg-g(V{8l-RQzH1?wrg78%hhb z%a+EdO@**4!-TXbP(A?}l`OqGq{SIyiu%+_v;U>n}O+JqItKrcHSID6%UPHX80jqrq7&O$5$VUsM+e?)kv#Iokt0t)J{gB}^fh zL{HgI?Uji+$eMl*e~)%+@t#QxyB$!uBw4w=znK|P%>XtY2V$w?l^l981ix7lL$3Pc zyuT-1XEij@VUn8%d+x^#VVY`r z&aClD&1In01s9%AykmA3U1EO9D+0z<(kauB;y(g&lSN8zy^l^T(I4!_kL&!wn#=BW-u+aBLhi^ zFRNn3u)KzhMIZ~&`k-D*UP3B) zDgT^cXu&apy|B5^P5s2wI@$Fnxqjgn9A1Lc(5WR0I8ib1>M7KYODGPoPOLpTNb%Xs z6s#1p9Zt4;etu#m*nPh^*m8Hx6IwcL&XYh}Wngt&O4 zs>1R&6G<$!36F_0QJ>#vDeT)>Y2gY>i&ZABH&6D_XL?@bd>--1r1tVi=ZkMF)Zo;m z_{eu1aq*r0ON2dk+J|6^p7%iqM!A?t_03M-Ns<@QC|J^^<0CBAlX&A?V1)E<=G!{Y z*Kla>hoBZh35Q%SZrM=!isJ)jyB67RU>m28#F>I_j=TEkJ51wuE!n*oDWNEqp z#Ydg2{8D#RnPITo3G*I8e(36908zeCr@ym#=S%jh5Z?vi-S8i`B>ItXqlbvoiupH{ z0qQbE;Z~N+gfej)MvZ#?dHOacknnfhS^SRGV>Cy{A;{tV>OKl{I9wWoqW6F9rJK#V zfp}AT9R)zTeH{?Pf}#M4+ZQ`Z(QH7mSs|WAr~eh1>Gf|!?VO4ecPn{iWT|~^N3SuB z?7P8Oa@usvJDFh*04nxJ#rA|8@=mA5Sj+oJ@q_P}8jL5-=V)1tv?`vG3E39)gDR)~ z=ji2z!)MW)n@Zr0t+-K{W#^Tq%J^K`SuVXJQkz@zzY~TVKZ(B^aGAo zxkd9^H%_XU{$ICsRv>2Ha)l7^BpO=`Btyi+1T-@6)s6k)2cJy~KD$B47dLz%-ws)_ zq;u}7(nX93Am8{@q}E%f+TTRP!Fh4i8bAd8H_YTym7%D3pEMhL!{#GG{Qx4atamZe zQL#GB?OyP51|^+}Ic(g}&3QKcCo9f z3bRW29T&^xK%=R5201trI7#1#ki&YNR$HFvRA`HvMDQ2vc{#(Yzaj3!-0>pNImC6!%|i)RO+kc@YMLDWkleq9mlKB3PP zx@)=_v^lKYT$TesN*|%20KAb9XdM-SR?qVA(p+__mw;A6xswYohdm@TMAL$?&)gX# zn+;YN)}{%^_n(Ayj)Vjn>=_x6tDCcn_#UYPFfvRYbzD`nF|>91A@C< z`1=H6K5s+=i+erAr~U&CbDHl(8J7z@!uk!oGsVQ;!p|v>?Slz&q;i?rU$67hf(g^WMS#XT;F3ZMAYxORfVWB>n<08BuBLxZI3)i=d_>Y=~ON<&pta6vzvq| zgrno>?G7R$6EGGr!=v}e7(~pBb`7ii$e+hhi2G+}UQ&%jq=#gDeT&R}fr%ImVm8oK z6N;+)%5B)-q~f_VriDmaWsqUwG`zq=6g}33NyfiYv9A)W+I19>dlob~t<}U@wA`@m zDE;4s#{It#ApHNtD~){Lad1U>-m=g7=JU7DTPY#Fr#@X2!M`zP!=xkD$MseywtSJfAoT{cvlD-B^3p6%B3_YlLQ}e-+FUm z3m(ML?675 zxQ>GO7u!+@g?^y+Y9r$gTsb5XSx~$sG^Csr43^@T7+EtCTmTsdEX-byDgV$z`k=2< z)?~7=`;8bZQb8a7!_N$Jlf@m#-C_+{z|PEAnmlJumqd)x4qC&0Jx3G;m0umVuMyse zNNq?|uRBrT)J#Uag0@LWQxD0dwT5(uVG#7*0)t2oYPl&g&( z1G$S_HY-@b;j2FvJbfZ4H5aBgM>jWg-c)L|`G}R!RE=F_Eqt~8!;f8)Eo(jjZ*P9h zCg(fPO-?g7JyewXW;==jxf&VaMX}A2D_0Udfn$VDQ8$l%?0thjqmAq5)vt#tL)(>N z>|#3Zf0fU+FK`BmdCn1X(2Sw8?a^lv!R1ujPim-Fc)O_*O((ZsaHWHchPD^8YEWy! z#|FRY2_9v7{6;L6-?&qG*B(60^%R2kin({T7jthfHfM}3ithZ$oaJ{s9y@EAD^?rX z`DZ?su(O*zFu8Av{8BxSKVH&@l{sfD&ENLcwn^42PZs}tN%X`($%G(-v3sux! zpYdc0U2Qt(4#uW&5ziG6l6ROXPc1T1wM#g}9Yp(Eo(+@)Vt}AeHMvnfPBrd*Wlsu z$%z5{doGh(7yS(LWhOO+&d_W(<+AYLKMKb_kDUPT~fdO z#sTyGw@wpq8mYlr#>8~Bv%BL*X=4ZysR??`aISi{?a=wbqNlJo7QmD6$b+8hsFx`H zDO|fuA4?iJuJy8e`Gy%$s8{x(W&HV@dzA79)>#50}<8h0>q_*5Q@Eid?S%{AVG!*vX59v z0B6pgTAJliPm4AaI)6I$`wox^&OL3$lDgv?Zqi!gx-04Tzx~a@&&TV$i*7p7Hnr#WFpz6rnN6}jyQdA7VUbVI6s(~(332=yF7U$cWjFxei2GBW%S|H zlmRM5c}SYsw>)^IO|eeXqgj4`7>ds-yNwQhKPzoQJaU@i%r*-R7;<-7-1QJ|vNEwf z4TGXoaL*hCn&)=AID3=5Y3~j~g-Cqg7y0T=)z&d{4omq(f%tk}vgO2k7HD`^5s1&R zmwvIb(&d)9)zRc5v+Z8QpOQHKkC7e`V{!5}Z;5j8Mmu}!TIqP$ZZ`Qww1pt%%aQiV z&)@az0d2DW%>siq?T43L_ZhQj*DXChc2}t`qz+erQ0;!*9a*#yx~Kwl32(dSTT(*O zHy|>Iru`SeXZL+(sb3-P2bLB(>nHz?=8Cq6I~e}Oh4ILOevvEW;3*W5)&)%Zzc?`x zy=?y)0MwiT+B^kL(*Ny0|4V@QUx)l(={5g3)c>nD417OP3V$j4;a@x;01YpzAkFuf z4=)>=&EU1umItU?EUPE(r5HuMPYWIiWKRr3ZN0W0PgW%MPYww>64F8RHHt$ZKY)w( z&p#gz8X>w<_g}4HbV84}?_Hj$e+0;3iH{r8mjjy>i;0i057EubDjOpM>>a8XuM z<0s+7#0AK3ZgxY7DggKF-)yx=EQ+69ssNGeeGTJYRA3@dSPO0o{kb=Le|Iy#QG$wz zf*SXV%|Cn~F4bN!8f`k^2arZ`#K=VsCZWbk%sapgZYM8I*nvM%_UtBU9=Sj?vV?hf z_GpIO-T?p`km^w!+9LHu_l8=yPzxC8snn_EY+%{w``;27E5HG%38g)+aHh~2kmKA? z2Q2?jRK0KZun-s6*&q%lt_6E6swG1T9dKvFY<|}Lb381jM}FTbpS85tixwhf1(!iA zv@v2gUJWFAFV&8It6b^zClpgrdfC#m5C87n=b)(Y*gGcP>tm4>AB0_~bjiz2gZ}tO zRWR_^8o-q!p13B(!5m@i!DErfh%4piV#7_c2TpKOt7FB3!z$0>_T_{W7h|tg}LUmQCbN`D34LQI7U3jr4g6#-?Y+?>`Sek995DmaT>4I$0oLO)6NM>)8-}FNFBu@8{cq8KQp(+=+`a(F zpI3IJ07;MG-&(TtiW?9$ioEB0=%&otXT$a|Stn_}yscT<8?;OI|I#sg=aI#T+7R~l z(a8Azr!{)TYY9W+dc|fRTp$1;q~v+O6rs2o4>`X5Pfh**kpAkowz}nHq>COG5YTa2 z0rjevz+$lc?~LdFf0oXpm~ZawZ`(F_W02SU)>?#6_)YAMb464Y(my1{Hd!Ty;bcD- zE;aElVb@0)(&Npz`6HBVvjmmP8q+D`<$Lc*v_D_Gxh|Nbxc2E0sStX@%b(E{e$DwE z!Ye>qnlr?yVdx)O_j?r=z_I?r{N#otCc58)i(@&ZS;E(gVz*8XWIY)4lx4mT2$?va z?C!2ibJPmvV@QXC}&5syi3ozb)0wXgq|&g(YY3 zwj}c;;{!k5ga4W^22&xBO>}G-OwPAnFzTgcs4nVd%X2lDN~g%XmRMBcPp2}H#tu!Y z0h{#?Rh0F*C#@xDcmprq>L`&^jeCA9X>wtKiObNaO&F#+c#M#EJ8XOL(oQ~SK zSKJP|3KMA_QJB5nP*^Ehl^s?IQcKe{RcrXk+GZUZ+}XFCxB9ENyW~*oz9fEeK!h{? zVbfQ#0?`ZIyj9($CQv<3c>WlYue!i^IBlkma1`w4dausr-!q-1ozO<|DKqtDfQa>s zZq3fWYx`U>Wwyb|a)fek)=4q{?N`UNn0c6;O@ftOZhS}i?&jq^91k{H8~0xOSJ0cS zGYDpsN{Vm9)A)7ccbYZe@;jc9aF7R4zT~POOSEZurgGWT3J=&+ z3pZ~0^Nc0X-g1Y<_6M*UIt@Y{{A8_U%o8o`CKJD((ZS{ zU}ODjm3tQ8x_aXc4-XICg%NN=IE%6ilikg28*=jLo43U(bKGx!<)Al-v;GYl%O` zeg`1I^iqTasl(UMRxd;{SDZYSdCL*ZVPh&v<;`>gYP}B3@4OK-S^9cX?gpOncNA_m zZ#P}bQE$zo`6FiMgZ<%seKHJnh2uYXy%A>siJ$-=8G^4r6I`Fl143F?5~z zHS8(qK6l#q7rz^=n`BLJQ}{_xtHr-X-x?9IH#NsNIzN>;3|=Qq5{k8EigasR2F zP}Fh&Sg76Uf8HaJUS>0zNBocuFxz*2Xf-VuTofb%9N6r8oc1It-5}P zrGq5N9g-zFsfru!aEhCx=JwE)DShfE*$s>-JV|ZF0x~$qgW`Aw*NK;4+&_JkU3Nzc z$?iFezoolygL{Q-R0Lo1ePAD*hR7nU-ZEcjR>iv((9 z)Z+fhB3^rhV7W_58RLmoDP~82Cq80*zCW)LqrZ{67Sjxse&GUqkHcJ%g3@~voA*!J71Lt(%_N7lK%$h$7(hk z?qJGzH)~zDyyciZ6PfN1e4e(`B?Tv@`GT9`8a%kS!dzUx!7i+LfP_f46?1W+7tJg+ z6Q7R8B_1^(=pq^$a42am9WJuz1< z6D@%ySraW=9`E$_#dI#welxi)XdVw~9^5Oht< z=C_d|3-v>egJ0W*Y#KZ7GqAcAjh<~xT$1|W8L5+l*7a?_ZT2hWq2Orfd*YIbJBmug z@f<5{G3Q;%^ouFwQRb?)<>z+(W6uHDW8Qd%vYAiZ;C!+L`O^Vxwj3*l>*1@#>6j;r z(@Gz-aDpMF924xJ-o_@4Js5fV zG@1TZEaLZvZoX0v;=M!mS|sSUh1oofnixyC^@(jS@{@e-LGH-76?HTYU*R`)*SCpRq`-AbioEo|0$v2gfG zeoinMo9#ArP*<>h7`DewmokABY>3T&PwC`!Z7ALlL~wZ>YURW_^e&$~;1>m678<@` zXo^AENcocTcJ@Pc_=F^tp5*%c{4?E(`xx$zF{r#Q;M9Ci>xJCC+=V-@9sYS4T_hmI z>G2-A-{Dx5c~+gp#4ZIkKlW0dc%qU{C7LFkduB{*omW!}PGNEpvK{j*eyph)yn&Ud z=TwLUB&2wz6Hbfx{3ff`uM4vs&nzAp1nZR9pqR6pGe2^U0Kzq2@O^;ydJDK26Yul~ zg`m^YD}e8*JJf&6L51<~^TXf}t_^HvW=2~$nq+fnG>uD-3G_+nLqfiTln3QZg)_i4J;2eV6bt2TWC0GhvkZ|m`ThgBkSNf%i;Yi z@;5c1h+xW*#I>M|oF>wIA zu~<@^;I;$r#Cq7@xaaXG7av?|zUiDQco)mRl_yN$iT?KqkoDPLU8Fh;F zaz`7JSYrA8&JLtjKDK>uTnQvtu3~mdcea1>PEXhgxhp`IrJd*+%4!5KM90ni`m@_( ze7yDCR45%f-?8U(sy-p2d1^HE#k*V3n7Ra4HeA;RC!K=ZfM)a|3MhA-jo5Mz>(bOY zJ7T*I&TaGdk`Bs}AF8-w-sZ7N=5lkqOb~i_oWe}l64+kzInJ<9;3Xkn9#}fz1(s2j z;EE{ri)pUMrKBQ+#sUma9gGu#K*;m+v`>W|-(UeI;5EvP;V6>&6#ok|isef~$&rv6 zNOO-i^DjlrSj#eh*MYb$BOX8MKRqk+kdx9GfRvc`zlQnxb(+>fN_Zi z#$J{)`>PK|Sff3Sdna)kDaoA=2O{_k0dm_IcJ=08Ze z-@m!kvvxy`)ORA1fLU#GEf~{@u;u|xk-Fk);A3uJ0Hi$gN4dQmOG%`Udqr7M!sn~3$$)A@DOWyuFingDH+)Ff0_UsP_s$S(*trUi*`4zB?#Z6lD7NK( z2gS_;ioW4LvmYUbjL($sa3D2V8W#IZwurnuV=)!Y ze81!pzuYCgdAa{ICC~fU-D~v<4DTaGb@ofekExh`7|IV}rO(5i z#U7xuw`oZ|8DxUDc=$yWa)bkyWO6!gISF}=1RYkZ;we#6;Wx(v77We%L0pWbj*J$-i}Fi9b|BHq8MO%{nOGGn;&qFig^=8C<2C8aQiV{X(f)Aqo z22F6KY7X}}VKh%&D2hsQzI<8ou4Yj&8D0_B%;Rz5U{(!1^J$Ln+FAsTd^5eH1m>{I zBNq1I9eDYMsmBsm{r-C0G4f5pO)-|TF_Fn*6Nhnto%cKRjuFP`YAd$(!?_q>pR61Bb z#-cwn9ASx{C}i2pNes&?67i1Ed-db|mlFHuIi;fGkn|WrfIibt9w*V7KTvi0(u|Zv zpXB*8(oYv>YR^Nyp43f?ot(@pNZ{?dchf!9HDc{?mIm}2P zG;8--$vJG_o!>b}p~)L=;oipmAgaV6!Kml>n5B1X~HMNn$XODkFH#56$mlrL{^ArgQ(WuA=3=v#IEdTY1JJ3=x$f2@sbDtfrt zY^fBm9ATgDy6M~H*Z+IzD^g6ZDY{#e^vz8G9p5vELN7D9+sS$?7ClmqwC2`DVs%hg zG_lPNS)yJy&qyA>DsDWuo25LtP~~Bo#Y;Nnxu~iN8*CyAI?GoM8!XT%7YRd_ZJd-W zAJw;kD3g7%rn549mGidxwK1@NU7Tl1L%{p7SZaI_q-bc|0H6 z;&obTwVBhxfonNKDsh!rSFS{+fI3%83;SC?HVtTOJ>fUw1C)@shl;FTht;c9lVS04 z$DR2h85*TG-Ol>om^R~vk>KY7h<+ZIL*wi~@46lwP})QAquUWyzvDOu4+ioqxtDMK z8(}&A;XPcXDWP26!0?feA`JA*g4xSm_sAqVh?b`EeD6unlR~Wx%H^N0vxR)nSdB$;M*IrV3( zk{E-XaURe1o{oOMU`K$BSDjm+>gJw5RQfei-`~^~6LKwh|MHg&MxbV>qr~&rLHFHilHRg zzx+?JJ^7`f?#fy;FUUbt7Ikb47S?3Tbw56dkQd7C;+{qF!x%U&omh zzD_cFBMMw@+(yF&tlG*CuMjVT>(x&_8w>e{8uNzVB=q||2i)10z!+3UY&3N*kD$YH zB=JdXNOgtNb41yjXNw>JPem`c%kwU5bFRs!Hn}y*)>&nvTf(1$Ve+dWpob;)6jG{-LdV?c*MJ5?8#)|e4*PNQd)~YdXPh)>8fb@F%tyFl2E0{y z)S_J%VWG7jkyHqNRz_D1f zS%X3yT|nN}RUj`U_)iA_IQYo0MAn07_N4F!Vk(p~WW!#WV1nT5UKJuwpS~keibA|^ zH8c9ge`HEY!hCZUx_%FumPhyq4}+`mUchUEXm;0)PJ(eiC{&N)R_LDBMWHH+V3Nc% z$o}KD)AT!%*3%C@)dc%S3uJ@$JHk&~Qby6OW_v+7AMF^pw+WE1@ywUmWZ$u*u%t%# z6Zv-W;p@`jOh$l47z0dTPg#nGiencpg<}^w_#ljqd9N9-sLfz}LjH~dDo^de{-9LK$BC-ezTe#r>mLv!rq}#k_ zOh;N{nn()CHtqo~JEu)$*>9U$CmZ0sNrQJM52x?hE=c8sVuXC9&CJ!{F>Qx_im;>h z+Fgwg@U0UXK=#G1!9spJ5_H6rC~a2q(_%>|hr) z=FiWF9&QkHcZ+6o9USoF==Kq;{$9wCV1dBC4?c?g2qS;}u=Uvz-6k{9s!2+vo$!7I zRB;Du*tdB8Y1HR(#1VFBngsXly=DF!n`kAj{jgT`^+S=enp_rg`0JOI4NgWSnt9N( zlu|oj`3DnA1)4B1`tLbvE5JNdj*DL|Gz~;_U4Z!U7N}W|9eI=C&sv}^jZrPlV<0Q( zL2iFm2c^CPhpQi)O(X^!!Pq`)<$v4Isa7y9y)Jb4bgtbjW76`=4Gso)+Y?qf* zG@u7fz*lg`|8=pbG;*6KPVMt4sgXe}aCh40XfZl?`cUKu&jk-q>RmzjSu@{eG zlQ;h|{Si01x9S5{v!N(=+-FnaDR;aOh!Z4b;E*?HXv6q4kzzUgyGYy)&Ag^ z?y{KXTR89Ux2Mu6EqG7~QsPnKh?7R)B04*QRWKi1+p5cg|>h3e#NAa~i31Ki@UDKu$g47mN z04<_U9S=A%9O#yL!$}M-_ANmzJo^mbHrlyvo$pxmkM6%YNQEEb;5I9`p!gQaFPVCa{lx z7+gXbv|_(hWDr||n7LEnpZ$e3yz%=M4@UcA%->rLzK##~4&<}=u^1s2(wfkft{@B{ zh=*46s%)OT1yW1CES6hgT5oT@dB|x9vXZSC;71WR))hZ;S*W0ma~8^~w9NdZ(O!_W*m8FMIK%tJctY$_f?lq zUo8KeQ@4aETK1-=U1!OJ0kFB{u872ty^Mg8uu7eQoJkq!Ro7X?!Vg855Cqpj3y-BZ zVZ7&TK;z7+Pc^cUU#M#?uXh)yeTpjhL49TVKX}}?LAn9xucS63&FG_7i$Q5k6Bp2x z;2Gn|BAmZV8aVjVa}}5iyvjIe@aTOmT6=}efs7M|D6fk0Cc(!y`4uQVwil?Y=s#0R zidwyWh9bb0i{$n2h@RGAH|!eaZvF?$^Tvpk)V0(onn&l3tQuu<`}s3U=6?KkFayyV zx=ZoSmqeCC7Q+=bj-K@$VTk^-{fFGZq=6tYC}73*m3{Im{9rYczHf+QQDV`5v02U} zney)$m?asW9PSrePWi(z-uWt+KSIs>)=srR5?qj)k3u$dI*HHKK$VK@Ya%?18lM`D zLU@i|5D$bF&eI&{Mpj%y_W)e^ZKC@VDZeL+ZYbfcweX^;FcD7lN~gCy_p6|ayD9L& zN!7DxYBtjocynoWtYPqGf;*?+svK*W_HjK}YM43I^RKXNl362c6a~E%>aa@+Y|@E)VMH-c7DC zDdq!{y5wzHfxqL7>1lA%Q^??CWzO5KmHE!`;L$*J+nJeB5b)X$Q-tty#Vil8y3Y|w z+TFn5WBc2*$W{$;A5+5*xf5WtV|CcfU&B`kNJ9Syx4-YUV(p5fzj4mdFoxc!`^sAz z>;8Q=M#z;e~`&n$GpZDi-g>H~^EgaJcT|RfFt)oM@ zd$;{PZUZFOXSqxW{;2V;o8*IbRpv&j-d$F(2Y!xgP8IfR4$HS(y3Al@ta<1H5Yc!N zihp3vH?@Sz6he5ZZH~7kvF-DV?Vm9W0G)7V;!a_S{_2~|r4Lp~_}0oRIXHAyLpFnS zcrrlHTSqy~z9X78X981N{CY8qw3FlZza;cis}LD4!qO3MUWydcV(CFG>v|1UqkKJm z19{9vQl}y$QDGDe%O@gVxQqXp&(Fn^|FZczptrM!kt zja(01o2B{^Ali2Ew^pdAgr3>R$2{R*0iLE%V|IOqOp>4%b4+mCYB_09t5F$cjUYdk zXXllh@)_RE080r&)?yIrr`oTBh*Ue+W;-OL6C55F0h8_E{h(U0(u~-uKV)yh{#oK5 z>Yf7Bg5J1M)t&N`%8}8|V8|K$KC2H9FS{ovc+>icLci}-uJmW2K z@CsL=BQ6(@D7m^5k)C@lV9a!=I zs|Z7R(ErOQ$zDZr_28nCtfCE0fj@ms*SM$uv-&$L;hR_~0w3;?wm^ca;VQ|5|5>`+ zB@L>lJ9MTMVyLYI4lw4lkW6Thr3yW}i zqi>Do+h2MwwFEmY_ynXZY9|ynr<$LrCcB8Ks6zb*M2DuI=5G=84nb*8W~EI1XPu)Z zPwEnIuLqwx12Ln;*)6}1Zc*)DY<;IM9*o7K#a=YH))du0y4Em@01(D|<>4K*15;45 z+q_9EvW)0zMh%=n7;R=EOBd7^X_+k~J7(#GYt>v*1`Ki^r&ws}Tho53&=f6Q}hGyiU7dmmJnB9n;_(6jj?leQCWM~CJ&sRg1b0`R;UA(s+o`R7X zJ3F0exicL?V-Ay9mmN=$rLXb3Y?*g&?XFFrmbzXZ)*Ob$j&o!6R@l91I1_U3jZWKS zhxy59NDHh_VUP!Mgr#ovaGaMiDCV*|r2bq|d!uXlWbjofcWPtFO~3T=cUQ-)CB}XM z%z5V?O-}>reD2c&j{t!QLwZGSPs&P7>a|#W=MNAwVrQ(qPBAWVfUyJ@(=DyGRwU1~ z+|tz_;y;a9ONxOhU~4(e&X-Up^x-lPTN<0^WMAcxL?Z*MTw(e)#J zG}2CwW@mcAIM++);wy_KOQw#OQ92hnviW_qCY3%oIgtj{vtX4t)O`qc*Mt`*#6f+V z0`H#VjFpUNs|Y<|k%!G4E;a5Xos|i*4!-C%@FcfM-#Ix$)>K?fr#}P0sHc-* zO)7kScm9cdTAD!^!v96 z+*hAe+K<*y_o|@0-qi^H7F#&;!uGO<9iEYM>)Z z?%TRPQqGyePIM}YP4^s^Lw_BaAMIcw`#feD^vUBPx-%|wCI@hY=4=@kOqeyskPzf3;fF}sedpdjE=|4I-|GoLgUjvr^Zkzny z%p~RiX%3{bhwrEn!N^}e37{eYprwhgdr|BdH&p-QIJKY5a(6v_=g>F*%atbM@w^C7 zIA`6~lg8q5|HD8Z-6JsgYmUPU38Kh&R3!;)z;{%xF9%69Axr^3qkmA@PVhAW$iKL7 zW_HZ2DKH?gpQ5gz5g9`G56@w4+Ii){wb{-!Sl^hq+&Cz-j++c1I%I9$IWsmi6M&qg zJ7kEQUAq$<)}n6&wE}SUPYXuQ*-ZaMshDZAY)zN{2i)F{bm(FO^c(%pMYg6Z!_?K) zKLcoZE8M?u_&Z_1D*05xFhO$0yWRJG{pFo74Fy0(5*`S0S^3CLl+Lum#xsl~aMNCj zaZajyN>>u7WQnig_A#NmNrFE?I9ZHs#KN@Fk`2Ou5l}`7OVeHup)4ig^83V>Om~1Q zm)zF#0_0_b2(B*@CQiCW^nV!rn&OOe6LV{h=%LgdTC$zpDR{JJAl|{!Blz&a-`|dN zq8bGV4-Rr!*K`NU0W!itHX&LX&0k_7n@BbxePQ5V`A~TYAl|EhA1KSJ05pWC%FF){ z7KAXj+{()rb=?MW??TDQ9}_B5r2LEi2mUT#O7!(!c$5-AyUt=xodRBu$p1>4xQ{8L zn{<#^BX(3kiAJ3?tWL>ANd9QS276RQDA5s(k zUwKO@(``LWtrMTIKKtuG<&20Xxb~l7r48Xz?FBpI2tl3;Jf{o^+IvI(h>X%elW1m9 zzSC`_+#mQKsjp8G%*0FlwjbRAGEWe-11QMf&GF$V7#&Iej`Cg7b4%{J>$f%hNy35X z|JFjPT3$s@`tSiX?LiWl#;-@KpOf2@_b!lNXp30nw8`a!7jiGy+bb0B2yi_VBts%7ByL z2X3INyu+XPOc>~1h~B*Z7J=P#xf5(o z1D0cPY>81>2w+JYJdXY(lQc?Df6erKhC<)XQh@1ob%4=mVkp3<77hG9It&~4v65T8 zFHm)^%Ks>I^wS6j>#1*r;?DWbdP%lGNt+0jy8lN`_Ky?EUEOhPxw68n`1*F5-?5X3kC&iLY*FlWdqO#d@A!I)- z`lk82@IlGLmO>G&`r^CF4uVNssXwGvc2#E+9N>5mp^R@WH3|G>V^W~{ot!N%9=7W3 z0`u3$9i{#m1jNk4NsN|u){Mq#E%KV!g_)Xt#f!X$$_^mWUv<1Zju@W-c>JlJ`t)Ys z%s!vw<)wnnff-}+h({=&sQ2aP10p?;ll2sqJj(F$lyG1}Btre0`j{VYZg{BrWW4$d zUCBw+|MhIEbI)|;m9Pj<_8s_w&Lq~5*XCf?uSm7UxwM4erMq(0s ztxjeLE_vb0@Jsrqg0vFb8A|wYT2&LN=slh!7S+vdy;u>Bk$52*mZQ{+Z9_Ns1D)AE z7k&sEg}N1bF?Vcn1n;0@Tyko^&LmDBQ<5yuxNP^xUPjae_U9wCclO@^M}JJOiHN_` z2heg>ZdEACo&^;26jb>fIwX%+=G|tpG9s!RHbQJ>q&aT9y~yM^otk_AtXm35EVMQYny!qGS({J@{oKn43NH0@F^_er z+KaUo0k1`)Lnz>YR%MFycuU9N0ql&|80sp|A-aerw?FG@*ObM)rguL93EQiVcR5LE zmg)mo8$9Bkn?$R{=?w-+T$iLTGJL*nYogBh)KcKdo)bBd2;tCF+n+{JklLX6&-b6f zp`d{MAABm37kluGDx&?x)Yn%vRkoUi{qE-ZNY9Jd`(&8d@I?5Ww4NT7{!2x^Z9+gd z6Da81ZikzH#wv3Q5ewHB-GRMA4B zbN%M;J70}86mIbk=S#~1J{BtP<2ZEia$Uc{U@C_jW7Vq>KXVQ@XD8z=4}PgtA#I#e z1Z-BqFR(`=86R%FozBdE(*)AG$i3^nH6}kU-7v13I7pGf@TSh_rr)7CL&K`!mG-gb zTFG0->~Bnp!>EB2Ej>&Bxnl!yZ@e_j#&}Wi=!Ttci@~@u;8arp2+588u;w>y=re|$ zv{a1=Bv1O#X*D&mUpPh1>&BkSKGqX3`f5+PGuPufhDs9>$p4c8kVU1|zPv(u(~(tz z`m+jaH3j1{Q&{A}Q66cpwJtT7SoK3%v@;~+vBN0Q6gKqtQiRlj-Q>Q$4UTVafcD*9 z3#T3oBHJctt#DQ3f3`6zawp;SbYhQix%*y9~I zkcJSm_l)@`1v;Urh>0t`w_=O{?t zb8)Qg_As^%XpJ!DQ^!eoY#vKQd%%_Z#}|{wK*BDeGy9ud&ubqaC$o(HctGz zGX$!yblxfpg$44`;zB1OZy=HBb~LfIXntkuzB$e=Uuw8L5g+7ZJ|}fWf1<=g9vSS! z79k%*5_!N+yWl;Q$O14VCm`GqYO$I2ov%kuB+2zo zMbxkh1$*!w3T#OTrjL$le8w^|>>Uu;Glv`>qzTML1Ss?o z9<5lnm>jt9$^bBoF^q3d4|r93b!HqQ75s)8{irZdn6xnEH%vcg6-dW_sAqrsM^0 zZzJ6y;y;E$ZjZH2OD}XKFVHORQF&k>+oRAXMSN~G>j2RpWfpD68}Y3cQO{R9Qomn2 za%$+1o#TD1u`$(Kv;R)CI%-tmD&<@Ec?#E{47%+me7DV6Xb zoEb3`!7^HD>N7mw(TLaz_8H|QXhI|Xu)|32gKSApI;qKONJfh%mGmRa(#)zm%1br& zv(JOa0s&LX*fuMhq{mfX3xHQt`8gAEM(=HmkqKb1fLjZ*u;!b zWPAr`ZwP?m!!~UbECjd%A@U%#GnuaeiyM269fnmMqnc>)(;G8IcOP*SEJr|lmwxiu zSkbz8Y6gS05w(z~Q)h^8gG-3hY#vg^vH$G+oURrV6Wk<%W#oC8d zy{eIvAf>oKZv2D<+J3}_R?&K(&o=Sz=Em)s+}l$YiV=K(70~42uHP~K-tg6SHg>yN zA?cO4cq`A-ZO7bClK5M}Z!Q*_j;%Ig3*6Qhr9oD_=KE&sfbWT0qkF(%)kn=pN}+f9 zu7J+9jdoE9)vL9phEY^c*EmzY`DJ{S{YcFdvXFWeEr)8Q5b}$H-Q&BlA}k~E6Hssr zAg!yJgiIjt4Sn-Q%(%EAZ16lxm_AR1-mQYRb}qelI-Uk_UJ%Sb^PdzhOGBSo>wwDk zq*SMwd1`G`lcc51tNbcCY3H2~I4n;Bldgj* zxKK|l{k8vPuKDu<)np)yZW=~MHIlSVH!+Y!+9YaB^eBlpc!5&vtHU25)g-uKEtQ;( zAg$7sjad8y9m&-$Za?CWs4GtTeNmA6hQN5Ce!@)krW8PS()q4i{*8oILJ0xKp0W4} zTMQ=W_D`@EaZAzfYSiBibdlN=vM+W^$oC6V3ACpLon6BCJ?Re7v+01Th_ZPAdue}U zBAfb=%vmJ#mw$6ngL{Fqkh$XXp&?gMI455k-Uf7r{C?aa>eZ;})tdVv0G%$9> zY$i4FWs}cQRyUouuDr!=uFXi*HuuJgysF;D-QKe+Y71l-#kg6`sto7{O^?+EhcB~GiaFEFYECqHF1*4XZEHaR zs3T=DR~iUE&ZmArOabv?ly-$jSIIboy& zKK<&~C~a@bAM|s#o5f3@{r)+E|0))xF(zlL5|*#NIjJ{0;SF%;b4m4@CfLhp&rcfZ zt>2|N8)vH!jSlRH8Y(}V?6cl|iL1x&psWf2ApzjVXT)^-&Wm6~{r@o1Ad$0mup$ z2qnt=7WBsR&$A|wZ#*AFN03q`z?v^vDD+Ju5vss)Xko*#>M^HYmHL+5)g>;@mLM)h zz%;ZeKn5#eH+4s`X6AB6yXAdAIW3-D;-M@Fsvvt}5HJxT&QktSY%{(Aq;A}6R~p(MEpFdqZ= zu@j2!&dWbWFeUU2Dl&1-DF=K0+}kP`fec{N2H`PmTc-Q=#XdDW40r04Ou zsR|F)tML>8h_bq?Z67Q210PfO`T;~IKLvVf+qBC0=tc6J3178W@$I-Xd$zZh#5}2! z&kgxO6&D|=8US%wO&v7yD14l;C{mNq;J4)3o)U`S1sr*NVV9S^-@=aA;4QN6dNh)C zv{O~Px}}vv5m_jtIAHwS-VPQ|Z1CccuF&%Jm;8x22Uq*Gha^fko?Jh}tJ94NQ>{C} z;xE~P8%7IicXGz2vpI`D@DxlmSJkKC0?8ipmTh^3#8oe%&mPA7=bapVq^*==q_tw{ zX9s1nV>`Y)L{C|MixaNCeG zmoGf@cZ2&*0l52Bk;By7vo~8)WlN0t5Tw6=#dpW%RW!o#dzJXuTtT3=kN@TQ?P|Y| z0S7mU==e1K;LIw?-+N!I6xcT&RrQ>W_#I5~+Kg1V8&UG8|6BXN9CL*|Wooae*i2i~VQ!uX{SzCBTD=s25@Fn}=LoTLuj5xJPJMM`&n zp@-vdU!)h)P-bb}2M6Av^IMdDyHH9bGs#x{7@h0@?pIDH6RUp9;(O94ZWJ^wTDrh zt~II%%aobk(^bQkd{Yqw${pjjpVYYScImTja{QusNlez8+L|qP?&$PAR59hW>5)lV&DBMuB;j^veC7!aWwjZ=oSHb;fV%-ol z)vxhP&Zf+z3`!;F@~l|5=3O!O2gBQmKEau+8hUsy@TufKlDlT+K~i2c`5dZaU4W7P z64nIps!Mn>Q{$j){yITB^c%vYK2V;JqLAfX>Ih?hWKaPgEuu%vvDvyWWhaCp5>?$Naov7!9@}>;Jd_751+A`bm2)KsPVrv}j;_mky3TGuK%z zOG$yYC###$@=Tt-IMC5g6+}!>{)>4Ojl;*D>wV5rC#tn(&Q5_acdd%cB8eS%0Db7Ld=J9 zMOqCimO*f)Q(tz%+Rr3y(t<{N;WgK__1nk4G%&ZhHG&_X7L0tKe217?^z)_yoMnNm z;}#**^Z08-yP>c3e9Fw&j-xp{*wa_<{v<^$(?3F>X!>X`4euj#-+*%g!^7 zY@X%?FLNp{u~6ha>2^P0K=}GWJZt68S?TK8!h++O>$OR&A*|Yjzts`IW3Ib2m!#c~ zpp7Tkw-z<|rh?y(=oG3Q&gJ)sL~|to*4s4!!nt_>bIn#NoQ03EV`O(E<2FzXXjqG+ z{kS(fdIxYt`*NZ{GgZa*HHtO@>~~FN4Xp@)dVhDar7jWQ8;TfkO}AQj zCl3uNgy;px9Q&&UmG~9n=wGZ4eQH_3hQluQsM*C9_1}66Nr%k%D|-5>^0jh2YF0>Tx%K1#yKA};xD$$?#p+&WI|V6ONjZty z%KOF~c495oZ+&)ZD2W(03>^)>V8Q8dIU!B+x>COwzdJVE;IbBHr_b?IaFtW-AfyWG~ ze+}1)g`Sz2EJwS>%Z!+`Spaa@Gbkmofw|C!I;54(UaiBo-C&Zd?S&?mGJ3LK_cKw| z8N0G}Lojh00^Gq`-_yH-f`O79*?-Dr?s}0ggtm~8k=dhF@#2NwmoC|Vy&mKN@48`Q z?~@Su_dVq!chjR!_R>(9a%=cbSJR%U@az#%Qb#_C-SX6Qt)w;=cNulYTwAS}XgYhj zmQaMhza`-{TEk5v+-%Tg>GYhP#4p9cxB5FTt?QxbBXq$x`GTG~9h}ovG2*+ob1$;S z;WYxvs3w^f|6Z8Iud!hcDa+gC9=?gkn%Opj-h0z)V&v+eR_ag+{5vq|xhUcGPq{pY;9>$s%TqmUGkRvhM} z`AgscILZQ6cGvTZj5yA0(gN}zZ@G~X>mIdEwNStIJlniE-saHJ^6ZBL zOhx=kvf-hHqVzp$Hn;LU`y>>4R}X03n8Uj4(jM?Ys>w-t^1?rCb9mtOBPcHV4_jJo z;CmkUR;LNQTH{R$;%*ybZ|s3DSD}@r+Gbu`JgIhI1Alk9PN!6&%5K^P(4qhhB#OSy z9T^}aZ~udo44f!7gfw{{tC_O^Hj2#3DFt`N0f)pfUX@gl0@zfC#{Bys&g)%W6~+fy zUb82f(J6YpEBC9VNxLr|)um&jo_zKC+bYU>sqdiDJk|d~^R_7!*_AI=!3M-__@odZ zcuh*^2eNkEhtV6k+=fG~S)a}xfAmo}Jt4X}eX>O)jHo0{q^j)UFpCKnQ;+Qc?^e0$K& z4pUL)xCbs$ulw9Um`$UFH$A^QgZGzUiwyu_<9&6*f$Pn|JCws1o;?A`CtJngk5r87 zzo+}PI!Nusatub=J8V8`E8C05eN$m6xOi;+cxr+B<7BZSR8G?vI15|42 z-Q;Qmp6X^ffIUlStWeR-+yQ&3$CmnZp`wk!d=ulj2gHZ0g5?F(oDIu4c`PICTgp~P zQF#KcI<~XTrW7QIZxIY2@RoI0*9YNeA2TlRX_$jFI&NHWP<5+!e}%@N38Trvmd^o)znM-Zf{jPShApF zX0J>#6tJsaVbd6=(fv+e?J*Amt?fwO>)DWe5?G@isU)=wPCRtho3@@oNgf zpS7cBGLKFxRDZd{c+5VWXG*Q~Sbu0{)m8PwPhBH&QYq3Y)+Y4afS#SOl%#wFBuSP7 zbxNJQrV|y4wLFVU*G^T0 zuqh)U?2bwBUO+*>YbI(E`dgmw@KKlJ??+v;=3L*x-LJQ&M7VH6LyG%zH6E8St5ue= zX0j}KE6K7UYnTe`K$%Im&grW;EVR(?Mjyhc(E$dya=dt3V@I5`6sE2!^n!OoxQwW% z#lE9Pe&WmKpnk(`!20NV_^y)(M@qbXGaw9d`JMVzV2k&6?^gHh{^dr2V%614l<${@ z`MjAULyQ^I4D<>AA6lS#sPexUFen#q-OO#68NEDCJFB^fNf>~4e&gWt$j&JB*nYNvA~-HH3dE@agC~s+^3|j* z?tINyOZQH0(47=^?IKFHxbO}R_d|uV*vIUNZt-nWFwnC+zkeH;7n``G;--OG;^a4XhBkG)rFu#i`~PS)mfbXNzZt5L3*PQ?O>dp957? z&OVr*_)^zIjva9IkukkE5vxz(3e+Iyg3RhGR?bAaPC(O;@{cL9Bvra{-9WC+l4;sB zRa+_Lw$I1Y4I^y^tK9@v*WN?VeAW5SZ1gP8>}*KSoD}K3U0njap}(cPjpvNKJ;qeM zT~2tsy>{<3v|O<^G!9uc6n9fL)MIoT>gPoo>c_nr>e06vvU{HCHFsZ_PwEzC_~9eL z#(+;*CzhO3?>q5qwc25D%YBCSMzoNgJ=exv!gnUvWI?8 zyZKZ0>HPq`-1M1*tLhT;9I3(~BZl^mzYTW@C?B^^v=0~S~48uVsK{jc6Z zZFrW)r8L7&n`gsn+!hyx{8h0)r)F0S`1l9 z-HYk*?eBq!1ETP*;jJ9Nbm&%3Phd7`^><6YP;2~g-gz^L=j=ju&6d#;6}GxYG0}c@ zrxR?CY#FMd_eE|6KUw~Hj+~VQ62ivFr!hPK<}wtzb|@Y`LuNQ6is~@|>J`oW#Wh7& zAStP-rNs}V6nXy0jOnzMj(`3p>k?&LHzR0+C%&=K31*OdsxZ#3L!zfUN=zvN4y<_+ z#DW37!DMmUnt>-vhtH2LD#uQfQS_qHR6o{8+%pmaL)p|Zx9)ii9iJ1{9DHWGSQUWe zmU1gA)SNAJMO~qel5O99A&9AfY)R%GEj5%2J^UO>0ho_gy{n{1WYK(^k-c1-8bH# z4TR44yXZXGKNuIl&apC3UPsWjKZZaUBVgSI^wo$0Ijf>hdr8ymXG?vWKP9u|t_}45 zLUg5t*Ct$P^QrPKHx-cQI^{2odiC54Ob2&OB(;!g z&u^yniK|-*S!IgQ@m{U%D)lD`9Iiu ztEjlvt!*%PAV>ni10*CHf(7@&Ed(hD4#C}B3JvZMf?MJ43GR~MUbst;!rcpcvG+OO z`TsL|^iB6o_vkyQTC0}4Hs3iP*^Y>Ppi`}wnM zjeKc1&A0O3;Y?<`cV!~RI{=zI2hv!{3&)~VAzK&9SU z8{+VS6Os4P9Z&$q#q8F`Jiak}PK-02#(9uVac3_fx3;CkKd;In$DfZzD~EVY_xn?I?*3c zNmj3i#X)(262JR-|DX%I`6Di-aPT#mjyaEg4TJ6Z_GFw>HzF(i<0CSfw-c17n|oIEfSFV{C0mZXHKW)pYB z=DmK2)Lh`qe!p_xJS@SqiQjAr2jIcqVSp)9&EZr_!Hq|TGZafFBr|WzxeVe7{D*?Tm4Orn}Z)2L_X`$%NUUDj4|IoHv40q z+k8SAcpEI9-4sz%Gsj;w`5mS!N@CTlF-I-3gu0Sdt~$p z@%{#Be-f1JGknMygcTjvjfTRbmHII+8_as-2Xp#Vn}iI8VNO?PSo`KpN1Jqi3B-FR z&9YVw!x+{ZKhNWYzjfChC|BAl+RL3(HXqmQhDBd9>l?L4Ow^l z!#FQEV!r8Qp}*nax|p8cs6TcR(9)~0d8mwG(&k}NY*r`%dn(cmye1ury|8JzQvot{ z>{d_tG1|VK|EvSm5I>QdtyE|K@ypbe`_2VGLTJU{6aAu6W++n`Ig9+^)ZKT@ zPM&K_LLiyf{ZlvVp;c`z0lghW!RH-BR534c?jf6VxPz1R;VrdCyjbo(&_So`_PcDL zOi-xSf?dWE7nE?x+SqNz=`#>VOZzya07%?A?e;{vE5oVNPK{Kgx#)N3C*srvs5vz1 zuN0VhE>*I$Debb16i^$8bpf}g*2=Xca>*g6%96c>n9`4552ao|9ZI{!-scAm_s5C= z}e1-2TDDipVr<}9rjs{{fMVU33N~wo9zTG{&7o8BM0{O!Xslc zR^}Cu6Vp#vtXb>~(~$9wrqg2AluT8b>C^4I^T{6;RkB=i`a`h^`Q2CC+uT8USi@;e zA7Mncsuh_<(_Zw>6ji-$-c{5T87qYUK}-6^b%#a-c1=I*h-+{1MU(wi{p}ZM4(*6yA59m^$~jZM)8IIchm~<1>9%7# zP502enasSG^5)L4%_|=Fu*OJD@#RS>(ZhsYX)4t{$z^UEaAYu7{^~9RtXHmMf^s2Z z=^n5!i}u-(@8U)+umm;7FfG0s2v|#6lw!{3Tg+F8l#ew#Tpd)KG(!6iW?o%LXGqEA z^;?0!ao7ii>TuI;tZYu{gcrOSDWB*lp{kB3G+SrM?9kxJg|ETBMXdYbe{Tqw24lZ@ z#6rArdV zNH-_uw6nTQPc+x^^~FGBI8x3wtn>3!er>Q3&DyC)V&d2z5S?f+C+o-B2W+3-Z?S1d z6Q86&Lg+nxVK1_TgPz-H0@;fxF-G9AGSUE=kF{^~pnH%` zgjc}*;?xcK^qHdJLE_}~97l@BBdQNF3W6I|L9nO;t}%D2J-0q*W_k-A3$|Uo-K2B( zjLcIaKOSuhOe(Mn-dQMhX`e+xv%Q3OCJftF4${n)F@@%|{=j?`rIt#eHRUHPBgML@ zf)XY<1837F)80#-+zWAzW;$*5bwb> zts;KvLZaOsWqQT-J*iK_Fmse#{Z2FQNId+tBVgNO_WOZ1^RE_MK)s6syTZk15eUAz z#udJ8rq!D*=9@OjpIa)VtnW8oL~L!;Lo=<3%5Pdc|45&5uyhn-$O#qekSX|GAY5P- z_D#Aa0Nx=KGCo%*+rrcOQq@gWQnI)MRny=CxMS#*ArJoti$5 z9@nFU?uO^7k>$%f!g6{H2;1*(8s?4WRRxNKeT0#pw!C&olH)C9wu1UAxV<}8(&`7> zmwUH}6Hn7n*2XJlY2>$RjF*0f4D;gt`EdGYW@}m^|AW`%LLosOhJ49x-04lIFn93+ zz;=ZCSGUjMW}(g31dS%o`55=6ZklIZXx9oaYC1z_Y3%rXwBboROh(#s=P~4u(Q!iu z2tGe?IXgiEU$!o@e!AVAk#L@yShPD>fTxsdG!+T>2Fd+6ghiY3+xpDy{Y^_f-t1FF zdaa=0E|r;zg|LkqzzJ&=ICaeLtq$sW6Y@F@zF3*gP%!e6UfhN#)@A9YYGQdVQG&R+ z^^ooR?NF@h$gt;)x0Kfm8A-a`9F{1oOlwI&(+>I*2XSk^0o*Y>Q||2ok&z8-j#kRs zu!XnA0tLP}m$LA;(qGIbCQXmzrmJdlfv{F~4aSYDmL>V#ru4LUzRj0;T5`cf)QnWL zE$3?KtTS>R=7+6+?wMkF_y+{v$>MGy8JZz}cP z>nVqLR<&L;x)|S*4_Yt4B%6#Jm(7oITC8{BDwd}>H<=@PUB5qI{WzjtnL}mwAY%KSqE!*hFpRc+6+3z17~J`ae(|MC)9U44Y_UKnxbIX$5l<80$=$Wx&!bU5ho)evJmfTw3b z+J*&O>vSVn@F5}tasFp6?$z<}BmK){<#QCN^K4qFIe-klW{v0w5y5`I^@7L*Ry;&Q<7_(XVSv0IH&@`FX zvg8Q(kUUyh3bo7~jwY~2(i7Z_&cE$0B~O)9xizv4Ms}~!PPEnCWc^9|_#9F|-!dBk zXO7=dgy4DnRf6@y8a5Q$W{9o)!N(=;aPJ-Gn-lvq$bM45R?kc!TKKptMeB(lP3%b( zN@OwBLtyq*+%U7knfy z{=-jH7qWr^B)^cy2`+7SJ+oZ04gQE6UC}6_c}m;qY&~=yV#o* zqobqi_J4}}w?j~V6qw^(fEZw1B{2=SMj=QpjfI`w1#}hV6b{D1geiC(#0i5&@K0Wn zm?5Qn<;r|vlc{c1N(DsxL65{pX|bz8G6M>L7rGLZ8kI@4NKQILFgg^wRIRr9;o7AZ!jCv3OJ=S z?HcB~ryeYq;mu(?pIulaTEPPY8NTde69&M(MQwkydyD_~aeB~&0GenQEcpe>eGbIG zkEu8B4R|~zE=rX2y)l#rDV3Ipj3`31il6MZW4rSUl~;2q|>{p766C;WeyxLJqeay<8s zmSg{7;<71lo&J)iAbruwM)={WyvxA?>Gqz}zlpxW45QfO?z%#5555msuM;J>x$k z^?#>v1B~XoI)LW-@?TrluI|v9$JTz_2>zeUn*Wc_bPoDT2Gnv%Jbm4{(y@`|Xs$mj zdtBfAl}>ahD27O+%I}6E?^g}!nuAMHO57RB5Lsi) zV+r879|?+Y0r#>;b(43SeSryOny{8j0kd`AU`oz>!i7E?isdRtx(WAT!Tx1uik1cQ zvgjxi9fu>$L}w27Mf?6`fQgkr-G_A(D* zU}6)W7@{K&jF$iM(f;!MG>+3KG3s#dH-~Ww$s)*W&Xpii(=`I0$+70v?Ylvu#Ktjy zMxkfyB6Ker; zS)YdIr;o0B|Lc0>ATp>l^-Y_vwWB1eF{Qvb0n;I0$cg5+S(>n~VCuGk1z(H7hF8@( z85Mfvrn2oCnSAwb+NKjcsActoaf+sXsAF(t1&I-hqdWdKEN zKSwb~a_n;12aG3l$AZ|m^-=1%9`U))iBhW25zzc;g7Y}{j>B>?{6lxOs|?Wc^74S% zBsCpf=*8hO*yCbPac{9H@p~-LC%SxZ0}jtKMf?lJL-EfR97o0;?k|Phx3hw`hq#w> z=>huG85{-&oDbqsQY;wX;@g4k({x(g$7nM=v!i?)n-CZCllJvXM~#F^-j9(FpvLyh z0Qw_nZ~VB$k`$f$)M=%<1*x~vjMY;2o4JABujfU6iU1H6*^6tgzwx`LQF?eEh-r>= zLE#l&s_01dB`NJ9Xm##0tfG|k<-b}(+z`sf#%8wO9jV;v3AyPJx))K?c8+J5dFa9u z8tn{xmcz!y(>+&XStOs%=d=uP@;Ilz7&2yD57~N)9k$+nz#(A~*7h%w<>msMF-8P$ z8Re2#I_ia90wx*xJ(M1@q`Y?X)^&7mO+M|SW6wm8vQK#3Ot;+|Hw&UqhpuzlEU2e* zAH)LZ<5CIA%B65*c^ov*{t-az>OOn|Jeu+}ewvK^4kwN@X8k+%OD*{GC$61XB=~Z@ zd5c;7Bd6tb96%V89f)E_ zGrXrRP%PS*z>@UFxllF-y2ZuGuH)2l3RH@9tnUq4g&yMgdKmMCRSdKN{MPw$jGFcuJxW<`s>A( z%Ox4W(IT{vLQ>gyrc(dxYCT%Y6MM6p+%^~RZ~7iZ83|&G*q%L~)~%ps=CR=jGCKLw zM{m*9({?du8L!BZ$LV{kSfa|*xRIdMNhVH_>$OuBI#S5vd=WoPvF}KX+^iwvTOQEX}=i|PK98>0-Wdt7D{`B=l(^@%l^b|-W;B<>w zp~rO-)^O+>YNa8_U?R3Jp14(Nxyol84PSk>B%S)3tBkdqsbasdUJ5gsiJ{LaNBGQfdl58cl zVgogX7eJ7=b=fdhplal0wQPMiYp?ZEh8RVkr@VSRfn?=2968_`CYhBSoIQ82Tu>+BS7~lRL zzEkOH0t$CLX1;tXW!(Lz?|?B*<>Y}QbP3+Nl++V>af(Ya1alQD_lygIwCwWbK;~S| zL>1g+AJ?v{B<~;ZVc$1)oc8r6nw#yqOxQiN#U{2-jO{YJ_mr&2Yuz*+n!RG|I#yfayx1@B3dWFE>JIp$d+fX(qTC zx{wXRxhlipV|Bhp2(r(^?ee4TR&f`1-gM|Bh=SiGq7`wWm1E!8phr0_!`vcH<#qO= z<#d?;Y><7VaQo$8I=es=ulw2dw;;T(@TBnkE&mwN>iyruy2?H62=}873^*|FI<9yd z*pMEa=F6ok2S-zi6E>Qpaw`ND#9#E@oMWymofBoM2UGwoKe_ScGE~%4^oOupV zAlaObb8&H8aRV%n)W#T;ASxx3fXs&nZ|?^H^O^io?GZOeszvRSU3yhnCV4txeJo;l zGceRlQ4raUIrf**O@^c`cS=DUP*=1I_Z3$HLlUp&Rpk!5>}~nG#~d2aD+^XZxxdixbLULgZ554-$4+m?{0(6B@RRffdOlKYXCkw%4LgU~H0#U2kCO!% z-efOTe2dzfOYLZS&7n`QUv3fxrXCW6Lqxv;jDT;&8NmVkMY z#dtjo)i>3qF?4e{o!p7>iBh2S$_>I7li^E!C7;?ANv;^edZ{_t{bH{uVm=+9SphAl zeB+!@^pAAkHRS%cXYHHjk3#@^Ob8jCP_)G6S=T_9Ivae8)?6Ak-{gkpYsK~anZ<+bGUIy)}tu6}ac zu=jBgw)R^l^0fG*?Vf0VrYiRPoi6f%Y+4t&Z7W3_(KW3cGw?nC7deDm!WauCE>Zq1;i;!G?q z`?GOuzf#YKj zh~S_6yT+$!v%~)@ZTO*}CVez2C8jQ(s&UDz!(wkUMXZQqW%%X_ACOxGcHF1;1G+ zD5u6@zaQ%){WY6KRt;F)qjY4=BC05+{jrDfcfS3AP6T!$+}KO=C_Iyc%IEXwc_K=! z&%N<#Cg`x`LGks_HO^!NV8mR%thiI8LO%q+bgiz*xy^|#ribIHDFJEzBrT64klc*Z zcl1&oQ2`>VY@2Z0#&OHOH}TAU{lpf5$08-2;Od7`-Rv>ou$K!fX>#&8MX6wgwvFJ) z`;SYJ!F}6~bs0ZSUS-KY!$sY0y~@<2)Ss`){g!X?YtB$|UHN?nNU`~-eF&JjtW=Mf z_YS7KY;3GCW=0}(Y;B)bwJQHio)W}xto?05Z!oFQ7RbNdU=j;YPEhhfF9v*)1jZcE zTiDzv0F1o7_@_V82PB=g|0-YR6Pe{W&Dd?J?Z@{EG)PgUYg&A zCF^YlHw>E22X0m|D059Syd@XKmhMlUG0zPz_cb@1njK||>>M%+-VOro{0xX1x^8gH z46vg+ddUA7hweA<0_${^1ZuPS+On{_q;Vr_R^76A)W~EIYgzPoFDyy zob_eLpI!f$JUc1{SV{tdri!EA7|HgfC_O(WhXL##O&Y@w&WCey)+K5Md`=-Wa%qLwSha>TQbM@xmmVE=*Yw4l4!@pqZkZ%H zgKsW(O*fJY$J<}6o~F(IG(Ut20OU9lGC?a6Yk*yDGLj-urdgfqanKx2>Ar%rGgZSN zMC9(+wLJA;l(0Wty{$v&-k2CHweLB@@owLPIyrET`O zZ-emEGN#Klas-(*n#Culb3_ zhz@$zeuqzTv{)xo1D@meVAOy!fFB z{+JUmgOBV!_jmmP`bi(A^iU2kAHy43l9-$q0jTyA!_7~2MfH7nPOI%!sn_p!I4uG< zR;4KM`uV+^D_)?&iCtjF_S2*|6AjKA%48e{c!GCHD#Wvf+HbgjbOQT6XUSwr@yM@3 zJV?Z+0kp$>gF<>*f9&THV#_z-Ex;r-DcdkM53mzkYinV@B55bSqp`?grxx64PtEqD z^b8k_Uy9X@I!@i|!G6sQ5hzXZQME88(WOQAfzs=rgP=m5LyAK@#wi7d=%#|1?JFuA zR%vOQ@FX8hjIWcua+~nJ+LFoQJ@@1o{P3V*4U;CfM6@%}#n3bPUD9fs2kB4ht+A?m z{33^E=_S|`>j}OVHC7DqX$>iq6l~#?l&q=6r8DBOc(1ZpZ}F7&<+S1<_3y}GJU)?sdZ)3furh~c-hfoiAgDF(y^y@*(u1zB%VLmPt0>36UyZ&K(cA#Zj zGE%qfP;ztGP>@lDv-}EV_f63K0mXgA=d!4fDC)9T3{SBGEU4JiBbe*2-?4SMO>a^BeLr%~rJu$l4b> z0o%4?n*Esr*y@iw85O=SD1vpC^QvASZqB)P+fh%&%hLnKF z-oO8sxSi6R-HLmqWSou3i%_;?`3YK8-|6U~l{EW@DnTNm-{P-3lZ9TsPvwl;9VDQU zms+zL8*Paju^4@IO?&0j2YAHP>`pwJI}H33UxM0?PF%}TGx+)Fu3+8JN2-W6p_qf5 z%P3>Abn(an>hcPlUDs&+l!P`GLeK8NkD~(#*t8Ln8@Ncz%OfhmOH+z%O5Z4&E_UKd zrA;dS!*R-tzrCS7@FjT=_FcdnI;EP%-10}b`SF=2fF+D ztIH^+S5;=)e`by7=S)7ds5??mlIm;2vMK11Cmp(JEO-o2ycT=}BNwWpRVhKz40!3e z?lSoBjj>bsXkhr+RP|qH6MwEb43<0EpD+E0z34^f^ANYt!(~}E2Pjb%l)?Am2}1v$fOl)GlAp*B9`U3GP1B)v=M+0g=4kOLGNICT2$4PtIM3G9WyGH(} z44vnp5M`YceHNYVZI&MFj12EJ>79kDShnB{;yc;Jj1TGjO_Gm}+TdKk z8}tKcF(`{hNvhPp?++#>rhbm%JW~#4SLe0f7nbY!IIhnujCmOYZ=*qLd}ON};9jj7 z3zg32dF=f`BS~VvqYtDEp>~HX(;u1f2H4tV9v6RVU-_I6+H+ZR`*PQ1FV|5{_{x%@ z{paRJv{=Mqb; z&qNM!`g2uY{aiFldy-m!6IC#MghjFh2v~+-!Og>QmtGk`zf!9B+XL+5!Dp}mWRfZO zDL(mckRPLOrB`}qUp`&~hHUM85g9ykTT4C|JMR@4pw8iRHz;wF|NE7DLq970f(x$o zj87PuoVrxWB{b81_i}9Wt!d%)f?Qn1rPYnwwkV$+S1A3kwp6SOMfOtarNXToe-SJ} zyOmb~wgt=W>Tnd+qksNF#5C&TQ`u*mcbVzGyt%+3ql`M$@4wk%k+}@?wm)C7wW(lf zMsqv`NcBbf4S*wXi4%)?iE24kZkNEP0SaRPAl8Zqb8>c#Mm$TgW&BgU0mtF z?gQ~~tE;!h)K9@?Ryh~dpgaSRDk;5>-lP_7&lg0`K_BAPCOP%5aA@G1yX@IEp2H;H z={^@$ehT+^dODG<;dp-i6D)}1^sAn#iHRv7jQX*n4(!l{#h+HNb$p9<9F8PNLcxaX z*Y}sf zW&0c^UG_N;_wp_aPU0|_vxeKK8PraI#nL@_?0E@}5bhm4^#lOxK+YH8Z zIh-rQCJ2h8Y@R(rQr6oH> zwUZ~o-&<~LbKhI!7|iqkNJ!+s5pNs@(noHfv(DwAHI^cW_(3{b7n@v5&cCINQP@2o zE@2}=4^|$?tZ@)g?bjB0iN1uLUCFTu0RNV)w|y=BYku|MYFF@NqgDv^&b6$bG+HiB z!P~$41PhNx8FLz43oeZX->DzKuq;8)oT6S$4+HW!ei!C$Mp*yI)=sCZ@H2F|0-3cKMuzuw_ z+qPPAr0URd%aax+GNPOtM24Ojq=%k)`b#UD?vbUWxQ(*?1~6)aIMufk5O;QcN`WO) zbOA0s8Ua2xA_0&mmjK8;${ovgHguHW`xw`7I=h;Lz8>mxPCyIi4TF~fLux@{neG`2 zMl(hF^iQxr+2#$Pd?$i)n#G8{YfpwA^M21UrNCg>iz<%DYPoh1P{%m=IbuCOMyq!V zNcQ9jHYFEiFeq1U%;_;8JK=5pPM^DR-kp4|=Zh!w zxf=(P;kjqeS+%g=^A2zh4V?nPS6)3Id#_yhO|$d`dgsK8E~8{hN>NnYYaR+}eszcT z>k0K-J)H#D-s_tUlP3V{>`8321dp?Sk6x-EHc>wy0QP=okc$5EORNb?!1~;JE3i#J zUk)y(XEUd!>y=0CY;zuTIUh(*@QCK&J4HQdJ68S3N}a2IyrmlfsiGtpuTL(h-S^x? zi(wFNNV`2A5EW9bHp(p+J;vlFiGZm%xsT1$<1E~2*|rr#1=kb+>NgHQo&iYD)7jfE zL$H9zF$pM378&!Ib1I9VQw?uprQ0;AzkNRk_?nSD=&u*U>fM9pX@EX5pZ>$|nvGsG zV8;3E;f~Y1?C8%1{&>0njcKPb9Xz)hDgJKc&;}UcyZuUImCVu1gQn$ zyJS2Ro2GQ@s2ryO4lUy8!i;2mu5aQ$ai+^u-1AJCWS&eX%N}xOh)~s~N(gM(Dp`s! z%{!)H;pvFmUZ%=aoM*^@FVabkGNZJs>uAjek9#>I(DB%_w0-S`O`~Z00aj2Xw;>C7 zcM@h5()h>Y_%G*{G~Axu(ef5Pc_1>l#Oev?`>N=05d90pn@A66YpqIoMoocMzt~aT zp6!%TOD_{5RGmR-xagVAC93pH5xSbiwyVR2QqiBk3oW9R_*g9dh))6CcX%9?Xm~=~ zj_R!>_^)g}^0Jq1XFVlGd5%a|cQ=Sd`%4utZew~pvrKZYcXn4UXIpOXgLf$5Jx1ub zWTiS;Yq_q6t@j+HP8RnAlIu>MYr1p-vzO0WT{Bv14e!+fn_*otHL{9L^RBFCXYSMI zlBWS}(^Mc^PorhbY$-!VCFFHr$_TMb?iWGredT$MeDCVp}9y;G~eO z*mA6yS1z3`a88{9(c-!cXbX6^bX!0vy|(o^tDUco^xZc$660B~vfMCJxSZ*kswU_rKpthsez<;9+F*o$QrvZrDTq-qyxFMGAEFZS)U4nq&fx{~Y55AYX^_tC zD96?pn}&hp7VN&Ex5GdB;~BQhNuH=~tp+Nzu#h_;l*C&O)Ju$PHg0N*)Xs5K@@HT=3smuNT2XvQB%B+!wBP3)J*Yn4w=;PUxAw5hQ88P(-13CS>Q2xgf8XW)3v8e&@NM00wk zQ*jQ&IC5dkcg>^rHDjRFyp9a5Thc)v;D?%T(8MBWj+`>RD;9y&F@n}cbu$!4IbD#! zY-MlX0$2H`qGi$NP6zka`*)le7SqR$XZj-zXBKrD91iP(?%B5yUwv=PL`|C7Uw8V- zzj{I7bbuY9lsqm}EYGfb8c>rxRjQFwD@VeugChs;=~!k_zv2f-rrtli&mDQI7*$qG zpe#ic89AKb`eAIJ30zCXMN`suR;G5$Lf7?Q`Ai7`eKw2(y89^@5(!UfK^~P9V03~2 z9LAADru6D%4g;!iV~QLzcpWz%>RdOGKEE_2mx{cMAu;5j=}t1^7!yB*_^UNPw-s)d zcp;+v2fss0{{MvPbdWizwzbJXT~agpqW_vp`G{L*&eO%yosMoh?_D_<&)Og0^y z?Va}EnKYJ^SmtLGxnGpkmBdnN_!(Jv4Y4B`vJLwC^y6ZV7u|mw1}s(6ataIIv#j!^ zL;P4TnAJls)>v6aqxMP2>s4>}>oN)`|A1EyBq@4(2?rIl*S6vxK9+keMG`Oj zxntyxUkVHOiOlou8tC1obj4wS*HAE%g7hXg@0YtfWY-~DAr`sFjC2M>fmcPgOs%6I z>uGpdH)h>Kq8iPaAF3S??#{i=>$HV7&rCyp=%*CCw(6#nv?@Y&WF36{CC8|X1>85N zsuwoO0WIoP5nC>seZA%6KFz7HPkF6w!l?dM8^eo!-K63h7fODA=w+l5`pzPATWm1R zaH7Z3?dKrxTIDEGo-tMh+%B4due^QIXHTAe#HHl;xOhF)IO*Jvr|>0Ff)Ew8RZr$v*`dHX&0;^;7Yq(A7zAAt^%H*0K_4^JRIowp7{ zlT4F2`U)ofv>An$U_r`VuvI3Nk4d|5!^Y+XJukVEjyu)OWqEp2;YB$IHpSN}kwv`o z3mUGsRG9Nxy7Qk}dVV~0Tb{kfc8kt6Z|+&k+O%g0omnd1$ANa(UX*HB%%XS>lcyi* zZGU@nvOk{J4|>D<<+4F{5X#h&CepHhZatyAyy&{L;d!rKZt)ywNWAiB$^o{q7lTjx z+qcn43Cv#ZQYZVfP~uz3P$@3Yb56TugY_y}E*^Ywu?yo$5C*YsM0U^#Hm#(;3lOS0;8^%~W0B_VMeXE6rQ8FnE6@`Uy>*P=)COi{9A7l6r3O<+G1Zk>(fd!K^Z?d0F%4+Yvt4#AwKiRLD3`RYHAmIs@GFSk?R#KY%v+#}1Eh0BH(o4o~ zn0rB(uQi(cG8<0*&@#Y2%!PcR0JjV`umoNWM$D}1+%23;aaTF=Z}y9CDWf5=2gU#P zx0g*q`Q8lkwhAGX?nln5%pyj=?)|#Xb0@QmJOp7dBmB zAXIDa%QuHoZBkCp=8q`5kq?z5D4s;Y_RkNW@wpZ^s*t}r;)8Kr4^_@)kO>kcjF{g9_s`~>26mA_C= zQYzUXZQh8u82^JxsvZM(0$#%_+`*eFE8jLjoN`}aj!B_qeEXG-e_l{Xips<9Ht;5S z@9wuTKRV+{U@Zn7W%girSt(TO6T)i=;>DgyMqDUt%i+Y-GQ7% zn z_1qgQ?`G8e>fs929CY;-VyZs`Qiwxk-#s0fJfq1!*74X-sbPGQjUT&|QFws+>x_c3 z%YdItDU+})Ll#Jw8Ue7dvM_Nyh2O7R0rXNKsx8Qq2z=P1Df#isg_N(|;7nC$f#g-k zN+bqqs-&cUW90(Ulv-yTeztPGKQ8Pm$u`0S`7lN{z3=c1AacNx-+Z%E;?RzSQQu=? z0DVG~DW2OqW%DPrV_}8eWp_#rkZ;W$eA8+un~}V6&I&M5org+rl33q)r7C$P5lN(* zraLEmafj#a+={m*J=_Ktkqdg(*XVpbRJSaAJp{WtCP<^wt#Ti&uoe<7ALGxFEI^cO z#L_8!kMdd{nh{O${v>$)+!7LZPecxe;gGeP=2NjvQ_gwnVvghm?O72iN}Bm%^w(WJ3Ry3H!z&MIGk zbM~R`c~B)8{k-~zu5QNfj{K1|B!y!R{nqv<&fq~|&K&`r59DkTo1TfDGD1a==S=2{ z#qp_4s9jE-_B{=KYfLG+jK;$=hX3>aVM(lprJFPqAy^&QM&rcMlmePKd$sOtNG5BU z{KOmkDn61n{(ZrQ&Ih`#)*gWIBgU` zI^zf^pG5S2x6Z8b4cP9EBrh?i)k^msHH{|Y{3M}!r(_Qo{-DUvwbZ2OH&ea@I*kb8&g&B*}s000Dn#%pwOwwubF&j+hk2Vu>D zVj6=ti&)wGa>Z8b;l7on6WIHmM}c^ zw0S3ad9bKehHIm>I*+=EG4NUsaM9~hHGSTD+TpmzVvP1}A_^|1HLKjt@kYeEJA!ly zA+o~XqwV14X5To1$#Nd_o68>Ov)tr0@IbSs8AZ-{gHru7!fc)ZxYHO2CCyEF=l)hPQ>}mj0n{0ev{~FDABz^&z|{33+n-{g_wLA$SgbdPXdq7+nFc=-xrr z`2#VkCSx&qRCT%k6zKEey?F@Qcr`sc)gXQ^BxeKE0fz9_o- zcDGH-?t`#C#IY5q!oHL3>2l)cMMSx>g4jF8$9QCjppVc@!t(w2CZ_8^ayPuVq#`C# zzRTKJda>QNf-8kC%mxp35tuVD--tf$Ee`lt@PW2zL|?+lNZ%pD^5ew{y( zlyzVx)|p<0U8KcxM2PFAEZ0W2Eb~+UG~Sobb{hoewQrC`*cRO~w$6Peqt^FTt@bv9 zek>Pe&=iZ|0>67$ZI|j4#fhl|J`mynzXR~iWp7ewKbP38yw(4&OFkbWC{p1$imCjs z#|YUBouGep^-;a>4L*^X#BT6r=pU@=Y))}t+!Eq?zGIwtj`<82USg->4nX6RWkaZI zurLJiiT>3YvLMtAz}v(b1I1_tqEKNz``6nY1;u;;?l%ay-?C2M=zzfgx_=IEuIm4b zZ~xj5sv7al1c&S+3e}KgdDwo2&O02kfLF(dvs5Ft1xX+_btLj>C&;*;s0A z-KhO9oIjEgj#6pvyhqnHRy%KKy-L5oVCsZ3^oz@h2_4#!dV$$Vt2!YRqrr{VAG0XV zLIcOQX1)Tgz7q-gG>I3J2K*g#G6?$3kL21k#yYW*nZ*}IB#*JhE-f+-Gfbl0y`T8S zH&30sX%jxF_B36ig&lm1J22tLwyW910oEY+3kPx6W}FkvQCs42<@_11Cg-tF?F2$T zP5=AX7eHuuWS^c!@hr3zS1zhaI=?1*n=3os`$E28{qRqiQNSU|F63oZZIPEim@-E4 zf_x74)92-U!4>KE!y`*k>WKcM)jNN$^qd>{bJMfh-w4H`3oa$0>dcG4tfgdH&kRa! z-lVyxUBdtmffi#WZPVyh&@|Dnvt3?W-{*hfY5f<4ce`?%s!x}u1Gqv0G7PT37! zU^})nF_SRwAcZaLn(y>FrVrh;xSvPG2LdYwXWa5;-$FhHj%ibYVTi6Q0;Q*QCeRU` z;53{37hjlpC=%wpV%tIQ8ii=_X`%e*!|?D?_Ify*jTG7Kvx&WsFV!I^bL0zA^?CwZ zA3cLNO^>T3(nqdz5X&AopAQF>?)Wgg3-VPxrFLnlI@^_t`71YH)i5>5fpJ}PK zi3x1~s;JuMCYy1+x_R?ptFu1#ohILhB@wan%)QAt0=>I$sBO+YN zLol+t|MyO{dDkmTgALE_GU0!9i`8t%@!ji4kuIm&13cZ9p8FYHlY=TjCrDd^@b#}+ z<*j39qD_ywboY!r+429QytfRCD*F0{K?Fov3_`k*?k?#XT95`o8l{Jn5G15K1|$ZC zmhKSg4rv(a9+V*@2A+fd@B4n956}C4ey?|~>wK8&%$dE-Sqrl4$WLz_TId zyL3`4(ac5Pcl%Jk8sW#2nhlF28+kZk?%=tdIKy3A3DianbO+ zMPo&V=?i|1)Ul;;U~CZjiYR{xXQ0&MVu_ayH<$iCe2b~xF11|kT=2h(Hyj-Kr9w+n z>djvFc-X7_fI~8?k6)g22tNEPM?2_2Oo*N{4>fY+w~LRf-BVh{t`P+NaN4=C#NOx~ zx6-i<3CSq}Cfc96huotSQlm~CiaTG=F84LzG8so~?597(!Q7%ux98dEE806lKPo3+ zPe`?9<3vcC{9_lk1tcNpEX7B^t}7Dv$BUb`ks34V8K)BxQE3mikt8EL~?Vc_%s(5!dymStbU@i?u8X1JPESl+~HEEMk5k)4^ zPjcaLd*V0#l0jsLwUOK~&Y|tm9pvk?U2620b9YB{c)2gtP&;{Udph$Dv!Pe-`O#H- z%}nO@pGMDIGb_y({CW9b{+vFz=D5OUIy#!H#B_jbg>Mb8A4#o*9Eo3`0g_jTdYGg_ zT@V%LL*0Q0+8U_*+{q)><4yAZPH8{LU=Hm2fScSn*ZAuzI!i8nR4UIY~IIY|0U#+GZx{2y<4d6}F+tZZUmldc(^`vs;J(G=rJSz{Bd(rw; z`;T_VikibeXeLh{YE8zSlixdXnHS>*W2c8cwd^yvBDh7{Dt(!_L=gUX!EJG~5-#4G zf0!m|)pxb4p`D)yJBQAXK@3#Tc!_VdQk_cjnTL%$N(Lm_7k0@#&rj5K|Y#IgIMn*x1<0l1cLJ zh^Z2DyZwwnGfa2J((9KC05R2vJwHYXng$eEtmUc(h^bcOER_DQn_LThoW#&+$pNMy zj~M&1oz_bRlDpR^r!A*!#lDpAkmK!|CN{+;D@xkh1wiTvn?tEXGv!9*%(V9xJ^)QE zb}Hh>yWh0~4XgF!j@kW+r>V$589-@m4Y|g$AIT6X;cb1}pJU6y`f^jYyB8VpP#S;aL<=PQQOXSSVLroVAVu5-xg1i#rU?SGm!t0f5L ze1_Uw&2}wv%roywPnqh-@|S%-tg`C|jRHlN zo@OwA3_o0OFxlxEo5mNAyPm7?j|-F5?2uC;g4#IF#n66_RTmTF)gYbC&gDQ)nx?KL zxW>q#x*ooT(OS}HhkcmmO5Z_+qGc-ZcIiKZb z6iq8sY7b~HKJbE?YbLjp34IK>A7(#aans_-n}~{(q43e->4=zIET*;WhSHW(1Nza+ zAe1P9(0e#hf?Dfa~%>!kkaV-9OD0i zx@K01|E|MKa2~J-?~H3=**sZ{DPeSpU$j}HpU{=DfqyVZyFVTqppGA}Hcmc?WqdBr zuNfNW*JA~7pT@~1mEEn6i6N4L2V9jqq^T9tCa{i23WA~~iDi!|OTlZMiq+^_1Fe{p zKP3d@crjlGIom=W&}x0yiUvs~a)ONCjsv|p zrp#b|MBV|OtOQ{o zB;1U=GEK^VEx~q`<=b%JJy?ExJ(oUXrQ{5pC#vom&Xbc#FDfaqPe1$7;Hr}|yZP$v zGVLP&0Rz6qPTKdXuQ{({nVYoow}2FnB|^^ek-Nw@>)9cN2h*d~2(M?Vzt7&P{1z-y z9e_Mj+U;2A4-<@4xyYneJq=SomfCJM+P@@&m6Rk54ywr4A;}!AmAL=DfELjf4s;il zj=MQG6Kgb*fMxTL`&hST`V-i-!ouTydQ!((e3D?Zd}QEiS|L_ZVd0w`n`+n&ChT>C z>CG!c>&liC!#1Bf@ynfj*MqNpDv3<$8$ggYaU@g7XlUyp`*SC^~6rY0-itFA9J@P)+8_VDw1c z{3c8n^|1fEi>Fl%$fwspigvbVrruxj9A+!Djg1{SsOA54sd@_sV``iyXOMsyzQ?1s zD6q$V+qh>z#_NNnJ~<}Gg4ESwH7XiOtkw}#9#|Q9v)uk{s2OfXw@=WjtwPwMF&NXX zHAudkcV_jl@}M9_+wjj0nfYvPrCz4ol*-B=LIvr(rP|JBWeEu4X$BS+7- zc+}Hk#Flu}bGQsFB*LbtEQWPXFV(9(hTT~M4`0rUzE9EhYgc0HFl5z}x@OQTjViQH zM&6z~6xv%qZ|xO)YjzqoqFv`$=SX>*?%2A^YS>_g%Q}B*Jn!CTu2*^JCx9Qob-vA)l2 z5q!z<UDf3P>D746j(Ry$aImQCT`TE4H=x)4X$r@ zfPIqR`Lb7u+EwKgN<^VQZ-b*=c*L8JHkz7YHZXt~9nZ^-@g!E~W=5&daJY(*iG zFcY&_9MMf^QSukwp>mT3tr$C$_fKBT4_h#sfdz3rJ=KdB)V&EE99t|;xHU2n)D?(f z)soo>rOm+hh~(u}cEiBcW&8P%0@tVJa#(~^Wr&QS(6NOER~GU20&R$FqFTpOBmwnFy1u7e=e-qetkTY#Pl!>+XlOjo(u)ebWV6t(J1@6 z-jNlEbY`}mOsbX;oZ#253vipVgpYk(uV}xtZ`vy<-AFNV);dfRbjr8}XtH+dfbgbe zP?75uKlQbW>_f{A82Z4lK5ez*J%3Zc32`;g45PGeA!$+St_LZH_rViHRP}vg=vVt^ z#A&HvwOZi}R!IcbSqLgNy;9YK(~3o!Olsc|jt)d6hxLBra7tk(&24JOb&mk^Zs%i4 zHS8ElZu1Y@&SIpQW0Ig`6hvvQ#l)mmZKC*+@}Tnnt#POF%G~Mn78+M zmM!=aTkwcAbBM%?H`%2;DbeLbp|F#)Xa$PsH!?AXy5#--o4=cD#d5+4Cnt}$Sg#s%cKwWKN*epTj((%&b#DUGX`Mj(2bptQMkgjLm<$ePy;_9dZ1IS4 z%glK$*cA)Y!--f-h$I~ittdB$_gcg;@Y9*)@MfR}PIlm>8{NI> zJbB!C=a$1I*F~3(woCEP!|bTl#($5%ltbcnBlH1SEm6vB4beTghQBDe%cIc;)Kk#EX&lwT zhM80gfUNuA2Ld9jEb}ce$AdXgN&8p0p?=#0j|PSUOaS88mwJQrf&FmuU6nKlWC)Z5v;z>2}Ouzh`FVSBZ zOg4En;mqDF;=ZGqF5r+-vR@9Q(r!`}<;7fg>!%Gb4t1BSki7Y01xg|~lw1uZ(M9}F zr1me^O9P0?k!ltT8Wf6hKdRuj%NQBVvPwz)4#?2{33PSe#@?O&6#s%|(XSnPm8s&r zupX0c@hPy(eRayXC15*Su%c&9{0`~i5?YC7#5rg{we;@3dS(!d&3I*6cAV?jSA-E( zV$~NzHFYn4yz#wNI(TFqi9yjAyhl-}&WdiQ(u(Pxd96tn*Is#78b9q`BSdRDf5}TBp z&-J4%Ni=l+=?OEyNpH&Xm@$O-6$?5oG+O^>t}B78#=E%2R-tmgHWl)IDb{qHF@)#1 z?booK4~Uy$bPG85%G#kihg|nYP#eYiu_d4MkYYJH5j*>Qt@KfIfPA<9wbYzoX2wFX zD9MzJJoNa<*~~E(tv^;@Ls`i>e`q{E*VIuLqx-XOB43dZWJ(0BjuAr$lZknjF<*%E zi?>}NTG(ifT(Hv+ykq%YM+m@nLZ(4XF?qppH-9s0c6T1 zpl&@Ij+(Y1Y=fa=!7$+s*;S1$F+}4_b&HItWNvBTSeIDYar0V!^u$wcRnzpG8o4#1 zv%S0A(h%vrip)He+{q0|Sit+Y;ejFe2<)Y;oqmT)?=#fq8l4w)o{m%s!*MSE4+59K z>*VJ?hDS-N&ko)zcs2QVk}4FqG8iF4J<=Q%6m_b%F{@Xb6$_3_3uo4E8wPkZ#$vR_ zYE&B(9z&6W^+yhs%xI`qQBn+v9jiT@Cgny4f~%2ZV8yFa(-11TU>j&oiR)v8oNQ&f z_zb^hLx$Q(vJ04(;yOsu9x0`M{A~ws2QVd_S>$q(&r%emZg&yD&PPp~1pYAv#ImH_N^4Vw#tCzN_haB|yX#i^+t-U~f6oqJ2guCh zO0OIv>~+4IDS1RY4&SpTn+HQgtzsN0zLvq(c%o7l^?eM57-QdGSUqNQsE!dooSw6H zmmc66OU37)PKp#UEA_~{?$a0Rh57w5Fg#B!E%07EiE_x36tCzp0}*kL0~LJ}{mr%PCIqo(TCU5eTn6GR- z^qQlFfV2?cacKMNyO9(8lu#R&e`ZoZr)toL(~Cp1S2(olM*o$Hn6bkm7^nN(6CicA z>`Pjxzc9+QyE|pkxr|I1QTy*^WVB^2ZALt#oX$&aIohDe(TF6ajlZ60KWenDUTxT3 z;z;gI!czv#Z0*LT&VL?7Y!8GD`6_Q7FrxRujO)dPG|H(UyZ18p2^x*-@hDSuUl9Sl zwjaup>^^w{o&F{Em6GHQq-sq#1$TV0%WX{JZ8^q^*zT-!CMF>17jPKCpaxc_0H57+ zueQ|-UtuBB{lfsFFS73VUhAg^^E!-m^cNJAA1f%7kN^19ucI&g0WzRyNmO9rtP5`g z*;(|c^h)|U|C$yX<>0-Qz-R~CxSWCptKHyijR^UI&MQ6=V%8B~zNZZ8Qy$)H98JFC z1~0rM`Sj0(*F75!$SAQIDN)_@OsW%A=T8zP&C2TiQOmEr76V_Q3hHTHR9rA9g!pp4D4P$AM*&28Ma=rIFX9a|86r^hNf}LGiBf&(upYyFEHCyyD1XFPcy*N`o`;_@-9e9XNxoUoB^RQSq8Z#(!S6+3(6b@-!MNpGPGr`t9RlGE>h9F&l7R93$P8+U5K|;!0wi{}V;Vwc-q1Hxw z9HIzw-=7&F(d%|t3JO2lYuWsD+xULHz-|gU+2r>22NzI`&#m=$FMzmmoemO!64gx+ zGnHA!h>E%IO+iw>;8BunAF`l?l5x%Nz+&6X zd!l_@inPLd8{Vt!sqhMx)--OPW$rg&)DS{yi7|DBPPGg4?jRQcO9X&%3~JI-rq7kkBYYr-wffs9tCKQ)S z*%kjz_O5cD$C1>+G9h=5SASufN$q!yp}b0b~1 z@PlX)#J3j`dtuQ<2i(~<8R+=YN<`eapJ*`CnWv*R?{jBw#@enL8?d2u7UW7YcxHNa z7xIF|J#kS)qJ^zSnIim4v9U^;X{Kz_gXZ!C zK!B96N+ZR(-`isE$*i?FQ;$zY-)&%78kdno({7wCY$mM5lB^i7Z5@){Z_!G2ixjxdV3zW4@}CabYHY6fesL9g=fRDU)$d z2L{Z_IE$!BAiP~$quoaJ9IToc8oNQMcFR}R;PsH(#-p#H!2wJ;feX%=VcFIcebdtYtDM#{m|M@4EF;%gIi2z~-Z;>&qL+lq){o;jmB*^=5w;^@$YC4v|$Zsrlj`Nc>oR1Nn-MVQb;$7E23KgNu76MBDL163D>fr$V;jvj?{ab}&cW7r8O&>PSWaE{(OW)%s2<<5k z`dMt{IUsFXl~2I)nOIp}ESm?h0YP@M6^~pahIhr->2sfLwC-25OgF1tK2JM)c~3Z@ zl?NXiBjmwT=!37Fj5E%>`SJnpvfqi%*nG>-1jai4#Rpbl(wZWOkIJ1RV*eNq&Fg`s zmKyuOhgPw3?>t^GAPE|&9J+6}R*-dOyg@gA>-Jm(h9L=jtG zmUAm2-k@LylsXhvAFF!N!dGB_K@MZ%Of0#*p-CR~G7~vBsx!CQOeHWAsoX}rVtdA( zI>-oD-jvSKVH+z6`Q+i-T;h5RpN>jp>!(j zg)303t6D`Oz`A==Ub}jkY)^{~aIVTq3^yD!X1+m@nOOTz`C7Nt1a|*ud$M1-Q<%cv zY2t2O?7w#2V}Jv)ZlFeTuc|euyv(M5=lgW})~`~k z-$_kS+k&^Zsw8h;j6$G`Eldf6T*7?s5dz2l82kgtdCwP1HD&NBTaj84R5@0YJ^|HUV_n5KZ?gq7{0mV_xrr zOZ@i)&jO^zFwM+rv~s$|JxYAZ=}!~q?~iLA9Fl!_BQG#Sxz6Xc%hq%468NrRy>2Cj z;k~2LB?^S72NOWSA@P$}XlkVOTxKR~&PI*w?;VV2lyAu zq*tH-`_X{JDEs5X|7zx*!H475RszRrbLXwTdiVSFsd1nRBZ)X_#4}B@?gX(0K`{A8 zQuT%2OB#|vVrxulX}L41yg5Q|F|=69gkuP$(fFhgLXYO5C6OleH)UYcf)zN7vVead9U zKaMnge%oaHV_m*3?>t}|{sOyhrw~GWJ|300vCsZghhgAl=GI^# z?Z5<-gij=X(YpBP2C z_jZ0vZ(1o8xk`p6K}PKoCyAG2{zra$vY7oX#oLEn8%1 z%%Ha4^*!f3cv{zdb~QE#)x}W&MtpaE*$W5rj(m3N=oOG8&2k6hIerbo!HD;-l1cXl zBaQbw{4|k-3tR1D zog9p8TseI_d@|pu=J6gZX(ko@l-{ZDBA?j~><{9kV6xj^ovq$tTu^4_Zp z^V&r}s=ogbeVVtH;4uwJZ>O9ma>5>i6oW>i*I^chn41aG});V?}G{`D(zgZb~q zxdQ``H~~IsjV15S=rjj0;6Mg>+-emRpR!cb-+Us!Sijx?c|eD&PkVej=M-I+ZjX z6_z@~%wY}$1nNH-o57OFZ0$^h&-<>ZhnF9;S7!&ovr7E**uv}~vLIg@i*+P%j0QYd zXzSJfKX8S30gz2?Zw-gDiXYSWXgV-nL}iwpBQGE5tCCKhF}@t+ZmbJt?xgnbOlGIt zW0RMIx@k~F^qNOPoP+o6)*mf83xIwX$8+?tFe3{_Gke8*7V~4ZdNl&?$;-4lOcx;P_BDkCI+j)jt ZLi%oAV{P28j%dI~Szbe~Le@O^e*tKtWA6X} From 9573f7828baa4b969796625a2869cec3ba840dc6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 15 Feb 2025 21:52:41 +0100 Subject: [PATCH 0562/1941] Update action description in ecovacs integration to match HA style (#138548) --- homeassistant/components/ecovacs/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 723bdef17f8..44c51c7ae43 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -250,7 +250,7 @@ "message": "Params are required for the command: {command}" }, "vacuum_raw_get_positions_not_supported": { - "message": "Getting the positions of the chargers and the device itself is not supported" + "message": "Retrieving the positions of the chargers and the device itself is not supported" } }, "selector": { @@ -264,7 +264,7 @@ "services": { "raw_get_positions": { "name": "Get raw positions", - "description": "Get the raw response for the positions of the chargers and the device itself." + "description": "Retrieves a raw response containing the positions of the chargers and the device itself." } } } From c75707ec79f50e8cc24a06c61b43b9ee610eab3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 16 Feb 2025 00:29:38 +0100 Subject: [PATCH 0563/1941] Use correct inputs for relative time and duration options (#138619) --- .../components/home_connect/__init__.py | 33 ++++--------------- .../components/home_connect/services.yaml | 24 ++++++++++---- tests/components/home_connect/test_init.py | 2 +- 3 files changed, 25 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 01eb6e8fbea..a020b2370b9 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Awaitable -from datetime import timedelta import logging from typing import Any, cast @@ -74,6 +73,9 @@ PROGRAM_OPTIONS = { value, ) for key, value in { + OptionKey.BSH_COMMON_DURATION: int, + OptionKey.BSH_COMMON_START_IN_RELATIVE: int, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, @@ -92,18 +94,6 @@ PROGRAM_OPTIONS = { }.items() } -TIME_PROGRAM_OPTIONS = { - bsh_key_to_translation_key(key): ( - key, - value, - ) - for key, value in { - OptionKey.BSH_COMMON_START_IN_RELATIVE: cv.time_period_str, - OptionKey.BSH_COMMON_DURATION: cv.time_period_str, - OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: cv.time_period_str, - }.items() -} - SERVICE_SETTING_SCHEMA = vol.Schema( { @@ -156,10 +146,7 @@ SERVICE_PROGRAM_SCHEMA = vol.Any( def _require_program_or_at_least_one_option(data: dict) -> dict: if ATTR_PROGRAM not in data and not any( - option_key in data - for option_key in ( - PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS - ) + option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) ): raise ServiceValidationError( translation_domain=DOMAIN, @@ -190,9 +177,7 @@ SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( .extend( { vol.Optional(translation_key): schema - for translation_key, (key, schema) in ( - PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS - ).items() + for translation_key, (key, schema) in PROGRAM_OPTIONS.items() } ), _require_program_or_at_least_one_option, @@ -486,13 +471,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif option in PROGRAM_OPTIONS: option_key = PROGRAM_OPTIONS[option][0] options.append(Option(option_key, value)) - elif option in TIME_PROGRAM_OPTIONS: - options.append( - Option( - TIME_PROGRAM_OPTIONS[option][0], - int(cast(timedelta, value).total_seconds()), - ) - ) + method_call: Awaitable[Any] exception_translation_key: str if program: diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 50e50afd598..91b0089d653 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -387,10 +387,14 @@ set_program_and_options: collapsed: true fields: b_s_h_common_option_start_in_relative: - example: "30:00" + example: 3600 required: false selector: - time: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s dishcare_dishwasher_option_intensiv_zone: example: false required: false @@ -493,10 +497,14 @@ set_program_and_options: mode: box unit_of_measurement: °C/°F b_s_h_common_option_duration: - example: "30:00" + example: 900 required: false selector: - time: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s cooking_oven_option_fast_pre_heat: example: false required: false @@ -561,10 +569,14 @@ set_program_and_options: - laundry_care_washer_enum_type_spin_speed_ul_medium - laundry_care_washer_enum_type_spin_speed_ul_high b_s_h_common_option_finish_in_relative: - example: "30:00" + example: 3600 required: false selector: - time: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s laundry_care_washer_option_i_dos1_active: example: false required: false diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 9e514824147..5e309a7446e 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -152,7 +152,7 @@ SERVICES_SET_PROGRAM_AND_OPTIONS = [ "device_id": "DEVICE_ID", "affects_to": "selected_program", "program": "dishcare_dishwasher_program_eco_50", - "b_s_h_common_option_start_in_relative": "00:30:00", + "b_s_h_common_option_start_in_relative": 1800, }, "blocking": True, }, From 21032ea7cd0ce7905a22f1eb0a5377233fcdba7b Mon Sep 17 00:00:00 2001 From: Teynar <97400690+teynar@users.noreply.github.com> Date: Sun, 16 Feb 2025 10:21:34 +0100 Subject: [PATCH 0564/1941] Add missing unit for Withings snore sensor (#138517) --- homeassistant/components/withings/sensor.py | 3 +++ tests/components/withings/snapshots/test_sensor.ambr | 11 ++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 96cb433deba..28a0fbd1492 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -425,6 +425,9 @@ SLEEP_SENSORS = [ key="sleep_snoring", value_fn=lambda sleep_summary: sleep_summary.snoring, translation_key="snoring", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 543cba05e21..ec9fc1ed3fc 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -3198,8 +3198,11 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Snoring', 'platform': 'withings', @@ -3207,21 +3210,23 @@ 'supported_features': 0, 'translation_key': 'snoring', 'unique_id': 'withings_12345_sleep_snoring', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_snoring-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'henk Snoring', 'state_class': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_snoring', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1080', + 'state': '18.0', }) # --- # name: test_all_entities[sensor.henk_snoring_episode_count-entry] From 3ce8e1683aac5f721e7720aa2b90f080abb1d630 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 16 Feb 2025 12:17:21 +0100 Subject: [PATCH 0565/1941] Fix sentence-casing in ZHA integration, capitalize names (#138636) * Fix sentence-casing in ZHA integration, capitalize names * Reorder title and description keys * Remove wrong trailing commas * Restore accidental deletion Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/zha/strings.json | 42 +++++++++++------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index c73a0989faa..2007adca0da 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -3,11 +3,11 @@ "flow_title": "{name}", "step": { "choose_serial_port": { - "title": "Select a Serial Port", + "title": "Select a serial port", + "description": "Select the serial port for your Zigbee radio", "data": { - "path": "Serial Device Path" - }, - "description": "Select the serial port for your Zigbee radio" + "path": "Serial device path" + } }, "confirm": { "description": "Do you want to set up {name}?" @@ -16,14 +16,14 @@ "description": "Do you want to set up {name}?" }, "manual_pick_radio_type": { + "title": "Select a radio type", + "description": "Pick your Zigbee radio type", "data": { - "radio_type": "Radio Type" - }, - "title": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]", - "description": "Pick your Zigbee radio type" + "radio_type": "Radio type" + } }, "manual_port_config": { - "title": "Serial Port Settings", + "title": "Serial port settings", "description": "Enter the serial port settings", "data": { "path": "Serial device path", @@ -36,7 +36,7 @@ "description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})." }, "choose_formation_strategy": { - "title": "Network Formation", + "title": "Network formation", "description": "Choose the network settings for your radio.", "menu_options": { "form_new_network": "Erase network settings and create a new network", @@ -47,21 +47,21 @@ } }, "choose_automatic_backup": { - "title": "Restore Automatic Backup", + "title": "Restore automatic backup", "description": "Restore your network settings from an automatic backup", "data": { "choose_automatic_backup": "Choose an automatic backup" } }, "upload_manual_backup": { - "title": "Upload a Manual Backup", + "title": "Upload a manual backup", "description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.", "data": { "uploaded_backup_file": "Upload a file" } }, "maybe_confirm_ezsp_restore": { - "title": "Overwrite Radio IEEE Address", + "title": "Overwrite radio IEEE address", "description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.", "data": { "overwrite_coordinator_ieee": "Permanently replace the radio IEEE address" @@ -74,10 +74,10 @@ }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "not_zha_device": "This device is not a zha device", - "usb_probe_failed": "Failed to probe the usb device", + "not_zha_device": "This device is not a ZHA device", + "usb_probe_failed": "Failed to probe the USB device", "wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.", - "invalid_zeroconf_data": "The coordinator has invalid zeroconf service info and cannot be identified by ZHA" + "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA" } }, "options": { @@ -307,7 +307,7 @@ } }, "set_zigbee_cluster_attribute": { - "name": "Set zigbee cluster attribute", + "name": "Set Zigbee cluster attribute", "description": "Sets an attribute value for the specified cluster on the specified entity.", "fields": { "ieee": { @@ -323,7 +323,7 @@ "description": "ZCL cluster to retrieve attributes for." }, "cluster_type": { - "name": "Cluster Type", + "name": "Cluster type", "description": "Type of the cluster." }, "attribute": { @@ -341,7 +341,7 @@ } }, "issue_zigbee_cluster_command": { - "name": "Issue zigbee cluster command", + "name": "Issue Zigbee cluster command", "description": "Issues a command on the specified cluster on the specified entity.", "fields": { "ieee": { @@ -383,8 +383,8 @@ } }, "issue_zigbee_group_command": { - "name": "Issue zigbee group command", - "description": "Issue command on the specified cluster on the specified group.", + "name": "Issue Zigbee group command", + "description": "Issues a command on the specified cluster on the specified group.", "fields": { "group": { "name": "Group", From 95b1cf465b4cfe34a3395d35184dbb8c20117841 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 16 Feb 2025 13:08:01 +0100 Subject: [PATCH 0566/1941] Use gibibytes for onedrive (#138637) * Use gibibytes for onedrive * also to strings --- homeassistant/components/onedrive/sensor.py | 6 +++--- .../components/onedrive/strings.json | 4 ++-- tests/components/onedrive/const.py | 4 ++-- .../onedrive/snapshots/test_sensor.ambr | 20 +++++++++---------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py index 35c59d0c644..0ca2b166e3f 100644 --- a/homeassistant/components/onedrive/sensor.py +++ b/homeassistant/components/onedrive/sensor.py @@ -36,7 +36,7 @@ DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = ( key="total_size", value_fn=lambda quota: quota.total, native_unit_of_measurement=UnitOfInformation.BYTES, - suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, suggested_display_precision=0, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -46,7 +46,7 @@ DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = ( key="used_size", value_fn=lambda quota: quota.used, native_unit_of_measurement=UnitOfInformation.BYTES, - suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, suggested_display_precision=2, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, @@ -55,7 +55,7 @@ DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = ( key="remaining_size", value_fn=lambda quota: quota.remaining, native_unit_of_measurement=UnitOfInformation.BYTES, - suggested_unit_of_measurement=UnitOfInformation.GIGABYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, suggested_display_precision=2, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 3a9f6d06594..20d139a4bc0 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -32,11 +32,11 @@ "issues": { "drive_full": { "title": "OneDrive data cap exceeded", - "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB." }, "drive_almost_full": { "title": "OneDrive near data cap", - "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB." } }, "exceptions": { diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index 44f50aa625d..0c04a6f4c82 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -110,9 +110,9 @@ MOCK_DRIVE = Drive( owner=IDENTITY_SET, quota=DriveQuota( deleted=5, - remaining=750000000, + remaining=805306368, state=DriveState.NEARING, - total=5000000000, + total=5368709120, used=4250000000, ), ) diff --git a/tests/components/onedrive/snapshots/test_sensor.ambr b/tests/components/onedrive/snapshots/test_sensor.ambr index 43c6921b0e5..742c069f206 100644 --- a/tests/components/onedrive/snapshots/test_sensor.ambr +++ b/tests/components/onedrive/snapshots/test_sensor.ambr @@ -86,7 +86,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), 'original_device_class': , @@ -97,7 +97,7 @@ 'supported_features': 0, 'translation_key': 'remaining_size', 'unique_id': 'mock_drive_id_remaining_size', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.my_drive_remaining_storage-state] @@ -105,7 +105,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'My Drive Remaining storage', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_drive_remaining_storage', @@ -141,7 +141,7 @@ 'suggested_display_precision': 0, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), 'original_device_class': , @@ -152,7 +152,7 @@ 'supported_features': 0, 'translation_key': 'total_size', 'unique_id': 'mock_drive_id_total_size', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.my_drive_total_available_storage-state] @@ -160,7 +160,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'My Drive Total available storage', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_drive_total_available_storage', @@ -196,7 +196,7 @@ 'suggested_display_precision': 2, }), 'sensor.private': dict({ - 'suggested_unit_of_measurement': , + 'suggested_unit_of_measurement': , }), }), 'original_device_class': , @@ -207,7 +207,7 @@ 'supported_features': 0, 'translation_key': 'used_size', 'unique_id': 'mock_drive_id_used_size', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_sensors[sensor.my_drive_used_storage-state] @@ -215,13 +215,13 @@ 'attributes': ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'My Drive Used storage', - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_drive_used_storage', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '4.25', + 'state': '3.95812094211578', }) # --- From 7f3270e982a80b2fd42a26835a827d31206872f8 Mon Sep 17 00:00:00 2001 From: Luca Bensi <130408125+lucab-91@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:09:15 +0100 Subject: [PATCH 0567/1941] Bump pysmarty2 to 0.10.2 (#138625) --- homeassistant/components/smarty/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index ca3133d8add..c295647b8e5 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pymodbus", "pysmarty2"], - "requirements": ["pysmarty2==0.10.1"] + "requirements": ["pysmarty2==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7081812b44..de28799abdd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysmartapp==0.3.5 pysmartthings==0.7.8 # homeassistant.components.smarty -pysmarty2==0.10.1 +pysmarty2==0.10.2 # homeassistant.components.smhi pysmhi==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c995a6bead..4e8544fcfbb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysmartapp==0.3.5 pysmartthings==0.7.8 # homeassistant.components.smarty -pysmarty2==0.10.1 +pysmarty2==0.10.2 # homeassistant.components.smhi pysmhi==1.0.0 From e767863ea4229ca53d23f0378ad2e29aa153ba37 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 16 Feb 2025 13:17:47 +0100 Subject: [PATCH 0568/1941] Replace opentherm_gw action key name with friendly name for UI (#138634) --- homeassistant/components/opentherm_gw/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 405af126c03..b49dea4a267 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -385,7 +385,7 @@ }, "set_central_heating_ovrd": { "name": "Set central heating override", - "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.", + "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a 'Set control set point' action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the 'Set control set point' action with temperature value 0. You will only need this if you are writing your own software thermostat.", "fields": { "gateway_id": { "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", @@ -393,7 +393,7 @@ }, "ch_override": { "name": "Central heating override", - "description": "The desired boolean value for the central heating override." + "description": "Whether to enable or disable the override." } } }, From 9e15a33c42d867b8a48c57f803aece45ac7c3384 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 16 Feb 2025 14:46:08 +0100 Subject: [PATCH 0569/1941] Fix sentence-casing and capitalization of "Zigbee" in smlight (#138647) --- homeassistant/components/smlight/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 21ff5098d27..ca52f6fea38 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Set up SMLIGHT Zigbee Integration", + "description": "Set up SMLIGHT Zigbee integration", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -111,7 +111,7 @@ "name": "Zigbee flash mode" }, "reconnect_zigbee_router": { - "name": "Reconnect zigbee router" + "name": "Reconnect Zigbee router" } }, "switch": { From 2d5e920de0e3dc52be5e072ea7679665aebfc46d Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Sun, 16 Feb 2025 14:55:05 +0100 Subject: [PATCH 0570/1941] Flexit bacnet/quality preparations (#138514) Add data_description for config flow --- homeassistant/components/flexit_bacnet/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index f7c54c88050..488d93fbd61 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -5,6 +5,10 @@ "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "device_id": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "ip_address": "The IP address of the Flexit Nordic device", + "device_id": "The device ID of the Flexit Nordic device" } } }, From f67fb9985e378467cc5d8aac07ebd636c1e76bda Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 16 Feb 2025 15:12:16 +0100 Subject: [PATCH 0571/1941] Allow wifi switches for mesh repeaters in AVM Fritz!Box Tools (#135456) * create wifi switches for mesh slaves, but disable them by default * check if mesh isbased on wifi uplink * fix --- homeassistant/components/fritz/coordinator.py | 7 +++++++ homeassistant/components/fritz/switch.py | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 38d76c92871..d60232ec8ad 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -196,6 +196,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): self.hass = hass self.host = host self.mesh_role = MeshRoles.NONE + self.mesh_wifi_uplink = False self.device_conn_type: str | None = None self.device_is_router: bool = False self.password = password @@ -610,6 +611,12 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): ssid=interf.get("ssid", ""), type=interf["type"], ) + + if interf["type"].lower() == "wlan" and interf[ + "name" + ].lower().startswith("uplink"): + self.mesh_wifi_uplink = True + if dr.format_mac(int_mac) == self.mac: self.mesh_role = MeshRoles(node["mesh_role"]) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 1548f8fc755..8b4816f7451 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -207,8 +207,9 @@ async def async_all_entities_list( local_ip: str, ) -> list[Entity]: """Get a list of all entities.""" - if avm_wrapper.mesh_role == MeshRoles.SLAVE: + if not avm_wrapper.mesh_wifi_uplink: + return [*await _async_wifi_entities_list(avm_wrapper, device_friendly_name)] return [] return [ @@ -565,6 +566,9 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch): self._attributes = {} self._attr_entity_category = EntityCategory.CONFIG + self._attr_entity_registry_enabled_default = ( + avm_wrapper.mesh_role is not MeshRoles.SLAVE + ) self._network_num = network_num switch_info = SwitchInfo( From 7063636db6a03c8b86b95215a3daf11470bce56a Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Sun, 16 Feb 2025 17:06:09 +0100 Subject: [PATCH 0572/1941] Add quality scale bronze for flexit_bacnet (#138309) * Add quality scale bronze for flexit_bacnet * Add new line at end of file * Remove flexit_bacnet from list of integrations without quality scale * Add missing translation strings * Fix review comments * Remove flexit_bacnet from list of integrations without quality scale * Review comment Co-authored-by: Josef Zweck * Review comment Co-authored-by: Josef Zweck * Add the complete list of quality scale rules * Fix lint error * Use correct formatting for todos * Fix lint error * Set all rules above bronze to todo * Update status for rules that are done * Update homeassistant/components/flexit_bacnet/quality_scale.yaml * Update homeassistant/components/flexit_bacnet/quality_scale.yaml --------- Co-authored-by: Josef Zweck --- .../components/flexit_bacnet/manifest.json | 1 + .../flexit_bacnet/quality_scale.yaml | 88 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/flexit_bacnet/quality_scale.yaml diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json index 6f6b094c950..5ef3f11a7b7 100644 --- a/homeassistant/components/flexit_bacnet/manifest.json +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "bronze", "requirements": ["flexit_bacnet==2.2.3"] } diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml new file mode 100644 index 00000000000..9b7e4deb4c0 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + Integration does not define custom actions. + appropriate-polling: done + 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 use any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities don't subscribe to events explicitly + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: | + Done implicitly with `await coordinator.async_config_entry_first_refresh()`. + unique-config-entry: done + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + Integration does not use options flow. + docs-installation-parameters: done + entity-unavailable: + status: done + comment: | + Done implicitly with coordinator. + integration-owner: done + log-when-unavailable: + status: done + comment: | + Done implicitly with coordinator. + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: todo + entity-disabled-by-default: todo + discovery: todo + stale-devices: + status: exempt + comment: | + Device type integration. + diagnostics: todo + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + dynamic-devices: + status: exempt + comment: | + Device type integration. + discovery-update-info: todo + repair-issues: + status: exempt + comment: | + This is not applicable for this integration. + docs-use-cases: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-data-update: done + docs-known-limitations: todo + docs-troubleshooting: todo + docs-examples: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 12b5932695d..bd8a5a9f318 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -391,7 +391,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "fjaraskupan", "fleetgo", "flexit", - "flexit_bacnet", "flic", "flick_electric", "flipr", @@ -1455,7 +1454,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "fjaraskupan", "fleetgo", "flexit", - "flexit_bacnet", "flic", "flick_electric", "flipr", From e0b50ee1e21e5d8b1986519a4104b6d9a3ad3c7a Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Sun, 16 Feb 2025 13:04:45 -0500 Subject: [PATCH 0573/1941] Bump sense_energy to 0.13.5 (#138659) --- 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 da3912a9d25..384dd3556a9 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.4"] + "requirements": ["sense-energy==0.13.5"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 966488b6a48..a7cee28f9c9 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.4"] + "requirements": ["sense-energy==0.13.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index de28799abdd..abbda498827 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2688,7 +2688,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.4 +sense-energy==0.13.5 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e8544fcfbb..f6223d56c2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2167,7 +2167,7 @@ securetar==2025.1.4 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.4 +sense-energy==0.13.5 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From ccd0e27e84bf66c83e76685125e54122eea9fdbb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 16 Feb 2025 20:00:17 +0100 Subject: [PATCH 0574/1941] Allow renaming of backup files in Synology DSM (#138652) * get backup base file name from meta file * use BackupNotFound --- .../components/synology_dsm/backup.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 83c3455bdf1..670c4c9bef0 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -14,6 +14,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupAgentError, + BackupNotFound, suggested_filename, ) from homeassistant.config_entries import ConfigEntry @@ -101,6 +102,7 @@ class SynologyDSMBackupAgent(BackupAgent): ) syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] self.api = syno_data.api + self.backup_base_names: dict[str, str] = {} @property def _file_station(self) -> SynoFileStation: @@ -109,18 +111,19 @@ class SynologyDSMBackupAgent(BackupAgent): assert self.api.file_station return self.api.file_station - async def _async_suggested_filenames( + async def _async_backup_filenames( self, backup_id: str, ) -> tuple[str, str]: - """Suggest filenames for the backup. + """Return the actual backup filenames. :param backup_id: The ID of the backup that was returned in async_list_backups. :return: A tuple of tar_filename and meta_filename """ - if (backup := await self.async_get_backup(backup_id)) is None: - raise BackupAgentError("Backup not found") - return suggested_filenames(backup) + if await self.async_get_backup(backup_id) is None: + raise BackupNotFound + base_name = self.backup_base_names[backup_id] + return (f"{base_name}.tar", f"{base_name}_meta.json") async def async_download_backup( self, @@ -132,7 +135,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ - (filename_tar, _) = await self._async_suggested_filenames(backup_id) + (filename_tar, _) = await self._async_backup_filenames(backup_id) try: resp = await self._file_station.download_file( @@ -193,7 +196,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ try: - (filename_tar, filename_meta) = await self._async_suggested_filenames( + (filename_tar, filename_meta) = await self._async_backup_filenames( backup_id ) except BackupAgentError: @@ -247,6 +250,7 @@ class SynologyDSMBackupAgent(BackupAgent): assert files backups: dict[str, AgentBackup] = {} + backup_base_names: dict[str, str] = {} for file in files: if file.name.endswith("_meta.json"): try: @@ -255,7 +259,10 @@ class SynologyDSMBackupAgent(BackupAgent): LOGGER.error("Failed to download meta data: %s", err) continue agent_backup = AgentBackup.from_dict(meta_data) - backups[agent_backup.backup_id] = agent_backup + backup_id = agent_backup.backup_id + backups[backup_id] = agent_backup + backup_base_names[backup_id] = file.name.replace("_meta.json", "") + self.backup_base_names = backup_base_names return backups async def async_get_backup( From 0b7ec9644889a88d4f27bc342dc3681269202ee4 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 16 Feb 2025 21:17:26 +0100 Subject: [PATCH 0575/1941] Improve remember the milk storage (#138618) --- .../components/remember_the_milk/__init__.py | 67 +++++--- tests/components/remember_the_milk/const.py | 2 +- .../components/remember_the_milk/test_init.py | 155 ++++++++++++------ 3 files changed, 148 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 0d1c54efb56..2a95ed46b20 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -2,7 +2,7 @@ import json import logging -import os +from pathlib import Path from rtmapi import Rtm import voluptuous as vol @@ -160,56 +160,64 @@ class RememberTheMilkConfiguration: This class stores the authentication token it get from the backend. """ - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Create new instance of configuration.""" self._config_file_path = hass.config.path(CONFIG_FILE_NAME) - if not os.path.isfile(self._config_file_path): - self._config = {} - return + self._config = {} + _LOGGER.debug("Loading configuration from file: %s", self._config_file_path) try: - _LOGGER.debug("Loading configuration from file: %s", self._config_file_path) - with open(self._config_file_path, encoding="utf8") as config_file: - self._config = json.load(config_file) - except ValueError: - _LOGGER.error( - "Failed to load configuration file, creating a new one: %s", + 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, ) - self._config = {} - def save_config(self): + def _save_config(self) -> None: """Write the configuration to a file.""" - with open(self._config_file_path, "w", encoding="utf8") as config_file: - json.dump(self._config, config_file) + Path(self._config_file_path).write_text( + json.dumps(self._config), encoding="utf8" + ) - def get_token(self, profile_name): + 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, token): + 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() + self._save_config() - def delete_token(self, profile_name): + 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() + self._save_config() - def _initialize_profile(self, profile_name): + 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, hass_id): + 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: @@ -221,7 +229,14 @@ class RememberTheMilkConfiguration: return None return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] - def set_rtm_id(self, profile_name, hass_id, list_id, time_series_id, rtm_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 = { @@ -230,11 +245,11 @@ class RememberTheMilkConfiguration: CONF_TASK_ID: rtm_task_id, } self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple - self.save_config() + self._save_config() - def delete_rtm_id(self, profile_name, hass_id): + 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() + self._save_config() diff --git a/tests/components/remember_the_milk/const.py b/tests/components/remember_the_milk/const.py index 8423c7f4651..3f1d0067219 100644 --- a/tests/components/remember_the_milk/const.py +++ b/tests/components/remember_the_milk/const.py @@ -8,7 +8,7 @@ JSON_STRING = json.dumps( { "myprofile": { "token": "mytoken", - "id_map": {"1234": {"list_id": "0", "timeseries_id": "1", "task_id": "2"}}, + "id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}}, } } ) diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py index 3ada2d343fe..517c8cebc0e 100644 --- a/tests/components/remember_the_milk/test_init.py +++ b/tests/components/remember_the_milk/test_init.py @@ -1,6 +1,9 @@ -"""Tests for the Remember The Milk component.""" +"""Tests for the Remember The Milk integration.""" -from unittest.mock import Mock, mock_open, patch +import json +from unittest.mock import mock_open, patch + +import pytest from homeassistant.components import remember_the_milk as rtm from homeassistant.core import HomeAssistant @@ -8,63 +11,117 @@ from homeassistant.core import HomeAssistant from .const import JSON_STRING, PROFILE, TOKEN -def test_create_new(hass: HomeAssistant) -> None: - """Test creating a new config file.""" - with ( - patch("builtins.open", mock_open()), - patch("os.path.isfile", Mock(return_value=False)), - patch.object(rtm.RememberTheMilkConfiguration, "save_config"), - ): +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): config = rtm.RememberTheMilkConfiguration(hass) + assert open_mock.return_value.write.call_count == 0 + assert config.get_token(PROFILE) is None + assert open_mock.return_value.write.call_count == 0 config.set_token(PROFILE, TOKEN) - assert config.get_token(PROFILE) == TOKEN + assert open_mock.return_value.write.call_count == 1 + assert open_mock.return_value.write.call_args[0][0] == json.dumps( + { + "myprofile": { + "id_map": {}, + "token": "mytoken", + } + } + ) + assert config.get_token(PROFILE) == TOKEN + assert open_mock.return_value.write.call_count == 1 + config.delete_token(PROFILE) + assert open_mock.return_value.write.call_count == 2 + assert open_mock.return_value.write.call_args[0][0] == json.dumps({}) + assert config.get_token(PROFILE) is None + assert open_mock.return_value.write.call_count == 2 -def test_load_config(hass: HomeAssistant) -> None: - """Test loading an existing token from the file.""" +def test_config_load(hass: HomeAssistant) -> None: + """Test loading from the file.""" with ( - patch("builtins.open", mock_open(read_data=JSON_STRING)), - patch("os.path.isfile", Mock(return_value=True)), - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert config.get_token(PROFILE) == TOKEN - - -def test_invalid_data(hass: HomeAssistant) -> None: - """Test starts with invalid data and should not raise an exception.""" - with ( - patch("builtins.open", mock_open(read_data="random characters")), - patch("os.path.isfile", Mock(return_value=True)), - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert config is not None - - -def test_id_map(hass: HomeAssistant) -> None: - """Test the hass to rtm task is mapping.""" - hass_id = "hass-id-1234" - list_id = "mylist" - timeseries_id = "my_timeseries" - rtm_id = "rtm-id-4567" - with ( - patch("builtins.open", mock_open()), - patch("os.path.isfile", Mock(return_value=False)), - patch.object(rtm.RememberTheMilkConfiguration, "save_config"), + patch( + "homeassistant.components.remember_the_milk.Path.open", + mock_open(read_data=JSON_STRING), + ), ): config = rtm.RememberTheMilkConfiguration(hass) + rtm_id = config.get_rtm_id(PROFILE, "123") + assert rtm_id is not None + assert rtm_id == ("1", "2", "3") + + +@pytest.mark.parametrize( + "side_effect", [FileNotFoundError("Missing file"), OSError("IO error")] +) +def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> None: + """Test loading with file error.""" + config = rtm.RememberTheMilkConfiguration(hass) + with ( + patch( + "homeassistant.components.remember_the_milk.Path.open", + side_effect=side_effect, + ), + ): + config = rtm.RememberTheMilkConfiguration(hass) + + # The config should be empty and we should not have any errors + # when trying to access it. + rtm_id = config.get_rtm_id(PROFILE, "123") + assert rtm_id is None + + +def test_config_load_invalid_data(hass: HomeAssistant) -> None: + """Test loading invalid data.""" + config = rtm.RememberTheMilkConfiguration(hass) + with ( + patch( + "homeassistant.components.remember_the_milk.Path.open", + mock_open(read_data="random characters"), + ), + ): + config = rtm.RememberTheMilkConfiguration(hass) + + # The config should be empty and we should not have any errors + # when trying to access it. + rtm_id = config.get_rtm_id(PROFILE, "123") + assert rtm_id is None + + +def test_config_set_delete_id(hass: HomeAssistant) -> None: + """Test setting and deleting an id from the config.""" + hass_id = "123" + list_id = "1" + timeseries_id = "2" + rtm_id = "3" + open_mock = mock_open() + config = rtm.RememberTheMilkConfiguration(hass) + with patch("homeassistant.components.remember_the_milk.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 + assert open_mock.return_value.write.call_count == 0 config.set_rtm_id(PROFILE, hass_id, list_id, timeseries_id, rtm_id) assert (list_id, timeseries_id, rtm_id) == config.get_rtm_id(PROFILE, hass_id) + assert open_mock.return_value.write.call_count == 1 + assert open_mock.return_value.write.call_args[0][0] == json.dumps( + { + "myprofile": { + "id_map": { + "123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"} + } + } + } + ) config.delete_rtm_id(PROFILE, hass_id) assert config.get_rtm_id(PROFILE, hass_id) is None - - -def test_load_key_map(hass: HomeAssistant) -> None: - """Test loading an existing key map from the file.""" - with ( - patch("builtins.open", mock_open(read_data=JSON_STRING)), - patch("os.path.isfile", Mock(return_value=True)), - ): - config = rtm.RememberTheMilkConfiguration(hass) - assert config.get_rtm_id(PROFILE, "1234") == ("0", "1", "2") + assert open_mock.return_value.write.call_count == 2 + assert open_mock.return_value.write.call_args[0][0] == json.dumps( + { + "myprofile": { + "id_map": {}, + } + } + ) From 09df6c870620ac7a04c84d3e38344253e9f2e560 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Sun, 16 Feb 2025 22:33:32 +0200 Subject: [PATCH 0576/1941] Rename "returned" state to "alert" (#138676) Rename "returned" state to "alert" in icons, services, and strings files --- homeassistant/components/seventeentrack/icons.json | 2 +- homeassistant/components/seventeentrack/services.yaml | 2 +- homeassistant/components/seventeentrack/strings.json | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/seventeentrack/icons.json b/homeassistant/components/seventeentrack/icons.json index a5cac0a9f84..c48e147e973 100644 --- a/homeassistant/components/seventeentrack/icons.json +++ b/homeassistant/components/seventeentrack/icons.json @@ -19,7 +19,7 @@ "delivered": { "default": "mdi:package" }, - "returned": { + "alert": { "default": "mdi:package" }, "package": { diff --git a/homeassistant/components/seventeentrack/services.yaml b/homeassistant/components/seventeentrack/services.yaml index d4592dc8aab..45d7c0a530a 100644 --- a/homeassistant/components/seventeentrack/services.yaml +++ b/homeassistant/components/seventeentrack/services.yaml @@ -11,7 +11,7 @@ get_packages: - "ready_to_be_picked_up" - "undelivered" - "delivered" - - "returned" + - "alert" translation_key: package_state config_entry_id: required: true diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index 982b15ab629..70fea2e2735 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -57,8 +57,8 @@ "delivered": { "name": "Delivered" }, - "returned": { - "name": "Returned" + "alert": { + "name": "Alert" }, "package": { "name": "Package {name}" @@ -104,7 +104,7 @@ "ready_to_be_picked_up": "[%key:component::seventeentrack::entity::sensor::ready_to_be_picked_up::name%]", "undelivered": "[%key:component::seventeentrack::entity::sensor::undelivered::name%]", "delivered": "[%key:component::seventeentrack::entity::sensor::delivered::name%]", - "returned": "[%key:component::seventeentrack::entity::sensor::returned::name%]" + "alert": "[%key:component::seventeentrack::entity::sensor::alert::name%]" } } } From bdeb24cb6136c4ff522760617a1f37ee633fddd0 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sun, 16 Feb 2025 21:02:29 +0000 Subject: [PATCH 0577/1941] Add OptionsFlow to Squeezebox to allow setting Browse Limit and Volume Step (#129578) * Initial * prettier strings * Updates * remove error strings * prettier again * Update strings.json vscode prettier fails check * update test to remove invalid value * Remove config_entry __init__ * remove param * Review updates * ruff fixes * Review changes * Shorten options flow ui string * Review changes * Remove errant mock attib --------- Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> --- .../components/squeezebox/__init__.py | 5 +- .../components/squeezebox/browse_media.py | 17 +++-- .../components/squeezebox/config_flow.py | 74 ++++++++++++++++++- homeassistant/components/squeezebox/const.py | 4 + .../components/squeezebox/media_player.py | 56 +++++++++----- .../components/squeezebox/strings.json | 15 ++++ tests/components/squeezebox/conftest.py | 6 ++ .../snapshots/test_media_player.ambr | 4 +- .../components/squeezebox/test_config_flow.py | 48 +++++++++++- .../squeezebox/test_media_player.py | 12 ++- 10 files changed, 206 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 789f6ddb3a8..fd641d3389d 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -129,10 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - server_coordinator = LMSStatusDataUpdateCoordinator(hass, entry, lms) - entry.runtime_data = SqueezeboxData( - coordinator=server_coordinator, - server=lms, - ) + entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms) # set up player discovery known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {}) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 331bf383c70..c0458067a23 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -81,11 +81,12 @@ CONTENT_TYPE_TO_CHILD_TYPE = { "New Music": MediaType.ALBUM, } -BROWSE_LIMIT = 1000 - async def build_item_response( - entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None] + entity: MediaPlayerEntity, + player: Player, + payload: dict[str, str | None], + browse_limit: int, ) -> BrowseMedia: """Create response payload for search described by payload.""" @@ -107,7 +108,7 @@ async def build_item_response( result = await player.async_browse( MEDIA_TYPE_TO_SQUEEZEBOX[search_type], - limit=BROWSE_LIMIT, + limit=browse_limit, browse_id=browse_id, ) @@ -237,7 +238,11 @@ def media_source_content_filter(item: BrowseMedia) -> bool: return item.media_content_type.startswith("audio/") -async def generate_playlist(player: Player, payload: dict[str, str]) -> list | None: +async def generate_playlist( + player: Player, + payload: dict[str, str], + browse_limit: int, +) -> list | None: """Generate playlist from browsing payload.""" media_type = payload["search_type"] media_id = payload["search_id"] @@ -247,7 +252,7 @@ async def generate_playlist(player: Player, payload: dict[str, str]) -> list | N browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id) result = await player.async_browse( - "titles", limit=BROWSE_LIMIT, browse_id=browse_id + "titles", limit=browse_limit, browse_id=browse_id ) if result and "items" in result: items: list = result["items"] diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 97eb848c21c..2853ad14217 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -11,15 +11,34 @@ from pysqueezebox import Server, async_discover import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MP_DOMAIN -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_PORT, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN +from .const import ( + CONF_BROWSE_LIMIT, + CONF_HTTPS, + CONF_VOLUME_STEP, + DEFAULT_BROWSE_LIMIT, + DEFAULT_PORT, + DEFAULT_VOLUME_STEP, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -77,6 +96,12 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): self.data_schema = _base_schema() self.discovery_info: dict[str, Any] | None = None + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler() + async def _discover(self, uuid: str | None = None) -> None: """Discover an unconfigured LMS server.""" self.discovery_info = None @@ -222,3 +247,48 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): # if the player is unknown, then we likely need to configure its server return await self.async_step_user() + + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_BROWSE_LIMIT): vol.All( + NumberSelector( + NumberSelectorConfig(min=1, max=65534, mode=NumberSelectorMode.BOX) + ), + vol.Coerce(int), + ), + vol.Required(CONF_VOLUME_STEP): vol.All( + NumberSelector( + NumberSelectorConfig(min=1, max=20, mode=NumberSelectorMode.SLIDER) + ), + vol.Coerce(int), + ), + } +) + + +class OptionsFlowHandler(OptionsFlow): + """Options Flow Handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Options Flow Steps.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, + { + CONF_BROWSE_LIMIT: self.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ), + CONF_VOLUME_STEP: self.config_entry.options.get( + CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP + ), + }, + ), + ) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 8bc33214170..f24c452282f 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -32,3 +32,7 @@ SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered" SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered" DISCOVERY_INTERVAL = 60 PLAYER_UPDATE_INTERVAL = 5 +CONF_BROWSE_LIMIT = "browse_limit" +CONF_VOLUME_STEP = "volume_step" +DEFAULT_BROWSE_LIMIT = 1000 +DEFAULT_VOLUME_STEP = 5 diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 1b810019373..a98ee13275c 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -52,6 +52,10 @@ from .browse_media import ( media_source_content_filter, ) from .const import ( + CONF_BROWSE_LIMIT, + CONF_VOLUME_STEP, + DEFAULT_BROWSE_LIMIT, + DEFAULT_VOLUME_STEP, DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, @@ -166,6 +170,7 @@ class SqueezeBoxMediaPlayerEntity( | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.SEEK @@ -184,10 +189,7 @@ class SqueezeBoxMediaPlayerEntity( _attr_name = None _last_update: datetime | None = None - def __init__( - self, - coordinator: SqueezeBoxPlayerUpdateCoordinator, - ) -> None: + def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: """Initialize the SqueezeBox device.""" super().__init__(coordinator) player = coordinator.player @@ -223,6 +225,23 @@ class SqueezeBoxMediaPlayerEntity( self._last_update = utcnow() self.async_write_ha_state() + @property + def volume_step(self) -> float: + """Return the step to be used for volume up down.""" + return float( + self.coordinator.config_entry.options.get( + CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP + ) + / 100 + ) + + @property + def browse_limit(self) -> int: + """Return the step to be used for volume up down.""" + return self.coordinator.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ) + @property def available(self) -> bool: """Return True if entity is available.""" @@ -366,16 +385,6 @@ class SqueezeBoxMediaPlayerEntity( await self._player.async_set_power(False) await self.coordinator.async_refresh() - async def async_volume_up(self) -> None: - """Volume up media player.""" - await self._player.async_set_volume("+5") - await self.coordinator.async_refresh() - - async def async_volume_down(self) -> None: - """Volume down media player.""" - await self._player.async_set_volume("-5") - await self.coordinator.async_refresh() - async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" volume_percent = str(int(volume * 100)) @@ -466,7 +475,11 @@ class SqueezeBoxMediaPlayerEntity( "search_id": media_id, "search_type": MediaType.PLAYLIST, } - playlist = await generate_playlist(self._player, payload) + playlist = await generate_playlist( + self._player, + payload, + self.browse_limit, + ) except BrowseError: # a list of urls content = json.loads(media_id) @@ -477,7 +490,11 @@ class SqueezeBoxMediaPlayerEntity( "search_id": media_id, "search_type": media_type, } - playlist = await generate_playlist(self._player, payload) + playlist = await generate_playlist( + self._player, + payload, + self.browse_limit, + ) _LOGGER.debug("Generated playlist: %s", playlist) @@ -587,7 +604,12 @@ class SqueezeBoxMediaPlayerEntity( "search_id": media_content_id, } - return await build_item_response(self, self._player, payload) + return await build_item_response( + self, + self._player, + payload, + self.browse_limit, + ) async def async_get_browse_image( self, diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index bce71ddb5f2..ed569989b56 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -103,5 +103,20 @@ "unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]" } } + }, + "options": { + "step": { + "init": { + "title": "LMS Configuration", + "data": { + "browse_limit": "Browse limit", + "volume_step": "Volume step" + }, + "data_description": { + "browse_limit": "Maximum number of items when browsing or in a playlist.", + "volume_step": "Amount to adjust the volume when turning volume up or down." + } + } + } } } diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 7b007114420..c960844ee2f 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -33,6 +33,9 @@ from homeassistant.helpers.device_registry import format_mac # from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +CONF_VOLUME_STEP = "volume_step" +TEST_VOLUME_STEP = 10 + TEST_HOST = "1.2.3.4" TEST_PORT = "9000" TEST_USE_HTTPS = False @@ -109,6 +112,9 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PORT: TEST_PORT, const.CONF_HTTPS: TEST_USE_HTTPS, }, + options={ + CONF_VOLUME_STEP: TEST_VOLUME_STEP, + }, ) config_entry.add_to_hass(hass) return config_entry diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index fd663c5eb63..47c2fea22c5 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -65,7 +65,7 @@ 'original_name': None, 'platform': 'squeezebox', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff', 'unit_of_measurement': None, @@ -88,7 +88,7 @@ }), 'repeat': , 'shuffle': False, - 'supported_features': , + 'supported_features': , 'volume_level': 0.01, }), 'context': , diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index c5efe66152f..cae3672061b 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,7 +6,12 @@ from unittest.mock import patch from pysqueezebox import Server from homeassistant import config_entries -from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN +from homeassistant.components.squeezebox.const import ( + CONF_BROWSE_LIMIT, + CONF_HTTPS, + CONF_VOLUME_STEP, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -19,6 +24,8 @@ HOST2 = "2.2.2.2" PORT = 9000 UUID = "test-uuid" UNKNOWN_ERROR = "1234" +BROWSE_LIMIT = 10 +VOLUME_STEP = 1 async def mock_discover(_discovery_callback): @@ -87,6 +94,45 @@ async def test_user_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_options_form(hass: HomeAssistant) -> None: + """Test we can configure options.""" + entry = MockConfigEntry( + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, + unique_id=UUID, + domain=DOMAIN, + options={CONF_BROWSE_LIMIT: 1000, CONF_VOLUME_STEP: 5}, + ) + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # simulate manual input of options + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_BROWSE_LIMIT: BROWSE_LIMIT, CONF_VOLUME_STEP: VOLUME_STEP}, + ) + + # put some meaningful asserts here + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_BROWSE_LIMIT: BROWSE_LIMIT, + CONF_VOLUME_STEP: VOLUME_STEP, + } + + async def test_user_form_timeout(hass: HomeAssistant) -> None: """Test we handle server search timeout.""" with ( diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 080a2161b4d..694f5c9a8a2 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -68,7 +68,7 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.dt import utcnow -from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC +from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC, TEST_VOLUME_STEP from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -183,26 +183,32 @@ async def test_squeezebox_volume_up( hass: HomeAssistant, configured_player: MagicMock ) -> None: """Test volume up service call.""" + configured_player.volume = 50 await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_volume.assert_called_once_with("+5") + configured_player.async_set_volume.assert_called_once_with( + str(configured_player.volume + TEST_VOLUME_STEP) + ) async def test_squeezebox_volume_down( hass: HomeAssistant, configured_player: MagicMock ) -> None: """Test volume down service call.""" + configured_player.volume = 50 await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_volume.assert_called_once_with("-5") + configured_player.async_set_volume.assert_called_once_with( + str(configured_player.volume - TEST_VOLUME_STEP) + ) async def test_squeezebox_volume_set( From 93f1597e6d87437a61093f8743afecb773608ac8 Mon Sep 17 00:00:00 2001 From: Markus Lanthaler Date: Sun, 16 Feb 2025 22:03:57 +0100 Subject: [PATCH 0578/1941] Add latest Nighthawk WiFi 7 routers to V2 models (#138675) --- homeassistant/components/netgear/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index f7a683326d3..c8ecd8e7e1d 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -62,6 +62,7 @@ MODELS_V2 = [ "RBR", "RBS", "RBW", + "RS", "LBK", "LBR", "CBK", From 56b51227bb6c915f92e5b655d9b2e4e95a41f7ff Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Mon, 17 Feb 2025 02:19:03 +0100 Subject: [PATCH 0579/1941] Bump stookwijzer==1.5.4 (#138678) --- homeassistant/components/stookwijzer/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index 0c97d1b20ed..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.2"] + "requirements": ["stookwijzer==1.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index abbda498827..9d340460b1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2796,7 +2796,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.2 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6223d56c2f..40b9fa85762 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2257,7 +2257,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.2 +stookwijzer==1.5.4 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 6b90e7b2c2be3ed29222a0828a11c20114b39ac1 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sun, 16 Feb 2025 20:33:48 -0700 Subject: [PATCH 0580/1941] Bump pyvesync for vesync (#138681) * bump pyvesync * fix tests * Test fix --- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vesync/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index b3697844f19..9e2fbcc1782 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.17"] + "requirements": ["pyvesync==2.1.18"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d340460b1d..3adfa7abb88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2522,7 +2522,7 @@ pyvera==0.3.15 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.17 +pyvesync==2.1.18 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40b9fa85762..6ff5c8bc7d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2040,7 +2040,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.15 # homeassistant.components.vesync -pyvesync==2.1.17 +pyvesync==2.1.18 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 1c409dbab00..407e18d65b6 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -171,6 +171,7 @@ 'models': list([ 'LV-PUR131S', 'LV-RH131S', + 'LV-RH131S-WM', ]), 'modes': list([ 'manual', From c357b3ae656c2d8f7f5555077b57090131b4292b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 16 Feb 2025 23:06:28 -0500 Subject: [PATCH 0581/1941] Move some setups during onboarding to background (#138558) * Move some setups during onboarding to background * Update homeassistant/components/onboarding/views.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/onboarding/views.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index cb0dc4fdfa7..ea955987d80 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -33,7 +33,6 @@ 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 -from homeassistant.util.async_ import create_eager_task if TYPE_CHECKING: from . import OnboardingData, OnboardingStorage, OnboardingStoreData @@ -235,22 +234,21 @@ class CoreConfigOnboardingView(_BaseOnboardingView): ): onboard_integrations.append("rpi_power") - coros: list[Coroutine[Any, Any, Any]] = [ - hass.config_entries.flow.async_init( - domain, context={"source": "onboarding"} + for domain in onboard_integrations: + # Create tasks so onboarding isn't affected + # by errors in these integrations. + hass.async_create_task( + hass.config_entries.flow.async_init( + domain, context={"source": "onboarding"} + ), + f"onboarding_setup_{domain}", ) - for domain in onboard_integrations - ] if "analytics" not in hass.config.components: # If by some chance that analytics has not finished # setting up, wait for it here so its ready for the # next step. - coros.append(async_setup_component(hass, "analytics", {})) - - # Set up integrations after onboarding and ensure - # analytics is ready for the next step. - await asyncio.gather(*(create_eager_task(coro) for coro in coros)) + await async_setup_component(hass, "analytics", {}) return self.json({}) From 89956adf2eea8d3dd6630506f6e0d9fcab436a39 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 17 Feb 2025 01:47:11 -0600 Subject: [PATCH 0582/1941] Allow removal of stale HEOS devices (#138677) Allow device removal --- homeassistant/components/heos/__init__.py | 11 +++++++ .../components/heos/quality_scale.yaml | 2 +- tests/components/heos/test_init.py | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 7bbd3765602..4df1a2fa0e1 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -69,3 +69,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: HeosConfigEntry, device: dr.DeviceEntry +) -> bool: + """Remove config entry from device if no longer present.""" + return not any( + (domain, key) + for domain, key in device.identifiers + if domain == DOMAIN and int(key) in entry.runtime_data.heos.players + ) diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 67022ec492c..a1220366fa3 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -58,7 +58,7 @@ rules: icon-translations: done reconfiguration-flow: done repair-issues: todo - stale-devices: todo + stale-devices: done # Platinum async-dependency: done inject-websession: diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 81acb7b3b8b..60bc2a72e51 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -11,10 +11,12 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from . import MockHeos from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_async_setup_entry_loads_platforms( @@ -226,3 +228,30 @@ async def test_device_id_migration_both_present( await hass.async_block_till_done(wait_background_tasks=True) assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type] assert device_registry.async_get_device({(DOMAIN, "1")}) is not None + + +@pytest.mark.parametrize( + ("player_id", "expected_result"), + [("1", False), ("5", True)], + ids=("Present device", "Stale device"), +) +async def test_remove_config_entry_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + hass_ws_client: WebSocketGenerator, + player_id: str, + expected_result: bool, +) -> None: + """Test manually removing an stale device.""" + assert await async_setup_component(hass, "config", {}) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, player_id)} + ) + + ws_client = await hass_ws_client(hass) + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] == expected_result From f2126a357a826e3107084b67aa6e50f246759315 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Mon, 17 Feb 2025 08:58:21 +0100 Subject: [PATCH 0583/1941] Comply with parallel updates quality rule (#138672) --- homeassistant/components/flexit_bacnet/binary_sensor.py | 4 ++++ homeassistant/components/flexit_bacnet/climate.py | 3 +++ homeassistant/components/flexit_bacnet/number.py | 3 +++ homeassistant/components/flexit_bacnet/quality_scale.yaml | 2 +- homeassistant/components/flexit_bacnet/sensor.py | 4 ++++ homeassistant/components/flexit_bacnet/switch.py | 3 +++ 6 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/binary_sensor.py b/homeassistant/components/flexit_bacnet/binary_sensor.py index faee803e915..50c49f45e3e 100644 --- a/homeassistant/components/flexit_bacnet/binary_sensor.py +++ b/homeassistant/components/flexit_bacnet/binary_sensor.py @@ -47,6 +47,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class FlexitBinarySensor(FlexitEntity, BinarySensorEntity): """Representation of a Flexit binary Sensor.""" diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index abfa59d0a6d..b9ae16739b9 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -43,6 +43,9 @@ async def async_setup_entry( async_add_entities([FlexitClimateEntity(config_entry.runtime_data)]) +PARALLEL_UPDATES = 1 + + class FlexitClimateEntity(FlexitEntity, ClimateEntity): """Flexit air handling unit.""" diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index dfcfc193692..061860e7d0d 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -205,6 +205,9 @@ async def async_setup_entry( ) +PARALLEL_UPDATES = 1 + + class FlexitNumber(FlexitEntity, NumberEntity): """Representation of a Flexit Number.""" diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml index 9b7e4deb4c0..548580f96d3 100644 --- a/homeassistant/components/flexit_bacnet/quality_scale.yaml +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -47,7 +47,7 @@ rules: status: done comment: | Done implicitly with coordinator. - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: todo # Gold diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py index 23d8f20da36..0506b13892b 100644 --- a/homeassistant/components/flexit_bacnet/sensor.py +++ b/homeassistant/components/flexit_bacnet/sensor.py @@ -161,6 +161,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class FlexitSensor(FlexitEntity, SensorEntity): """Representation of a Flexit (bacnet) Sensor.""" diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index 283d0e1ec3b..ac69bb86023 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -68,6 +68,9 @@ async def async_setup_entry( ) +PARALLEL_UPDATES = 1 + + class FlexitSwitch(FlexitEntity, SwitchEntity): """Representation of a Flexit Switch.""" From ed3ca766964a735d5d39ff6accf9743e7069c3d9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 09:03:28 +0100 Subject: [PATCH 0584/1941] Update foscam action descriptions to match HA style (#138664) Update foscam action description to match HA style --- homeassistant/components/foscam/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 2784e541809..03351e3238f 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -35,7 +35,7 @@ "services": { "ptz": { "name": "PTZ", - "description": "Pan/Tilt action for Foscam camera.", + "description": "Moves a Foscam camera to a specified direction.", "fields": { "movement": { "name": "Movement", @@ -49,7 +49,7 @@ }, "ptz_preset": { "name": "PTZ preset", - "description": "PTZ Preset action for Foscam camera.", + "description": "Moves a Foscam camera to a predefined position.", "fields": { "preset_name": { "name": "Preset name", From 66d16336ea23d2f9967d547dd9544e1fc2784478 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Mon, 17 Feb 2025 08:07:18 +0000 Subject: [PATCH 0585/1941] Add preconditioning number entity to Ohme (#138346) * Add preconditioning number entity * Updated test snapshots for ohme * Update test snapshots --- homeassistant/components/ohme/icons.json | 3 + homeassistant/components/ohme/number.py | 14 ++++- homeassistant/components/ohme/strings.json | 3 + tests/components/ohme/conftest.py | 1 + .../ohme/snapshots/test_number.ambr | 57 +++++++++++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 7a27156b2fe..ade48b4f80f 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -6,6 +6,9 @@ } }, "number": { + "preconditioning_duration": { + "default": "mdi:fan-clock" + }, "target_percentage": { "default": "mdi:battery-heart" } diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py index 8c5be2b48be..0c71bab009f 100644 --- a/homeassistant/components/ohme/number.py +++ b/homeassistant/components/ohme/number.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from ohme import ApiException, OhmeApiClient from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -37,6 +37,18 @@ NUMBER_DESCRIPTION = [ native_step=1, native_unit_of_measurement=PERCENTAGE, ), + OhmeNumberDescription( + key="preconditioning_duration", + translation_key="preconditioning_duration", + value_fn=lambda client: client.preconditioning, + set_fn=lambda client, value: client.async_set_target( + pre_condition_length=value + ), + native_min_value=0, + native_max_value=60, + native_step=5, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), ] diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index b337c013727..46ccfca71fd 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -51,6 +51,9 @@ } }, "number": { + "preconditioning_duration": { + "name": "Preconditioning duration" + }, "target_percentage": { "name": "Target percentage" } diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 3d3db730d08..01cc668ae32 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -57,6 +57,7 @@ def mock_client(): client.target_soc = 50 client.target_time = (8, 0) client.battery = 80 + client.preconditioning = 15 client.serial = "chargerid" client.ct_connected = True client.energy = 1000 diff --git a/tests/components/ohme/snapshots/test_number.ambr b/tests/components/ohme/snapshots/test_number.ambr index dbcf6134252..69e18d0b2a7 100644 --- a/tests/components/ohme/snapshots/test_number.ambr +++ b/tests/components/ohme/snapshots/test_number.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_numbers[number.ohme_home_pro_preconditioning_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ohme_home_pro_preconditioning_duration', + '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': 'Preconditioning duration', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preconditioning_duration', + 'unique_id': 'chargerid_preconditioning_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.ohme_home_pro_preconditioning_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Preconditioning duration', + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.ohme_home_pro_preconditioning_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- # name: test_numbers[number.ohme_home_pro_target_percentage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From e77193fa2e5250e33b3ac1b941be3f00d02d36fe Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 09:08:40 +0100 Subject: [PATCH 0586/1941] Improve 17track action descriptions by using those from the online docs (#138698) * Improve 17Track action descriptions using those from the online docs Also change them to third-person singular to match the descriptive style that Home Assistant prefers. * Add missing period on 2nd description --- homeassistant/components/seventeentrack/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index 70fea2e2735..c95a553ae7b 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -68,7 +68,7 @@ "services": { "get_packages": { "name": "Get packages", - "description": "Get packages from 17Track", + "description": "Queries the 17track API for the latest package data.", "fields": { "package_state": { "name": "Package states", @@ -82,7 +82,7 @@ }, "archive_package": { "name": "Archive package", - "description": "Archive a package", + "description": "Archives a package using the 17track API.", "fields": { "package_tracking_number": { "name": "Package tracking number", From cd13eff8ae89575e1786bc3a97ea4d828fda8312 Mon Sep 17 00:00:00 2001 From: Alberto Geniola Date: Mon, 17 Feb 2025 10:01:27 +0100 Subject: [PATCH 0587/1941] Elmax - fix issue 136877 (#138419) * Fix IPv6 zero-conf discovery not handling hostname correctly. * Aligned tests. * Remove redundant !s notation. * Add IPv6 discovery tests * Parametrize input_uri to avoid duplicated code * Update tests/components/elmax/conftest.py --------- Co-authored-by: Josef Zweck --- homeassistant/components/elmax/config_flow.py | 6 +- tests/components/elmax/__init__.py | 1 + tests/components/elmax/conftest.py | 14 +++-- tests/components/elmax/test_config_flow.py | 63 +++++++++++++++++-- 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index b8697552626..98e49cc8056 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -498,7 +498,11 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle device found via zeroconf.""" - host = discovery_info.host + host = ( + f"[{discovery_info.ip_address}]" + if discovery_info.ip_address.version == 6 + else str(discovery_info.ip_address) + ) https_port = ( int(discovery_info.port) if discovery_info.port is not None diff --git a/tests/components/elmax/__init__.py b/tests/components/elmax/__init__.py index e1a6728f1f5..391c3ccbfb2 100644 --- a/tests/components/elmax/__init__.py +++ b/tests/components/elmax/__init__.py @@ -30,6 +30,7 @@ MOCK_PANEL_PIN = "000000" MOCK_WRONG_PANEL_PIN = "000000" MOCK_PASSWORD = "password" MOCK_DIRECT_HOST = "1.1.1.1" +MOCK_DIRECT_HOST_V6 = "fd00::be2:54:34:2" MOCK_DIRECT_HOST_CHANGED = "2.2.2.2" MOCK_DIRECT_PORT = 443 MOCK_DIRECT_SSL = True diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py index f8cf33ffe1a..02f01036996 100644 --- a/tests/components/elmax/conftest.py +++ b/tests/components/elmax/conftest.py @@ -18,6 +18,7 @@ import respx from . import ( MOCK_DIRECT_HOST, + MOCK_DIRECT_HOST_V6, MOCK_DIRECT_PORT, MOCK_DIRECT_SSL, MOCK_PANEL_ID, @@ -29,6 +30,7 @@ from tests.common import load_fixture MOCK_DIRECT_BASE_URI = ( f"{'https' if MOCK_DIRECT_SSL else 'http'}://{MOCK_DIRECT_HOST}:{MOCK_DIRECT_PORT}" ) +MOCK_DIRECT_BASE_URI_V6 = f"{'https' if MOCK_DIRECT_SSL else 'http'}://[{MOCK_DIRECT_HOST_V6}]:{MOCK_DIRECT_PORT}" @pytest.fixture(autouse=True) @@ -58,12 +60,16 @@ def httpx_mock_cloud_fixture() -> Generator[respx.MockRouter]: yield respx_mock +@pytest.fixture +def base_uri() -> str: + """Configure the base-uri for the respx mock fixtures.""" + return MOCK_DIRECT_BASE_URI + + @pytest.fixture(autouse=True) -def httpx_mock_direct_fixture() -> Generator[respx.MockRouter]: +def httpx_mock_direct_fixture(base_uri: str) -> Generator[respx.MockRouter]: """Configure httpx fixture for direct Panel-API communication.""" - with respx.mock( - base_url=MOCK_DIRECT_BASE_URI, assert_all_called=False - ) as respx_mock: + with respx.mock(base_url=base_uri, assert_all_called=False) as respx_mock: # Mock Login POST. login_route = respx_mock.post(f"/api/v2/{ENDPOINT_LOGIN}", name="login") diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py index be89ee4d5d6..379cfa98bbc 100644 --- a/tests/components/elmax/test_config_flow.py +++ b/tests/components/elmax/test_config_flow.py @@ -1,8 +1,10 @@ """Tests for the Elmax config flow.""" +from ipaddress import IPv4Address, IPv6Address from unittest.mock import patch from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError +import pytest from homeassistant import config_entries from homeassistant.components.elmax.const import ( @@ -28,6 +30,7 @@ from . import ( MOCK_DIRECT_CERT, MOCK_DIRECT_HOST, MOCK_DIRECT_HOST_CHANGED, + MOCK_DIRECT_HOST_V6, MOCK_DIRECT_PORT, MOCK_DIRECT_SSL, MOCK_PANEL_ID, @@ -37,12 +40,27 @@ from . import ( MOCK_USERNAME, MOCK_WRONG_PANEL_PIN, ) +from .conftest import MOCK_DIRECT_BASE_URI_V6 from tests.common import MockConfigEntry MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( - ip_address=MOCK_DIRECT_HOST, - ip_addresses=[MOCK_DIRECT_HOST], + ip_address=IPv4Address(address=MOCK_DIRECT_HOST), + ip_addresses=[IPv4Address(address=MOCK_DIRECT_HOST)], + hostname="VideoBox.local", + name="VideoBox", + port=443, + properties={ + "idl": MOCK_PANEL_ID, + "idr": MOCK_PANEL_ID, + "v1": "PHANTOM64PRO_GSM 11.9.844", + "v2": "4.9.13", + }, + type="_elmax-ssl._tcp", +) +MOCK_ZEROCONF_DISCOVERY_INFO_V6 = ZeroconfServiceInfo( + ip_address=IPv6Address(address=MOCK_DIRECT_HOST_V6), + ip_addresses=[IPv6Address(address=MOCK_DIRECT_HOST_V6)], hostname="VideoBox.local", name="VideoBox", port=443, @@ -55,8 +73,8 @@ MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo( type="_elmax-ssl._tcp", ) MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo( - ip_address=MOCK_DIRECT_HOST_CHANGED, - ip_addresses=[MOCK_DIRECT_HOST_CHANGED], + ip_address=IPv4Address(address=MOCK_DIRECT_HOST_CHANGED), + ip_addresses=[IPv4Address(address=MOCK_DIRECT_HOST_CHANGED)], hostname="VideoBox.local", name="VideoBox", port=443, @@ -69,8 +87,8 @@ MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo( type="_elmax-ssl._tcp", ) MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = ZeroconfServiceInfo( - ip_address=MOCK_DIRECT_HOST, - ip_addresses=[MOCK_DIRECT_HOST], + ip_address=IPv4Address(MOCK_DIRECT_HOST), + ip_addresses=[IPv4Address(MOCK_DIRECT_HOST)], hostname="VideoBox.local", name="VideoBox", port=443, @@ -194,6 +212,18 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: assert result["errors"] is None +async def test_zeroconf_discovery_ipv6(hass: HomeAssistant) -> None: + """Test discovery of Elmax local api panel.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY_INFO_V6, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_setup" + assert result["errors"] is None + + async def test_zeroconf_setup_show_form(hass: HomeAssistant) -> None: """Test discovery shows a form when activated.""" result = await hass.config_entries.flow.async_init( @@ -230,6 +260,27 @@ async def test_zeroconf_setup(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.parametrize("base_uri", [MOCK_DIRECT_BASE_URI_V6]) +async def test_zeroconf_ipv6_setup(hass: HomeAssistant) -> None: + """Test the successful creation of config entry via discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DISCOVERY_INFO_V6, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN, + CONF_ELMAX_MODE_DIRECT_SSL: MOCK_DIRECT_SSL, + }, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Ensure local discovery aborts when same panel is already added to ha.""" MockConfigEntry( From 1fe644d0567019b6aa1c62b063a22c0506071f37 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 11:05:39 +0100 Subject: [PATCH 0588/1941] Fix casing in Sensibo action descriptions (#138701) - treat "Pure Boost" as a feature name - fix sentence-casing - capitalize first word --- homeassistant/components/sensibo/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 6c5210d12bf..6aba2be52fc 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -429,16 +429,16 @@ } }, "enable_pure_boost": { - "name": "Enable pure boost", + "name": "Enable Pure Boost", "description": "Enables and configures Pure Boost settings.", "fields": { "ac_integration": { "name": "AC integration", - "description": "Integrate with Air Conditioner." + "description": "Integrate with air conditioner." }, "geo_integration": { "name": "Geo integration", - "description": "Integrate with Presence." + "description": "Integrate with presence." }, "indoor_integration": { "name": "Indoor air quality", @@ -468,7 +468,7 @@ }, "fan_mode": { "name": "Fan mode", - "description": "set fan mode." + "description": "Set fan mode." }, "swing_mode": { "name": "Swing mode", From 168e45b0f9b357b80096b991e1e470e0943b028b Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 17 Feb 2025 19:24:56 +0800 Subject: [PATCH 0589/1941] Bump yolink api 0.4.8 (#138703) --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 78b553d7978..52ae8281f59 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.4.7"] + "requirements": ["yolink-api==0.4.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3adfa7abb88..9153674fdcc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3110,7 +3110,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.4.7 +yolink-api==0.4.8 # homeassistant.components.youless youless-api==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ff5c8bc7d7..164562a485b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2505,7 +2505,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.4.7 +yolink-api==0.4.8 # homeassistant.components.youless youless-api==2.2.0 From b4fac38d8a658fef1700440996cf11d780cca01d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 17 Feb 2025 12:42:02 +0100 Subject: [PATCH 0590/1941] Bump uv to 0.6.0 (#138707) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 19b2c97b181..42a90107c4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.5.27 +RUN pip3 install uv==0.6.0 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7aa76de2620..2b9e5c307a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -67,7 +67,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.27 +uv==0.6.0 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 66b25b75f92..44fef7dea9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.5.27", + "uv==0.6.0", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", diff --git a/requirements.txt b/requirements.txt index 2cbd3780eae..c06beefab37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,7 +45,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.5.27 +uv==0.6.0 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5598c839257..9d652ec1641 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.5.27,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.6.0,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From a7f63e3847b38785eb6640b433faae6ac60a559e Mon Sep 17 00:00:00 2001 From: ashionky <35916938+ashionky@users.noreply.github.com> Date: Mon, 17 Feb 2025 20:02:52 +0800 Subject: [PATCH 0591/1941] Optimize Refoss state_class of Sensor (#138266) TOTAL_INCREASING --- homeassistant/components/refoss/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py index 82637aae538..92090a192e8 100644 --- a/homeassistant/components/refoss/sensor.py +++ b/homeassistant/components/refoss/sensor.py @@ -94,7 +94,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { key="energy", translation_key="this_month_energy", device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=2, subkey="mConsume", @@ -104,7 +104,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = { key="energy_returned", translation_key="this_month_energy_returned", device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=2, subkey="mConsume", From df6cb0b824972fa990616c3e6cd774b470bb1cd2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:03:31 +0100 Subject: [PATCH 0592/1941] Add repair-issue that backup location setup is missing in Synology DSM (#138233) * add missing backup location setup repair-issue * add tests * tweak translation strings * add test for other fixable issues * remove senseless abort reason no_file_station --- .../components/synology_dsm/common.py | 17 + .../components/synology_dsm/const.py | 2 + .../components/synology_dsm/repairs.py | 125 +++++++ .../components/synology_dsm/strings.json | 31 ++ tests/components/synology_dsm/test_repairs.py | 321 ++++++++++++++++++ 5 files changed, 496 insertions(+) create mode 100644 homeassistant/components/synology_dsm/repairs.py create mode 100644 tests/components/synology_dsm/test_repairs.py diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index dfc372e6bde..d61944c146d 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -35,13 +35,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( + CONF_BACKUP_PATH, CONF_DEVICE_TOKEN, DEFAULT_TIMEOUT, + DOMAIN, EXCEPTION_DETAILS, EXCEPTION_UNKNOWN, + ISSUE_MISSING_BACKUP_SETUP, SYNOLOGY_CONNECTION_EXCEPTIONS, ) @@ -174,6 +178,19 @@ class SynoApi: " permissions or no writable shared folders available" ) + if shares and not self._entry.options.get(CONF_BACKUP_PATH): + ir.async_create_issue( + self._hass, + DOMAIN, + f"{ISSUE_MISSING_BACKUP_SETUP}_{self._entry.unique_id}", + data={"entry_id": self._entry.entry_id}, + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_MISSING_BACKUP_SETUP, + translation_placeholders={"title": self._entry.title}, + ) + LOGGER.debug( "State of File Station during setup of '%s': %s", self._entry.unique_id, diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 8fb436e8fa6..758fad53970 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -35,6 +35,8 @@ PLATFORMS = [ EXCEPTION_DETAILS = "details" EXCEPTION_UNKNOWN = "unknown" +ISSUE_MISSING_BACKUP_SETUP = "missing_backup_setup" + # Configuration CONF_SERIAL = "serial" CONF_VOLUMES = "volumes" diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py new file mode 100644 index 00000000000..725e77a2593 --- /dev/null +++ b/homeassistant/components/synology_dsm/repairs.py @@ -0,0 +1,125 @@ +"""Repair flows for the Synology DSM integration.""" + +from __future__ import annotations + +from contextlib import suppress +import logging +from typing import cast + +from synology_dsm.api.file_station.models import SynoFileSharedFolder +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DOMAIN, + ISSUE_MISSING_BACKUP_SETUP, + SYNOLOGY_CONNECTION_EXCEPTIONS, +) +from .models import SynologyDSMData + +LOGGER = logging.getLogger(__name__) + + +class MissingBackupSetupRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry, issue_id: str) -> None: + """Create flow.""" + self.entry = entry + self.issue_id = issue_id + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return self.async_show_menu( + menu_options=["confirm", "ignore"], + description_placeholders={ + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + }, + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + + syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.entry.unique_id] + + if user_input is not None: + self.hass.config_entries.async_update_entry( + self.entry, options={**dict(self.entry.options), **user_input} + ) + return self.async_create_entry(data={}) + + shares: list[SynoFileSharedFolder] | None = None + if syno_data.api.file_station: + with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): + shares = await syno_data.api.file_station.get_shared_folders( + only_writable=True + ) + + if not shares: + return self.async_abort(reason="no_shares") + + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required( + CONF_BACKUP_SHARE, + default=self.entry.options[CONF_BACKUP_SHARE], + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=s.path, label=s.name) + for s in shares + ], + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Required( + CONF_BACKUP_PATH, + default=self.entry.options[CONF_BACKUP_PATH], + ): str, + } + ), + ) + + async def async_step_ignore( + self, _: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True) + return self.async_abort(reason="ignored") + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + entry = None + if data and (entry_id := data.get("entry_id")): + entry_id = cast(str, entry_id) + entry = hass.config_entries.async_get_entry(entry_id) + + if entry and issue_id.startswith(ISSUE_MISSING_BACKUP_SETUP): + return MissingBackupSetupRepairFlow(entry, issue_id) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index c14f8da1037..f51184ef1cb 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -185,6 +185,37 @@ } } }, + "issues": { + "missing_backup_setup": { + "title": "Backup location not configured for {title}", + "fix_flow": { + "step": { + "init": { + "description": "The backup location for {title} is not configured. Do you want to set it up now? Details can be found in the integration documentation under [Backup Location]({docs_url})", + "menu_options": { + "confirm": "Set up the backup location now", + "ignore": "Don't set it up now" + } + }, + "confirm": { + "title": "[%key:component::synology_dsm::config::step::backup_share::title%]", + "data": { + "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%]" + } + } + }, + "abort": { + "no_shares": "There are no shared folders available for the user.\nPlease check the documentation.", + "ignored": "The backup location has not been configured.\nYou can still set it up later via the integration options." + } + } + } + }, "services": { "reboot": { "name": "Reboot", diff --git a/tests/components/synology_dsm/test_repairs.py b/tests/components/synology_dsm/test_repairs.py new file mode 100644 index 00000000000..b2e7352f214 --- /dev/null +++ b/tests/components/synology_dsm/test_repairs.py @@ -0,0 +1,321 @@ +"""Test repairs for synology dsm.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from synology_dsm.api.file_station.models import SynoFileSharedFolder + +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.components.synology_dsm.const import ( + CONF_BACKUP_PATH, + CONF_BACKUP_SHARE, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +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 tests.common import ANY, MockConfigEntry +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture +def mock_dsm_with_filestation(): + """Mock a successful service with filestation support.""" + with patch("homeassistant.components.synology_dsm.common.SynologyDSM") as dsm: + dsm.login = AsyncMock(return_value=True) + dsm.update = AsyncMock(return_value=True) + + dsm.surveillance_station.update = AsyncMock(return_value=True) + 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.storage = Mock( + disks_ids=["sda", "sdb", "sdc"], + volumes_ids=["volume_1"], + update=AsyncMock(return_value=True), + ) + dsm.information = Mock(serial=SERIAL) + dsm.file = AsyncMock( + get_shared_folders=AsyncMock( + return_value=[ + SynoFileSharedFolder( + additional=None, + is_dir=True, + name="HA Backup", + path="/ha_backup", + ) + ] + ), + ) + dsm.logout = AsyncMock(return_value=True) + yield dsm + + +@pytest.fixture +async def setup_dsm_with_filestation( + hass: HomeAssistant, + mock_dsm_with_filestation: MagicMock, +): + """Mock setup of synology dsm config entry.""" + with ( + patch( + "homeassistant.components.synology_dsm.common.SynologyDSM", + return_value=mock_dsm_with_filestation, + ), + patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + options={ + CONF_BACKUP_PATH: None, + CONF_BACKUP_SHARE: None, + }, + unique_id="my_serial", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert await async_setup_component(hass, REPAIRS_DOMAIN, {}) + await hass.async_block_till_done() + + yield mock_dsm_with_filestation + + +async def test_create_issue( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the issue is created.""" + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["breaks_in_ha_version"] is None + assert issue["domain"] == DOMAIN + assert issue["issue_id"] == "missing_backup_setup_my_serial" + assert issue["translation_key"] == "missing_backup_setup" + + +async def test_missing_backup_ignore( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test missing backup location setup issue is ignored by the user.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + + # get repair issues + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert not issue["ignored"] + + # start repair flow + data = await start_repair_fix_flow(client, DOMAIN, "missing_backup_setup_my_serial") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + } + assert data["step_id"] == "init" + assert data["menu_options"] == ["confirm", "ignore"] + + # seelct to ignore the flow + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "ignore"} + ) + assert data["type"] == "abort" + assert data["reason"] == "ignored" + + # check issue is ignored + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert issue["ignored"] + + +async def test_missing_backup_success( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the missing backup location setup repair flow is fully processed by the user.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.options == {"backup_path": None, "backup_share": None} + + # get repair issues + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + issue = msg["result"]["issues"][0] + assert not issue["ignored"] + + # start repair flow + data = await start_repair_fix_flow(client, DOMAIN, "missing_backup_setup_my_serial") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + } + assert data["step_id"] == "init" + assert data["menu_options"] == ["confirm", "ignore"] + + # seelct to confirm the flow + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "confirm"} + ) + assert data["step_id"] == "confirm" + assert data["type"] == "form" + + # fill out the form and submit + data = await process_repair_fix_flow( + client, + flow_id, + json={"backup_share": "/ha_backup", "backup_path": "backup_ha_dev"}, + ) + assert data["type"] == "create_entry" + assert entry.options == { + "backup_path": "backup_ha_dev", + "backup_share": "/ha_backup", + } + + +async def test_missing_backup_no_shares( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the missing backup location setup repair flow errors out.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + + # get repair issues + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + + # start repair flow + data = await start_repair_fix_flow(client, DOMAIN, "missing_backup_setup_my_serial") + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location" + } + assert data["step_id"] == "init" + assert data["menu_options"] == ["confirm", "ignore"] + + # inject error + setup_dsm_with_filestation.file.get_shared_folders.return_value = [] + + # select to confirm the flow + data = await process_repair_fix_flow( + client, flow_id, json={"next_step_id": "confirm"} + ) + assert data["type"] == "abort" + assert data["reason"] == "no_shares" + + +@pytest.mark.parametrize( + "ignore_translations", + ["component.synology_dsm.issues.other_issue.title"], +) +async def test_other_fixable_issues( + hass: HomeAssistant, + setup_dsm_with_filestation: MagicMock, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing another issue.""" + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + + issue = { + "breaks_in_ha_version": None, + "domain": DOMAIN, + "issue_id": "other_issue", + "is_fixable": True, + "severity": "error", + "translation_key": "other_issue", + } + ir.async_create_issue( + hass, + issue["domain"], + issue["issue_id"], + is_fixable=issue["is_fixable"], + severity=issue["severity"], + translation_key=issue["translation_key"], + ) + + await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + results = msg["result"]["issues"] + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "synology_dsm", + "ignored": False, + "is_fixable": True, + "issue_domain": None, + "issue_id": "other_issue", + "learn_more_url": None, + "severity": "error", + "translation_key": "other_issue", + "translation_placeholders": None, + } in results + + data = await start_repair_fix_flow(client, DOMAIN, "other_issue") + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, flow_id) + + assert data["type"] == "create_entry" + await hass.async_block_till_done() From 4a385ed26c2cc4adaf50a33352340c6acda73726 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 13:38:42 +0100 Subject: [PATCH 0593/1941] Use correct camel-case for OpenThread, reword error message (#138651) * Use correct camel-case for OpenThread, reword error message * Treat "Border Agent ID" as a name by capitalizing it --- homeassistant/components/otbr/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index e1afa5b8909..3a9661c454d 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -5,7 +5,7 @@ "data": { "url": "[%key:common::config_flow::data::url%]" }, - "description": "Provide URL for the Open Thread Border Router's REST API" + "description": "Provide URL for the OpenThread Border Router's REST API" } }, "error": { @@ -20,8 +20,8 @@ }, "issues": { "get_get_border_agent_id_unsupported": { - "title": "The OTBR does not support border agent ID", - "description": "Your OTBR does not support border agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nTo update the OTBR, update the Open Thread Border Router or Silicon Labs Multiprotocol add-on if you use the OTBR from the add-on, otherwise update your self managed OTBR." + "title": "The OTBR does not support Border Agent ID", + "description": "Your OTBR does not support Border Agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nIf you are using an OTBR integrated in Home Assistant, update either the OpenThread Border Router add-on or the Silicon Labs Multiprotocol add-on. Otherwise update your self-managed OTBR." }, "insecure_thread_network": { "title": "Insecure Thread network settings detected", From d8d054e7dd62ce0f0e83c48cf7c466b266edb989 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 17 Feb 2025 14:45:00 +0100 Subject: [PATCH 0594/1941] Improve type hints in base entities (#138708) --- homeassistant/components/broadlink/entity.py | 6 +++--- homeassistant/components/enocean/entity.py | 2 +- homeassistant/components/flo/entity.py | 4 ++-- homeassistant/components/hlk_sw16/entity.py | 4 ++-- homeassistant/components/homematic/entity.py | 4 ++-- homeassistant/components/ihc/entity.py | 2 +- homeassistant/components/insteon/entity.py | 4 ++-- homeassistant/components/lupusec/entity.py | 2 +- homeassistant/components/lutron_caseta/entity.py | 2 +- homeassistant/components/onvif/entity.py | 2 +- homeassistant/components/pilight/entity.py | 4 ++-- homeassistant/components/plaato/entity.py | 4 ++-- homeassistant/components/point/entity.py | 4 ++-- homeassistant/components/qwikswitch/entity.py | 2 +- homeassistant/components/raincloud/entity.py | 2 +- homeassistant/components/rflink/entity.py | 8 ++++---- homeassistant/components/roomba/entity.py | 2 +- homeassistant/components/soma/entity.py | 2 +- homeassistant/components/starline/entity.py | 8 ++++---- homeassistant/components/tellduslive/entity.py | 6 +++--- homeassistant/components/tellstick/entity.py | 4 ++-- homeassistant/components/upb/entity.py | 4 ++-- homeassistant/components/velux/entity.py | 2 +- homeassistant/components/vera/entity.py | 4 ++-- homeassistant/components/volvooncall/entity.py | 2 +- homeassistant/components/wiffi/entity.py | 2 +- homeassistant/components/wirelesstag/entity.py | 4 ++-- homeassistant/components/xiaomi_aqara/entity.py | 4 ++-- homeassistant/components/xiaomi_miio/entity.py | 2 +- homeassistant/components/xs1/entity.py | 2 +- homeassistant/components/yamaha_musiccast/entity.py | 4 ++-- 31 files changed, 54 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/broadlink/entity.py b/homeassistant/components/broadlink/entity.py index 6c956d8c80a..a97374680f9 100644 --- a/homeassistant/components/broadlink/entity.py +++ b/homeassistant/components/broadlink/entity.py @@ -17,13 +17,13 @@ class BroadlinkEntity(Entity): self._device = device self._coordinator = device.update_manager.coordinator - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when the entity is added to hass.""" self.async_on_remove(self._coordinator.async_add_listener(self._recv_data)) if self._coordinator.data: self._update_state(self._coordinator.data) - async def async_update(self): + async def async_update(self) -> None: """Update the state of the entity.""" await self._coordinator.async_request_refresh() @@ -49,7 +49,7 @@ class BroadlinkEntity(Entity): """ @property - def available(self): + def available(self) -> bool: """Return True if the entity is available.""" return self._device.available diff --git a/homeassistant/components/enocean/entity.py b/homeassistant/components/enocean/entity.py index 5c12fc12a68..b2d73e65443 100644 --- a/homeassistant/components/enocean/entity.py +++ b/homeassistant/components/enocean/entity.py @@ -16,7 +16,7 @@ class EnOceanEntity(Entity): """Initialize the device.""" self.dev_id = dev_id - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index b0cf8d04313..072afbae4f2 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -45,10 +45,10 @@ class FloEntity(Entity): """Return True if device is available.""" return self._device.available - async def async_update(self): + async def async_update(self) -> None: """Update Flo entity.""" await self._device.async_request_refresh() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove(self._device.async_add_listener(self.async_write_ha_state)) diff --git a/homeassistant/components/hlk_sw16/entity.py b/homeassistant/components/hlk_sw16/entity.py index fdef5f6764b..91510760968 100644 --- a/homeassistant/components/hlk_sw16/entity.py +++ b/homeassistant/components/hlk_sw16/entity.py @@ -35,7 +35,7 @@ class SW16Entity(Entity): self.async_write_ha_state() @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return bool(self._client.is_connected) @@ -44,7 +44,7 @@ class SW16Entity(Entity): """Update availability state.""" self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register update callback.""" self._client.register_status_callback( self.handle_event_callback, self._device_port diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 5a5b2a3b8c8..44e95e98f38 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -62,7 +62,7 @@ class HMDevice(Entity): if self._state: self._state = self._state.upper() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Load data init callbacks.""" self._subscribe_homematic_events() @@ -77,7 +77,7 @@ class HMDevice(Entity): return self._name @property - def available(self): + def available(self) -> bool: """Return true if device is available.""" return self._available diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py index f90b2ee943c..8847ffc9f49 100644 --- a/homeassistant/components/ihc/entity.py +++ b/homeassistant/components/ihc/entity.py @@ -54,7 +54,7 @@ class IHCEntity(Entity): self.ihc_note = "" self.ihc_position = "" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add callback for IHC changes.""" _LOGGER.debug("Adding IHC entity notify event: %s", self.ihc_id) self.ihc_controller.add_notify_event(self.ihc_id, self.on_ihc_change, True) diff --git a/homeassistant/components/insteon/entity.py b/homeassistant/components/insteon/entity.py index 79e5c18a934..b7886723fdf 100644 --- a/homeassistant/components/insteon/entity.py +++ b/homeassistant/components/insteon/entity.py @@ -109,7 +109,7 @@ class InsteonEntity(Entity): ) self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register INSTEON update events.""" _LOGGER.debug( "Tracking updates for device %s group %d name %s", @@ -137,7 +137,7 @@ class InsteonEntity(Entity): ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unsubscribe to INSTEON update events.""" _LOGGER.debug( "Remove tracking updates for device %s group %d name %s", diff --git a/homeassistant/components/lupusec/entity.py b/homeassistant/components/lupusec/entity.py index dc0dac89dc8..8cfb559b84f 100644 --- a/homeassistant/components/lupusec/entity.py +++ b/homeassistant/components/lupusec/entity.py @@ -18,7 +18,7 @@ class LupusecDevice(Entity): self._device = device self._attr_unique_id = device.device_id - def update(self): + def update(self) -> None: """Update automation state.""" self._device.refresh() diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py index f954be74f1d..5ab211ed87b 100644 --- a/homeassistant/components/lutron_caseta/entity.py +++ b/homeassistant/components/lutron_caseta/entity.py @@ -63,7 +63,7 @@ class LutronCasetaEntity(Entity): info[ATTR_SUGGESTED_AREA] = area self._attr_device_info = info - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state) diff --git a/homeassistant/components/onvif/entity.py b/homeassistant/components/onvif/entity.py index c9900106256..783df743e86 100644 --- a/homeassistant/components/onvif/entity.py +++ b/homeassistant/components/onvif/entity.py @@ -17,7 +17,7 @@ class ONVIFBaseEntity(Entity): self.device: ONVIFDevice = device @property - def available(self): + def available(self) -> bool: """Return True if device is available.""" return self.device.available diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py index fbb924d7f8f..fbfa5cfb5e1 100644 --- a/homeassistant/components/pilight/entity.py +++ b/homeassistant/components/pilight/entity.py @@ -86,7 +86,7 @@ class PilightBaseDevice(RestoreEntity): self._brightness = 255 - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): @@ -99,7 +99,7 @@ class PilightBaseDevice(RestoreEntity): return self._name @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return True diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index 7ab8367bd1d..9cc63a38a64 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -73,13 +73,13 @@ class PlaatoEntity(entity.Entity): return None @property - def available(self): + def available(self) -> bool: """Return if sensor is available.""" if self._coordinator is not None: return self._coordinator.last_update_success return True - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" if self._coordinator is not None: self.async_on_remove( diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py index 4784dd43180..5c52e81e6f7 100644 --- a/homeassistant/components/point/entity.py +++ b/homeassistant/components/point/entity.py @@ -52,7 +52,7 @@ class MinutPointEntity(Entity): ) await self._update_callback() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" if self._async_unsub_dispatcher_connect: self._async_unsub_dispatcher_connect() @@ -61,7 +61,7 @@ class MinutPointEntity(Entity): """Update the value of the sensor.""" @property - def available(self): + def available(self) -> bool: """Return true if device is not offline.""" return self._client.is_available(self.device_id) diff --git a/homeassistant/components/qwikswitch/entity.py b/homeassistant/components/qwikswitch/entity.py index 3a2ec5a9206..ff7a1d2e98a 100644 --- a/homeassistant/components/qwikswitch/entity.py +++ b/homeassistant/components/qwikswitch/entity.py @@ -35,7 +35,7 @@ class QSEntity(Entity): """Receive update packet from QSUSB. Match dispather_send signature.""" self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Listen for updates from QSUSb via dispatcher.""" self.async_on_remove( async_dispatcher_connect(self.hass, self.qsid, self.update_packet) diff --git a/homeassistant/components/raincloud/entity.py b/homeassistant/components/raincloud/entity.py index 337324d96eb..b45684ac72b 100644 --- a/homeassistant/components/raincloud/entity.py +++ b/homeassistant/components/raincloud/entity.py @@ -45,7 +45,7 @@ class RainCloudEntity(Entity): """Return the name of the sensor.""" return self._name - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py index 26153acf7ba..0caec4ea2c3 100644 --- a/homeassistant/components/rflink/entity.py +++ b/homeassistant/components/rflink/entity.py @@ -105,12 +105,12 @@ class RflinkDevice(Entity): return self._state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Assume device state until first device event sets state.""" return self._state is None @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available @@ -120,7 +120,7 @@ class RflinkDevice(Entity): self._available = availability self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register update callback.""" await super().async_added_to_hass() # Remove temporary bogus entity_id if added @@ -300,7 +300,7 @@ class RflinkCommand(RflinkDevice): class SwitchableRflinkDevice(RflinkCommand, RestoreEntity): """Rflink entity which can switch on/off (eg: light, switch).""" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore RFLink device state (ON/OFF).""" await super().async_added_to_hass() if (old_state := await self.async_get_last_state()) is not None: diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index ae5577da4e4..14c7ac3af3e 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -80,7 +80,7 @@ class IRobotEntity(Entity): return None return dt_util.utc_from_timestamp(ts) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callback function.""" self.vacuum.register_on_message_callback(self.on_message) diff --git a/homeassistant/components/soma/entity.py b/homeassistant/components/soma/entity.py index f9824d107b1..4b2fcee5405 100644 --- a/homeassistant/components/soma/entity.py +++ b/homeassistant/components/soma/entity.py @@ -71,7 +71,7 @@ class SomaEntity(Entity): self.api_is_available = True @property - def available(self): + def available(self) -> bool: """Return true if the last API commands returned successfully.""" return self.is_available diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index 74807996dfb..f8846c2a97f 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -27,20 +27,20 @@ class StarlineEntity(Entity): self._unsubscribe_api: Callable | None = None @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._account.api.available - def update(self): + def update(self) -> None: """Read new state data.""" self.schedule_update_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" await super().async_added_to_hass() self._unsubscribe_api = self._account.api.add_update_listener(self.update) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Call when entity is being removed from Home Assistant.""" await super().async_will_remove_from_hass() if self._unsubscribe_api is not None: diff --git a/homeassistant/components/tellduslive/entity.py b/homeassistant/components/tellduslive/entity.py index a71fcb685c0..5366e4c27df 100644 --- a/homeassistant/components/tellduslive/entity.py +++ b/homeassistant/components/tellduslive/entity.py @@ -33,7 +33,7 @@ class TelldusLiveEntity(Entity): self._id = device_id self._client = client - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" _LOGGER.debug("Created device %s", self) self.async_on_remove( @@ -58,12 +58,12 @@ class TelldusLiveEntity(Entity): return self.device.state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if unable to access real state of entity.""" return True @property - def available(self): + def available(self) -> bool: """Return true if device is not offline.""" return self._client.is_available(self.device_id) diff --git a/homeassistant/components/tellstick/entity.py b/homeassistant/components/tellstick/entity.py index 746c7f4dd4d..5be3d1f48f4 100644 --- a/homeassistant/components/tellstick/entity.py +++ b/homeassistant/components/tellstick/entity.py @@ -40,7 +40,7 @@ class TellstickDevice(Entity): self._attr_name = tellcore_device.name self._attr_unique_id = tellcore_device.id - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -146,6 +146,6 @@ class TellstickDevice(Entity): except TelldusError as err: _LOGGER.error(err) - def update(self): + def update(self) -> None: """Poll the current state of the device.""" self._update_from_tellcore() diff --git a/homeassistant/components/upb/entity.py b/homeassistant/components/upb/entity.py index 13037adf680..8a9afa453b1 100644 --- a/homeassistant/components/upb/entity.py +++ b/homeassistant/components/upb/entity.py @@ -30,7 +30,7 @@ class UpbEntity(Entity): return self._element.as_dict() @property - def available(self): + def available(self) -> bool: """Is the entity available to be updated.""" return self._upb.is_connected() @@ -43,7 +43,7 @@ class UpbEntity(Entity): self._element_changed(element, changeset) self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callback for UPB changes and update entity state.""" self._element.add_callback(self._element_callback) self._element_callback(self._element, {}) diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index 674ba5dde45..1231a98e0a8 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -31,6 +31,6 @@ class VeluxEntity(Entity): self.node.register_device_updated_cb(after_update_callback) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Store register state change callback.""" self.async_register_callbacks() diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py index 84e21e54983..b3013c288c1 100644 --- a/homeassistant/components/vera/entity.py +++ b/homeassistant/components/vera/entity.py @@ -52,7 +52,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): """Update the state.""" self.schedule_update_ha_state(True) - def update(self): + def update(self) -> None: """Force a refresh from the device if the device is unavailable.""" refresh_needed = self.vera_device.should_poll or not self.available _LOGGER.debug("%s: update called (refresh=%s)", self._name, refresh_needed) @@ -90,7 +90,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): return attr @property - def available(self): + def available(self) -> bool: """If device communications have failed return false.""" return not self.vera_device.comm_failure diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py index 6ebc4bdc754..5a1194e8b1a 100644 --- a/homeassistant/components/volvooncall/entity.py +++ b/homeassistant/components/volvooncall/entity.py @@ -57,7 +57,7 @@ class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]): return f"{self._vehicle_name} {self._entity_name}" @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if unable to access real state of entity.""" return True diff --git a/homeassistant/components/wiffi/entity.py b/homeassistant/components/wiffi/entity.py index fd774c930c8..84bbc9b3df1 100644 --- a/homeassistant/components/wiffi/entity.py +++ b/homeassistant/components/wiffi/entity.py @@ -41,7 +41,7 @@ class WiffiEntity(Entity): self._value = None self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Entity has been added to hass.""" self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/wirelesstag/entity.py b/homeassistant/components/wirelesstag/entity.py index 31f8ee99d0d..73b13cdc397 100644 --- a/homeassistant/components/wirelesstag/entity.py +++ b/homeassistant/components/wirelesstag/entity.py @@ -60,11 +60,11 @@ class WirelessTagBaseSensor(Entity): return f"{value:.1f}" @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._tag.is_alive - def update(self): + def update(self) -> None: """Update state.""" if not self.should_poll: return diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py index db47015c0cf..59107984ddf 100644 --- a/homeassistant/components/xiaomi_aqara/entity.py +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -57,7 +57,7 @@ class XiaomiDevice(Entity): self._is_gateway = False self._device_id = self._sid - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Start unavailability tracking.""" self._xiaomi_hub.callbacks[self._sid].append(self.push_data) self._async_track_unavailable() @@ -100,7 +100,7 @@ class XiaomiDevice(Entity): return device_info @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._is_available diff --git a/homeassistant/components/xiaomi_miio/entity.py b/homeassistant/components/xiaomi_miio/entity.py index 0343a7526d7..ba1148985ba 100644 --- a/homeassistant/components/xiaomi_miio/entity.py +++ b/homeassistant/components/xiaomi_miio/entity.py @@ -185,7 +185,7 @@ class XiaomiGatewayDevice(CoordinatorEntity, Entity): ) @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" if self.coordinator.data is None: return False diff --git a/homeassistant/components/xs1/entity.py b/homeassistant/components/xs1/entity.py index 7239a6fd446..c1ec43ec33c 100644 --- a/homeassistant/components/xs1/entity.py +++ b/homeassistant/components/xs1/entity.py @@ -17,7 +17,7 @@ class XS1DeviceEntity(Entity): """Initialize the XS1 device.""" self.device = device - async def async_update(self): + async def async_update(self) -> None: """Retrieve latest device state.""" async with UPDATE_LOCK: await self.hass.async_add_executor_job(self.device.update) diff --git a/homeassistant/components/yamaha_musiccast/entity.py b/homeassistant/components/yamaha_musiccast/entity.py index 4f1add825e4..8023b13c10a 100644 --- a/homeassistant/components/yamaha_musiccast/entity.py +++ b/homeassistant/components/yamaha_musiccast/entity.py @@ -78,13 +78,13 @@ class MusicCastDeviceEntity(MusicCastEntity): return device_info - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when this Entity has been added to HA.""" await super().async_added_to_hass() # All entities should register callbacks to update HA when their state changes self.coordinator.musiccast.register_callback(self.async_write_ha_state) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Entity being removed from hass.""" await super().async_will_remove_from_hass() self.coordinator.musiccast.remove_callback(self.async_write_ha_state) From 7e388f69b0f3b009aef587c1ed70e7a60cef87b6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 17 Feb 2025 14:45:32 +0100 Subject: [PATCH 0595/1941] Add common entity module to pylint plugin (#138706) * Add common entity module to pylint plugin * Fix pylint errors --- homeassistant/components/isy994/entity.py | 4 ++-- homeassistant/components/switchbot/entity.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 10 ++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py index 893b33644fe..1da727fdee8 100644 --- a/homeassistant/components/isy994/entity.py +++ b/homeassistant/components/isy994/entity.py @@ -106,7 +106,7 @@ class ISYNodeEntity(ISYEntity): return getattr(self._node, TAG_ENABLED, True) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Get the state attributes for the device. The 'aux_properties' in the pyisy Node class are combined with the @@ -189,7 +189,7 @@ class ISYProgramEntity(ISYEntity): self._actions = actions @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Get the state attributes for the device.""" attr = {} if self._actions: diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py index bde69429bc3..282d23bfd1a 100644 --- a/homeassistant/components/switchbot/entity.py +++ b/homeassistant/components/switchbot/entity.py @@ -61,7 +61,7 @@ class SwitchbotEntity( return self.coordinator.device.parsed_data @property - def extra_state_attributes(self) -> Mapping[Any, Any]: + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes.""" return {"last_run_success": self._last_run_success} diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index e2b6de6e6a3..a4590207294 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1410,6 +1410,16 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ], ), ], + "entity": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), + ], "fan": [ ClassTypeHintMatch( base_class="Entity", From 51aea58c7ac441a3d4c84935ca4a2be4d9d5e23c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 17 Feb 2025 14:46:33 +0100 Subject: [PATCH 0596/1941] Update mypy-dev to 1.16.0a3 (#138655) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 2731114043b..0a7a3bb18e5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.10 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a2 +mypy-dev==1.16.0a3 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.4 From 4cdc3de94a491fdf7fb98667666e3656b66806e5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 17 Feb 2025 15:38:28 +0100 Subject: [PATCH 0597/1941] Correct backup filename on delete or download of cloud backup (#138704) * Correct backup filename on delete or download of cloud backup * Improve tests * Address review comments --- homeassistant/components/cloud/backup.py | 43 +++++++++++------ tests/components/cloud/test_backup.py | 61 +++++++++++++++++++++--- 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 61edeccdd9c..b31fe16fbe9 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -11,7 +11,11 @@ from typing import Any from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError -from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list +from hass_nabucasa.cloud_api import ( + FilesHandlerListEntry, + async_files_delete_file, + async_files_list, +) from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError @@ -76,11 +80,6 @@ class CloudBackupAgent(BackupAgent): self._cloud = cloud self._hass = hass - @callback - def _get_backup_filename(self) -> str: - """Return the backup filename.""" - return f"{self._cloud.client.prefs.instance_id}.tar" - async def async_download_backup( self, backup_id: str, @@ -91,13 +90,13 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ - if not await self.async_get_backup(backup_id): + if not (backup := await self._async_get_backup(backup_id)): raise BackupAgentError("Backup not found") try: content = await self._cloud.files.download( storage_type=StorageType.BACKUP, - filename=self._get_backup_filename(), + filename=backup["Key"], ) except CloudError as err: raise BackupAgentError(f"Failed to download backup: {err}") from err @@ -124,7 +123,7 @@ class CloudBackupAgent(BackupAgent): base64md5hash = await calculate_b64md5(open_stream, size) except FilesError as err: raise BackupAgentError(err) from err - filename = self._get_backup_filename() + filename = f"{self._cloud.client.prefs.instance_id}.tar" metadata = backup.as_dict() tries = 1 @@ -172,29 +171,34 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - if not await self.async_get_backup(backup_id): + if not (backup := await self._async_get_backup(backup_id)): return try: await async_files_delete_file( self._cloud, storage_type=StorageType.BACKUP, - filename=self._get_backup_filename(), + filename=backup["Key"], ) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to delete backup") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups = await self._async_list_backups() + return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] + + async def _async_list_backups(self) -> list[FilesHandlerListEntry]: """List backups.""" try: backups = await async_files_list( self._cloud, storage_type=StorageType.BACKUP ) - _LOGGER.debug("Cloud backups: %s", backups) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err - return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] + _LOGGER.debug("Cloud backups: %s", backups) + return backups async def async_get_backup( self, @@ -202,10 +206,19 @@ class CloudBackupAgent(BackupAgent): **kwargs: Any, ) -> AgentBackup | None: """Return a backup.""" - backups = await self.async_list_backups() + if not (backup := await self._async_get_backup(backup_id)): + return None + return AgentBackup.from_dict(backup["Metadata"]) + + async def _async_get_backup( + self, + backup_id: str, + ) -> FilesHandlerListEntry | None: + """Return a backup.""" + backups = await self._async_list_backups() for backup in backups: - if backup.backup_id == backup_id: + if backup["Metadata"]["backup_id"] == backup_id: return backup return None diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index c6bb0bdad54..18793cc00bb 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -3,12 +3,12 @@ from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import ANY, Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError from hass_nabucasa.api import CloudApiNonRetryableError -from hass_nabucasa.files import FilesError +from hass_nabucasa.files import FilesError, StorageType import pytest from homeassistant.components.backup import ( @@ -90,7 +90,26 @@ def mock_list_files() -> Generator[MagicMock]: "size": 34519040, "storage-type": "backup", }, - } + }, + { + "Key": "462e16810d6841228828d9dd2f9e341f.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aed", + "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, + "storage-type": "backup", + }, + }, ] yield list_files @@ -148,7 +167,21 @@ async def test_agents_list_backups( "name": "Core 2024.12.0.dev0", "failed_agent_ids": [], "with_automatic_settings": None, - } + }, + { + "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, + "backup_id": "23e64aed", + "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", + "failed_agent_ids": [], + "with_automatic_settings": None, + }, ] @@ -242,6 +275,10 @@ async def test_agents_download( resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 200 assert await resp.content.read() == b"backup data" + cloud.files.download.assert_called_once_with( + filename="462e16810d6841228828d9dd2f9e341e.tar", + storage_type=StorageType.BACKUP, + ) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") @@ -317,7 +354,14 @@ async def test_agents_upload( data={"file": StringIO(backup_data)}, ) - assert len(cloud.files.upload.mock_calls) == 1 + cloud.files.upload.assert_called_once_with( + storage_type=StorageType.BACKUP, + open_stream=ANY, + filename=f"{cloud.client.prefs.instance_id}.tar", + base64md5hash=ANY, + metadata=ANY, + size=ANY, + ) metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] assert metadata["backup_id"] == backup_id @@ -552,6 +596,7 @@ async def test_agents_upload_wrong_size( async def test_agents_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + cloud: Mock, mock_delete_file: Mock, ) -> None: """Test agent delete backup.""" @@ -568,7 +613,11 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_delete_file.assert_called_once() + mock_delete_file.assert_called_once_with( + cloud, + filename="462e16810d6841228828d9dd2f9e341e.tar", + storage_type=StorageType.BACKUP, + ) @pytest.mark.parametrize("side_effect", [ClientError, CloudError]) From 9422c4de65aafc693440af1f4b8f41fbd7d17744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 17 Feb 2025 15:01:03 +0000 Subject: [PATCH 0598/1941] Fix snapshots timezone in Cloud tests (#138393) * Fix snapshots timezone in Cloud tests * Add explanation comment --- tests/components/cloud/test_http_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index c8852b911e9..ef4b93a8aab 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1943,7 +1943,10 @@ async def test_download_support_package( ) now = dt_util.utcnow() - freezer.move_to(datetime.datetime.fromisoformat("2025-02-10T12:00:00.0+00:00")) + # The logging is done with local time according to the system timezone. Set the + # fake time to 12:00 local time + tz = now.astimezone().tzinfo + freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz)) logging.getLogger("hass_nabucasa.iot").info( "This message will be dropped since this test patches MAX_RECORDS" ) From 82f2e72327c7baa5cf6b700baeef93015d096def Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Mon, 17 Feb 2025 16:18:46 +0100 Subject: [PATCH 0599/1941] Add translations for exceptions (#138669) * Add translations for exceptions * Review comment * Add translation for exception in the coordinator * Use same translation string for switch exceptions --- .../components/flexit_bacnet/climate.py | 17 +++++++++++++-- .../components/flexit_bacnet/coordinator.py | 6 +++++- .../components/flexit_bacnet/number.py | 9 +++++++- .../flexit_bacnet/quality_scale.yaml | 2 +- .../components/flexit_bacnet/strings.json | 17 +++++++++++++++ .../components/flexit_bacnet/switch.py | 21 +++++++++++++++---- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index b9ae16739b9..7dc855e3106 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -25,6 +25,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( + DOMAIN, MAX_TEMP, MIN_TEMP, PRESET_TO_VENTILATION_MODE_MAP, @@ -133,7 +134,13 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): try: await self.device.set_ventilation_mode(ventilation_mode) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_preset_mode", + translation_placeholders={ + "preset": str(ventilation_mode), + }, + ) from exc finally: await self.coordinator.async_refresh() @@ -153,6 +160,12 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): else: await self.device.set_ventilation_mode(VENTILATION_MODE_HOME) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_hvac_mode", + translation_placeholders={ + "mode": str(hvac_mode), + }, + ) from exc finally: await self.coordinator.async_refresh() diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py index da9415f2b87..9148ec87883 100644 --- a/homeassistant/components/flexit_bacnet/coordinator.py +++ b/homeassistant/components/flexit_bacnet/coordinator.py @@ -49,7 +49,11 @@ class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]): await self.device.update() except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: raise ConfigEntryNotReady( - f"Timeout while connecting to {self.config_entry.data[CONF_IP_ADDRESS]}" + translation_domain=DOMAIN, + translation_key="not_ready", + translation_placeholders={ + "ip": str(self.config_entry.data[CONF_IP_ADDRESS]), + }, ) from exc return self.device diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py index 061860e7d0d..b8c329bd1d4 100644 --- a/homeassistant/components/flexit_bacnet/number.py +++ b/homeassistant/components/flexit_bacnet/number.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import FlexitConfigEntry, FlexitCoordinator from .entity import FlexitEntity @@ -249,6 +250,12 @@ class FlexitNumber(FlexitEntity, NumberEntity): try: await set_native_value_fn(int(value)) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_value_error", + translation_placeholders={ + "value": str(value), + }, + ) from exc finally: await self.coordinator.async_refresh() diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml index 548580f96d3..f59435bad0d 100644 --- a/homeassistant/components/flexit_bacnet/quality_scale.yaml +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -62,7 +62,7 @@ rules: comment: | Device type integration. diagnostics: todo - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo dynamic-devices: diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index 488d93fbd61..e9acbd46a37 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -119,5 +119,22 @@ "name": "Cooker hood mode" } } + }, + "exceptions": { + "set_value_error": { + "message": "Failed setting the value {value}." + }, + "switch_turn": { + "message": "Failed to turn the switch {state}." + }, + "set_preset_mode": { + "message": "Failed to set preset mode {preset}." + }, + "set_hvac_mode": { + "message": "Failed to set HVAC mode {mode}." + }, + "not_ready": { + "message": "Timeout while connecting to {ip}." + } } } diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index ac69bb86023..bdeff006181 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import FlexitConfigEntry, FlexitCoordinator from .entity import FlexitEntity @@ -97,19 +98,31 @@ class FlexitSwitch(FlexitEntity, SwitchEntity): return self.entity_description.is_on_fn(self.coordinator.data) async def async_turn_on(self, **kwargs: Any) -> None: - """Turn electric heater on.""" + """Turn switch on.""" try: await self.entity_description.turn_on_fn(self.coordinator.data) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_turn", + translation_placeholders={ + "state": "on", + }, + ) from exc finally: await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: - """Turn electric heater off.""" + """Turn switch off.""" try: await self.entity_description.turn_off_fn(self.coordinator.data) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_turn", + translation_placeholders={ + "state": "off", + }, + ) from exc finally: await self.coordinator.async_refresh() From 34a33e0465e6d34d9f831857bd32135bb0af15a5 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 17 Feb 2025 09:28:55 -0600 Subject: [PATCH 0600/1941] Create HEOS devices after integration setup (#138721) * Create entities for new players * Fix docstring typo --- homeassistant/components/heos/coordinator.py | 30 ++++++-- homeassistant/components/heos/media_player.py | 17 +++-- .../components/heos/quality_scale.yaml | 2 +- tests/components/heos/conftest.py | 62 ++++++++------- tests/components/heos/test_init.py | 75 ++++++++++++++++++- 5 files changed, 147 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 94aa4ad0ab5..0303d150794 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -16,6 +16,7 @@ from pyheos import ( HeosError, HeosNowPlayingMedia, HeosOptions, + HeosPlayer, MediaItem, MediaType, PlayerUpdateResult, @@ -58,6 +59,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): credentials=credentials, ) ) + self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = [] self._update_sources_pending: bool = False self._source_list: list[str] = [] self._favorites: dict[int, MediaItem] = {} @@ -124,6 +126,27 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self.async_update_listeners() return remove_listener + def async_add_platform_callback( + self, add_entities_callback: Callable[[Sequence[HeosPlayer]], None] + ) -> None: + """Add a callback to add entities for a platform.""" + self._platform_callbacks.append(add_entities_callback) + + def _async_handle_player_update_result( + self, update_result: PlayerUpdateResult + ) -> None: + """Handle a player update result.""" + if update_result.added_player_ids and self._platform_callbacks: + new_players = [ + self.heos.players[player_id] + for player_id in update_result.added_player_ids + ] + for add_entities_callback in self._platform_callbacks: + add_entities_callback(new_players) + + if update_result.updated_player_ids: + self._async_update_player_ids(update_result.updated_player_ids) + async def _async_on_auth_failure(self) -> None: """Handle when the user credentials are no longer valid.""" assert self.config_entry is not None @@ -147,8 +170,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): """Handle a controller event, such as players or groups changed.""" if event == const.EVENT_PLAYERS_CHANGED: assert data is not None - if data.updated_player_ids: - self._async_update_player_ids(data.updated_player_ids) + self._async_handle_player_update_result(data) elif ( event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) and not self._update_sources_pending @@ -242,9 +264,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]): except HeosError as error: _LOGGER.error("Unable to refresh players: %s", error) return - # After reconnecting, player_id may have changed - if player_updates.updated_player_ids: - self._async_update_player_ids(player_updates.updated_player_ids) + self._async_handle_player_update_result(player_updates) @callback def async_get_source_list(self) -> list[str]: diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 4dbaead67a7..b9aa05810e5 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine, Sequence from datetime import datetime from functools import reduce, wraps from operator import ior @@ -93,11 +93,16 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add media players for a config entry.""" - devices = [ - HeosMediaPlayer(entry.runtime_data, player) - for player in entry.runtime_data.heos.players.values() - ] - async_add_entities(devices) + + def add_entities_callback(players: Sequence[HeosPlayer]) -> None: + """Add entities for each player.""" + async_add_entities( + [HeosMediaPlayer(entry.runtime_data, player) for player in players] + ) + + coordinator = entry.runtime_data + coordinator.async_add_platform_callback(add_entities_callback) + add_entities_callback(list(coordinator.heos.players.values())) type _FuncType[**_P] = Callable[_P, Awaitable[Any]] diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index a1220366fa3..a08e2dca544 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -49,7 +49,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 39937a8355f..7bed05a0289 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from unittest.mock import Mock, patch from pyheos import ( @@ -130,16 +130,17 @@ def system_info_fixture() -> HeosSystem: ) -@pytest.fixture(name="players") -def players_fixture() -> dict[int, HeosPlayer]: - """Create two mock HeosPlayers.""" - players = {} - for i in (1, 2): - player = HeosPlayer( - player_id=i, +@pytest.fixture(name="player_factory") +def player_factory_fixture() -> Callable[[int, str, str], HeosPlayer]: + """Return a method that creates players.""" + + def factory(player_id: int, name: str, model: str) -> HeosPlayer: + """Create a player.""" + return HeosPlayer( + player_id=player_id, group_id=999, - name="Test Player" if i == 1 else f"Test Player {i}", - model="HEOS Drive HS2" if i == 1 else "Speaker", + name=name, + model=model, serial="123456", version="1.0.0", supported_version=True, @@ -147,26 +148,37 @@ def players_fixture() -> dict[int, HeosPlayer]: is_muted=False, available=True, state=PlayState.STOP, - ip_address=f"127.0.0.{i}", + ip_address=f"127.0.0.{player_id}", network=NetworkType.WIRED, shuffle=False, repeat=RepeatType.OFF, volume=25, + now_playing_media=HeosNowPlayingMedia( + type=MediaType.STATION, + song="Song", + station="Station Name", + album="Album", + artist="Artist", + image_url="http://", + album_id="1", + media_id="1", + queue_id=1, + source_id=10, + ), ) - player.now_playing_media = HeosNowPlayingMedia( - type=MediaType.STATION, - song="Song", - station="Station Name", - album="Album", - artist="Artist", - image_url="http://", - album_id="1", - media_id="1", - queue_id=1, - source_id=10, - ) - players[player.player_id] = player - return players + + return factory + + +@pytest.fixture(name="players") +def players_fixture( + player_factory: Callable[[int, str, str], HeosPlayer], +) -> dict[int, HeosPlayer]: + """Create two mock HeosPlayers.""" + return { + 1: player_factory(1, "Test Player", "HEOS Drive HS2"), + 2: player_factory(2, "Test Player 2", "Speaker"), + } @pytest.fixture(name="group") diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 60bc2a72e51..87cc8dd7dde 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -1,16 +1,26 @@ """Tests for the init module.""" +from collections.abc import Callable from typing import cast from unittest.mock import Mock -from pyheos import HeosError, HeosOptions, SignalHeosEvent, SignalType +from pyheos import ( + HeosError, + HeosOptions, + HeosPlayer, + PlayerUpdateResult, + SignalHeosEvent, + SignalType, + const, +) import pytest from homeassistant.components.heos.const import DOMAIN +from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from . import MockHeos @@ -255,3 +265,64 @@ async def test_remove_config_entry_device( ws_client = await hass_ws_client(hass) response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) assert response["success"] == expected_result + + +async def test_reconnected_new_entities_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + controller: MockHeos, + player_factory: Callable[[int, str, str], HeosPlayer], +) -> None: + """Test new entities are created for new players after reconnecting.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Assert initial entity doesn't exist + assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") + + # Create player + players = controller.players.copy() + players[3] = player_factory(3, "Test Player 3", "HEOS Link") + controller.mock_set_players(players) + controller.load_players.return_value = PlayerUpdateResult([3], [], {}) + + # Simulate reconnection + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() + + # Assert new entity created + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") + + +async def test_players_changed_new_entities_created( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, + controller: MockHeos, + player_factory: Callable[[int, str, str], HeosPlayer], +) -> None: + """Test new entities are created for new players on change event.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + # Assert initial entity doesn't exist + assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") + + # Create player + players = controller.players.copy() + players[3] = player_factory(3, "Test Player 3", "HEOS Link") + controller.mock_set_players(players) + + # Simulate players changed event + await controller.dispatcher.wait_send( + SignalType.CONTROLLER_EVENT, + const.EVENT_PLAYERS_CHANGED, + PlayerUpdateResult([3], [], {}), + ) + await hass.async_block_till_done() + + # Assert new entity created + assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") From 67fcbc4c286a12a8040e77967a96b57f1386fbb5 Mon Sep 17 00:00:00 2001 From: Daniel O'Connor Date: Tue, 18 Feb 2025 01:59:28 +1030 Subject: [PATCH 0601/1941] Add LV-RH131S-WM Air Purifier (#138626) * Add LV-RH131S-WM Air Purifier Fix 138486 * Update homeassistant/components/vesync/const.py --- homeassistant/components/vesync/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 897c8d2b745..2e51b96451c 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -63,6 +63,7 @@ SKU_TO_BASE_DEVICE = { # Air Purifiers "LV-PUR131S": "LV-PUR131S", "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S + "LV-RH131S-WM": "LV-PUR131S", # Alt ID Model LV-PUR131S "Core200S": "Core200S", "LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S "LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S From 25296e1b8f98c8ef201f2af5d755d8a9bd010e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 17 Feb 2025 16:12:55 +0000 Subject: [PATCH 0602/1941] Move ZHA debug logs handling out of event loop (#138568) --- homeassistant/components/zha/helpers.py | 31 +++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index c31627d3dc3..700e2833705 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -11,6 +11,7 @@ import enum import functools import itertools import logging +import queue import re import time from types import MappingProxyType @@ -111,9 +112,10 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType +from homeassistant.util.logging import HomeAssistantQueueHandler from .const import ( ATTR_ACTIVE_COORDINATOR, @@ -505,7 +507,14 @@ class ZHAGatewayProxy(EventBase): DEBUG_LEVEL_CURRENT: async_capture_log_levels(), } self.debug_enabled: bool = False - self._log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self) + + log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self) + log_simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue() + self._log_queue_handler = HomeAssistantQueueHandler(log_simple_queue) + self._log_queue_handler.listener = logging.handlers.QueueListener( + log_simple_queue, log_relay_handler + ) + self._unsubs: list[Callable[[], None]] = [] self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol)) self._reload_task: asyncio.Task | None = None @@ -736,10 +745,13 @@ class ZHAGatewayProxy(EventBase): self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() if filterer: - self._log_relay_handler.addFilter(filterer) + self._log_queue_handler.addFilter(filterer) + + if self._log_queue_handler.listener: + self._log_queue_handler.listener.start() for logger_name in DEBUG_RELAY_LOGGERS: - logging.getLogger(logger_name).addHandler(self._log_relay_handler) + logging.getLogger(logger_name).addHandler(self._log_queue_handler) self.debug_enabled = True @@ -749,9 +761,14 @@ class ZHAGatewayProxy(EventBase): async_set_logger_levels(self._log_levels[DEBUG_LEVEL_ORIGINAL]) self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() for logger_name in DEBUG_RELAY_LOGGERS: - logging.getLogger(logger_name).removeHandler(self._log_relay_handler) + logging.getLogger(logger_name).removeHandler(self._log_queue_handler) + + if self._log_queue_handler.listener: + self._log_queue_handler.listener.stop() + if filterer: - self._log_relay_handler.removeFilter(filterer) + self._log_queue_handler.removeFilter(filterer) + self.debug_enabled = False async def shutdown(self) -> None: @@ -978,7 +995,7 @@ class LogRelayHandler(logging.Handler): entry = LogEntry( record, self.paths_re, figure_out_source=record.levelno >= logging.WARNING ) - async_dispatcher_send( + dispatcher_send( self.hass, ZHA_GW_MSG, {ATTR_TYPE: ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_LOG_ENTRY: entry.to_dict()}, From 04b826daa12bb367d978330d1a0f3f5bc338f40e Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 18 Feb 2025 01:30:41 +0900 Subject: [PATCH 0603/1941] Add sensors for washer and system boiler in LG ThinQ (#137514) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/icons.json | 6 +++++ homeassistant/components/lg_thinq/sensor.py | 27 +++++++++++++++++++ .../components/lg_thinq/strings.json | 15 +++++++++++ 3 files changed, 48 insertions(+) diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 42ae5746f24..db33106da79 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -407,6 +407,12 @@ }, "power_level_for_location": { "default": "mdi:radiator" + }, + "cycle_count": { + "default": "mdi:counter" + }, + "cycle_count_for_location": { + "default": "mdi:counter" } } } diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index bb190cccde9..95198d931a1 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -248,6 +248,24 @@ TEMPERATURE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, translation_key=ThinQProperty.CURRENT_TEMPERATURE, ), + ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE: SensorEntityDescription( + key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + translation_key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE, + ), + ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE: SensorEntityDescription( + key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + translation_key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE, + ), + ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE: SensorEntityDescription( + key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + translation_key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE, + ), } WATER_FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.USED_TIME: SensorEntityDescription( @@ -341,6 +359,10 @@ TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { } WASHER_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=ThinQProperty.CYCLE_COUNT, + translation_key=ThinQProperty.CYCLE_COUNT, + ), RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], TIMER_SENSOR_DESC[TimerProperty.TOTAL], TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], @@ -470,6 +492,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], ), DeviceType.STYLER: WASHER_SENSORS, + DeviceType.SYSTEM_BOILER: ( + TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE], + TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE], + TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE], + ), DeviceType.WASHCOMBO_MAIN: WASHER_SENSORS, DeviceType.WASHCOMBO_MINI: WASHER_SENSORS, DeviceType.WASHER: WASHER_SENSORS, diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index dee2d21e05a..359ac40e1f1 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -305,6 +305,15 @@ "current_temperature": { "name": "Current temperature" }, + "room_air_current_temperature": { + "name": "Indoor temperature" + }, + "room_in_water_current_temperature": { + "name": "Inlet temperature" + }, + "room_out_water_current_temperature": { + "name": "Outlet temperature" + }, "temperature": { "name": "Temperature" }, @@ -848,6 +857,12 @@ }, "power_level_for_location": { "name": "{location} power level" + }, + "cycle_count": { + "name": "Cycles" + }, + "cycle_count_for_location": { + "name": "{location} cycles" } }, "select": { From ff16e587e8aeb1afc9ebe15a738001657eaabe71 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 17 Feb 2025 17:45:26 +0100 Subject: [PATCH 0604/1941] Bump airgradient to 0.9.2 (#138725) * Bump airgradient to 0.9.2 * Bump airgradient to 0.9.2 --- homeassistant/components/airgradient/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../airgradient/snapshots/test_diagnostics.ambr | 6 +++--- .../components/airgradient/snapshots/test_sensor.ambr | 10 +++++----- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index 13764142697..afaf2698ced 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/airgradient", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["airgradient==0.9.1"], + "requirements": ["airgradient==0.9.2"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 9153674fdcc..9efa46334a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ aiowithings==3.1.5 aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.9.1 +airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 164562a485b..0ced3ce92ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -413,7 +413,7 @@ aiowithings==3.1.5 aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.9.1 +airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 diff --git a/tests/components/airgradient/snapshots/test_diagnostics.ambr b/tests/components/airgradient/snapshots/test_diagnostics.ambr index a96dfb95382..624a6f76f8d 100644 --- a/tests/components/airgradient/snapshots/test_diagnostics.ambr +++ b/tests/components/airgradient/snapshots/test_diagnostics.ambr @@ -25,13 +25,13 @@ 'nitrogen_index': 1, 'pm003_count': 270, 'pm01': 22, - 'pm02': 34, + 'pm02': 34.0, 'pm10': 41, 'raw_ambient_temperature': 27.96, - 'raw_nitrogen': 16931, + 'raw_nitrogen': 16931.0, 'raw_pm02': 34, 'raw_relative_humidity': 48.0, - 'raw_total_volatile_organic_component': 31792, + 'raw_total_volatile_organic_component': 31792.0, 'rco2': 778, 'relative_humidity': 47.0, 'serial_number': '84fce612f5b8', diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 38a6774b6db..374d9a60e4e 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -724,7 +724,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '34', + 'state': '34.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_raw_nox-entry] @@ -775,7 +775,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '16931', + 'state': '16931.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-entry] @@ -878,7 +878,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '31792', + 'state': '31792.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_signal_strength-entry] @@ -1280,7 +1280,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '16359', + 'state': '16359.0', }) # --- # name: test_all_entities[outdoor][sensor.airgradient_raw_voc-entry] @@ -1331,7 +1331,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30802', + 'state': '30802.0', }) # --- # name: test_all_entities[outdoor][sensor.airgradient_signal_strength-entry] From e0795e6d07fd9a29d58d3a7233fe34a742429528 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 17 Feb 2025 18:16:57 +0100 Subject: [PATCH 0605/1941] Improve config entry state transitions when unloading and removing entries (#138522) * Improve config entry state transitions when unloading and removing entries * Update integrations which check for a single loaded entry * Update tests checking state after unload fails * Update homeassistant/config_entries.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/adguard/__init__.py | 9 ++------ .../google_assistant_sdk/__init__.py | 9 ++------ .../components/google_mail/__init__.py | 9 ++------ .../components/google_sheets/__init__.py | 9 ++------ homeassistant/components/guardian/__init__.py | 9 ++------ homeassistant/components/lookin/__init__.py | 9 ++------ .../components/motion_blinds/__init__.py | 9 ++------ .../components/netgear_lte/__init__.py | 8 +------ .../components/rainmachine/__init__.py | 9 ++------ .../components/simplisafe/__init__.py | 9 ++------ .../components/tplink_omada/__init__.py | 9 ++------ .../components/xiaomi_aqara/__init__.py | 9 ++------ homeassistant/config_entries.py | 23 +++++++++++++------ tests/components/matter/test_init.py | 2 +- tests/components/unifi/test_hub.py | 2 +- tests/components/zwave_js/test_init.py | 2 +- tests/test_config_entries.py | 8 +++---- 17 files changed, 46 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index f8ddeba6767..bbc763d7ec3 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from adguardhome import AdGuardHome, AdGuardHomeConnectionError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -123,12 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool: """Unload AdGuard Home config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # This is the last loaded instance of AdGuard, deregister any services hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 4ea496f2824..a08d7554516 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -10,7 +10,7 @@ from google.oauth2.credentials import Credentials import voluptuous as vol from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform from homeassistant.core import ( HomeAssistant, @@ -99,12 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 7fae5f18da5..8ef978568dc 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery @@ -59,12 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool: """Unload a config entry.""" - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index faf1ff1ee0b..afafce816a9 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -12,7 +12,7 @@ from gspread.exceptions import APIError from gspread.utils import ValueInputOption import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ( @@ -81,12 +81,7 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleSheetsConfigEntry ) -> bool: """Unload a config entry.""" - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): for service_name in hass.services.async_services_for_domain(DOMAIN): hass.services.async_remove(DOMAIN, service_name) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index c1cbb4c0e5a..075c388c4e4 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -11,7 +11,7 @@ from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, CONF_DEVICE_ID, @@ -247,12 +247,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of Guardian, deregister any services # defined during integration setup: for service_name in SERVICES: diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 2fbabc12747..247282309e4 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -19,7 +19,7 @@ from aiolookin import ( ) from aiolookin.models import UDPCommandType, UDPEvent -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -192,12 +192,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER] await manager.async_stop() return unload_ok diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index fa1664353e1..df06ffb75fc 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from motionblinds import AsyncMotionMulticast -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -124,12 +124,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST]) hass.data[DOMAIN].pop(config_entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # No motion gateways left, stop Motion multicast unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP) unsub_stop() diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index a756d85c866..47a39a39be0 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -6,7 +6,6 @@ from aiohttp.cookiejar import CookieJar import eternalegypt from eternalegypt.eternalegypt import SMS -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -117,12 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): hass.data.pop(DOMAIN, None) for service_name in hass.services.async_services()[DOMAIN]: hass.services.async_remove(DOMAIN, service_name) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 4d486c9c6aa..65648b8d44f 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -13,7 +13,7 @@ from regenmaschine.controller import Controller from regenmaschine.errors import RainMachineError, UnknownAPICallError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_IP_ADDRESS, @@ -465,12 +465,7 @@ async def async_unload_entry( ) -> bool: """Unload an RainMachine config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state is ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of RainMachine, deregister any services # defined during integration setup: for service_name in ( diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 2f19c5117a4..8a75baa69c6 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -39,7 +39,7 @@ from simplipy.websocket import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, ATTR_DEVICE_ID, @@ -402,12 +402,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # If this is the last loaded instance of SimpliSafe, deregister any services # defined during integration setup: for service_name in SERVICES: diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 06df118463b..7ea7fd95fef 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -11,7 +11,7 @@ from tplink_omada_client.exceptions import ( UnsupportedControllerVersion, ) -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -80,12 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo async def async_unload_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # This is the last loaded instance of Omada, deregister any services hass.services.async_remove(DOMAIN, "reconnect_client") diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 579994aaf6b..6e4d143d84e 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from xiaomi_gateway import AsyncXiaomiGatewayMulticast, XiaomiGateway from homeassistant.components import persistent_notification -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, CONF_HOST, @@ -216,12 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok: hass.data[DOMAIN][GATEWAYS_KEY].pop(config_entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: + if not hass.config_entries.async_loaded_entries(DOMAIN): # No gateways left, stop Xiaomi socket unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP) unsub_stop() diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a103148e3b1..871b476227c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -155,6 +155,8 @@ class ConfigEntryState(Enum): """An error occurred when trying to unload the entry""" SETUP_IN_PROGRESS = "setup_in_progress", False """The config entry is setting up.""" + UNLOAD_IN_PROGRESS = "unload_in_progress", False + """The config entry is being unloaded.""" _recoverable: bool @@ -955,18 +957,25 @@ class ConfigEntry[_DataT = Any]: ) return False + if domain_is_integration: + self._async_set_state(hass, ConfigEntryState.UNLOAD_IN_PROGRESS, None) try: result = await component.async_unload_entry(hass, self) assert isinstance(result, bool) - # Only adjust state if we unloaded the component - if domain_is_integration and result: - await self._async_process_on_unload(hass) - if hasattr(self, "runtime_data"): - object.__delattr__(self, "runtime_data") + # Only do side effects if we unloaded the integration + if domain_is_integration: + if result: + await self._async_process_on_unload(hass) + if hasattr(self, "runtime_data"): + object.__delattr__(self, "runtime_data") - self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + else: + self._async_set_state( + hass, ConfigEntryState.FAILED_UNLOAD, "Unload failed" + ) except Exception as exc: _LOGGER.exception( @@ -2052,9 +2061,9 @@ class ConfigEntries: else: unload_success = await self.async_unload(entry_id, _lock=False) + del self._entries[entry.entry_id] await entry.async_remove(self.hass) - del self._entries[entry.entry_id] self.async_update_issues() self._async_schedule_save() diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index f6576689413..553358f12e3 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -502,7 +502,7 @@ async def test_issue_registry_invalid_version( ("stop_addon_side_effect", "entry_state"), [ (None, ConfigEntryState.NOT_LOADED), - (SupervisorError("Boom"), ConfigEntryState.LOADED), + (SupervisorError("Boom"), ConfigEntryState.FAILED_UNLOAD), ], ) async def test_stop_addon( diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index 5492f6fe0df..8b129d3d648 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -76,7 +76,7 @@ async def test_reset_fails( return_value=False, ): assert not await hass.config_entries.async_unload(config_entry_setup.entry_id) - assert config_entry_setup.state is ConfigEntryState.LOADED + assert config_entry_setup.state is ConfigEntryState.FAILED_UNLOAD @pytest.mark.usefixtures("mock_device_registry") diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4f858f3e545..c575066b57c 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -847,7 +847,7 @@ async def test_issue_registry( ("stop_addon_side_effect", "entry_state"), [ (None, ConfigEntryState.NOT_LOADED), - (SupervisorError("Boom"), ConfigEntryState.LOADED), + (SupervisorError("Boom"), ConfigEntryState.FAILED_UNLOAD), ], ) async def test_stop_addon( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index bf2280790fa..acc79deb538 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -468,8 +468,8 @@ async def test_remove_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> None: """Mock removing an entry.""" - # Check that the entry is not yet removed from config entries - assert hass.config_entries.async_get_entry(entry.entry_id) + # Check that the entry is no longer in the config entries + assert not hass.config_entries.async_get_entry(entry.entry_id) remove_entry_calls.append(None) entity = MockEntity(unique_id="1234", name="Test Entity") @@ -2623,7 +2623,7 @@ async def test_entry_setup_invalid_state( ("unload_result", "expected_result", "expected_state", "has_runtime_data"), [ (True, True, config_entries.ConfigEntryState.NOT_LOADED, False), - (False, False, config_entries.ConfigEntryState.LOADED, True), + (False, False, config_entries.ConfigEntryState.FAILED_UNLOAD, True), ], ) async def test_entry_unload( @@ -2648,7 +2648,7 @@ async def test_entry_unload( """Mock unload entry.""" unload_entry_calls.append(None) verify_runtime_data() - assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.state is config_entries.ConfigEntryState.UNLOAD_IN_PROGRESS return unload_result entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) From d7e796e9f9c7c44986b378c5544448697a192db7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 18:37:46 +0100 Subject: [PATCH 0606/1941] Fix typos in qBittorrent exceptions strings (#138728) --- homeassistant/components/qbittorrent/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 83d93766ee4..eb7cd19faca 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -109,16 +109,16 @@ }, "exceptions": { "invalid_device": { - "message": "No device with id {device_id} was found" + "message": "No device with ID {device_id} was found" }, "invalid_entry_id": { - "message": "No entry with id {device_id} was found" + "message": "No entry with ID {device_id} was found" }, "login_error": { - "message": "A login error occured. Please check you username and password." + "message": "A login error occured. Please check your username and password." }, "cannot_connect": { - "message": "Can't connect to QBittorrent, please check your configuration." + "message": "Can't connect to qBittorrent, please check your configuration." } } } From da9fbf21dffc93c1392f85007f67d5294826399f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:04:39 -0600 Subject: [PATCH 0607/1941] Update HEOS repair issues quality scale item (#138724) --- homeassistant/components/heos/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index a08e2dca544..6ade4e6ffb9 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -57,7 +57,7 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: todo + repair-issues: done stale-devices: done # Platinum async-dependency: done From 3b6e3fe457a7f206a562dea43fdfb74ba9bca541 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 17 Feb 2025 20:10:56 +0100 Subject: [PATCH 0608/1941] Fix race condition on eheimdigital coordinator setup (#138580) --- .../components/eheimdigital/coordinator.py | 19 +++++++-- tests/components/eheimdigital/conftest.py | 13 ++++++ tests/components/eheimdigital/test_climate.py | 35 +++++++++------- tests/components/eheimdigital/test_init.py | 5 ++- tests/components/eheimdigital/test_light.py | 42 ++++++++++--------- 5 files changed, 76 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index 4359a314494..6e96fb388ee 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -2,16 +2,18 @@ from __future__ import annotations +import asyncio from collections.abc import Callable from aiohttp import ClientError from eheimdigital.device import EheimDigitalDevice from eheimdigital.hub import EheimDigitalHub -from eheimdigital.types import EheimDeviceType +from eheimdigital.types import EheimDeviceType, EheimDigitalClientError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -43,12 +45,14 @@ class EheimDigitalUpdateCoordinator( name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL, ) + self.main_device_added_event = asyncio.Event() self.hub = EheimDigitalHub( host=self.config_entry.data[CONF_HOST], session=async_get_clientsession(hass), loop=hass.loop, receive_callback=self._async_receive_callback, device_found_callback=self._async_device_found, + main_device_added_event=self.main_device_added_event, ) self.known_devices: set[str] = set() self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set() @@ -76,8 +80,17 @@ class EheimDigitalUpdateCoordinator( self.async_set_updated_data(self.hub.devices) async def _async_setup(self) -> None: - await self.hub.connect() - await self.hub.update() + try: + await self.hub.connect() + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + await self.hub.update() + except (TimeoutError, EheimDigitalClientError) as err: + raise ConfigEntryNotReady from err async def _async_update_data(self) -> dict[str, EheimDigitalDevice]: try: diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py index afb97b97569..ae1bc74df90 100644 --- a/tests/components/eheimdigital/conftest.py +++ b/tests/components/eheimdigital/conftest.py @@ -11,6 +11,7 @@ import pytest from homeassistant.components.eheimdigital.const import DOMAIN from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -79,3 +80,15 @@ def eheimdigital_hub_mock( } eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock yield eheimdigital_hub_mock + + +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Initialize the integration.""" + + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", new=AsyncMock + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py index f1f29ce9d34..4abc33e449e 100644 --- a/tests/components/eheimdigital/test_climate.py +++ b/tests/components/eheimdigital/test_climate.py @@ -1,6 +1,6 @@ """Tests for the climate module.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from eheimdigital.types import ( EheimDeviceType, @@ -31,6 +31,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from .conftest import init_integration + from tests.common import MockConfigEntry, snapshot_platform @@ -45,7 +47,13 @@ async def test_setup_heater( """Test climate platform setup for heater.""" mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( @@ -69,7 +77,13 @@ async def test_dynamic_new_devices( eheimdigital_hub_mock.return_value.devices = {} - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) assert ( @@ -108,9 +122,7 @@ async def test_set_preset_mode( heater_mode: HeaterMode, ) -> None: """Test setting a preset mode.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER @@ -146,9 +158,7 @@ async def test_set_temperature( mock_config_entry: MockConfigEntry, ) -> None: """Test setting a preset mode.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER @@ -189,9 +199,7 @@ async def test_set_hvac_mode( active: bool, ) -> None: """Test setting a preset mode.""" - mock_config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER @@ -231,9 +239,8 @@ async def test_state_update( heater_mock.is_heating = False heater_mock.operation_mode = HeaterMode.BIO - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER ) diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py index 211a8b3b6fd..c64997ee372 100644 --- a/tests/components/eheimdigital/test_init.py +++ b/tests/components/eheimdigital/test_init.py @@ -8,6 +8,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from .conftest import init_integration + from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -21,9 +23,8 @@ async def test_remove_device( ) -> None: """Test removing a device.""" assert await async_setup_component(hass, "config", {}) - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py index da224979c43..81b63218085 100644 --- a/tests/components/eheimdigital/test_light.py +++ b/tests/components/eheimdigital/test_light.py @@ -1,7 +1,7 @@ """Tests for the light module.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError from eheimdigital.types import EheimDeviceType, LightMode @@ -26,6 +26,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util.color import value_to_brightness +from .conftest import init_integration + from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -51,7 +53,13 @@ async def test_setup_classic_led_ctrl( classic_led_ctrl_mock.tankconfig = tankconfig - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( @@ -75,7 +83,13 @@ async def test_dynamic_new_devices( eheimdigital_hub_mock.return_value.devices = {} - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): + with ( + patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]), + patch( + "homeassistant.components.eheimdigital.coordinator.asyncio.Event", + new=AsyncMock, + ), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) assert ( @@ -106,10 +120,8 @@ async def test_turn_off( classic_led_ctrl_mock: MagicMock, ) -> None: """Test turning off the light.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await mock_config_entry.runtime_data._async_device_found( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -143,10 +155,8 @@ async def test_turn_on_brightness( expected_dim_value: int, ) -> None: """Test turning on the light with different brightness values.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -173,12 +183,10 @@ async def test_turn_on_effect( classic_led_ctrl_mock: MagicMock, ) -> None: """Test turning on the light with an effect value.""" - mock_config_entry.add_to_hass(hass) - classic_led_ctrl_mock.light_mode = LightMode.MAN_MODE - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) + await init_integration(hass, mock_config_entry) + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -204,10 +212,8 @@ async def test_state_update( classic_led_ctrl_mock: MagicMock, ) -> None: """Test the light state update.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) @@ -228,10 +234,8 @@ async def test_update_failed( freezer: FrozenDateTimeFactory, ) -> None: """Test an failed update.""" - mock_config_entry.add_to_hass(hass) + await init_integration(hass, mock_config_entry) - with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]): - await hass.config_entries.async_setup(mock_config_entry.entry_id) await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) From 9ac60f1c7f5061a761b4f7895cc7504f6f6ad80d Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Mon, 17 Feb 2025 20:37:33 +0100 Subject: [PATCH 0609/1941] Fix small typo in qbittorrent strings.json (#138734) --- homeassistant/components/qbittorrent/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index eb7cd19faca..0dcb9298f1f 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -53,7 +53,7 @@ "connection_status": { "name": "Connection status", "state": { - "connected": "Conencted", + "connected": "Connected", "firewalled": "Firewalled", "disconnected": "Disconnected" } From 772e7147bd9dfab5f4c42707eabab4ff4db95b07 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 20:51:30 +0100 Subject: [PATCH 0610/1941] Fix user-facing strings of the NWS integration (#138727) - fix sentence-casing of "API key" to match common string - remove excessive trailing period from action name - reword action description to match HA style - make "Forecast type" description UI-friendly (a selector is available) --- homeassistant/components/nws/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nws/strings.json b/homeassistant/components/nws/strings.json index c9ee8349631..72b6a2c86b6 100644 --- a/homeassistant/components/nws/strings.json +++ b/homeassistant/components/nws/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, an API Key can be anything. It is recommended to use a valid email address.", + "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, the API key can be anything. It is recommended to use a valid email address.", "title": "Connect to the National Weather Service", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", @@ -30,12 +30,12 @@ }, "services": { "get_forecasts_extra": { - "name": "Get extra forecasts data.", - "description": "Get extra data for weather forecasts.", + "name": "Get extra forecasts data", + "description": "Retrieves extra data for weather forecasts.", "fields": { "type": { "name": "Forecast type", - "description": "Forecast type: hourly or twice_daily." + "description": "The scope of the weather forecast." } } } From bbfb9fbdaeb3c9bcb023e1c01ddfb722023d00f1 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Mon, 17 Feb 2025 21:10:18 +0100 Subject: [PATCH 0611/1941] Mark reauthentication-flow as exempt for flexit_bacnet (#138740) --- homeassistant/components/flexit_bacnet/quality_scale.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml index f59435bad0d..9a4a4eace40 100644 --- a/homeassistant/components/flexit_bacnet/quality_scale.yaml +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -48,7 +48,10 @@ rules: comment: | Done implicitly with coordinator. parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: + status: exempt + comment: | + Integration doesn't require any form of authentication. test-coverage: todo # Gold entity-translations: done From f9047d022342222733858a76d906915a9e530792 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Mon, 17 Feb 2025 21:15:37 +0100 Subject: [PATCH 0612/1941] Mark action-exceptions as exempt for flexit_bacnet (#138739) * Mark action-exceptions as exempt for flexit_bacnet * Update homeassistant/components/flexit_bacnet/quality_scale.yaml --------- Co-authored-by: Josef Zweck --- homeassistant/components/flexit_bacnet/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml index 9a4a4eace40..eb649656c9d 100644 --- a/homeassistant/components/flexit_bacnet/quality_scale.yaml +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -31,7 +31,7 @@ rules: Done implicitly with `await coordinator.async_config_entry_first_refresh()`. unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt From 5658f9ca405eadc96be42a76fc4896b5d6c08ade Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Feb 2025 22:28:45 +0100 Subject: [PATCH 0613/1941] Fix wrong description of teslemetry.set_scheduled_charging action (#138723) The action allows the user to set a time at which to start charging, but the action's description uses the wrong word "completed". --- homeassistant/components/teslemetry/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 68ad12a46b6..b6b3d17e37c 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -712,7 +712,7 @@ "name": "Navigate to coordinates" }, "set_scheduled_charging": { - "description": "Sets a time at which charging should be completed.", + "description": "Sets a time at which charging should be started.", "fields": { "device_id": { "description": "Vehicle to schedule.", From 25865b4849b1a2eb5133fe3ab2fdfe3fd3b46818 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 17 Feb 2025 23:28:49 +0100 Subject: [PATCH 0614/1941] Bump PyViCare to 2.43.1 (#138737) bump PyViCare to 2.43.1 --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index a5718962f55..e39adaf6c4c 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.43.0"] + "requirements": ["PyViCare==2.43.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9efa46334a6..56eb939bbd5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.43.0 +PyViCare==2.43.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ced3ce92ab..cc2b3578e2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.43.0 +PyViCare==2.43.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 0dc1151a25a9768e2c6c24de61b3a8439a466152 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Feb 2025 17:08:38 -0600 Subject: [PATCH 0615/1941] Bump aioesphomeapi to 29.1.0 (#138742) --- 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 8f9f06e6967..08be23ae001 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.0.2", + "aioesphomeapi==29.1.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.7.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 56eb939bbd5..60eb811f076 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.0.2 +aioesphomeapi==29.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc2b3578e2a..b4921cccf89 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.0.2 +aioesphomeapi==29.1.0 # homeassistant.components.flo aioflo==2021.11.0 From 33df20829634661431567c3b1ff2415ee9ef1dc6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 18 Feb 2025 08:38:43 +0100 Subject: [PATCH 0616/1941] Fix temp files of mqtt CI tests not cleaned up properly (#138741) * Fix temp files of mqtt CI tests not cleaned up properly * Do not cleanup tempfiles, patch gettempdir only --- tests/components/mqtt/conftest.py | 22 +++++++++++++++------ tests/components/mqtt/test_binary_sensor.py | 1 + tests/components/mqtt/test_sensor.py | 1 + 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 87bbcecebe5..efe5d0f1a4e 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import AsyncGenerator, Generator +from pathlib import Path from random import getrandbits from typing import Any from unittest.mock import AsyncMock, patch @@ -39,13 +40,22 @@ def temp_dir_prefix() -> str: @pytest.fixture(autouse=True) -def mock_temp_dir(temp_dir_prefix: str) -> Generator[str]: +async def mock_temp_dir( + hass: HomeAssistant, tmp_path: Path, temp_dir_prefix: str +) -> AsyncGenerator[str]: """Mock the certificate temp directory.""" - with patch( - # Patch temp dir name to avoid tests fail running in parallel - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - f"home-assistant-mqtt-{temp_dir_prefix}-{getrandbits(10):03x}", - ) as mocked_temp_dir: + mqtt_temp_dir = f"home-assistant-mqtt-{temp_dir_prefix}-{getrandbits(10):03x}" + with ( + patch( + "homeassistant.components.mqtt.util.tempfile.gettempdir", + return_value=tmp_path, + ), + patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + mqtt_temp_dir, + ) as mocked_temp_dir, + ): yield mocked_temp_dir diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 34be237fb72..8809f2201f2 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -1034,6 +1034,7 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) +@pytest.mark.usefixtures("mock_temp_dir") @pytest.mark.parametrize( ("hass_config", "payload1", "state1", "payload2", "state2"), [ diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 6b3bbd6334c..9226b03a7d2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -1409,6 +1409,7 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) +@pytest.mark.usefixtures("mock_temp_dir") @pytest.mark.parametrize( "hass_config", [ From 800cdee4094d498b7982e11f5726dba72b9881a9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 18 Feb 2025 18:44:29 +1000 Subject: [PATCH 0617/1941] Update Diagnostics in Teslemetry (#138759) * Testing * Diag --- .../components/teslemetry/diagnostics.py | 5 +++- .../snapshots/test_diagnostics.ambr | 30 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py index fc601a58ae6..755935951fc 100644 --- a/homeassistant/components/teslemetry/diagnostics.py +++ b/homeassistant/components/teslemetry/diagnostics.py @@ -35,7 +35,9 @@ async def async_get_config_entry_diagnostics( vehicles = [ { "data": async_redact_data(x.coordinator.data, VEHICLE_REDACT), - # Stream diag will go here when implemented + "stream": { + "config": x.stream_vehicle.config, + }, } for x in entry.runtime_data.vehicles ] @@ -45,6 +47,7 @@ async def async_get_config_entry_diagnostics( if x.live_coordinator else None, "info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT), + "history": x.history_coordinator.data if x.history_coordinator else None, } for x in entry.runtime_data.energysites ] diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 16cabfddd09..56a8f759a21 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -3,6 +3,29 @@ dict({ 'energysites': list([ dict({ + 'history': dict({ + 'battery_energy_exported': 36, + 'battery_energy_imported_from_generator': 0, + 'battery_energy_imported_from_grid': 0, + 'battery_energy_imported_from_solar': 684, + 'consumer_energy_imported_from_battery': 36, + 'consumer_energy_imported_from_generator': 0, + 'consumer_energy_imported_from_grid': 0, + 'consumer_energy_imported_from_solar': 38, + 'generator_energy_exported': 0, + 'grid_energy_exported_from_battery': 0, + 'grid_energy_exported_from_generator': 0, + 'grid_energy_exported_from_solar': 2, + 'grid_energy_imported': 0, + 'grid_services_energy_exported': 0, + 'grid_services_energy_imported': 0, + 'solar_energy_exported': 724, + 'total_battery_charge': 684, + 'total_battery_discharge': 36, + 'total_grid_energy_exported': 2, + 'total_home_usage': 74, + 'total_solar_generation': 724, + }), 'info': dict({ 'backup_reserve_percent': 0, 'battery_count': 2, @@ -432,6 +455,13 @@ 'vehicle_state_webcam_available': True, 'vin': '**REDACTED**', }), + 'stream': dict({ + 'config': dict({ + 'fields': dict({ + }), + 'prefer_typed': None, + }), + }), }), ]), }) From f5e1fa6a21273b243a8ebb59e5424c1011640388 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 18 Feb 2025 11:17:13 +0100 Subject: [PATCH 0618/1941] Allow playback of h265 encoded Reolink video (#138667) --- .../components/reolink/media_source.py | 53 ++++++++----------- tests/components/reolink/test_media_source.py | 17 ++++-- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index e912bfb5100..91c50fb7da5 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -222,7 +222,7 @@ class ReolinkVODMediaSource(MediaSource): if main_enc == "h265": _LOGGER.debug( "Reolink camera %s uses h265 encoding for main stream," - "playback only possible using sub stream", + "playback at high resolution may not work in all browsers/apps", host.api.camera_name(channel), ) @@ -236,34 +236,29 @@ class ReolinkVODMediaSource(MediaSource): can_play=False, can_expand=True, ), + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|main", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="High resolution", + can_play=False, + can_expand=True, + ), ] - if main_enc != "h265": - children.append( - BrowseMediaSource( - domain=DOMAIN, - identifier=f"RES|{config_entry_id}|{channel}|main", - media_class=MediaClass.CHANNEL, - media_content_type=MediaType.PLAYLIST, - title="High resolution", - can_play=False, - can_expand=True, - ), - ) if host.api.supported(channel, "autotrack_stream"): - children.append( - BrowseMediaSource( - domain=DOMAIN, - identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub", - media_class=MediaClass.CHANNEL, - media_content_type=MediaType.PLAYLIST, - title="Autotrack low resolution", - can_play=False, - can_expand=True, - ), - ) - if main_enc != "h265": - children.append( + children.extend( + [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="Autotrack low resolution", + can_play=False, + can_expand=True, + ), BrowseMediaSource( domain=DOMAIN, identifier=f"RES|{config_entry_id}|{channel}|autotrack_main", @@ -273,11 +268,7 @@ class ReolinkVODMediaSource(MediaSource): can_play=False, can_expand=True, ), - ) - - if len(children) == 1: - return await self._async_generate_camera_days( - config_entry_id, channel, "sub" + ] ) title = host.api.camera_name(channel) diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 9c5be08e9b6..a5a34514598 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -235,12 +235,12 @@ async def test_browsing( reolink_connect.model = TEST_HOST_MODEL -async def test_browsing_unsupported_encoding( +async def test_browsing_h265_encoding( hass: HomeAssistant, reolink_connect: MagicMock, config_entry: MockConfigEntry, ) -> None: - """Test browsing a Reolink camera with unsupported stream encoding.""" + """Test browsing a Reolink camera with h265 stream encoding.""" entry_id = config_entry.entry_id with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): @@ -249,7 +249,6 @@ async def test_browsing_unsupported_encoding( browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}" - # browse resolution select/camera recording days when main encoding unsupported mock_status = MagicMock() mock_status.year = TEST_YEAR mock_status.month = TEST_MONTH @@ -261,6 +260,18 @@ async def test_browsing_unsupported_encoding( browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") + browse_resolution_id = f"RESs|{entry_id}|{TEST_CHANNEL}" + browse_res_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|sub" + browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main" + + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME}" + assert browse.identifier == browse_resolution_id + assert browse.children[0].identifier == browse_res_sub_id + assert browse.children[1].identifier == browse_res_main_id + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_sub_id}") + browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|sub" browse_day_0_id = ( f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" From e6600968017c1207a827cf839e4eb3f3a3289e54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Feb 2025 04:38:48 -0600 Subject: [PATCH 0619/1941] Bump zeroconf to 0.145.1 (#138763) --- 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 7a17c0dc5c3..8abaa4a838e 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.144.3"] + "requirements": ["zeroconf==0.145.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2b9e5c307a6..883ec737268 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.144.3 +zeroconf==0.145.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 44fef7dea9a..72a66437c55 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.144.3" + "zeroconf==0.145.1" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index c06beefab37..3c0fc1f9a57 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.144.3 +zeroconf==0.145.1 diff --git a/requirements_all.txt b/requirements_all.txt index 60eb811f076..b0e3bb9ffc9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.144.3 +zeroconf==0.145.1 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4921cccf89..9fec5fd1673 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.144.3 +zeroconf==0.145.1 # homeassistant.components.zeversolar zeversolar==0.3.2 From 350b935fa7e9b45d512b3b9d120ff5524bbbd913 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Feb 2025 12:06:10 +0100 Subject: [PATCH 0620/1941] Fixing casing mistakes in user-facing strings of renault (#138729) - use sentence-casing for strings - use uppercase for "ID" --- homeassistant/components/renault/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 7d9cae1bcf1..8649a5c7b47 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -18,7 +18,7 @@ "data_description": { "kamereon_account_id": "The Kamereon account ID associated with your vehicle" }, - "title": "Kamereon Account ID", + "title": "Kamereon account ID", "description": "You have multiple Kamereon accounts associated to this email, please select one" }, "reauth_confirm": { @@ -228,10 +228,10 @@ }, "exceptions": { "invalid_device_id": { - "message": "No device with id {device_id} was found" + "message": "No device with ID {device_id} was found" }, "no_config_entry_for_device": { - "message": "No loaded config entry was found for device with id {device_id}" + "message": "No loaded config entry was found for device with ID {device_id}" } } } From 94d3b3919d713bc67d15b8b5627bb4700ad0fad5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Feb 2025 12:58:29 +0100 Subject: [PATCH 0621/1941] Make spelling of "BSB-Lan" consistent (#138766) --- homeassistant/components/bsblan/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index a73a89ca1cc..93562763999 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -30,7 +30,7 @@ "message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto" }, "set_data_error": { - "message": "An error occurred while sending the data to the BSBLAN device" + "message": "An error occurred while sending the data to the BSB-Lan device" }, "set_temperature_error": { "message": "An error occurred while setting the temperature" From 46c604fcbe8a29b3232bcce4498b509818a41c25 Mon Sep 17 00:00:00 2001 From: Niv Steingarten Date: Tue, 18 Feb 2025 15:23:25 +0200 Subject: [PATCH 0622/1941] Bump pyrympro from 0.0.8 to 0.0.9 (#138753) --- homeassistant/components/rympro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index 046e778f05b..51c26b312fb 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.8"] + "requirements": ["pyrympro==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0e3bb9ffc9..7f5ddd7a351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2259,7 +2259,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.8 +pyrympro==0.0.9 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9fec5fd1673..26f76b8e58b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1843,7 +1843,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.8 +pyrympro==0.0.9 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 From 22c634e626d00a8ff40dadbdf64c43b00915271e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 18 Feb 2025 15:16:44 +0100 Subject: [PATCH 0623/1941] Don't allow setting backup retention to 0 days or copies (#138771) * Don't allow setting backup retention to 0 days or copies * Add tests --- homeassistant/components/backup/store.py | 9 +- homeassistant/components/backup/websocket.py | 6 +- .../backup/snapshots/test_store.ambr | 99 +++++++++- .../backup/snapshots/test_websocket.ambr | 176 ++++++++++++++++-- tests/components/backup/test_store.py | 32 ++++ tests/components/backup/test_websocket.py | 16 +- 6 files changed, 312 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 9b4af823c77..8287080b5a2 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 = 3 +STORAGE_VERSION_MINOR = 4 class StoredBackupData(TypedDict): @@ -60,6 +60,13 @@ class _BackupStore(Store[StoredBackupData]): else: data["config"]["schedule"]["days"] = [state] data["config"]["schedule"]["recurrence"] = "custom_days" + if old_minor_version < 4: + # Workaround for a bug in frontend which incorrectly set days to 0 + # instead of to None for unlimited retention. + if data["config"]["retention"]["copies"] == 0: + data["config"]["retention"]["copies"] = None + if data["config"]["retention"]["days"] == 0: + data["config"]["retention"]["days"] = 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 b6d092e1913..8453046cabb 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -368,8 +368,10 @@ async def handle_config_info( ), vol.Optional("retention"): vol.Schema( { - vol.Optional("copies"): vol.Any(int, None), - vol.Optional("days"): vol.Any(int, None), + # Note: We can't use cv.positive_int because it allows 0 even + # though 0 is not positive. + vol.Optional("copies"): vol.Any(vol.All(int, vol.Range(min=1)), None), + vol.Optional("days"): vol.Any(vol.All(int, vol.Range(min=1)), None), }, ), vol.Optional("schedule"): vol.Schema( diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 2fd81d6841a..04f88b84a97 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': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -84,11 +84,100 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- # name: test_store_migration[store_data1] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + }), + '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': 4, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data1].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + }), + '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': 4, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data2] dict({ 'data': dict({ 'backups': list([ @@ -131,11 +220,11 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- -# name: test_store_migration[store_data1].1 +# name: test_store_migration[store_data2].1 dict({ 'data': dict({ 'backups': list([ @@ -179,7 +268,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 4452d191d5a..19a85de62ad 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -686,7 +686,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -800,7 +800,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -914,7 +914,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1038,7 +1038,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1205,7 +1205,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1319,7 +1319,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1435,7 +1435,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1549,7 +1549,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1667,7 +1667,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1789,7 +1789,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1903,7 +1903,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2017,7 +2017,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2131,7 +2131,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2245,7 +2245,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2323,6 +2323,154 @@ 'type': 'result', }) # --- +# name: test_config_update_errors[command10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_errors[command10].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_errors[command11] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_errors[command11].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_errors[command1] dict({ 'id': 1, diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index f05afbea9ec..eff53bda777 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -57,6 +57,38 @@ def mock_delay_save() -> Generator[None]: "key": DOMAIN, "version": 1, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "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": 0, + "days": 0, + }, + "schedule": { + "state": "never", + }, + }, + }, + "key": DOMAIN, + "version": 1, + }, { "data": { "backups": [ diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 8632fb1e957..5e9d7f3c70a 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1361,6 +1361,14 @@ async def test_config_update( "type": "backup/config/update", "agents": {"test-agent1": {"favorite": True}}, }, + { + "type": "backup/config/update", + "retention": {"copies": 0}, + }, + { + "type": "backup/config/update", + "retention": {"days": 0}, + }, ], ) async def test_config_update_errors( @@ -2158,7 +2166,7 @@ async def test_config_schedule_logic( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "retention": {"copies": 0, "days": None}, + "retention": {"copies": 1, "days": None}, "schedule": {"recurrence": "daily"}, }, { @@ -2232,7 +2240,7 @@ async def test_config_schedule_logic( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "retention": {"copies": 0, "days": None}, + "retention": {"copies": 1, "days": None}, "schedule": {"recurrence": "daily"}, }, { @@ -2301,7 +2309,7 @@ async def test_config_schedule_logic( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "retention": {"copies": 0, "days": None}, + "retention": {"copies": 1, "days": None}, "schedule": {"recurrence": "daily"}, }, { @@ -3019,7 +3027,7 @@ async def test_config_retention_copies_logic_manual_backup( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 0}, + "retention": {"copies": None, "days": 1}, "schedule": {"recurrence": "never"}, } ], From a003f89a5ea640f59d8b148934ccfb763d2562f1 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 18 Feb 2025 16:17:13 +0200 Subject: [PATCH 0624/1941] Fix Z-WaveJS inclusion in the background (#138717) * Fix Z-WaveJS inclusion in the background * improve async handling * just return the `requested_grant` to the driver * handle controller busy state --- homeassistant/components/zwave_js/api.py | 22 ++++++++++++++++++---- tests/components/zwave_js/test_api.py | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 37ce9a51c91..aef23cb73ea 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -805,7 +805,7 @@ async def websocket_add_node( ] msg[DATA_UNSUBSCRIBE] = unsubs - if controller.inclusion_state == InclusionState.INCLUDING: + if controller.inclusion_state in (InclusionState.INCLUDING, InclusionState.BUSY): connection.send_result( msg[ID], True, # Inclusion is already in progress @@ -883,6 +883,11 @@ async def websocket_subscribe_s2_inclusion( ) -> None: """Subscribe to S2 inclusion initiated by the controller.""" + @callback + def async_cleanup() -> None: + for unsub in unsubs: + unsub() + @callback def forward_dsk(event: dict) -> None: connection.send_message( @@ -891,9 +896,18 @@ async def websocket_subscribe_s2_inclusion( ) ) - unsub = driver.controller.on("validate dsk and enter pin", forward_dsk) - connection.subscriptions[msg["id"]] = unsub - msg[DATA_UNSUBSCRIBE] = [unsub] + @callback + def handle_requested_grant(event: dict) -> None: + """Accept the requested security classes without user interaction.""" + hass.async_create_task( + driver.controller.async_grant_security_classes(event["requested_grant"]) + ) + + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + driver.controller.on("grant security classes", handle_requested_grant), + driver.controller.on("validate dsk and enter pin", forward_dsk), + ] connection.send_result(msg[ID]) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 6f341f8f77b..42c5d59d7ad 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5284,6 +5284,20 @@ async def test_subscribe_s2_inclusion( assert msg["success"] assert msg["result"] is None + # Test receiving requested grant event + event = Event( + type="grant security classes", + data={ + "source": "controller", + "event": "grant security classes", + "requested": { + "securityClasses": [SecurityClass.S2_UNAUTHENTICATED], + "clientSideAuth": False, + }, + }, + ) + client.driver.receive_event(event) + # Test receiving DSK request event event = Event( type="validate dsk and enter pin", From e9fcef1b570bc80e6ab25cc7e17b70b9cbcfc02d Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 18 Feb 2025 09:43:00 -0500 Subject: [PATCH 0625/1941] Fix TV input source option for Sonos Arc Ultra (#138778) initial commit --- homeassistant/components/sonos/const.py | 1 + tests/components/sonos/conftest.py | 10 +++++++-- tests/components/sonos/test_media_player.py | 25 +++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 610a68afedf..8fb704cbfbc 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -170,6 +170,7 @@ MODELS_TV_ONLY = ( "BEAM", "PLAYBAR", "PLAYBASE", + "ULTRA", ) MODELS_LINEIN_AND_TV = ("AMP",) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 0f56794b9f2..e22f18c6d77 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -580,13 +580,19 @@ def alarm_clock_fixture_extended(): return alarm_clock +@pytest.fixture(name="speaker_model") +def speaker_model_fixture(request: pytest.FixtureRequest): + """Create fixture for the speaker model.""" + return getattr(request, "param", "Model Name") + + @pytest.fixture(name="speaker_info") -def speaker_info_fixture(): +def speaker_info_fixture(speaker_model): """Create speaker_info fixture.""" return { "zone_name": "Zone A", "uid": "RINCON_test", - "model_name": "Model Name", + "model_name": speaker_model, "model_number": "S12", "hardware_version": "1.20.1.6-1.1", "software_version": "49.2-64250", diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 63b2c8889ec..cec40c997a7 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -10,6 +10,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, @@ -1205,3 +1206,27 @@ async def test_media_get_queue( ) soco_mock.get_queue.assert_called_with(max_items=0) assert result == snapshot + + +@pytest.mark.parametrize( + ("speaker_model", "source_list"), + [ + ("Sonos Arc Ultra", [SOURCE_TV]), + ("Sonos Arc", [SOURCE_TV]), + ("Sonos Playbar", [SOURCE_TV]), + ("Sonos Connect", [SOURCE_LINEIN]), + ("Sonos Play:5", [SOURCE_LINEIN]), + ("Sonos Amp", [SOURCE_LINEIN, SOURCE_TV]), + ("Sonos Era", None), + ], + indirect=["speaker_model"], +) +async def test_media_source_list( + hass: HomeAssistant, + async_autosetup_sonos, + speaker_model: str, + source_list: list[str] | None, +) -> None: + """Test the mapping between the speaker model name and source_list.""" + state = hass.states.get("media_player.zone_a") + assert state.attributes.get(ATTR_INPUT_SOURCE_LIST) == source_list From a45fb57595f0f545880026f9205da3087f12ae8a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Feb 2025 15:43:51 +0100 Subject: [PATCH 0626/1941] Fix grammar in evohome.reset_system action, consistently add "mode" (#138777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix grammar in evohome.reset_system action, consistently add "mode" - fix the grammar with "Sets … and resets …" - add "mode" to all mode names for consistency * Revert, removing one excessive "mode" --- homeassistant/components/evohome/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index ca032643c9d..4fc51c30b97 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -10,17 +10,17 @@ }, "period": { "name": "Period", - "description": "A period of time in days; used only with Away, DayOff, or Custom. The system will revert to Auto at midnight (up to 99 days, today is day 1)." + "description": "A period of time in days; used only with Away, DayOff, or Custom mode. The system will revert to Auto mode at midnight (up to 99 days, today is day 1)." }, "duration": { "name": "Duration", - "description": "The duration in hours; used only with AutoWithEco (up to 24 hours)." + "description": "The duration in hours; used only with AutoWithEco mode (up to 24 hours)." } } }, "reset_system": { "name": "Reset system", - "description": "Sets the system to Auto mode and reset all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode)." + "description": "Sets the system to Auto mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode)." }, "refresh_system": { "name": "Refresh system", From d1f0e0a70f4530fe2fa059961d265fb5a523e70b Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 18 Feb 2025 17:22:19 +0000 Subject: [PATCH 0627/1941] Add support for announce to Squeezebox media player (#129460) * initial * Add support for announce: true to media player * Move play_announcement to _player * update snapshot * conftest update * remove conftest update * Update conftest.py * Test Updates * Updates post moving functions to library * test fixes * Review updates * Snapshot update * rebase updates * Merge updates * Review updates * Review updates --- homeassistant/components/squeezebox/const.py | 2 + .../components/squeezebox/media_player.py | 56 ++++++++- tests/components/squeezebox/conftest.py | 10 ++ .../snapshots/test_media_player.ambr | 4 +- .../squeezebox/test_media_player.py | 113 ++++++++++++++++++ 5 files changed, 182 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index f24c452282f..61ec3cac2fa 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -36,3 +36,5 @@ CONF_BROWSE_LIMIT = "browse_limit" CONF_VOLUME_STEP = "volume_step" DEFAULT_BROWSE_LIMIT = 1000 DEFAULT_VOLUME_STEP = 5 +ATTR_ANNOUNCE_VOLUME = "announce_volume" +ATTR_ANNOUNCE_TIMEOUT = "announce_timeout" diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index a98ee13275c..48015f86ba0 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -14,6 +14,7 @@ import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, + ATTR_MEDIA_EXTRA, BrowseError, BrowseMedia, MediaPlayerEnqueue, @@ -52,6 +53,8 @@ from .browse_media import ( media_source_content_filter, ) from .const import ( + ATTR_ANNOUNCE_TIMEOUT, + ATTR_ANNOUNCE_VOLUME, CONF_BROWSE_LIMIT, CONF_VOLUME_STEP, DEFAULT_BROWSE_LIMIT, @@ -157,6 +160,26 @@ async def async_setup_entry( entry.async_on_unload(async_at_start(hass, start_server_discovery)) +def get_announce_volume(extra: dict) -> float | None: + """Get announce volume from extra service data.""" + if ATTR_ANNOUNCE_VOLUME not in extra: + return None + announce_volume = float(extra[ATTR_ANNOUNCE_VOLUME]) + if not (0 < announce_volume <= 1): + raise ValueError + return announce_volume * 100 + + +def get_announce_timeout(extra: dict) -> int | None: + """Get announce volume from extra service data.""" + if ATTR_ANNOUNCE_TIMEOUT not in extra: + return None + announce_timeout = int(extra[ATTR_ANNOUNCE_TIMEOUT]) + if announce_timeout < 1: + raise ValueError + return announce_timeout + + class SqueezeBoxMediaPlayerEntity( CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator], MediaPlayerEntity ): @@ -184,6 +207,7 @@ class SqueezeBoxMediaPlayerEntity( | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.MEDIA_ENQUEUE + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE ) _attr_has_entity_name = True _attr_name = None @@ -437,7 +461,11 @@ class SqueezeBoxMediaPlayerEntity( await self.coordinator.async_refresh() async def async_play_media( - self, media_type: MediaType | str, media_id: str, **kwargs: Any + self, + media_type: MediaType | str, + media_id: str, + announce: bool | None = None, + **kwargs: Any, ) -> None: """Send the play_media command to the media player.""" index = None @@ -460,6 +488,32 @@ class SqueezeBoxMediaPlayerEntity( ) media_id = play_item.url + if announce: + if media_type not in MediaType.MUSIC: + raise ServiceValidationError( + "Announcements must have media type of 'music'. Playlists are not supported" + ) + + extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) + cmd = "announce" + try: + announce_volume = get_announce_volume(extra) + except ValueError: + raise ServiceValidationError( + f"{ATTR_ANNOUNCE_VOLUME} must be a number greater than 0 and less than or equal to 1" + ) from None + else: + self._player.set_announce_volume(announce_volume) + + try: + announce_timeout = get_announce_timeout(extra) + except ValueError: + raise ServiceValidationError( + f"{ATTR_ANNOUNCE_TIMEOUT} must be a whole number greater than 0" + ) from None + else: + self._player.set_announce_timeout(announce_timeout) + if media_type in MediaType.MUSIC: if not media_id.startswith(SQUEEZEBOX_SOURCE_STRINGS): # do not process special squeezebox "source" media ids diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index c960844ee2f..9224334a716 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -120,6 +120,11 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry +async def mock_async_play_announcement(media_id: str) -> bool: + """Mock the announcement.""" + return True + + async def mock_async_browse( media_type: MediaType, limit: int, browse_id: tuple | None = None ) -> dict | None: @@ -222,6 +227,11 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.generate_image_url_from_track_id = MagicMock( return_value="http://lms.internal:9000/html/images/favorites.png" ) + mock_player.set_announce_volume = MagicMock(return_value=True) + mock_player.set_announce_timeout = MagicMock(return_value=True) + mock_player.async_play_announcement = AsyncMock( + side_effect=mock_async_play_announcement + ) mock_player.name = TEST_PLAYER_NAME mock_player.player_id = uuid mock_player.mode = "stop" diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index 47c2fea22c5..34d6ae16af8 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -65,7 +65,7 @@ 'original_name': None, 'platform': 'squeezebox', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': 'aa:bb:cc:dd:ee:ff', 'unit_of_measurement': None, @@ -88,7 +88,7 @@ }), 'repeat': , 'shuffle': False, - 'supported_features': , + 'supported_features': , 'volume_level': 0.01, }), 'context': , diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 694f5c9a8a2..f3292f1b469 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -10,9 +10,11 @@ from syrupy import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_ENQUEUE, + ATTR_MEDIA_EXTRA, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_REPEAT, @@ -31,6 +33,8 @@ from homeassistant.components.media_player import ( RepeatMode, ) from homeassistant.components.squeezebox.const import ( + ATTR_ANNOUNCE_TIMEOUT, + ATTR_ANNOUNCE_VOLUME, DISCOVERY_INTERVAL, DOMAIN, PLAYER_UPDATE_INTERVAL, @@ -436,6 +440,115 @@ async def test_squeezebox_play( configured_player.async_play.assert_called_once() +async def test_squeezebox_play_media_with_announce( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test play service call with announce.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + configured_player.async_load_url.assert_called_once_with( + FAKE_VALID_ITEM_ID, "announce" + ) + + +@pytest.mark.parametrize( + "announce_volume", + ["0.2", 0.2], +) +async def test_squeezebox_play_media_with_announce_volume( + hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int +) -> None: + """Test play service call with announce.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_VOLUME: announce_volume}, + }, + blocking=True, + ) + configured_player.set_announce_volume.assert_called_once_with(20) + configured_player.async_load_url.assert_called_once_with( + FAKE_VALID_ITEM_ID, "announce" + ) + + +@pytest.mark.parametrize("announce_volume", ["1.1", 1.1, "text", "-1", -1, 0, "0"]) +async def test_squeezebox_play_media_with_announce_volume_invalid( + hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int +) -> None: + """Test play service call with announce and volume zero.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_VOLUME: announce_volume}, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("announce_timeout", ["-1", "text", -1, 0, "0"]) +async def test_squeezebox_play_media_with_announce_timeout_invalid( + hass: HomeAssistant, configured_player: MagicMock, announce_timeout: str | int +) -> None: + """Test play service call with announce and invalid timeout.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_TIMEOUT: announce_timeout}, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("announce_timeout", ["100", 100]) +async def test_squeezebox_play_media_with_announce_timeout( + hass: HomeAssistant, configured_player: MagicMock, announce_timeout: str | int +) -> None: + """Test play service call with announce.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_ANNOUNCE: True, + ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_TIMEOUT: announce_timeout}, + }, + blocking=True, + ) + configured_player.set_announce_timeout.assert_called_once_with(100) + configured_player.async_load_url.assert_called_once_with( + FAKE_VALID_ITEM_ID, "announce" + ) + + async def test_squeezebox_play_pause( hass: HomeAssistant, configured_player: MagicMock ) -> None: From 3659fa4c4ef9d9b10b084ddea3a5e8fbe78910df Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 18 Feb 2025 11:56:50 -0600 Subject: [PATCH 0628/1941] Add HEOS entity service to set group volume level (#136885) --- homeassistant/components/heos/const.py | 1 + homeassistant/components/heos/icons.json | 3 + homeassistant/components/heos/media_player.py | 30 +++++++++- homeassistant/components/heos/services.yaml | 14 +++++ homeassistant/components/heos/strings.json | 13 +++++ tests/components/heos/__init__.py | 1 + tests/components/heos/test_media_player.py | 58 ++++++++++++++++++- 7 files changed, 117 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 7f03fa11e79..0203def3885 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -3,5 +3,6 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" DOMAIN = "heos" +SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json index 23c2c8faeaf..a634701037c 100644 --- a/homeassistant/components/heos/icons.json +++ b/homeassistant/components/heos/icons.json @@ -1,5 +1,8 @@ { "services": { + "group_volume_set": { + "service": "mdi:volume-medium" + }, "sign_in": { "service": "mdi:login" }, diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index b9aa05810e5..a649740a933 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -17,10 +17,12 @@ from pyheos import ( RepeatType, const as heos_const, ) +import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, + ATTR_MEDIA_VOLUME_LEVEL, BrowseMedia, MediaPlayerEnqueue, MediaPlayerEntity, @@ -33,13 +35,17 @@ from homeassistant.components.media_player import ( from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from .const import DOMAIN as HEOS_DOMAIN +from .const import DOMAIN as HEOS_DOMAIN, SERVICE_GROUP_VOLUME_SET from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 @@ -93,6 +99,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add media players for a config entry.""" + # Register custom entity services + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_GROUP_VOLUME_SET, + {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, + "async_set_group_volume_level", + ) def add_entities_callback(players: Sequence[HeosPlayer]) -> None: """Add entities for each player.""" @@ -346,6 +359,19 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Set volume level, range 0..1.""" await self._player.set_volume(int(volume * 100)) + @catch_action_error("set group volume level") + async def async_set_group_volume_level(self, volume_level: float) -> None: + """Set group volume level.""" + if self._player.group_id is None: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="entity_not_grouped", + translation_placeholders={"entity_id": self.entity_id}, + ) + await self.coordinator.heos.set_group_volume( + self._player.group_id, int(volume_level * 100) + ) + @catch_action_error("join players") async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index 8dc222d65ba..948aeb919f4 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -1,3 +1,17 @@ +group_volume_set: + target: + entity: + integration: heos + domain: media_player + fields: + volume_level: + required: true + selector: + number: + min: 0 + max: 1 + step: 0.01 + sign_in: fields: username: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 53e20a032b5..af70c0c786e 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -71,6 +71,16 @@ } }, "services": { + "group_volume_set": { + "name": "Set group volume", + "description": "Sets the group's volume while preserving member volume ratios.", + "fields": { + "volume_level": { + "name": "Level", + "description": "The volume. 0 is inaudible, 1 is the maximum volume." + } + } + }, "sign_in": { "name": "Sign in", "description": "Signs in to a HEOS account.", @@ -94,6 +104,9 @@ "action_error": { "message": "Unable to {action}: {error}" }, + "entity_not_grouped": { + "message": "Entity {entity_id} is not joined to a group" + }, "entity_not_found": { "message": "Entity {entity_id} was not found" }, diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index cf0d10790b7..bc72981d805 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -34,6 +34,7 @@ class MockHeos(Heos): self.player_set_play_state: AsyncMock = AsyncMock() self.player_set_volume: AsyncMock = AsyncMock() self.set_group: AsyncMock = AsyncMock() + self.set_group_volume: AsyncMock = AsyncMock() self.sign_in: AsyncMock = AsyncMock() self.sign_out: AsyncMock = AsyncMock() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 3768462eada..5a0ed0aa7c4 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -22,7 +22,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.heos.const import DOMAIN +from homeassistant.components.heos.const import DOMAIN, SERVICE_GROUP_VOLUME_SET from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, @@ -724,6 +724,62 @@ async def test_volume_set_error( controller.player_set_volume.assert_called_once_with(1, 100) +async def test_group_volume_set( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume set service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.set_group_volume.assert_called_once_with(999, 100) + + +async def test_group_volume_set_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume set service errors.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.set_group_volume.side_effect = CommandFailedError("", "Failure", 1) + with pytest.raises( + HomeAssistantError, + match=re.escape("Unable to set group volume level: Failure (1)"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.set_group_volume.assert_called_once_with(999, 100) + + +async def test_group_volume_set_not_grouped_error( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume set service when not grouped raises error.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.group_id = None + with pytest.raises( + ServiceValidationError, + match=re.escape("Entity media_player.test_player is not joined to a group"), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1}, + blocking=True, + ) + controller.set_group_volume.assert_not_called() + + async def test_select_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, From 096468baa47824382ce7c83499ecffee834f84db Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Tue, 18 Feb 2025 19:03:47 +0100 Subject: [PATCH 0629/1941] airq: add more verbose debug logging (#138192) --- homeassistant/components/airq/config_flow.py | 1 + homeassistant/components/airq/coordinator.py | 16 ++- tests/components/airq/test_config_flow.py | 5 +- tests/components/airq/test_coordinator.py | 129 +++++++++++++++++++ 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 tests/components/airq/test_coordinator.py diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 0c57b399b1b..f87b73b5283 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -83,6 +83,7 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(device_info["id"]) self._abort_if_unique_id_configured() + _LOGGER.debug("Creating an entry for %s", device_info["name"]) return self.async_create_entry(title=device_info["name"], data=user_input) return self.async_show_form( diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index b48d8047910..743d12d40e5 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import timedelta import logging -from aioairq import AirQ +from aioairq.core import AirQ, identify_warming_up_sensors from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD @@ -55,6 +55,9 @@ class AirQCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict: """Fetch the data from the device.""" if "name" not in self.device_info: + _LOGGER.debug( + "'name' not found in AirQCoordinator.device_info, fetching from the device" + ) info = await self.airq.fetch_device_info() self.device_info.update( DeviceInfo( @@ -64,7 +67,16 @@ class AirQCoordinator(DataUpdateCoordinator): hw_version=info["hw_version"], ) ) - return await self.airq.get_latest_data( # type: ignore[no-any-return] + _LOGGER.debug( + "Updated AirQCoordinator.device_info for 'name' %s", + self.device_info.get("name"), + ) + data: dict = await self.airq.get_latest_data( return_average=self.return_average, clip_negative_values=self.clip_negative, ) + if warming_up_sensors := identify_warming_up_sensors(data): + _LOGGER.debug( + "Following sensors are still warming up: %s", warming_up_sensors + ) + return data diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index d70c1526510..09da6343e05 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,5 +1,6 @@ """Test the air-Q config flow.""" +import logging from unittest.mock import patch from aioairq import DeviceInfo, InvalidAuth @@ -37,8 +38,9 @@ DEFAULT_OPTIONS = { } -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test we get the form.""" + caplog.set_level(logging.DEBUG) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -54,6 +56,7 @@ async def test_form(hass: HomeAssistant) -> None: TEST_USER_DATA, ) await hass.async_block_till_done() + assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_DEVICE_INFO["name"] diff --git a/tests/components/airq/test_coordinator.py b/tests/components/airq/test_coordinator.py new file mode 100644 index 00000000000..69f7c9dee17 --- /dev/null +++ b/tests/components/airq/test_coordinator.py @@ -0,0 +1,129 @@ +"""Test the air-Q coordinator.""" + +import logging +from unittest.mock import patch + +from aioairq import DeviceInfo as AirQDeviceInfo +import pytest + +from homeassistant.components.airq import AirQCoordinator +from homeassistant.components.airq.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") +MOCKED_ENTRY = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "192.168.0.0", + CONF_PASSWORD: "password", + }, + unique_id="123-456", +) + +TEST_DEVICE_INFO = AirQDeviceInfo( + id="id", + name="name", + model="model", + sw_version="sw", + hw_version="hw", +) +TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"} +STATUS_WARMUP = { + "co": "co sensor still in warm up phase; waiting time = 18 s", + "tvoc": "tvoc sensor still in warm up phase; waiting time = 18 s", + "so2": "so2 sensor still in warm up phase; waiting time = 17 s", +} + + +async def test_logging_in_coordinator_first_update_data( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the first AirQCoordinator._async_update_data call logs necessary setup. + + The fields of AirQCoordinator.device_info that are specific to the device are only + populated upon the first call to AirQCoordinator._async_update_data. The one field + which is actually necessary is 'name', and its absence is checked and logged, + as well as its being set. + """ + caplog.set_level(logging.DEBUG) + coordinator = AirQCoordinator(hass, MOCKED_ENTRY) + + # check that the name _is_ missing + assert "name" not in coordinator.device_info + + # First call: fetch missing device info + with ( + patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), + patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), + ): + await coordinator._async_update_data() + + # check that the missing name is logged... + assert ( + "'name' not found in AirQCoordinator.device_info, fetching from the device" + in caplog.text + ) + # ...and fixed + assert coordinator.device_info.get("name") == TEST_DEVICE_INFO["name"] + assert ( + f"Updated AirQCoordinator.device_info for 'name' {TEST_DEVICE_INFO['name']}" + in caplog.text + ) + + # Also that no warming up sensors is found as none are mocked + assert "Following sensors are still warming up" not in caplog.text + + +async def test_logging_in_coordinator_subsequent_update_data( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the second AirQCoordinator._async_update_data call has nothing to log. + + The second call is emulated by setting up AirQCoordinator.device_info correctly, + instead of actually calling the _async_update_data, which would populate the log + with the messages we want to see not being repeated. + """ + caplog.set_level(logging.DEBUG) + coordinator = AirQCoordinator(hass, MOCKED_ENTRY) + coordinator.device_info.update(DeviceInfo(**TEST_DEVICE_INFO)) + + with ( + patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), + patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), + ): + await coordinator._async_update_data() + # check that the name _is not_ missing + assert "name" in coordinator.device_info + # and that nothing of the kind is logged + assert ( + "'name' not found in AirQCoordinator.device_info, fetching from the device" + not in caplog.text + ) + assert ( + f"Updated AirQCoordinator.device_info for 'name' {TEST_DEVICE_INFO['name']}" + not in caplog.text + ) + + +async def test_logging_when_warming_up_sensor_present( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that warming up sensors are logged.""" + caplog.set_level(logging.DEBUG) + coordinator = AirQCoordinator(hass, MOCKED_ENTRY) + with ( + patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), + patch( + "aioairq.AirQ.get_latest_data", + return_value=TEST_DEVICE_DATA | {"Status": STATUS_WARMUP}, + ), + ): + await coordinator._async_update_data() + assert ( + f"Following sensors are still warming up: {set(STATUS_WARMUP.keys())}" + in caplog.text + ) From 8dd1e9d1018964d75d4d527ae2001917d7adc7c2 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Tue, 18 Feb 2025 11:16:50 -0700 Subject: [PATCH 0630/1941] Add threshold sensor to Aranet (#137291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add threshold level sensor description to Aranet component * Use Color enum for status options * Add threshold level sensor tests for Aranet components * Rename `threshold_level` key to `status` * Update test to expect 7 sensors instead of 6 * Map sensor status to more human-friendly strings * Rename `threshold_level` key to `concentration_status` * Update docstring for function * Simplify `get_friendly_status()` * Rename `concentration_status` to `concentration_level` * Rename `concentration_status` to `concentration_level` in sensor tests * Refactor concentration level handling and tests * Normalize concentration level status values to lowercase * Add error to translations * Don't scale status string * Apply suggestions from code review Co-authored-by: Shay Levy * Rename `concentration_level` to `threshold_indication` * Update threshold indication translations * `threshold_indication` → `threshold` * Capitalize sensor name Co-Authored-By: Shay Levy --------- Co-authored-by: Shay Levy --- homeassistant/components/aranet/sensor.py | 14 ++++++++++++-- homeassistant/components/aranet/strings.json | 12 ++++++++++++ tests/components/aranet/test_sensor.py | 18 +++++++++++++++--- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index c5750de1c12..ee2eb8c8a75 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from aranet4.client import Aranet4Advertisement +from aranet4.client import Aranet4Advertisement, Color from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth.passive_update_processor import ( @@ -74,6 +74,13 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, ), + "status": AranetSensorEntityDescription( + key="threshold", + translation_key="threshold", + name="Threshold", + device_class=SensorDeviceClass.ENUM, + options=[status.name.lower() for status in Color], + ), "co2": AranetSensorEntityDescription( key="co2", name="Carbon Dioxide", @@ -161,7 +168,10 @@ def sensor_update_to_bluetooth_data_update( val = getattr(adv.readings, key) if val == -1: continue - val *= desc.scale + if key == "status": + val = val.name.lower() + else: + val *= desc.scale data[tag] = val names[tag] = desc.name descs[tag] = desc diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json index 1cc695637d4..f786f4b2d4d 100644 --- a/homeassistant/components/aranet/strings.json +++ b/homeassistant/components/aranet/strings.json @@ -21,5 +21,17 @@ "no_devices_found": "No unconfigured Aranet devices found.", "outdated_version": "This device is using outdated firmware. Please update it to at least v1.2.0 and try again." } + }, + "entity": { + "sensor": { + "threshold": { + "state": { + "error": "Error", + "green": "Green", + "yellow": "Yellow", + "red": "Red" + } + } + } } } diff --git a/tests/components/aranet/test_sensor.py b/tests/components/aranet/test_sensor.py index 78a1d4aa9c9..a1a5ca32378 100644 --- a/tests/components/aranet/test_sensor.py +++ b/tests/components/aranet/test_sensor.py @@ -3,7 +3,7 @@ import pytest from homeassistant.components.aranet.const import DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.components.sensor import ATTR_OPTIONS, ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -170,7 +170,7 @@ async def test_sensors_aranet4( assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, VALID_DATA_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 6 + assert len(hass.states.async_all("sensor")) == 7 batt_sensor = hass.states.get("sensor.aranet4_12345_battery") batt_sensor_attrs = batt_sensor.attributes @@ -214,6 +214,12 @@ async def test_sensors_aranet4( assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + status_sensor = hass.states.get("sensor.aranet4_12345_threshold") + status_sensor_attrs = status_sensor.attributes + assert status_sensor.state == "green" + assert status_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet4 12345 Threshold" + assert status_sensor_attrs[ATTR_OPTIONS] == ["error", "green", "yellow", "red"] + # Check device context for the battery sensor entity = entity_registry.async_get("sensor.aranet4_12345_battery") device = device_registry.async_get(entity.device_id) @@ -245,7 +251,7 @@ async def test_sensors_aranetrn( assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, VALID_ARANET_RADON_DATA_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 6 + assert len(hass.states.async_all("sensor")) == 7 batt_sensor = hass.states.get("sensor.aranetrn_12345_battery") batt_sensor_attrs = batt_sensor.attributes @@ -291,6 +297,12 @@ async def test_sensors_aranetrn( assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + status_sensor = hass.states.get("sensor.aranetrn_12345_threshold") + status_sensor_attrs = status_sensor.attributes + assert status_sensor.state == "green" + assert status_sensor_attrs[ATTR_FRIENDLY_NAME] == "AranetRn+ 12345 Threshold" + assert status_sensor_attrs[ATTR_OPTIONS] == ["error", "green", "yellow", "red"] + # Check device context for the battery sensor entity = entity_registry.async_get("sensor.aranetrn_12345_battery") device = device_registry.async_get(entity.device_id) From e6217efcd666940ceb2da6d7c6986fe8cf0d16a1 Mon Sep 17 00:00:00 2001 From: Matrix Date: Wed, 19 Feb 2025 02:23:27 +0800 Subject: [PATCH 0631/1941] Add switch flex button support. (#137524) --- homeassistant/components/yolink/__init__.py | 5 ++- homeassistant/components/yolink/const.py | 4 ++ .../components/yolink/device_trigger.py | 38 ++++++++++++------- homeassistant/components/yolink/switch.py | 11 +++--- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 0c92aa696ca..7ba7433f53f 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -from yolink.const import ATTR_DEVICE_SMART_REMOTER +from yolink.const import ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError from yolink.home_manager import YoLinkHome @@ -75,7 +75,8 @@ class YoLinkHomeMessageListener(MessageListener): device_coordinator.async_set_updated_data(msg_data) # handling events if ( - device_coordinator.device.device_type == ATTR_DEVICE_SMART_REMOTER + device_coordinator.device.device_type + in [ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH] and msg_data.get("event") is not None ): device_registry = dr.async_get(self._hass) diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index eb6169eccad..8879ef15125 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -33,3 +33,7 @@ DEV_MODEL_PLUG_YS6602_UC = "YS6602-UC" DEV_MODEL_PLUG_YS6602_EC = "YS6602-EC" DEV_MODEL_PLUG_YS6803_UC = "YS6803-UC" DEV_MODEL_PLUG_YS6803_EC = "YS6803-EC" +DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC" +DEV_MODEL_SWITCH_YS5708_EC = "YS5708-EC" +DEV_MODEL_SWITCH_YS5709_UC = "YS5709-UC" +DEV_MODEL_SWITCH_YS5709_EC = "YS5709-EC" diff --git a/homeassistant/components/yolink/device_trigger.py b/homeassistant/components/yolink/device_trigger.py index 6e247bf858e..6f5ed8b24fa 100644 --- a/homeassistant/components/yolink/device_trigger.py +++ b/homeassistant/components/yolink/device_trigger.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any import voluptuous as vol -from yolink.const import ATTR_DEVICE_SMART_REMOTER +from yolink.const import ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import event as event_trigger @@ -21,6 +21,10 @@ from .const import ( DEV_MODEL_FLEX_FOB_YS3604_UC, DEV_MODEL_FLEX_FOB_YS3614_EC, DEV_MODEL_FLEX_FOB_YS3614_UC, + DEV_MODEL_SWITCH_YS5708_EC, + DEV_MODEL_SWITCH_YS5708_UC, + DEV_MODEL_SWITCH_YS5709_EC, + DEV_MODEL_SWITCH_YS5709_UC, ) CONF_BUTTON_1 = "button_1" @@ -30,7 +34,7 @@ CONF_BUTTON_4 = "button_4" CONF_SHORT_PRESS = "short_press" CONF_LONG_PRESS = "long_press" -FLEX_FOB_4_BUTTONS = { +FLEX_BUTTONS_4 = { f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}", f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}", f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}", @@ -41,7 +45,7 @@ FLEX_FOB_4_BUTTONS = { f"{CONF_BUTTON_4}_{CONF_LONG_PRESS}", } -FLEX_FOB_2_BUTTONS = { +FLEX_BUTTONS_2 = { f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}", f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}", f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}", @@ -49,16 +53,19 @@ FLEX_FOB_2_BUTTONS = { } TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( - {vol.Required(CONF_TYPE): vol.In(FLEX_FOB_4_BUTTONS)} + {vol.Required(CONF_TYPE): vol.In(FLEX_BUTTONS_4)} ) - -# YoLink Remotes YS3604/YS3614 -FLEX_FOB_TRIGGER_TYPES: dict[str, set[str]] = { - DEV_MODEL_FLEX_FOB_YS3604_EC: FLEX_FOB_4_BUTTONS, - DEV_MODEL_FLEX_FOB_YS3604_UC: FLEX_FOB_4_BUTTONS, - DEV_MODEL_FLEX_FOB_YS3614_UC: FLEX_FOB_2_BUTTONS, - DEV_MODEL_FLEX_FOB_YS3614_EC: FLEX_FOB_2_BUTTONS, +# YoLink Remotes YS3604/YS3614, Switch YS5708/YS5709 +TRIGGER_MAPPINGS: dict[str, set[str]] = { + DEV_MODEL_FLEX_FOB_YS3604_EC: FLEX_BUTTONS_4, + DEV_MODEL_FLEX_FOB_YS3604_UC: FLEX_BUTTONS_4, + DEV_MODEL_FLEX_FOB_YS3614_UC: FLEX_BUTTONS_2, + DEV_MODEL_FLEX_FOB_YS3614_EC: FLEX_BUTTONS_2, + DEV_MODEL_SWITCH_YS5708_EC: FLEX_BUTTONS_2, + DEV_MODEL_SWITCH_YS5708_UC: FLEX_BUTTONS_2, + DEV_MODEL_SWITCH_YS5709_EC: FLEX_BUTTONS_2, + DEV_MODEL_SWITCH_YS5709_UC: FLEX_BUTTONS_2, } @@ -68,9 +75,12 @@ async def async_get_triggers( """List device triggers for YoLink devices.""" device_registry = dr.async_get(hass) registry_device = device_registry.async_get(device_id) - if not registry_device or registry_device.model != ATTR_DEVICE_SMART_REMOTER: + if not registry_device or registry_device.model not in [ + ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_SWITCH, + ]: return [] - if registry_device.model_id not in list(FLEX_FOB_TRIGGER_TYPES.keys()): + if registry_device.model_id not in list(TRIGGER_MAPPINGS.keys()): return [] return [ { @@ -79,7 +89,7 @@ async def async_get_triggers( CONF_PLATFORM: "device", CONF_TYPE: trigger, } - for trigger in FLEX_FOB_TRIGGER_TYPES[registry_device.model_id] + for trigger in TRIGGER_MAPPINGS[registry_device.model_id] ] diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index f2b3c83711c..2af7a3c9ddc 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -162,11 +162,12 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): @callback def update_entity_state(self, state: dict[str, str | list[str]]) -> None: """Update HA Entity State.""" - self._attr_is_on = self._get_state( - state.get("state"), - self.entity_description.plug_index_fn(self.coordinator.device), - ) - self.async_write_ha_state() + if (state_value := state.get("state")) is not None: + self._attr_is_on = self._get_state( + state_value, + self.entity_description.plug_index_fn(self.coordinator.device), + ) + self.async_write_ha_state() async def call_state_change(self, state: str) -> None: """Call setState api to change switch state.""" From c48797804d71949ed7cb2f32394d9d4244461bb6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 18 Feb 2025 19:57:10 +0100 Subject: [PATCH 0632/1941] Add `_shelly._tcp` to Shelly zeroconf configuration (#138782) Add _shelly._tcp to zeroconf --- homeassistant/components/shelly/manifest.json | 3 +++ homeassistant/generated/zeroconf.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 4c9927f515a..c8073d6dbc2 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -13,6 +13,9 @@ { "type": "_http._tcp.local.", "name": "shelly*" + }, + { + "type": "_shelly._tcp.local." } ] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index ab965e27472..cc1683a3603 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -803,6 +803,11 @@ ZEROCONF = { "domain": "russound_rio", }, ], + "_shelly._tcp.local.": [ + { + "domain": "shelly", + }, + ], "_sideplay._tcp.local.": [ { "domain": "ecobee", From 82ac3e3fdf39fe0a4df4e76ffbd0994183e2e3d6 Mon Sep 17 00:00:00 2001 From: SLaks Date: Tue, 18 Feb 2025 14:11:37 -0500 Subject: [PATCH 0633/1941] Ecobee: Report Humidifier Action (#138756) Co-authored-by: Josef Zweck --- homeassistant/components/ecobee/humidifier.py | 36 ++++++++++++++----- .../ecobee/fixtures/ecobee-data.json | 2 +- tests/components/ecobee/test_humidifier.py | 3 ++ 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py index ab6831d8f26..a6f3c16f84a 100644 --- a/homeassistant/components/ecobee/humidifier.py +++ b/homeassistant/components/ecobee/humidifier.py @@ -3,11 +3,13 @@ from __future__ import annotations from datetime import timedelta +from typing import Any from homeassistant.components.humidifier import ( DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, MODE_AUTO, + HumidifierAction, HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, @@ -41,6 +43,12 @@ async def async_setup_entry( async_add_entities(entities, True) +ECOBEE_HUMIDIFIER_ACTION_TO_HASS = { + "humidifier": HumidifierAction.HUMIDIFYING, + "dehumidifier": HumidifierAction.DRYING, +} + + class EcobeeHumidifier(HumidifierEntity): """A humidifier class for an ecobee thermostat with humidifier attached.""" @@ -52,7 +60,7 @@ class EcobeeHumidifier(HumidifierEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, data, thermostat_index): + def __init__(self, data, thermostat_index) -> None: """Initialize ecobee humidifier platform.""" self.data = data self.thermostat_index = thermostat_index @@ -80,11 +88,11 @@ class EcobeeHumidifier(HumidifierEntity): ) @property - def available(self): + def available(self) -> bool: """Return if device is available.""" return self.thermostat["runtime"]["connected"] - async def async_update(self): + async def async_update(self) -> None: """Get the latest state from the thermostat.""" if self.update_without_throttle: await self.data.update(no_throttle=True) @@ -96,12 +104,20 @@ class EcobeeHumidifier(HumidifierEntity): self._last_humidifier_on_mode = self.mode @property - def is_on(self): + def action(self) -> HumidifierAction: + """Return the current action.""" + for status in self.thermostat["equipmentStatus"].split(","): + if status in ECOBEE_HUMIDIFIER_ACTION_TO_HASS: + return ECOBEE_HUMIDIFIER_ACTION_TO_HASS[status] + return HumidifierAction.IDLE if self.is_on else HumidifierAction.OFF + + @property + def is_on(self) -> bool: """Return True if the humidifier is on.""" return self.mode != MODE_OFF @property - def mode(self): + def mode(self) -> str: """Return the current mode, e.g., off, auto, manual.""" return self.thermostat["settings"]["humidifierMode"] @@ -118,9 +134,11 @@ class EcobeeHumidifier(HumidifierEntity): except KeyError: return None - def set_mode(self, mode): + def set_mode(self, mode: str) -> None: """Set humidifier mode (auto, off, manual).""" - if mode.lower() not in (self.available_modes): + if self.available_modes is None: + raise NotImplementedError("Humidifier does not support modes.") + if mode.lower() not in self.available_modes: raise ValueError( f"Invalid mode value: {mode} Valid values are" f" {', '.join(self.available_modes)}." @@ -134,10 +152,10 @@ class EcobeeHumidifier(HumidifierEntity): self.data.ecobee.set_humidity(self.thermostat_index, humidity) self.update_without_throttle = True - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Set humidifier to off mode.""" self.set_mode(MODE_OFF) - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Set humidifier to on mode.""" self.set_mode(self._last_humidifier_on_mode) diff --git a/tests/components/ecobee/fixtures/ecobee-data.json b/tests/components/ecobee/fixtures/ecobee-data.json index e0e82d68863..87d85250780 100644 --- a/tests/components/ecobee/fixtures/ecobee-data.json +++ b/tests/components/ecobee/fixtures/ecobee-data.json @@ -67,7 +67,7 @@ "hasHeatPump": false, "humidity": "30" }, - "equipmentStatus": "fan", + "equipmentStatus": "fan,humidifier", "events": [ { "name": "Event1", diff --git a/tests/components/ecobee/test_humidifier.py b/tests/components/ecobee/test_humidifier.py index 696ca3d6c0d..6f20d38deaa 100644 --- a/tests/components/ecobee/test_humidifier.py +++ b/tests/components/ecobee/test_humidifier.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.ecobee.humidifier import MODE_MANUAL, MODE_OFF from homeassistant.components.humidifier import ( + ATTR_ACTION, ATTR_AVAILABLE_MODES, ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, @@ -17,6 +18,7 @@ from homeassistant.components.humidifier import ( MODE_AUTO, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, + HumidifierAction, HumidifierDeviceClass, HumidifierEntityFeature, ) @@ -44,6 +46,7 @@ async def test_attributes(hass: HomeAssistant) -> None: state = hass.states.get(DEVICE_ID) assert state.state == STATE_ON + assert state.attributes[ATTR_ACTION] == HumidifierAction.HUMIDIFYING assert state.attributes[ATTR_CURRENT_HUMIDITY] == 15 assert state.attributes[ATTR_MIN_HUMIDITY] == DEFAULT_MIN_HUMIDITY assert state.attributes[ATTR_MAX_HUMIDITY] == DEFAULT_MAX_HUMIDITY From df5086387272f93087415339466311f2a52d6d71 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 18 Feb 2025 20:28:41 +0100 Subject: [PATCH 0634/1941] Bump uv to 0.6.1 (#138790) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 42a90107c4d..a4d882eb8a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.6.0 +RUN pip3 install uv==0.6.1 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 883ec737268..77a19e75137 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -67,7 +67,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.6.0 +uv==0.6.1 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 72a66437c55..e4eae2e4647 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.6.0", + "uv==0.6.1", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", diff --git a/requirements.txt b/requirements.txt index 3c0fc1f9a57..bd92428465d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,7 +45,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.2.0 urllib3>=1.26.5,<2 -uv==0.6.0 +uv==0.6.1 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 9d652ec1641..2eeb19fb547 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.6.0,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From 13fe2a9929c2adc17b33d0d558d101882a77c966 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 18 Feb 2025 20:31:41 +0100 Subject: [PATCH 0635/1941] Reorder Dockerfile to improve caching (#138789) --- Dockerfile | 36 ++++++++++++++++++------------------ script/hassfest/docker.py | 36 ++++++++++++++++++------------------ 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/Dockerfile b/Dockerfile index a4d882eb8a1..3ab0bb37b9a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,24 @@ ENV \ ARG QEMU_CPU +# Home Assistant S6-Overlay +COPY rootfs / + +# Needs to be redefined inside the FROM statement to be set for RUN commands +ARG BUILD_ARCH +# Get go2rtc binary +RUN \ + case "${BUILD_ARCH}" in \ + "aarch64") go2rtc_suffix='arm64' ;; \ + "armhf") go2rtc_suffix='armv6' ;; \ + "armv7") go2rtc_suffix='arm' ;; \ + *) go2rtc_suffix=${BUILD_ARCH} ;; \ + esac \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && chmod +x /bin/go2rtc \ + # Verify go2rtc can be executed + && go2rtc --version + # Install uv RUN pip3 install uv==0.6.1 @@ -42,22 +60,4 @@ RUN \ && python3 -m compileall \ homeassistant/homeassistant -# Home Assistant S6-Overlay -COPY rootfs / - -# Needs to be redefined inside the FROM statement to be set for RUN commands -ARG BUILD_ARCH -# Get go2rtc binary -RUN \ - case "${BUILD_ARCH}" in \ - "aarch64") go2rtc_suffix='arm64' ;; \ - "armhf") go2rtc_suffix='armv6' ;; \ - "armv7") go2rtc_suffix='arm' ;; \ - *) go2rtc_suffix=${BUILD_ARCH} ;; \ - esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ - && chmod +x /bin/go2rtc \ - # Verify go2rtc can be executed - && go2rtc --version - WORKDIR /config diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index edc47e2f9d7..4bf6c3bb0a6 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -26,6 +26,24 @@ ENV \ ARG QEMU_CPU +# Home Assistant S6-Overlay +COPY rootfs / + +# Needs to be redefined inside the FROM statement to be set for RUN commands +ARG BUILD_ARCH +# Get go2rtc binary +RUN \ + case "${{BUILD_ARCH}}" in \ + "aarch64") go2rtc_suffix='arm64' ;; \ + "armhf") go2rtc_suffix='armv6' ;; \ + "armv7") go2rtc_suffix='arm' ;; \ + *) go2rtc_suffix=${{BUILD_ARCH}} ;; \ + esac \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \ + && chmod +x /bin/go2rtc \ + # Verify go2rtc can be executed + && go2rtc --version + # Install uv RUN pip3 install uv=={uv} @@ -56,24 +74,6 @@ RUN \ && python3 -m compileall \ homeassistant/homeassistant -# Home Assistant S6-Overlay -COPY rootfs / - -# Needs to be redefined inside the FROM statement to be set for RUN commands -ARG BUILD_ARCH -# Get go2rtc binary -RUN \ - case "${{BUILD_ARCH}}" in \ - "aarch64") go2rtc_suffix='arm64' ;; \ - "armhf") go2rtc_suffix='armv6' ;; \ - "armv7") go2rtc_suffix='arm' ;; \ - *) go2rtc_suffix=${{BUILD_ARCH}} ;; \ - esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \ - && chmod +x /bin/go2rtc \ - # Verify go2rtc can be executed - && go2rtc --version - WORKDIR /config """ From 8ae52cdc4c00cc4df0ad1aeca2d5330aa5c44aa8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Feb 2025 14:05:05 -0600 Subject: [PATCH 0636/1941] Fix shelly not being able to be setup from user flow when already discovered (#138807) raise_on_progress=False was missing in the user flow which made it impossible to configure a shelly by IP when there was an active discovery because the flow would abort --- .../components/shelly/config_flow.py | 4 +- tests/components/shelly/test_config_flow.py | 67 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index f53da8bd766..45655745403 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -164,7 +164,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(self.info[CONF_MAC]) + await self.async_set_unique_id( + self.info[CONF_MAC], raise_on_progress=False + ) self._abort_if_unique_id_configured({CONF_HOST: host}) self.host = host self.port = port diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index b5f87a874c3..50b8b552268 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -117,6 +117,73 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_flow_overrides_existing_discovery( + hass: HomeAssistant, + mock_rpc_device: Mock, +) -> None: + """Test setting up from the user flow when the devices is already discovered.""" + with ( + patch( + "homeassistant.components.shelly.config_flow.get_info", + return_value={ + "mac": "AABBCCDDEEFF", + "model": MODEL_PLUS_2PM, + "auth": False, + "gen": 2, + "port": 80, + }, + ), + patch( + "homeassistant.components.shelly.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.shelly.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ZeroconfServiceInfo( + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], + hostname="mock_hostname", + name="shelly2pm-aabbccddeeff", + port=None, + properties={ATTR_PROPERTIES_ID: "shelly2pm-aabbccddeeff"}, + type="mock_type", + ), + context={"source": config_entries.SOURCE_ZEROCONF}, + ) + assert discovery_result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1", "port": 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Test name" + assert result2["data"] == { + "host": "1.1.1.1", + "port": 80, + "model": MODEL_PLUS_2PM, + "sleep_period": 0, + "gen": 2, + } + assert result2["context"]["unique_id"] == "AABBCCDDEEFF" + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + # discovery flow should have been aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) + + async def test_form_gen1_custom_port( hass: HomeAssistant, mock_block_device: Mock, From 141bcae79345998f3b632c19d94594f49454a719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 18 Feb 2025 21:50:19 +0100 Subject: [PATCH 0637/1941] Add Home Connect to .strict-typing (#138799) * Add Home Connect to .strict-typing * Fix mypy errors --- .strict-typing | 1 + .../components/home_connect/__init__.py | 26 +++++++++++-------- homeassistant/components/home_connect/api.py | 4 ++- .../components/home_connect/coordinator.py | 4 +-- mypy.ini | 10 +++++++ 5 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.strict-typing b/.strict-typing index 1e3187980cc..9543ccc3989 100644 --- a/.strict-typing +++ b/.strict-typing @@ -234,6 +234,7 @@ homeassistant.components.here_travel_time.* homeassistant.components.history.* homeassistant.components.history_stats.* homeassistant.components.holiday.* +homeassistant.components.home_connect.* homeassistant.components.homeassistant.* homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_green.* diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index a020b2370b9..b4ceb11be92 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -237,7 +237,7 @@ async def _get_client_and_ha_id( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up Home Connect component.""" - async def _async_service_program(call: ServiceCall, start: bool): + async def _async_service_program(call: ServiceCall, start: bool) -> None: """Execute calls to services taking a program.""" program = call.data[ATTR_PROGRAM] client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) @@ -323,7 +323,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: }, ) from err - async def _async_service_set_program_options(call: ServiceCall, active: bool): + async def _async_service_set_program_options( + call: ServiceCall, active: bool + ) -> None: """Execute calls to services taking a program.""" option_key = call.data[ATTR_KEY] value = call.data[ATTR_VALUE] @@ -396,7 +398,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: }, ) from err - async def _async_service_command(call: ServiceCall, command_key: CommandKey): + async def _async_service_command( + call: ServiceCall, command_key: CommandKey + ) -> None: """Execute calls to services executing a command.""" client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) @@ -412,15 +416,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: }, ) from err - async def async_service_option_active(call: ServiceCall): + async def async_service_option_active(call: ServiceCall) -> None: """Service for setting an option for an active program.""" await _async_service_set_program_options(call, True) - async def async_service_option_selected(call: ServiceCall): + async def async_service_option_selected(call: ServiceCall) -> None: """Service for setting an option for a selected program.""" await _async_service_set_program_options(call, False) - async def async_service_setting(call: ServiceCall): + async def async_service_setting(call: ServiceCall) -> None: """Service for changing a setting.""" key = call.data[ATTR_KEY] value = call.data[ATTR_VALUE] @@ -439,19 +443,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: }, ) from err - async def async_service_pause_program(call: ServiceCall): + async def async_service_pause_program(call: ServiceCall) -> None: """Service for pausing a program.""" await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - async def async_service_resume_program(call: ServiceCall): + async def async_service_resume_program(call: ServiceCall) -> None: """Service for resuming a paused program.""" await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - async def async_service_select_program(call: ServiceCall): + async def async_service_select_program(call: ServiceCall) -> None: """Service for selecting a program.""" await _async_service_program(call, False) - async def async_service_set_program_and_options(call: ServiceCall): + async def async_service_set_program_and_options(call: ServiceCall) -> None: """Service for setting a program and options.""" data = dict(call.data) program = data.pop(ATTR_PROGRAM, None) @@ -521,7 +525,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: }, ) from err - async def async_service_start_program(call: ServiceCall): + async def async_service_start_program(call: ServiceCall) -> None: """Service for starting a program.""" await _async_service_program(call, True) diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 5d711dae032..b66236c367d 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -1,5 +1,7 @@ """API for Home Connect bound to HASS OAuth.""" +from typing import cast + from aiohomeconnect.client import AbstractAuth from aiohomeconnect.const import API_ENDPOINT @@ -25,4 +27,4 @@ class AsyncConfigEntryAuth(AbstractAuth): """Return a valid access token.""" await self.session.async_ensure_token_valid() - return self.session.token["access_token"] + return cast(str, self.session.token["access_token"]) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index da47d8ec91c..ceedde7fe72 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -254,7 +254,7 @@ class HomeConnectCoordinator( await self.async_refresh() @callback - def _call_event_listener(self, event_message: EventMessage): + def _call_event_listener(self, event_message: EventMessage) -> None: """Call listener for event.""" for event in event_message.data.items: for listener in self.context_listeners.get( @@ -263,7 +263,7 @@ class HomeConnectCoordinator( listener() @callback - def _call_all_event_listeners_for_appliance(self, ha_id: str): + def _call_all_event_listeners_for_appliance(self, ha_id: str) -> None: for listener, context in self._listeners.values(): if isinstance(context, tuple) and context[0] == ha_id: listener() diff --git a/mypy.ini b/mypy.ini index 2d9821b1c64..f15ad433a52 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2096,6 +2096,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.home_connect.*] +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.homeassistant.*] check_untyped_defs = true disallow_incomplete_defs = true From 6ef401251c31c86300923d6251aa7b705fb168d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 18 Feb 2025 22:01:13 +0100 Subject: [PATCH 0638/1941] Add Home Connect entities that weren't added before (#138796) Added entities that weren't added before --- .../components/home_connect/binary_sensor.py | 18 ++++++++++++++++++ .../components/home_connect/number.py | 10 ++++++++++ .../components/home_connect/strings.json | 15 +++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index c0e978dbba4..3b2c7c23d68 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -93,12 +93,24 @@ BINARY_SENSORS = ( key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST, translation_key="lost", ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_BOTTLE_COOLER, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="bottle_cooler_door", + ), HomeConnectBinarySensorEntityDescription( key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_FLEX_COMPARTMENT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="flex_compartment_door", + ), HomeConnectBinarySensorEntityDescription( key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, @@ -111,6 +123,12 @@ BINARY_SENSORS = ( device_class=BinarySensorDeviceClass.DOOR, translation_key="refrigerator_door", ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_WINE_COMPARTMENT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="wine_compartment_door", + ), ) diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index b0adea508c1..26c4aa02372 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -76,6 +76,16 @@ NUMBERS = ( device_class=NumberDeviceClass.TEMPERATURE, translation_key="wine_compartment_3_setpoint_temperature", ), + NumberEntityDescription( + key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL, + device_class=NumberDeviceClass.VOLUME, + translation_key="washer_i_dos_1_base_level", + ), + NumberEntityDescription( + key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_2_BASE_LEVEL, + device_class=NumberDeviceClass.VOLUME, + translation_key="washer_i_dos_2_base_level", + ), ) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 3ffd84e61b2..3ac9f90ba81 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -793,14 +793,23 @@ "lost": { "name": "Lost" }, + "bottle_cooler_door": { + "name": "Bottle cooler door" + }, "chiller_door": { "name": "Chiller door" }, + "flex_compartment_door": { + "name": "Flex compartment door" + }, "freezer_door": { "name": "Freezer door" }, "refrigerator_door": { "name": "Refrigerator door" + }, + "wine_compartment_door": { + "name": "Wine compartment door" } }, "light": { @@ -844,6 +853,12 @@ }, "wine_compartment_3_setpoint_temperature": { "name": "Wine compartment 3 temperature" + }, + "washer_i_dos_1_base_level": { + "name": "i-Dos 1 base level" + }, + "washer_i_dos_2_base_level": { + "name": "i-Dos 2 base level" } }, "select": { From 1af8b69dd6806b3aa6ccd80577c152a9fa2b3969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 18 Feb 2025 22:03:35 +0100 Subject: [PATCH 0639/1941] Set Home Connect beverages counters as diagnostics (#138798) Set beverages counters as diagnostics --- homeassistant/components/home_connect/sensor.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 971f87d72fd..d9f45c8c31d 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, UnitOfTime, UnitOfVolume +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify @@ -99,16 +99,19 @@ SENSORS = ( ), HomeConnectSensorEntityDescription( key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_counter", ), HomeConnectSensorEntityDescription( key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="powder_coffee_counter", ), HomeConnectSensorEntityDescription( key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolume.MILLILITERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, @@ -116,31 +119,37 @@ SENSORS = ( ), HomeConnectSensorEntityDescription( key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_water_cups_counter", ), HomeConnectSensorEntityDescription( key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="hot_milk_counter", ), HomeConnectSensorEntityDescription( key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="frothy_milk_counter", ), HomeConnectSensorEntityDescription( key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="milk_counter", ), HomeConnectSensorEntityDescription( key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="coffee_and_milk_counter", ), HomeConnectSensorEntityDescription( key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, translation_key="ristretto_espresso_counter", ), From 8e887f550ee73f64e848ab65dfdb027c4f660f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 18 Feb 2025 22:08:40 +0100 Subject: [PATCH 0640/1941] Add connectivity binary sensor to Home Connect (#138795) Add connectivity binary sensor --- .../components/home_connect/binary_sensor.py | 29 ++++++++++++- .../home_connect/test_binary_sensor.py | 43 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 3b2c7c23d68..57ede4b2ff4 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import cast -from aiohomeconnect.model import StatusKey +from aiohomeconnect.model import EventKey, StatusKey from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( @@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.components.script import scripts_with_entity +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -131,13 +132,22 @@ BINARY_SENSORS = ( ), ) +CONNECTED_BINARY_ENTITY_DESCRIPTION = BinarySensorEntityDescription( + key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED, + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) + def _get_entities_for_appliance( entry: HomeConnectConfigEntry, appliance: HomeConnectApplianceData, ) -> list[HomeConnectEntity]: """Get a list of entities.""" - entities: list[HomeConnectEntity] = [] + entities: list[HomeConnectEntity] = [ + HomeConnectConnectivityBinarySensor( + entry.runtime_data, appliance, CONNECTED_BINARY_ENTITY_DESCRIPTION + ) + ] entities.extend( HomeConnectBinarySensor(entry.runtime_data, appliance, description) for description in BINARY_SENSORS @@ -177,6 +187,21 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): self._attr_is_on = None +class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity): + """Binary sensor for Home Connect appliance's connection status.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def update_native_value(self) -> None: + """Set the native value of the binary sensor.""" + self._attr_is_on = self.appliance.info.connected + + @property + def available(self) -> bool: + """Return the availability.""" + return self.coordinator.last_update_success + + class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): """Binary sensor for Home Connect Generic Door.""" diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 211192f592b..a06e386b84f 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -346,6 +346,49 @@ async def test_binary_sensors_functionality( assert hass.states.is_state(entity_id, expected) +async def test_connected_sensor_functionality( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if the connected binary sensor reports the right values.""" + entity_id = "binary_sensor.washer_connectivity" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state(entity_id, STATE_ON) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, STATE_OFF) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.is_state(entity_id, STATE_ON) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_create_issue( hass: HomeAssistant, From b71d5737a5cf733250f00bd3d93791b0c8547e09 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 18 Feb 2025 22:34:08 +0100 Subject: [PATCH 0641/1941] Update Home Assistant base image to 2025.02.1 (#138746) * Update Home Assistant base image to 2025.02.1 * Require Python 3.13.2 now --- build.yaml | 10 +++++----- homeassistant/const.py | 4 ++-- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/build.yaml b/build.yaml index e6e149cf700..cd54e410493 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.12.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.12.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.12.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.12.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.12.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.02.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.02.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.02.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.02.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.02.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/const.py b/homeassistant/const.py index 7775b618795..84f16cd08b7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -28,8 +28,8 @@ MINOR_VERSION: Final = 3 PATCH_VERSION: Final = "0.dev0" __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) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) # Truthy date string triggers showing related deprecation warning messages. REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" diff --git a/pyproject.toml b/pyproject.toml index e4eae2e4647..d090d897716 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] -requires-python = ">=3.13.0" +requires-python = ">=3.13.2" dependencies = [ "aiodns==3.2.0", # Integrations may depend on hassio integration without listing it to From 1579e90d5802b5a605fd4793db1aaa65aafda186 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 18 Feb 2025 22:36:28 +0100 Subject: [PATCH 0642/1941] Fix typos in strings.json files (#138601) * fix codespell issues * update nextcloud snapshots * update weheat snapshots * update waqi snapshots --- homeassistant/components/anthemav/strings.json | 2 +- .../components/aurora_abb_powerone/strings.json | 2 +- homeassistant/components/elvia/strings.json | 2 +- homeassistant/components/emoncms/strings.json | 2 +- homeassistant/components/enphase_envoy/strings.json | 2 +- homeassistant/components/feedreader/strings.json | 2 +- homeassistant/components/habitica/strings.json | 2 +- homeassistant/components/heos/strings.json | 2 +- homeassistant/components/history_stats/strings.json | 2 +- homeassistant/components/homeassistant/strings.json | 2 +- homeassistant/components/homeworks/strings.json | 2 +- homeassistant/components/madvr/strings.json | 4 ++-- homeassistant/components/mastodon/strings.json | 2 +- homeassistant/components/mcp_server/strings.json | 2 +- homeassistant/components/nextcloud/strings.json | 4 ++-- homeassistant/components/proximity/strings.json | 2 +- homeassistant/components/qbittorrent/strings.json | 6 +++--- homeassistant/components/qbus/strings.json | 2 +- homeassistant/components/reolink/strings.json | 2 +- homeassistant/components/statistics/strings.json | 4 ++-- homeassistant/components/stookwijzer/strings.json | 2 +- homeassistant/components/tessie/strings.json | 2 +- homeassistant/components/waqi/strings.json | 2 +- homeassistant/components/weheat/strings.json | 2 +- .../components/nextcloud/snapshots/test_sensor.ambr | 12 ++++++------ tests/components/waqi/snapshots/test_sensor.ambr | 4 ++-- .../weheat/snapshots/test_binary_sensor.ambr | 12 ++++++------ 27 files changed, 43 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/anthemav/strings.json b/homeassistant/components/anthemav/strings.json index 1f1dd0ec75b..15e365b3e63 100644 --- a/homeassistant/components/anthemav/strings.json +++ b/homeassistant/components/anthemav/strings.json @@ -10,7 +10,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "cannot_receive_deviceinfo": "Failed to retreive MAC Address. Make sure the device is turned on" + "cannot_receive_deviceinfo": "Failed to retrieve MAC Address. Make sure the device is turned on" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 319bcb0adc4..6b28d9d8c1c 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -11,7 +11,7 @@ }, "error": { "cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)", - "invalid_serial_port": "Serial port is not a valid device or could not be openned", + "invalid_serial_port": "Serial port is not a valid device or could not be opened", "cannot_open_serial_port": "Cannot open serial port, please check and try again" }, "abort": { diff --git a/homeassistant/components/elvia/strings.json b/homeassistant/components/elvia/strings.json index 888a5ab8e76..a2c3cb81f54 100644 --- a/homeassistant/components/elvia/strings.json +++ b/homeassistant/components/elvia/strings.json @@ -19,7 +19,7 @@ }, "abort": { "metering_point_id_already_configured": "Metering point with ID `{metering_point_id}` is already configured.", - "no_metering_points": "The provived API token has no metering points." + "no_metering_points": "The provided API token has no metering points." } } } diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 77216a3fb2f..451a3fb88e5 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -1,7 +1,7 @@ { "config": { "error": { - "api_error": "An error occured in the pyemoncms API : {details}" + "api_error": "An error occurred in the pyemoncms API : {details}" }, "step": { "user": { diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index e99c45c5c7a..0c1facca1ea 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -57,7 +57,7 @@ "init": { "title": "Envoy {serial} {host} options", "data": { - "diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activies. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.", + "diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activities. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.", "disable_keep_alive": "Always use a new connection when requesting data from the Envoy. May resolve communication issues with some Envoy firmwares." }, "data_description": { diff --git a/homeassistant/components/feedreader/strings.json b/homeassistant/components/feedreader/strings.json index 0f0492eb6c9..3132aadbda8 100644 --- a/homeassistant/components/feedreader/strings.json +++ b/homeassistant/components/feedreader/strings.json @@ -36,7 +36,7 @@ "issues": { "import_yaml_error_url_error": { "title": "The Feedreader YAML configuration import failed", - "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessable for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." + "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." } } } diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index a5f64dca7c2..396a10e05f9 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -548,7 +548,7 @@ }, "cancel_quest": { "name": "Cancel a pending quest", - "description": "Cancels a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", + "description": "Cancels a quest that has not yet started. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", "fields": { "config_entry": { "name": "[%key:component::habitica::common::config_entry_name%]", diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index af70c0c786e..2f3b82efc8d 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -8,7 +8,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Host name or IP address of a HEOS-capable product (preferrably one connected via wire to the network)." + "host": "Host name or IP address of a HEOS-capable product (preferably one connected via wire to the network)." } }, "reconfigure": { diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json index aff2ac50bef..e10a72f6742 100644 --- a/homeassistant/components/history_stats/strings.json +++ b/homeassistant/components/history_stats/strings.json @@ -24,7 +24,7 @@ } }, "options": { - "description": "Read the documention for further details on how to configure the history stats sensor using these options.", + "description": "Read the documentation for further details on how to configure the history stats sensor using these options.", "data": { "start": "Start", "end": "End", diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 3283d480fdd..590afd697b5 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -44,7 +44,7 @@ }, "no_platform_setup": { "title": "Unused YAML configuration for the {platform} integration", - "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}" + "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurrences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}" }, "storage_corruption": { "title": "Storage corruption detected for {storage_key}", diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index 10cc2e61fb9..1a144615e89 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -14,7 +14,7 @@ }, "step": { "import_finish": { - "description": "The existing YAML configuration has succesfully been imported.\n\nYou can now remove the `homeworks` configuration from your configuration.yaml file." + "description": "The existing YAML configuration has successfully been imported.\n\nYou can now remove the `homeworks` configuration from your configuration.yaml file." }, "import_controller_name": { "description": "Lutron Homeworks is no longer configured through configuration.yaml.\n\nPlease fill in the form to import the existing configuration to the UI.", diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json index 19f23afddaf..38b949ee5d6 100644 --- a/homeassistant/components/madvr/strings.json +++ b/homeassistant/components/madvr/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Set up madVR Envy", - "description": "Your device needs to be on in order to add the integation.", + "description": "Your device needs to be on in order to add the integration.", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" @@ -15,7 +15,7 @@ }, "reconfigure": { "title": "Reconfigure madVR Envy", - "description": "Your device needs to be on in order to reconfigure the integation.", + "description": "Your device needs to be on in order to reconfigure the integration.", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 24a4247636d..9e6cf6db6bf 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -22,7 +22,7 @@ "error": { "unauthorized_error": "The credentials are incorrect.", "network_error": "The Mastodon instance was not found.", - "unknown": "Unknown error occured when connecting to the Mastodon instance." + "unknown": "Unknown error occurred when connecting to the Mastodon instance." } }, "exceptions": { diff --git a/homeassistant/components/mcp_server/strings.json b/homeassistant/components/mcp_server/strings.json index fbd14038ddc..57f1baf183c 100644 --- a/homeassistant/components/mcp_server/strings.json +++ b/homeassistant/components/mcp_server/strings.json @@ -7,7 +7,7 @@ "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" }, "data_description": { - "llm_hass_api": "The method for controling Home Assistant to expose with the Model Context Protocol." + "llm_hass_api": "The method for controlling Home Assistant to expose with the Model Context Protocol." } } }, diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index f9f7e4c2294..9b22a6924bc 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -21,7 +21,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "connection_error_during_import": "Connection error occured during yaml configuration import", + "connection_error_during_import": "Connection error occurred during yaml configuration import", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { @@ -70,7 +70,7 @@ "name": "Cache memory" }, "nextcloud_cache_num_entries": { - "name": "Cache number of entires" + "name": "Cache number of entries" }, "nextcloud_cache_num_hits": { "name": "Cache number of hits" diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index 118004e908e..5f713174f50 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -61,7 +61,7 @@ "step": { "confirm": { "title": "[%key:component::proximity::issues::tracked_entity_removed::title%]", - "description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entites were set to unavailable and can be removed." + "description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entities were set to unavailable and can be removed." } } } diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 0dcb9298f1f..ee613eb96c2 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -33,10 +33,10 @@ "name": "Upload speed limit" }, "alltime_download": { - "name": "Alltime download" + "name": "All-time download" }, "alltime_upload": { - "name": "Alltime upload" + "name": "All-time upload" }, "global_ratio": { "name": "Global ratio" @@ -115,7 +115,7 @@ "message": "No entry with ID {device_id} was found" }, "login_error": { - "message": "A login error occured. Please check your username and password." + "message": "A login error occurred. Please check your username and password." }, "cannot_connect": { "message": "Can't connect to qBittorrent, please check your configuration." diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index b8918497c41..e6df18c393c 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -10,7 +10,7 @@ "abort": { "already_configured": "Controller already configured", "discovery_in_progress": "Discovery in progress", - "not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documention." + "not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documentation." }, "error": { "no_controller": "No controllers were found" diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index b72e7bbd00d..3da463beddf 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -126,7 +126,7 @@ }, "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", - "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are depricated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned." + "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are deprecated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned." } }, "services": { diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json index 51858034340..e1085a016ce 100644 --- a/homeassistant/components/statistics/strings.json +++ b/homeassistant/components/statistics/strings.json @@ -21,7 +21,7 @@ } }, "state_characteristic": { - "description": "Read the documention for further details on available options and how to use them.", + "description": "Read the documentation for further details on available options and how to use them.", "data": { "state_characteristic": "Statistic characteristic" }, @@ -30,7 +30,7 @@ } }, "options": { - "description": "Read the documention for further details on how to configure the statistics sensor using these options.", + "description": "Read the documentation for further details on how to configure the statistics sensor using these options.", "data": { "sampling_size": "Sampling size", "max_age": "Max age", diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 189af89b282..d7304fa1238 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Select the location you want to recieve the Stookwijzer information for.", + "description": "Select the location you want to receive the Stookwijzer information for.", "data": { "location": "[%key:common::config_flow::data::location%]" }, diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 8384bb3d8fb..ccd17fbf6c8 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -506,7 +506,7 @@ }, "exceptions": { "unknown": { - "message": "An unknown issue occured changing {name}." + "message": "An unknown issue occurred changing {name}." }, "not_supported": { "message": "{name} is not supported." diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json index a1feb217249..f455e3ead33 100644 --- a/homeassistant/components/waqi/strings.json +++ b/homeassistant/components/waqi/strings.json @@ -57,7 +57,7 @@ "name": "[%key:component::sensor::entity_component::pm25::name%]" }, "neph": { - "name": "Visbility using nephelometry" + "name": "Visibility using nephelometry" }, "dominant_pollutant": { "name": "Dominant pollutant", diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 2a208c2f8ca..3959acad053 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -37,7 +37,7 @@ "name": "Indoor unit water pump" }, "indoor_unit_auxiliary_pump_state": { - "name": "Indoor unit auxilary water pump" + "name": "Indoor unit auxiliary water pump" }, "indoor_unit_dhw_valve_or_pump_state": { "name": "Indoor unit DHW valve or water pump" diff --git a/tests/components/nextcloud/snapshots/test_sensor.ambr b/tests/components/nextcloud/snapshots/test_sensor.ambr index d01bcc112bf..84c1d33f886 100644 --- a/tests/components/nextcloud/snapshots/test_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_sensor.ambr @@ -1101,7 +1101,7 @@ 'state': '0.175296', }) # --- -# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entires-entry] +# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entries-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1116,7 +1116,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.my_nc_url_local_cache_number_of_entires', + 'entity_id': 'sensor.my_nc_url_local_cache_number_of_entries', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1128,7 +1128,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cache number of entires', + 'original_name': 'Cache number of entries', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1137,14 +1137,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entires-state] +# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entries-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local Cache number of entires', + 'friendly_name': 'my.nc_url.local Cache number of entries', 'state_class': , }), 'context': , - 'entity_id': 'sensor.my_nc_url_local_cache_number_of_entires', + 'entity_id': 'sensor.my_nc_url_local_cache_number_of_entries', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr index 3d00f1cff26..08e58a74524 100644 --- a/tests/components/waqi/snapshots/test_sensor.ambr +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -36,11 +36,11 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Visbility using nephelometry', + 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_visbility_using_nephelometry', + 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/weheat/snapshots/test_binary_sensor.ambr b/tests/components/weheat/snapshots/test_binary_sensor.ambr index cd2aa13135a..bdcd727fbcc 100644 --- a/tests/components/weheat/snapshots/test_binary_sensor.ambr +++ b/tests/components/weheat/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxilary_water_pump-entry] +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxiliary_water_pump-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,7 +12,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.test_model_indoor_unit_auxilary_water_pump', + 'entity_id': 'binary_sensor.test_model_indoor_unit_auxiliary_water_pump', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Indoor unit auxilary water pump', + 'original_name': 'Indoor unit auxiliary water pump', 'platform': 'weheat', 'previous_unique_id': None, 'supported_features': 0, @@ -33,14 +33,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxilary_water_pump-state] +# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxiliary_water_pump-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', - 'friendly_name': 'Test Model Indoor unit auxilary water pump', + 'friendly_name': 'Test Model Indoor unit auxiliary water pump', }), 'context': , - 'entity_id': 'binary_sensor.test_model_indoor_unit_auxilary_water_pump', + 'entity_id': 'binary_sensor.test_model_indoor_unit_auxiliary_water_pump', 'last_changed': , 'last_reported': , 'last_updated': , From 6613b46071228edf0210f2ecee24c5a3612e2a09 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 18 Feb 2025 15:53:59 -0600 Subject: [PATCH 0643/1941] Add HEOS group volume down/up actions (#138801) Add group volume down/up actions --- homeassistant/components/heos/const.py | 2 + homeassistant/components/heos/icons.json | 6 ++ homeassistant/components/heos/media_player.py | 35 +++++++++- homeassistant/components/heos/services.yaml | 12 ++++ homeassistant/components/heos/strings.json | 8 +++ tests/components/heos/__init__.py | 2 + tests/components/heos/test_media_player.py | 65 ++++++++++++++++++- 7 files changed, 128 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index 0203def3885..e9ab51bf16e 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -4,5 +4,7 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" DOMAIN = "heos" SERVICE_GROUP_VOLUME_SET = "group_volume_set" +SERVICE_GROUP_VOLUME_DOWN = "group_volume_down" +SERVICE_GROUP_VOLUME_UP = "group_volume_up" SERVICE_SIGN_IN = "sign_in" SERVICE_SIGN_OUT = "sign_out" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json index a634701037c..d7a998b6aec 100644 --- a/homeassistant/components/heos/icons.json +++ b/homeassistant/components/heos/icons.json @@ -3,6 +3,12 @@ "group_volume_set": { "service": "mdi:volume-medium" }, + "group_volume_down": { + "service": "mdi:volume-low" + }, + "group_volume_up": { + "service": "mdi:volume-high" + }, "sign_in": { "service": "mdi:login" }, diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index a649740a933..9edc674d1cf 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -45,7 +45,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow -from .const import DOMAIN as HEOS_DOMAIN, SERVICE_GROUP_VOLUME_SET +from .const import ( + DOMAIN as HEOS_DOMAIN, + SERVICE_GROUP_VOLUME_DOWN, + SERVICE_GROUP_VOLUME_SET, + SERVICE_GROUP_VOLUME_UP, +) from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 @@ -106,6 +111,12 @@ async def async_setup_entry( {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, "async_set_group_volume_level", ) + platform.async_register_entity_service( + SERVICE_GROUP_VOLUME_DOWN, None, "async_group_volume_down" + ) + platform.async_register_entity_service( + SERVICE_GROUP_VOLUME_UP, None, "async_group_volume_up" + ) def add_entities_callback(players: Sequence[HeosPlayer]) -> None: """Add entities for each player.""" @@ -372,6 +383,28 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self._player.group_id, int(volume_level * 100) ) + @catch_action_error("group volume down") + async def async_group_volume_down(self) -> None: + """Turn group volume down for media player.""" + if self._player.group_id is None: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="entity_not_grouped", + translation_placeholders={"entity_id": self.entity_id}, + ) + await self.coordinator.heos.group_volume_down(self._player.group_id) + + @catch_action_error("group volume up") + async def async_group_volume_up(self) -> None: + """Turn group volume up for media player.""" + if self._player.group_id is None: + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="entity_not_grouped", + translation_placeholders={"entity_id": self.entity_id}, + ) + await self.coordinator.heos.group_volume_up(self._player.group_id) + @catch_action_error("join players") async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index 948aeb919f4..8f3a43421f6 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -12,6 +12,18 @@ group_volume_set: max: 1 step: 0.01 +group_volume_down: + target: + entity: + integration: heos + domain: media_player + +group_volume_up: + target: + entity: + integration: heos + domain: media_player + sign_in: fields: username: diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 2f3b82efc8d..cd3f0b998a1 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -81,6 +81,14 @@ } } }, + "group_volume_down": { + "name": "Turn down group volume", + "description": "Turns down the group volume." + }, + "group_volume_up": { + "name": "Turn up group volume", + "description": "Turns up the group volume." + }, "sign_in": { "name": "Sign in", "description": "Signs in to a HEOS account.", diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index bc72981d805..0b8aed91edf 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -20,6 +20,8 @@ class MockHeos(Heos): self.get_input_sources: AsyncMock = AsyncMock() self.get_playlists: AsyncMock = AsyncMock() self.get_players: AsyncMock = AsyncMock() + self.group_volume_down: AsyncMock = AsyncMock() + self.group_volume_up: AsyncMock = AsyncMock() self.load_players: AsyncMock = AsyncMock() self.play_media: AsyncMock = AsyncMock() self.play_preset_station: AsyncMock = AsyncMock() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 5a0ed0aa7c4..3e755a29a0a 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -22,7 +22,12 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.heos.const import DOMAIN, SERVICE_GROUP_VOLUME_SET +from homeassistant.components.heos.const import ( + DOMAIN, + SERVICE_GROUP_VOLUME_DOWN, + SERVICE_GROUP_VOLUME_SET, + SERVICE_GROUP_VOLUME_UP, +) from homeassistant.components.media_player import ( ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, @@ -780,6 +785,64 @@ async def test_group_volume_set_not_grouped_error( controller.set_group_volume.assert_not_called() +async def test_group_volume_down( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume down service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + controller.group_volume_down.assert_called_with(999) + + +async def test_group_volume_up( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the group volume up service.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.services.async_call( + DOMAIN, + SERVICE_GROUP_VOLUME_UP, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + controller.group_volume_up.assert_called_with(999) + + +@pytest.mark.parametrize( + "service", [SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_UP] +) +async def test_group_volume_down_up_ungrouped_raises( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + service: str, +) -> None: + """Test the group volume down and up service raise if player ungrouped.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + player = controller.players[1] + player.group_id = None + with pytest.raises( + ServiceValidationError, + match=re.escape("Entity media_player.test_player is not joined to a group"), + ): + await hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + controller.group_volume_down.assert_not_called() + controller.group_volume_up.assert_not_called() + + async def test_select_favorite( hass: HomeAssistant, config_entry: MockConfigEntry, From f8ffbf0506c168ee87df85df113c6f42e9e8bc51 Mon Sep 17 00:00:00 2001 From: skobow Date: Tue, 18 Feb 2025 23:11:21 +0100 Subject: [PATCH 0644/1941] Set clean_start=True on connect to MQTT broker (#136026) * Addresses #135443: Set on connect. * Make clean start implementation compatible with v2 API * Add tests * Do not pass default value for `clean_start` on_connect * Revert "Do not pass default value for `clean_start` on_connect" This reverts commit 75806736cf511a6d6b6496454843de34f05f7758. * Use partial top pass kwargs to mqtt client connect --------- Co-authored-by: Jan Bouwhuis Co-authored-by: jbouwh --- homeassistant/components/mqtt/client.py | 40 +++++++++++++--- tests/components/mqtt/test_client.py | 62 +++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index af62851e15b..d35b3db7518 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -298,12 +298,15 @@ class MqttClientSetup: from .async_client import AsyncMQTTClient config = self._config + clean_session: bool | None = None if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31: proto = mqtt.MQTTv31 + clean_session = True elif protocol == PROTOCOL_5: proto = mqtt.MQTTv5 else: proto = mqtt.MQTTv311 + clean_session = True if (client_id := config.get(CONF_CLIENT_ID)) is None: # PAHO MQTT relies on the MQTT server to generate random client IDs. @@ -313,6 +316,19 @@ class MqttClientSetup: self._client = AsyncMQTTClient( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=client_id, + # See: https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html + # clean_session (bool defaults to None) + # a boolean that determines the client type. + # If True, the broker will remove all information about this client when it + # disconnects. If False, the client is a persistent client and subscription + # information and queued messages will be retained when the client + # disconnects. Note that a client will never discard its own outgoing + # messages on disconnect. Calling connect() or reconnect() will cause the + # messages to be resent. Use reinitialise() to reset a client to its + # original state. The clean_session argument only applies to MQTT versions + # v3.1.1 and v3.1. It is not accepted if the MQTT version is v5.0 - use the + # clean_start argument on connect() instead. + clean_session=clean_session, protocol=proto, transport=transport, # type: ignore[arg-type] reconnect_on_failure=False, @@ -371,6 +387,7 @@ class MQTT: self.loop = hass.loop self.config_entry = config_entry self.conf = conf + self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5 self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict( set @@ -652,14 +669,25 @@ class MQTT: result: int | None = None self._available_future = client_available self._should_reconnect = True + connect_partial = partial( + self._mqttc.connect, + host=self.conf[CONF_BROKER], + port=self.conf.get(CONF_PORT, DEFAULT_PORT), + keepalive=self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE), + # See: + # https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html + # `clean_start` (bool) – (MQTT v5.0 only) `True`, `False` or + # `MQTT_CLEAN_START_FIRST_ONLY`. Sets the MQTT v5.0 clean_start flag + # always, never or on the first successful connect only, + # respectively. MQTT session data (such as outstanding messages and + # subscriptions) is cleared on successful connect when the + # clean_start flag is set. For MQTT v3.1.1, the clean_session + # argument of Client should be used for similar result. + clean_start=True if self.is_mqttv5 else mqtt.MQTT_CLEAN_START_FIRST_ONLY, + ) try: async with self._connection_lock, self._async_connect_in_executor(): - result = await self.hass.async_add_executor_job( - self._mqttc.connect, - self.conf[CONF_BROKER], - self.conf.get(CONF_PORT, DEFAULT_PORT), - self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE), - ) + result = await self.hass.async_add_executor_job(connect_partial) except (OSError, mqtt.WebsocketConnectionError) as err: _LOGGER.error("Failed to connect to MQTT server due to exception: %s", err) self._async_connection_result(False) diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index b526d70490b..9d5401fd437 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -1271,7 +1271,7 @@ async def test_publish_error( with patch( "homeassistant.components.mqtt.async_client.AsyncMQTTClient" ) as mock_client: - mock_client().connect = lambda *args: 1 + mock_client().connect = lambda **kwargs: 1 mock_client().publish().rc = 1 assert await hass.config_entries.async_setup(entry.entry_id) with pytest.raises(HomeAssistantError): @@ -1330,7 +1330,7 @@ async def test_handle_message_callback( @pytest.mark.parametrize( - ("mqtt_config_entry_data", "protocol"), + ("mqtt_config_entry_data", "protocol", "clean_session"), [ ( { @@ -1338,6 +1338,7 @@ async def test_handle_message_callback( CONF_PROTOCOL: "3.1", }, 3, + True, ), ( { @@ -1345,6 +1346,7 @@ async def test_handle_message_callback( CONF_PROTOCOL: "3.1.1", }, 4, + True, ), ( { @@ -1352,22 +1354,72 @@ async def test_handle_message_callback( CONF_PROTOCOL: "5", }, 5, + None, ), ], + ids=["v3.1", "v3.1.1", "v5"], ) -async def test_setup_mqtt_client_protocol( - mqtt_mock_entry: MqttMockHAClientGenerator, protocol: int +async def test_setup_mqtt_client_clean_session_and_protocol( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_client_mock: MqttMockPahoClient, + protocol: int, + clean_session: bool | None, ) -> None: - """Test MQTT client protocol setup.""" + """Test MQTT client clean_session and protocol setup.""" with patch( "homeassistant.components.mqtt.async_client.AsyncMQTTClient" ) as mock_client: await mqtt_mock_entry() + # check if clean_session was correctly + assert mock_client.call_args[1]["clean_session"] == clean_session + # check if protocol setup was correctly assert mock_client.call_args[1]["protocol"] == protocol +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "connect_args"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1", + }, + call(host="mock-broker", port=1883, keepalive=60, clean_start=3), + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "3.1.1", + }, + call(host="mock-broker", port=1883, keepalive=60, clean_start=3), + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + CONF_PROTOCOL: "5", + }, + call(host="mock-broker", port=1883, keepalive=60, clean_start=True), + ), + ], + ids=["v3.1", "v3.1.1", "v5"], +) +async def test_setup_mqtt_client_clean_start( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_client_mock: MqttMockPahoClient, + connect_args: tuple[Any], +) -> None: + """Test MQTT client protocol connects with `clean_start` set correctly.""" + await mqtt_mock_entry() + + # check if clean_start was set correctly + assert len(mqtt_client_mock.connect.mock_calls) == 1 + assert mqtt_client_mock.connect.mock_calls[0] == connect_args + + @patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.2) async def test_handle_mqtt_timeout_on_callback( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_debouncer: asyncio.Event From a6bb5dbe2a9a49ae2813e281a95a5ae5033a439f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 18 Feb 2025 19:39:44 -0600 Subject: [PATCH 0645/1941] Add assistant filter to expose entities list command (#138817) --- .../homeassistant/exposed_entities.py | 11 +++- .../homeassistant/test_exposed_entities.py | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 7bd9f9ab7bc..0c815502669 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -432,6 +432,7 @@ def ws_expose_entity( @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_entity/list", + vol.Optional("assistant"): vol.In(KNOWN_ASSISTANTS), } ) def ws_list_exposed_entities( @@ -441,10 +442,18 @@ def ws_list_exposed_entities( result: dict[str, Any] = {} exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] + required_assistant = msg.get("assistant") entity_registry = er.async_get(hass) for entity_id in chain(exposed_entities.entities, entity_registry.entities): - result[entity_id] = {} entity_settings = async_get_entity_settings(hass, entity_id) + if required_assistant and ( + (required_assistant not in entity_settings) + or (not entity_settings[required_assistant].get("should_expose")) + ): + # Not exposed to required assistant + continue + + result[entity_id] = {} for assistant, settings in entity_settings.items(): if "should_expose" not in settings: continue diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 1f1955c2f82..0c57aad58ea 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -539,6 +539,70 @@ async def test_list_exposed_entities( } +async def test_list_exposed_entities_with_filter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test list exposed entities with filter.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("test", "test", "unique1") + entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + + # Expose 1 to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": [entry1.entity_id], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # Expose 2 to Google + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.google_assistant"], + "entity_ids": [entry2.entity_id], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # List with filter + await ws_client.send_json_auto_id( + {"type": "homeassistant/expose_entity/list", "assistant": "cloud.alexa"} + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "exposed_entities": { + "test.test_unique1": {"cloud.alexa": True}, + }, + } + + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity/list", + "assistant": "cloud.google_assistant", + } + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "exposed_entities": { + "test.test_unique2": {"cloud.google_assistant": True}, + }, + } + + async def test_listeners( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: From ee5e25aca6eda1f58e8046097bf15a02665ca509 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Feb 2025 21:14:38 -0600 Subject: [PATCH 0646/1941] Bump aioesphomeapi to 29.1.1 (#138827) --- 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 08be23ae001..403da9286ab 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.0", + "aioesphomeapi==29.1.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.7.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 7f5ddd7a351..50bc799a09b 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.0 +aioesphomeapi==29.1.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26f76b8e58b..00839efd567 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.0 +aioesphomeapi==29.1.1 # homeassistant.components.flo aioflo==2021.11.0 From 689421eddf72f56302b348c89c6f66c2fc938f06 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 19 Feb 2025 06:14:07 +0100 Subject: [PATCH 0647/1941] Move blocking code to executor job in MQTT CI test helper (#138815) --- tests/components/mqtt/test_common.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index a34907adbaf..3bb8657e2f2 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1854,9 +1854,14 @@ async def help_test_reload_with_config( ) -> None: """Test reloading with supplied config.""" new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump(config) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config + + def _write_yaml_config() -> None: + new_yaml_config = yaml.dump(config) + new_yaml_config_file.write_text(new_yaml_config) + assert new_yaml_config_file.read_text() == new_yaml_config + return new_yaml_config + + await hass.async_add_executor_job(_write_yaml_config) with patch.object(module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): await hass.services.async_call( From 46599a4ac4b8b28c0be3562ac65e7ded3592f8c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Feb 2025 23:50:11 -0600 Subject: [PATCH 0648/1941] Bump habluetooth to 3.22.0 (#138812) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_diagnostics.py | 6 ++++++ tests/components/esphome/test_diagnostics.py | 2 ++ tests/components/shelly/test_diagnostics.py | 2 ++ 7 files changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 5d2b8ab6285..a21b7126a8e 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.23.4", "dbus-fast==2.33.0", - "habluetooth==3.21.1" + "habluetooth==3.22.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 77a19e75137..03da649b32f 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.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.21.1 +habluetooth==3.22.0 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 50bc799a09b..00493cea3d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.21.1 +habluetooth==3.22.0 # homeassistant.components.cloud hass-nabucasa==0.92.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00839efd567..4821ae08423 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.21.1 +habluetooth==3.22.0 # homeassistant.components.cloud hass-nabucasa==0.92.0 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 682cff62969..e38ae19ce52 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -182,6 +182,7 @@ async def test_diagnostics( "scanners": [ { "adapter": "hci0", + "connectable": True, "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, @@ -218,6 +219,7 @@ async def test_diagnostics( "rssi": -127, } ], + "connectable": True, "last_detection": ANY, "monotonic_time": ANY, "name": "hci1 (00:00:00:00:00:02)", @@ -391,6 +393,7 @@ async def test_diagnostics_macos( "scanners": [ { "adapter": "Core Bluetooth", + "connectable": True, "discovered_devices_and_advertisement_data": [ { "address": "44:44:33:11:23:45", @@ -593,6 +596,7 @@ async def test_diagnostics_remote_adapter( "scanners": [ { "adapter": "hci0", + "connectable": True, "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, @@ -612,6 +616,8 @@ async def test_diagnostics_remote_adapter( }, { "connectable": True, + "current_mode": None, + "requested_mode": None, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 0beeae71df3..2b2629324d2 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -49,6 +49,8 @@ async def test_diagnostics_with_bluetooth( "connections_limit": 0, "scanner": { "connectable": True, + "current_mode": None, + "requested_mode": None, "discovered_device_timestamps": {}, "discovered_devices_and_advertisement_data": [], "last_detection": ANY, diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index f576524ba60..c0f78d48d9b 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -109,6 +109,8 @@ async def test_rpc_config_entry_diagnostics( "bluetooth": { "scanner": { "connectable": False, + "current_mode": None, + "requested_mode": None, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY}, "discovered_devices_and_advertisement_data": [ { From ff83a145703971b87296b385c15694e3be77a03d Mon Sep 17 00:00:00 2001 From: HA-Roberto <80992882+HA-Roberto@users.noreply.github.com> Date: Wed, 19 Feb 2025 00:48:29 -0600 Subject: [PATCH 0649/1941] Add button for bond light temp toggle feature (#135379) Co-authored-by: J. Nick Koston --- homeassistant/components/bond/button.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 47c8356d08e..9cea0251b41 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -91,6 +91,13 @@ BUTTONS: tuple[BondButtonEntityDescription, ...] = ( mutually_exclusive=Action.SET_BRIGHTNESS, argument=None, ), + BondButtonEntityDescription( + key=Action.TOGGLE_LIGHT_TEMP, + name="Toggle Light Temperature", + translation_key="toggle_light_temp", + mutually_exclusive=None, # No mutually exclusive action + argument=None, + ), BondButtonEntityDescription( key=Action.START_UP_LIGHT_DIMMER, name="Start Up Light Dimmer", From 6cf31e08078acb003bfb5bc12147a2297a392bd3 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Wed, 19 Feb 2025 20:43:45 +1300 Subject: [PATCH 0650/1941] Electric Kiwi: Add quality scale (#138680) * add quality scale file * Apply suggestions from code review Co-authored-by: Josef Zweck * add suggestions and add extra missing icon * update a few based on documentation * exempt installation parameters * set a few more documentation items to done * Update homeassistant/components/electric_kiwi/quality_scale.yaml Co-authored-by: Josef Zweck * update reason for no installation parameters * set docs installation parameters to done * revert back to exempt * add bronze scale --------- Co-authored-by: Josef Zweck --- .../components/electric_kiwi/manifest.json | 1 + .../electric_kiwi/quality_scale.yaml | 105 ++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/electric_kiwi/quality_scale.yaml diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json index 45bb09ca475..b2f19000825 100644 --- a/homeassistant/components/electric_kiwi/manifest.json +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "integration_type": "hub", "iot_class": "cloud_polling", + "quality_scale": "bronze", "requirements": ["electrickiwi-api==0.9.14"] } diff --git a/homeassistant/components/electric_kiwi/quality_scale.yaml b/homeassistant/components/electric_kiwi/quality_scale.yaml new file mode 100644 index 00000000000..0be310680f1 --- /dev/null +++ b/homeassistant/components/electric_kiwi/quality_scale.yaml @@ -0,0 +1,105 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Does not subscribe to event explicitly. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + Has no options flow + docs-installation-parameters: + status: exempt + comment: | + Handled by OAuth flow (HA is only one with credentials, users cannot get them) + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: + status: exempt + comment: | + Web services only + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + Web services only + discovery: + status: exempt + comment: | + Web services only + docs-data-update: todo + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: | + No devices + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: | + No devices + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + No unnecessary or noisy entities + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + Handled by OAuth + repair-issues: + status: exempt + comment: | + Does not have any repairs + stale-devices: + status: exempt + comment: | + Does not have devices + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index bd8a5a9f318..195dd93e630 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -335,7 +335,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "egardia", "eight_sleep", "electrasmart", - "electric_kiwi", "eliqonline", "elkm1", "elmax", @@ -1394,7 +1393,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "egardia", "eight_sleep", "electrasmart", - "electric_kiwi", "elevenlabs", "eliqonline", "elkm1", From c5222708ed16fa528f7336189a6e5994d0f44230 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Wed, 19 Feb 2025 21:05:29 +1300 Subject: [PATCH 0651/1941] add icon to select (#138834) --- homeassistant/components/electric_kiwi/icons.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/electric_kiwi/icons.json b/homeassistant/components/electric_kiwi/icons.json index 1932ce19432..e5dbbb5ac48 100644 --- a/homeassistant/components/electric_kiwi/icons.json +++ b/homeassistant/components/electric_kiwi/icons.json @@ -13,6 +13,11 @@ "hop_power_savings": { "default": "mdi:percent" } + }, + "select": { + "hop_selector": { + "default": "mdi:lightning-bolt" + } } } } From b6cb2bfe5bc573972937bb35430f2cd2abcef5f8 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Wed, 19 Feb 2025 09:15:07 +0100 Subject: [PATCH 0652/1941] Add test for flexit_bacnet hvac mode (#138748) Add test for hvac mode --- .../components/flexit_bacnet/test_climate.py | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index 79ee84bdc14..5baac1c5077 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -2,9 +2,17 @@ from unittest.mock import AsyncMock +from flexit_bacnet import VENTILATION_MODE_AWAY, VENTILATION_MODE_HOME from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.climate import ( + ATTR_PRESET_MODE, + PRESET_AWAY, + PRESET_HOME, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.components.flexit_bacnet.const import PRESET_TO_VENTILATION_MODE_MAP +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -12,6 +20,8 @@ from . import setup_with_selected_platforms from tests.common import MockConfigEntry, snapshot_platform +ENTITY_ID = "climate.device_name" + async def test_climate_entity( hass: HomeAssistant, @@ -24,3 +34,50 @@ async def test_climate_entity( await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_hvac_preset_mode( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the initial parameters.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Set preset mode to away + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_AWAY + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_PRESET_MODE: PRESET_AWAY, + }, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY + + mock_flexit_bacnet.set_ventilation_mode.assert_called_once_with( + PRESET_TO_VENTILATION_MODE_MAP[PRESET_AWAY] + ) + + # Set preset mode to home + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_HOME + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_PRESET_MODE: PRESET_HOME, + }, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME + + mock_flexit_bacnet.set_ventilation_mode.assert_called_with( + PRESET_TO_VENTILATION_MODE_MAP[PRESET_HOME] + ) From d97194303a64d2e32337de6af72d765f14a9e439 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Feb 2025 02:43:41 -0600 Subject: [PATCH 0653/1941] Improve performance of calculating state (#138832) ``` print(timeit.timeit("x.update(y)", setup=x={a:b} --- homeassistant/helpers/entity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 2b9f2d7069e..bed5ce586c5 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1085,9 +1085,9 @@ class Entity( state = self._stringify_state(available) if available: if state_attributes := self.state_attributes: - attr.update(state_attributes) + attr |= state_attributes if extra_state_attributes := self.extra_state_attributes: - attr.update(extra_state_attributes) + attr |= extra_state_attributes if (unit_of_measurement := self.unit_of_measurement) is not None: attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement @@ -1214,7 +1214,7 @@ class Entity( else: # Overwrite properties that have been set in the config file. if custom := customize.get(entity_id): - attr.update(custom) + attr |= custom if ( self._context_set is not None From 68085ed4f98824a7886caac6bce2ffbbaafa7c3d Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 19 Feb 2025 09:44:12 +0100 Subject: [PATCH 0654/1941] Add sensors for pellets boiler in ViCare integration (#138563) * add buffer sensors * remove duplicate sensor * add labels * Bump PyViCare to 2.43.0 * add fuel need sensor --- homeassistant/components/vicare/sensor.py | 42 ++++++++++++++++++++ homeassistant/components/vicare/strings.json | 15 +++++++ 2 files changed, 57 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index cc79812b504..cddc5ca021a 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -29,6 +29,7 @@ from homeassistant.const import ( PERCENTAGE, EntityCategory, UnitOfEnergy, + UnitOfMass, UnitOfPower, UnitOfPressure, UnitOfTemperature, @@ -635,6 +636,38 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="buffer_mid_top_temperature", + translation_key="buffer_mid_top_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getBufferMidTopTemperature(), + ), + ViCareSensorEntityDescription( + key="buffer_middle_temperature", + translation_key="buffer_middle_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getBufferMiddleTemperature(), + ), + ViCareSensorEntityDescription( + key="buffer_mid_bottom_temperature", + translation_key="buffer_mid_bottom_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getBufferMidBottomTemperature(), + ), + ViCareSensorEntityDescription( + key="buffer_bottom_temperature", + translation_key="buffer_bottom_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getBufferBottomTemperature(), + ), ViCareSensorEntityDescription( key="buffer main temperature", translation_key="buffer_main_temperature", @@ -891,6 +924,15 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, value_getter=lambda api: api.getBatteryLevel(), ), + ViCareSensorEntityDescription( + key="fuel_need", + translation_key="fuel_need", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + value_getter=lambda api: api.getFuelNeed(), + unit_getter=lambda api: api.getFuelUnit(), + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 50eeaf038e0..733cda363e5 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -338,6 +338,18 @@ "buffer_top_temperature": { "name": "Buffer top temperature" }, + "buffer_mid_top_temperature": { + "name": "Buffer mid top temperature" + }, + "buffer_middle_temperature": { + "name": "Buffer middle temperature" + }, + "buffer_mid_bottom_temperature": { + "name": "Buffer mid bottom temperature" + }, + "buffer_bottom_temperature": { + "name": "Buffer bottom temperature" + }, "buffer_main_temperature": { "name": "Buffer main temperature" }, @@ -478,6 +490,9 @@ }, "spf_heating": { "name": "Seasonal performance factor - heating" + }, + "fuel_need": { + "name": "Fuel need" } }, "water_heater": { From 8d39f298c0d7f9e8a59928d98ed6876c14517f57 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Wed, 19 Feb 2025 22:16:06 +1300 Subject: [PATCH 0655/1941] Electric Kiwi: Parallel updates (#138839) * parallel updates * Update homeassistant/components/electric_kiwi/select.py --- homeassistant/components/electric_kiwi/quality_scale.yaml | 2 +- homeassistant/components/electric_kiwi/select.py | 2 ++ homeassistant/components/electric_kiwi/sensor.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/electric_kiwi/quality_scale.yaml b/homeassistant/components/electric_kiwi/quality_scale.yaml index 0be310680f1..a7db8d203b6 100644 --- a/homeassistant/components/electric_kiwi/quality_scale.yaml +++ b/homeassistant/components/electric_kiwi/quality_scale.yaml @@ -45,7 +45,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: todo diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index 38dc595b087..2ba2a089557 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -13,6 +13,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION from .coordinator import ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator +PARALLEL_UPDATES = 1 + _LOGGER = logging.getLogger(__name__) ATTR_EK_HOP_SELECT = "hop_select" diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 291208b74b8..27f13a82e09 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -27,6 +27,8 @@ from .coordinator import ( ElectricKiwiHOPDataCoordinator, ) +PARALLEL_UPDATES = 0 + ATTR_EK_HOP_START = "hop_power_start" ATTR_EK_HOP_END = "hop_power_end" ATTR_TOTAL_RUNNING_BALANCE = "total_running_balance" From 36c7546e262f423f183e4a446934413178b3f225 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Wed, 19 Feb 2025 10:26:16 +0100 Subject: [PATCH 0656/1941] Remove unused code in the climate entity of the flexit_bacnet integration (#138840) Removes unused code in the climate entity This was unintentionally left in the code when adding a coordinator --- homeassistant/components/flexit_bacnet/climate.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 7dc855e3106..f611528a6c3 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -80,10 +80,6 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): super().__init__(coordinator) self._attr_unique_id = coordinator.device.serial_number - async def async_update(self) -> None: - """Refresh unit state.""" - await self.device.update() - @property def hvac_action(self) -> HVACAction | None: """Return current HVAC action.""" From 0c28b6926964ed798ef91950ea21229d5870f6e8 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 19 Feb 2025 10:38:52 +0100 Subject: [PATCH 0657/1941] Update xknx to 3.6.0 (#138838) --- 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 86c050443e3..8cfb034a793 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -10,7 +10,7 @@ "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], "requirements": [ - "xknx==3.5.0", + "xknx==3.6.0", "xknxproject==3.8.1", "knx-frontend==2025.1.30.194235" ], diff --git a/requirements_all.txt b/requirements_all.txt index 00493cea3d9..273e50b0ffd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3076,7 +3076,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.5.0 +xknx==3.6.0 # homeassistant.components.knx xknxproject==3.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4821ae08423..a6d473a21a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2477,7 +2477,7 @@ xbox-webapi==2.1.0 xiaomi-ble==0.33.0 # homeassistant.components.knx -xknx==3.5.0 +xknx==3.6.0 # homeassistant.components.knx xknxproject==3.8.1 From 38efe94defea3042fb1f1dade5ba75439d24ffe0 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 19 Feb 2025 19:00:25 +0900 Subject: [PATCH 0658/1941] Modify string water_heater's off state (#137627) * Modify string water_heater's off state * Modify washer's delay name --------- Co-authored-by: yunseon.park --- .../components/lg_thinq/binary_sensor.py | 3 ++- homeassistant/components/lg_thinq/icons.json | 3 +++ homeassistant/components/lg_thinq/strings.json | 16 ++++++++++++---- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py index aeade4d132a..61b600037a7 100644 --- a/homeassistant/components/lg_thinq/binary_sensor.py +++ b/homeassistant/components/lg_thinq/binary_sensor.py @@ -76,7 +76,8 @@ BINARY_SENSOR_DESC: dict[ThinQProperty, ThinQBinarySensorEntityDescription] = { ), ThinQProperty.WATER_HEATER_OPERATION_MODE: ThinQBinarySensorEntityDescription( key=ThinQProperty.WATER_HEATER_OPERATION_MODE, - translation_key="operation_mode", + device_class=BinarySensorDeviceClass.POWER, + translation_key=ThinQProperty.WATER_HEATER_OPERATION_MODE, on_key="power_on", ), ThinQProperty.ONE_TOUCH_FILTER: ThinQBinarySensorEntityDescription( diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index db33106da79..787b50167c1 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -80,6 +80,9 @@ }, "one_touch_filter": { "default": "mdi:air-filter" + }, + "water_heater_operation_mode": { + "default": "mdi:power" } }, "climate": { diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 359ac40e1f1..a930860aa35 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -105,6 +105,12 @@ }, "one_touch_filter": { "name": "Fresh air filter" + }, + "water_heater_operation_mode": { + "name": "[%key:component::binary_sensor::entity_component::power::name%]", + "state": { + "off": "[%key:common::state::standby%]" + } } }, "climate": { @@ -264,10 +270,10 @@ "name": "{location} schedule turn-on" }, "relative_hour_to_start_wm": { - "name": "Delay starts in" + "name": "Delayed start" }, "relative_hour_to_start_wm_for_location": { - "name": "{location} delay starts in" + "name": "{location} delayed start" }, "relative_hour_to_stop": { "name": "Schedule turn-off" @@ -276,10 +282,10 @@ "name": "{location} schedule turn-off" }, "relative_hour_to_stop_wm": { - "name": "Delay ends in" + "name": "Delayed end" }, "relative_hour_to_stop_wm_for_location": { - "name": "{location} delay ends in" + "name": "{location} delayed end" }, "sleep_timer_relative_hour_to_stop": { "name": "Sleep timer" @@ -927,6 +933,7 @@ "state": { "cancel": "[%key:component::lg_thinq::entity::sensor::current_state::state::cancel%]", "power_off": "Power off", + "power_on": "Power on", "preheating": "Preheating", "start": "[%key:common::action::start%]", "stop": "[%key:common::action::stop%]", @@ -938,6 +945,7 @@ "state": { "cancel": "[%key:component::lg_thinq::entity::sensor::current_state::state::cancel%]", "power_off": "[%key:component::lg_thinq::entity::select::operation_mode::state::power_off%]", + "power_on": "[%key:component::lg_thinq::entity::select::operation_mode::state::power_on%]", "preheating": "[%key:component::lg_thinq::entity::select::operation_mode::state::preheating%]", "start": "[%key:common::action::start%]", "stop": "[%key:common::action::stop%]", From 618bdba4d3b49f4ea81cc50854c09818e0d406f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 19 Feb 2025 11:19:03 +0100 Subject: [PATCH 0659/1941] Add check_connection parameter to cloud login methods and handle AlreadyConnectedError (#138699) --- homeassistant/components/cloud/http_api.py | 33 ++++++++++++++++++---- tests/components/cloud/conftest.py | 7 ++++- tests/components/cloud/test_http_api.py | 33 +++++++++++++++++++++- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index af1c72f54f6..73952d80f6c 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -8,14 +8,15 @@ from contextlib import suppress import dataclasses from functools import wraps from http import HTTPStatus +import json import logging import time -from typing import Any, Concatenate +from typing import Any, Concatenate, cast import aiohttp from aiohttp import web import attr -from hass_nabucasa import Cloud, auth, thingtalk +from hass_nabucasa import AlreadyConnectedError, Cloud, auth, thingtalk from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import TTS_VOICES import voluptuous as vol @@ -64,7 +65,9 @@ from .subscription import async_subscription_info _LOGGER = logging.getLogger(__name__) -_CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = { +_CLOUD_ERRORS: dict[ + type[Exception], tuple[HTTPStatus, Callable[[Exception], str] | str] +] = { TimeoutError: ( HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant cloud.", @@ -133,6 +136,10 @@ def async_setup(hass: HomeAssistant) -> None: HTTPStatus.BAD_REQUEST, "Multi-factor authentication expired, or not started. Please try again.", ), + AlreadyConnectedError: ( + HTTPStatus.CONFLICT, + lambda x: json.dumps(cast(AlreadyConnectedError, x).details), + ), } ) @@ -197,7 +204,11 @@ def _process_cloud_exception(exc: Exception, where: str) -> tuple[HTTPStatus, st for err, value_info in _CLOUD_ERRORS.items(): if isinstance(exc, err): - err_info = value_info + status, content = value_info + err_info = ( + status, + content if isinstance(content, str) else content(exc), + ) break if err_info is None: @@ -240,6 +251,7 @@ class CloudLoginView(HomeAssistantView): vol.All( { vol.Required("email"): str, + vol.Optional("check_connection", default=False): bool, vol.Exclusive("password", "login"): str, vol.Exclusive("code", "login"): str, }, @@ -258,7 +270,11 @@ class CloudLoginView(HomeAssistantView): code = data.get("code") if email and password: - await cloud.login(email, password) + await cloud.login( + email, + password, + check_connection=data["check_connection"], + ) else: if ( @@ -270,7 +286,12 @@ class CloudLoginView(HomeAssistantView): # Voluptuous should ensure that code is not None because password is assert code is not None - await cloud.login_verify_totp(email, code, self._mfa_tokens) + await cloud.login_verify_totp( + email, + code, + self._mfa_tokens, + check_connection=data["check_connection"], + ) self._mfa_tokens = {} self._mfa_tokens_set_time = 0 diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 276a06a7f46..2d594fd9345 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -145,7 +145,12 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: # Methods that we mock with a custom side effect. - async def mock_login(email: str, password: str) -> None: + async def mock_login( + email: str, + password: str, + *, + check_connection: bool = False, + ) -> None: """Mock login. When called, it should call the on_start callback. diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index ef4b93a8aab..81e8554ebf2 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from freezegun.api import FrozenDateTimeFactory -from hass_nabucasa import thingtalk +from hass_nabucasa import AlreadyConnectedError, thingtalk from hass_nabucasa.auth import ( InvalidTotpCode, MFARequired, @@ -373,9 +373,40 @@ async def test_login_view_request_timeout( "/api/cloud/login", json={"email": "my_username", "password": "my_password"} ) + assert cloud.login.call_args[1]["check_connection"] is False + assert req.status == HTTPStatus.BAD_GATEWAY +async def test_login_view_with_already_existing_connection( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test request timeout while trying to log in.""" + cloud_client = await hass_client() + cloud.login.side_effect = AlreadyConnectedError( + details={"remote_ip_address": "127.0.0.1", "connected_at": "1"} + ) + + req = await cloud_client.post( + "/api/cloud/login", + json={ + "email": "my_username", + "password": "my_password", + "check_connection": True, + }, + ) + + assert cloud.login.call_args[1]["check_connection"] is True + assert req.status == HTTPStatus.CONFLICT + resp = await req.json() + assert resp == { + "code": "alreadyconnectederror", + "message": '{"remote_ip_address": "127.0.0.1", "connected_at": "1"}', + } + + async def test_login_view_invalid_credentials( cloud: MagicMock, setup_cloud: None, From d655c51ef9fe329a397458d717d4a22bfbb31bfc Mon Sep 17 00:00:00 2001 From: proohit <46965017+proohit@users.noreply.github.com> Date: Wed, 19 Feb 2025 12:24:04 +0100 Subject: [PATCH 0660/1941] Adds Tado Child Lock support (#135837) --- homeassistant/components/tado/__init__.py | 1 + homeassistant/components/tado/coordinator.py | 11 +++ homeassistant/components/tado/icons.json | 10 +++ homeassistant/components/tado/strings.json | 5 ++ homeassistant/components/tado/switch.py | 88 ++++++++++++++++++++ tests/components/tado/fixtures/devices.json | 19 +++++ tests/components/tado/fixtures/zones.json | 3 +- tests/components/tado/test_switch.py | 47 +++++++++++ 8 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/tado/switch.py create mode 100644 tests/components/tado/test_switch.py diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 4087183bfe5..4b0203acda3 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -31,6 +31,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.DEVICE_TRACKER, Platform.SENSOR, + Platform.SWITCH, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 6e932c8ccfc..559bc4a16fb 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -342,6 +342,17 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): except RequestException as err: raise UpdateFailed(f"Error setting Tado meter reading: {err}") from err + async def set_child_lock(self, device_id: str, enabled: bool) -> None: + """Set child lock of device.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_child_lock, + device_id, + enabled, + ) + except RequestException as exc: + raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc + class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage the mobile devices from Tado via PyTado.""" diff --git a/homeassistant/components/tado/icons.json b/homeassistant/components/tado/icons.json index c799bef0260..65b86359950 100644 --- a/homeassistant/components/tado/icons.json +++ b/homeassistant/components/tado/icons.json @@ -1,4 +1,14 @@ { + "entity": { + "switch": { + "child_lock": { + "default": "mdi:lock-open-variant", + "state": { + "on": "mdi:lock" + } + } + } + }, "services": { "set_climate_timer": { "service": "mdi:timer" diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index f1550517457..ff1afc3c03d 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -64,6 +64,11 @@ } } }, + "switch": { + "child_lock": { + "name": "Child lock" + } + }, "sensor": { "outdoor_temperature": { "name": "Outdoor temperature" diff --git a/homeassistant/components/tado/switch.py b/homeassistant/components/tado/switch.py new file mode 100644 index 00000000000..b3f355462b8 --- /dev/null +++ b/homeassistant/components/tado/switch.py @@ -0,0 +1,88 @@ +"""Module for Tado child lock switch entity.""" + +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TadoConfigEntry +from .entity import TadoDataUpdateCoordinator, TadoZoneEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Tado switch platform.""" + + tado = entry.runtime_data.coordinator + entities: list[TadoChildLockSwitchEntity] = [] + for zone in tado.zones: + zoneChildLockSupported = ( + len(zone["devices"]) > 0 and "childLockEnabled" in zone["devices"][0] + ) + + if not zoneChildLockSupported: + continue + + entities.append( + TadoChildLockSwitchEntity( + tado, zone["name"], zone["id"], zone["devices"][0] + ) + ) + async_add_entities(entities, True) + + +class TadoChildLockSwitchEntity(TadoZoneEntity, SwitchEntity): + """Representation of a Tado child lock switch entity.""" + + _attr_translation_key = "child_lock" + + def __init__( + self, + coordinator: TadoDataUpdateCoordinator, + zone_name: str, + zone_id: int, + device_info: dict[str, Any], + ) -> None: + """Initialize the Tado child lock switch entity.""" + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) + + self._device_info = device_info + self._device_id = self._device_info["shortSerialNo"] + self._attr_unique_id = f"{zone_id} {coordinator.home_id} child-lock" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.coordinator.set_child_lock(self._device_id, True) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.coordinator.set_child_lock(self._device_id, False) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_callback() + super()._handle_coordinator_update() + + @callback + def _async_update_callback(self) -> None: + """Handle update callbacks.""" + try: + self._device_info = self.coordinator.data["device"][self._device_id] + except KeyError: + _LOGGER.error( + "Could not update child lock info for device %s in zone %s", + self._device_id, + self.zone_name, + ) + else: + self._attr_is_on = self._device_info.get("childLockEnabled", False) is True diff --git a/tests/components/tado/fixtures/devices.json b/tests/components/tado/fixtures/devices.json index 6d990082b96..a9313ae051b 100644 --- a/tests/components/tado/fixtures/devices.json +++ b/tests/components/tado/fixtures/devices.json @@ -15,5 +15,24 @@ "value": true }, "shortSerialNo": "WR1" + }, + { + "duties": ["ZONE_UI", "ZONE_DRIVER", "ZONE_LEADER"], + "currentFwVersion": "59.4", + "deviceType": "WR02", + "serialNo": "WR4", + "shortSerialNo": "WR4", + "commandTableUploadState": "FINISHED", + "connectionState": { + "value": true, + "timestamp": "2020-03-23T18:30:07.377Z" + }, + "accessPointWiFi": { + "ssid": "tado8480" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "childLockEnabled": false } ] diff --git a/tests/components/tado/fixtures/zones.json b/tests/components/tado/fixtures/zones.json index e1d2ec759ba..acc4612b393 100644 --- a/tests/components/tado/fixtures/zones.json +++ b/tests/components/tado/fixtures/zones.json @@ -27,7 +27,8 @@ }, "characteristics": { "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] - } + }, + "childLockEnabled": false } ], "dateCreated": "2019-11-28T15:58:48.968Z", diff --git a/tests/components/tado/test_switch.py b/tests/components/tado/test_switch.py new file mode 100644 index 00000000000..2112f3a1ac7 --- /dev/null +++ b/tests/components/tado/test_switch.py @@ -0,0 +1,47 @@ +"""The sensor tests for the tado platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +CHILD_LOCK_SWITCH_ENTITY = "switch.baseboard_heater_child_lock" + + +async def test_child_lock(hass: HomeAssistant) -> None: + """Test creation of child lock entity.""" + + await async_init_integration(hass) + state = hass.states.get(CHILD_LOCK_SWITCH_ENTITY) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("method", "expected"), [(SERVICE_TURN_ON, True), (SERVICE_TURN_OFF, False)] +) +async def test_set_child_lock(hass: HomeAssistant, method, expected) -> None: + """Test enable child lock on switch.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_child_lock" + ) as mock_set_state: + await hass.services.async_call( + SWITCH_DOMAIN, + method, + {ATTR_ENTITY_ID: CHILD_LOCK_SWITCH_ENTITY}, + blocking=True, + ) + + mock_set_state.assert_called_once() + assert mock_set_state.call_args[0][1] is expected From 97c558b694520eeb7564a9118d739cdb9cdee92a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 19 Feb 2025 12:24:22 +0100 Subject: [PATCH 0661/1941] Add WIND_DIRECTION to SensorDeviceClass and NumberDeviceClass (#138714) * Add WIND_DIRECTION to SensorDeviceClass * Add WIND_DIRECTION to NumberDeviceClass * Fix tests --- homeassistant/components/ambient_network/sensor.py | 1 + homeassistant/components/ambient_station/sensor.py | 4 ++++ homeassistant/components/arwn/sensor.py | 7 ++++++- homeassistant/components/buienradar/sensor.py | 6 ++++++ homeassistant/components/ecowitt/sensor.py | 4 +++- homeassistant/components/environment_canada/sensor.py | 1 + homeassistant/components/homematic/sensor.py | 1 + homeassistant/components/lacrosse_view/sensor.py | 1 + homeassistant/components/meteoclimatic/sensor.py | 1 + homeassistant/components/mysensors/sensor.py | 1 + homeassistant/components/number/const.py | 8 ++++++++ homeassistant/components/number/icons.json | 3 +++ homeassistant/components/number/strings.json | 3 +++ homeassistant/components/nws/sensor.py | 1 + homeassistant/components/sensor/const.py | 9 +++++++++ homeassistant/components/sensor/device_condition.py | 3 +++ homeassistant/components/sensor/device_trigger.py | 3 +++ homeassistant/components/sensor/icons.json | 3 +++ homeassistant/components/sensor/strings.json | 5 +++++ .../ambient_network/snapshots/test_sensor.ambr | 9 ++++++--- 20 files changed, 69 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py index eff99503cc8..9ec6db6ff45 100644 --- a/homeassistant/components/ambient_network/sensor.py +++ b/homeassistant/components/ambient_network/sensor.py @@ -239,6 +239,7 @@ SENSOR_DESCRIPTIONS = ( native_unit_of_measurement=DEGREE, suggested_display_precision=0, entity_registry_enabled_default=False, + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key=TYPE_WINDGUSTMPH, diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index d1ac39ba01a..730b798bd15 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -608,21 +608,25 @@ SENSOR_DESCRIPTIONS = ( key=TYPE_WINDDIR, translation_key="wind_direction", native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG10M, translation_key="wind_direction_average_10m", native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key=TYPE_WINDDIR_AVG2M, translation_key="wind_direction_average_2m", native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key=TYPE_WINDGUSTDIR, translation_key="wind_gust_direction", native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key=TYPE_WINDGUSTMPH, diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index ada96c07340..a31156bbba6 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -92,7 +92,12 @@ def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] | device_class=SensorDeviceClass.WIND_SPEED, ), ArwnSensor( - topic + "/dir", "Wind Direction", "direction", DEGREE, "mdi:compass" + topic + "/dir", + "Wind Direction", + "direction", + DEGREE, + "mdi:compass", + device_class=SensorDeviceClass.WIND_DIRECTION, ), ] return None diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index f9a110586ba..a4d39ea07cc 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -169,6 +169,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( translation_key="windazimuth", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key="pressure", @@ -530,30 +531,35 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( translation_key="windazimuth_1d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key="windazimuth_2d", translation_key="windazimuth_2d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key="windazimuth_3d", translation_key="windazimuth_3d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key="windazimuth_4d", translation_key="windazimuth_4d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key="windazimuth_5d", translation_key="windazimuth_5d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key="condition_1d", diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index b7816de0f35..6968acdfa4f 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -65,7 +65,9 @@ ECOWITT_SENSORS_MAPPING: Final = { state_class=SensorStateClass.MEASUREMENT, ), EcoWittSensorTypes.DEGREE: SensorEntityDescription( - key="DEGREE", native_unit_of_measurement=DEGREE + key="DEGREE", + native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), EcoWittSensorTypes.WATT_METERS_SQUARED: SensorEntityDescription( key="WATT_METERS_SQUARED", diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 93f3a0f0d80..3a789289c74 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -167,6 +167,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = ( translation_key="wind_bearing", native_unit_of_measurement=DEGREE, value_fn=lambda data: data.conditions.get("wind_bearing", {}).get("value"), + device_class=SensorDeviceClass.WIND_DIRECTION, ), ECSensorEntityDescription( key="wind_chill", diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index b33a725db0f..24172e196c1 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -177,6 +177,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { "WIND_DIRECTION": SensorEntityDescription( key="WIND_DIRECTION", native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), "WIND_DIRECTION_RANGE": SensorEntityDescription( key="WIND_DIRECTION_RANGE", diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index ea5a82a3df8..667fcbb8dcc 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -105,6 +105,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, native_unit_of_measurement=DEGREE, suggested_display_precision=2, + device_class=SensorDeviceClass.WIND_DIRECTION, ), "WetDry": LaCrosseSensorEntityDescription( key="WetDry", diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index e51fcfd3f20..169da7a0a18 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -101,6 +101,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Wind Bearing", native_unit_of_measurement=DEGREE, icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_DIRECTION, ), SensorEntityDescription( key="rain", diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 33f3d6afaf4..759cf7b010f 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -102,6 +102,7 @@ SENSORS: dict[str, SensorEntityDescription] = { key="V_DIRECTION", native_unit_of_measurement=DEGREE, icon="mdi:compass", + device_class=SensorDeviceClass.WIND_DIRECTION, ), "V_WEIGHT": SensorEntityDescription( key="V_WEIGHT", diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index bdde3a4567e..61a4fa644b0 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + DEGREE, LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, @@ -424,6 +425,12 @@ class NumberDeviceClass(StrEnum): - USCS / imperial: `oz`, `lb` """ + WIND_DIRECTION = "wind_direction" + """Wind direction. + + Unit of measurement: `°` + """ + WIND_SPEED = "wind_speed" """Wind speed. @@ -516,6 +523,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfVolume.LITERS, }, NumberDeviceClass.WEIGHT: set(UnitOfMass), + NumberDeviceClass.WIND_DIRECTION: {DEGREE}, NumberDeviceClass.WIND_SPEED: set(UnitOfSpeed), } diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index 636fa0a7751..49103f5cd41 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -150,6 +150,9 @@ "weight": { "default": "mdi:weight" }, + "wind_direction": { + "default": "mdi:compass-rose" + }, "wind_speed": { "default": "mdi:weather-windy" } diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index cc77d224d72..993120ef3ad 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -169,6 +169,9 @@ "weight": { "name": "[%key:component::sensor::entity_component::weight::name%]" }, + "wind_direction": { + "name": "[%key:component::sensor::entity_component::wind_direction::name%]" + }, "wind_speed": { "name": "[%key:component::sensor::entity_component::wind_speed::name%]" } diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 63579c95883..4cfb3b85e0f 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -114,6 +114,7 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( icon="mdi:compass-rose", native_unit_of_measurement=DEGREE, unit_convert=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), NWSSensorEntityDescription( key="barometricPressure", diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index c46aca548c8..8eccb758756 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + DEGREE, LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, @@ -454,6 +455,12 @@ class SensorDeviceClass(StrEnum): - USCS / imperial: `oz`, `lb` """ + WIND_DIRECTION = "wind_direction" + """Wind direction. + + Unit of measurement: `°` + """ + WIND_SPEED = "wind_speed" """Wind speed. @@ -612,6 +619,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfVolume.LITERS, }, SensorDeviceClass.WEIGHT: set(UnitOfMass), + SensorDeviceClass.WIND_DIRECTION: {DEGREE}, SensorDeviceClass.WIND_SPEED: set(UnitOfSpeed), } @@ -683,5 +691,6 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.WIND_DIRECTION: set(), SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 4a68fbabe8f..f52393f28ff 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -83,6 +83,7 @@ CONF_IS_VOLUME = "is_volume" CONF_IS_VOLUME_FLOW_RATE = "is_volume_flow_rate" CONF_IS_WATER = "is_water" CONF_IS_WEIGHT = "is_weight" +CONF_IS_WIND_DIRECTION = "is_wind_direction" CONF_IS_WIND_SPEED = "is_wind_speed" ENTITY_CONDITIONS = { @@ -145,6 +146,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_IS_VOLUME_FLOW_RATE}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_IS_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}], + SensorDeviceClass.WIND_DIRECTION: [{CONF_TYPE: CONF_IS_WIND_DIRECTION}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_IS_WIND_SPEED}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_VALUE}], } @@ -204,6 +206,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_VOLUME_FLOW_RATE, CONF_IS_WATER, CONF_IS_WEIGHT, + CONF_IS_WIND_DIRECTION, CONF_IS_WIND_SPEED, CONF_IS_VALUE, ] diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 0003b83d05a..dee48434294 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -82,6 +82,7 @@ CONF_VOLUME = "volume" CONF_VOLUME_FLOW_RATE = "volume_flow_rate" CONF_WATER = "water" CONF_WEIGHT = "weight" +CONF_WIND_DIRECTION = "wind_direction" CONF_WIND_SPEED = "wind_speed" ENTITY_TRIGGERS = { @@ -144,6 +145,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_VOLUME_FLOW_RATE}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}], + SensorDeviceClass.WIND_DIRECTION: [{CONF_TYPE: CONF_WIND_DIRECTION}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_WIND_SPEED}], DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}], } @@ -204,6 +206,7 @@ TRIGGER_SCHEMA = vol.All( CONF_VOLUME_FLOW_RATE, CONF_WATER, CONF_WEIGHT, + CONF_WIND_DIRECTION, CONF_WIND_SPEED, CONF_VALUE, ] diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 5f770765ee3..497c1544b3b 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -156,6 +156,9 @@ "weight": { "default": "mdi:weight" }, + "wind_direction": { + "default": "mdi:compass-rose" + }, "wind_speed": { "default": "mdi:weather-windy" } diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index dcbb4d3c826..ae414a178e9 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -52,6 +52,7 @@ "is_volume_flow_rate": "Current {entity_name} volume flow rate", "is_water": "Current {entity_name} water", "is_weight": "Current {entity_name} weight", + "is_wind_direction": "Current {entity_name} wind direction", "is_wind_speed": "Current {entity_name} wind speed" }, "trigger_type": { @@ -105,6 +106,7 @@ "volume_flow_rate": "{entity_name} volume flow rate changes", "water": "{entity_name} water changes", "weight": "{entity_name} weight changes", + "wind_direction": "{entity_name} wind direction changes", "wind_speed": "{entity_name} wind speed changes" }, "extra_fields": { @@ -299,6 +301,9 @@ "weight": { "name": "Weight" }, + "wind_direction": { + "name": "Wind direction" + }, "wind_speed": { "name": "Wind speed" } diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr index 7266afcfd96..8637471cc60 100644 --- a/tests/components/ambient_network/snapshots/test_sensor.ambr +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -836,7 +836,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind direction', 'platform': 'ambient_network', @@ -851,6 +851,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_direction', 'friendly_name': 'Station A Wind direction', 'last_measured': HAFakeDatetime(2023, 11, 8, 12, 12, 0, 914000, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'unit_of_measurement': '°', @@ -1820,7 +1821,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind direction', 'platform': 'ambient_network', @@ -1835,6 +1836,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_direction', 'friendly_name': 'Station C Wind direction', 'last_measured': HAFakeDatetime(2024, 6, 6, 8, 28, 3, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')), 'unit_of_measurement': '°', @@ -2741,7 +2743,7 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Wind direction', 'platform': 'ambient_network', @@ -2756,6 +2758,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_direction', 'friendly_name': 'Station D Wind direction', 'unit_of_measurement': '°', }), From 1733f5d3fb7c1dd1aca5bc5ea79160c89400288f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 19 Feb 2025 13:42:53 +0100 Subject: [PATCH 0662/1941] Fix playback for encrypted Reolink files (#138852) --- homeassistant/components/reolink/media_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 91c50fb7da5..3505b4093ae 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -71,7 +71,7 @@ class ReolinkVODMediaSource(MediaSource): host = get_host(self.hass, config_entry_id) def get_vod_type() -> VodRequestType: - if filename.endswith(".mp4"): + if filename.endswith((".mp4", ".vref")): if host.api.is_nvr: return VodRequestType.DOWNLOAD return VodRequestType.PLAYBACK From af0a862aabf4814e9e08eaf8e6fea9c4ae021d39 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Feb 2025 13:49:31 +0100 Subject: [PATCH 0663/1941] Clean up translations for mocked integrations inbetween tests (#138732) * Clean up translations for mocked integrations inbetween tests * Adjust code, add test * Fix docstring * Improve cleanup, add test * Fix test --- tests/common.py | 17 ----------- tests/components/stt/test_init.py | 4 --- tests/components/tts/test_init.py | 4 --- tests/conftest.py | 33 ++++++++++++++++++--- tests/test_test_fixtures.py | 48 +++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 29 deletions(-) diff --git a/tests/common.py b/tests/common.py index 4d767f0611c..df674d1824c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1867,23 +1867,6 @@ async def snapshot_platform( assert state == snapshot(name=f"{entity_entry.entity_id}-state") -def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None: - """Reset translation cache for specified components. - - Use this if you are mocking a core component (for example via - mock_integration), to ensure that the mocked translations are not - persisted in the shared session cache. - """ - translations_cache = translation._async_get_translations_cache(hass) - for loaded_components in translations_cache.cache_data.loaded.values(): - for component_to_unload in components: - loaded_components.discard(component_to_unload) - for loaded_categories in translations_cache.cache_data.cache.values(): - for loaded_components in loaded_categories.values(): - for component_to_unload in components: - loaded_components.pop(component_to_unload, None) - - @lru_cache def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: """Load quality scale for integration.""" diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index e36ece52f57..cada4b0c533 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -34,7 +34,6 @@ from tests.common import ( mock_integration, mock_platform, mock_restore_cache, - reset_translation_cache, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -519,9 +518,6 @@ async def test_default_engine_prefer_cloud_entity( assert provider_engine.name == "test" assert async_default_engine(hass) == "stt.cloud_stt_entity" - # Reset the `cloud` translations cache to avoid flaky translation checks - reset_translation_cache(hass, ["cloud"]) - async def test_get_engine_legacy( hass: HomeAssistant, tmp_path: Path, mock_provider: MockSTTProvider diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index d115546c9bc..4d0767cddf3 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -44,7 +44,6 @@ from tests.common import ( mock_integration, mock_platform, mock_restore_cache, - reset_translation_cache, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -1987,6 +1986,3 @@ async def test_default_engine_prefer_cloud_entity( provider_engine = tts.async_resolve_engine(hass, "test") assert provider_engine == "test" assert tts.async_default_engine(hass) == "tts.cloud_tts_entity" - - # Reset the `cloud` translations cache to avoid flaky translation checks - reset_translation_cache(hass, ["cloud"]) diff --git a/tests/conftest.py b/tests/conftest.py index 7d9fa7eda2e..6bc346eb3b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ import gc import itertools import logging import os +import pathlib import reprlib from shutil import rmtree import sqlite3 @@ -49,7 +50,7 @@ from . import patch_recorder # Setup patching of dt_util time functions before any other Home Assistant imports from . import patch_time # noqa: F401, isort:skip -from homeassistant import core as ha, loader, runner +from homeassistant import components, core as ha, loader, runner from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant @@ -85,6 +86,7 @@ from homeassistant.helpers import ( issue_registry as ir, label_registry as lr, recorder as recorder_helper, + translation as translation_helper, ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.translation import _TranslationsCacheData @@ -1234,9 +1236,8 @@ def mock_get_source_ip() -> Generator[_patch]: def translations_once() -> Generator[_patch]: """Only load translations once per session. - Warning: having this as a session fixture can cause issues with tests that - create mock integrations, overriding the real integration translations - with empty ones. Translations should be reset after such tests (see #131628) + Note: To avoid issues with tests that mock integrations, translations for + mocked integrations are cleaned up by the evict_faked_translations fixture. """ cache = _TranslationsCacheData({}, {}) patcher = patch( @@ -1250,6 +1251,30 @@ def translations_once() -> Generator[_patch]: patcher.stop() +@pytest.fixture(autouse=True, scope="module") +def evict_faked_translations(translations_once) -> Generator[_patch]: + """Clear translations for mocked integrations from the cache after each module.""" + real_component_strings = translation_helper._async_get_component_strings + with patch( + "homeassistant.helpers.translation._async_get_component_strings", + wraps=real_component_strings, + ) as mock_component_strings: + yield + cache: _TranslationsCacheData = translations_once.kwargs["return_value"] + component_paths = components.__path__ + + for call in mock_component_strings.mock_calls: + integrations: dict[str, loader.Integration] = call.args[3] + for domain, integration in integrations.items(): + if any( + pathlib.Path(f"{component_path}/{domain}") == integration.file_path + for component_path in component_paths + ): + continue + for loaded_for_lang in cache.loaded.values(): + loaded_for_lang.discard(domain) + + @pytest.fixture def disable_translations_once( translations_once: _patch, diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 78f66ceb549..0b8fd20a7c0 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -1,6 +1,8 @@ """Test test fixture configuration.""" +from collections.abc import Generator from http import HTTPStatus +import pathlib import socket from aiohttp import web @@ -9,8 +11,11 @@ import pytest_socket from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.helpers import translation from homeassistant.setup import async_setup_component +from .common import MockModule, mock_integration +from .conftest import evict_faked_translations from .typing import ClientSessionGenerator @@ -70,3 +75,46 @@ async def test_aiohttp_client_frozen_router_view( assert response.status == HTTPStatus.OK result = await response.json() assert result["test"] is True + + +async def test_evict_faked_translations_assumptions(hass: HomeAssistant) -> None: + """Test assumptions made when detecting translations for mocked integrations. + + If this test fails, the evict_faked_translations may need to be updated. + """ + integration = mock_integration(hass, MockModule("test"), built_in=True) + assert integration.file_path == pathlib.Path("") + + +async def test_evict_faked_translations(hass: HomeAssistant, translations_once) -> None: + """Test the evict_faked_translations fixture.""" + cache: translation._TranslationsCacheData = translations_once.kwargs["return_value"] + fake_domain = "test" + real_domain = "homeassistant" + + # Evict the real domain from the cache in case it's been loaded before + cache.loaded["en"].discard(real_domain) + + assert fake_domain not in cache.loaded["en"] + assert real_domain not in cache.loaded["en"] + + # The evict_faked_translations fixture has module scope, so we set it up and + # tear it down manually + real_func = evict_faked_translations.__pytest_wrapped__.obj + gen: Generator = real_func(translations_once) + + # Set up the evict_faked_translations fixture + next(gen) + + mock_integration(hass, MockModule(fake_domain), built_in=True) + await translation.async_load_integrations(hass, {fake_domain, real_domain}) + assert fake_domain in cache.loaded["en"] + assert real_domain in cache.loaded["en"] + + # Tear down the evict_faked_translations fixture + with pytest.raises(StopIteration): + next(gen) + + # The mock integration should be removed from the cache, the real domain should still be there + assert fake_domain not in cache.loaded["en"] + assert real_domain in cache.loaded["en"] From 600bfed704f0cac661656c5f8cecace156c946e9 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:54:25 +0100 Subject: [PATCH 0664/1941] Refactor eheimdigital setup_device_entities (#138837) --- homeassistant/components/eheimdigital/climate.py | 4 +--- homeassistant/components/eheimdigital/coordinator.py | 6 ++---- homeassistant/components/eheimdigital/light.py | 4 +--- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py index 0af7eb0c623..3cde9e758cd 100644 --- a/homeassistant/components/eheimdigital/climate.py +++ b/homeassistant/components/eheimdigital/climate.py @@ -40,12 +40,10 @@ async def async_setup_entry( coordinator = entry.runtime_data def async_setup_device_entities( - device_address: str | dict[str, EheimDigitalDevice], + device_address: 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)) diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py index 6e96fb388ee..df5475b6567 100644 --- a/homeassistant/components/eheimdigital/coordinator.py +++ b/homeassistant/components/eheimdigital/coordinator.py @@ -20,9 +20,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER -type AsyncSetupDeviceEntitiesCallback = Callable[ - [str | dict[str, EheimDigitalDevice]], None -] +type AsyncSetupDeviceEntitiesCallback = Callable[[dict[str, EheimDigitalDevice]], None] type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator] @@ -74,7 +72,7 @@ class EheimDigitalUpdateCoordinator( if device_address not in self.known_devices: for platform_callback in self.platform_callbacks: - platform_callback(device_address) + platform_callback({device_address: self.hub.devices[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 02062831fd3..2725315befd 100644 --- a/homeassistant/components/eheimdigital/light.py +++ b/homeassistant/components/eheimdigital/light.py @@ -38,12 +38,10 @@ async def async_setup_entry( coordinator = entry.runtime_data def async_setup_device_entities( - device_address: str | dict[str, EheimDigitalDevice], + device_address: 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): From b70c5710a9742a841dc747ef5e40faf704ed03b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Feb 2025 16:24:30 +0100 Subject: [PATCH 0665/1941] Correct invalid automatic backup settings when loading from store (#138716) * Correct invalid automatic backup settings when loading from store * Improve docstring * Improve tests --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/backup/manager.py | 49 +- homeassistant/components/hassio/backup.py | 4 + .../backup/snapshots/test_websocket.ambr | 494 +++++++++++++++++- tests/components/backup/test_websocket.py | 85 ++- 5 files changed, 618 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 71a4f5ea41a..1b19b185b4f 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -16,6 +16,7 @@ from .agent import ( BackupAgentPlatformProtocol, LocalBackupAgent, ) +from .config import BackupConfig from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views from .manager import ( @@ -47,6 +48,7 @@ __all__ = [ "BackupAgent", "BackupAgentError", "BackupAgentPlatformProtocol", + "BackupConfig", "BackupManagerError", "BackupNotFound", "BackupPlatformProtocol", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 81826ffcb24..5a1bcde2b3b 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -43,7 +43,11 @@ from .agent import ( BackupAgentPlatformProtocol, LocalBackupAgent, ) -from .config import BackupConfig, delete_backups_exceeding_configured_count +from .config import ( + BackupConfig, + CreateBackupParametersDict, + delete_backups_exceeding_configured_count, +) from .const import ( BUF_SIZE, DATA_MANAGER, @@ -282,6 +286,10 @@ class BackupReaderWriter(abc.ABC): ) -> None: """Get restore events after core restart.""" + @abc.abstractmethod + async def async_validate_config(self, *, config: BackupConfig) -> None: + """Validate backup config.""" + class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" @@ -333,6 +341,7 @@ class BackupManager: self.config.load(stored["config"]) self.known_backups.load(stored["backups"]) + await self._reader_writer.async_validate_config(config=self.config) await self._reader_writer.async_resume_restore_progress_after_restart( on_progress=self.async_on_backup_event ) @@ -1832,6 +1841,44 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) on_progress(IdleEvent()) + async def async_validate_config(self, *, config: BackupConfig) -> None: + """Validate backup config. + + Update automatic backup settings to not include addons or folders and remove + hassio agents in case a backup created by supervisor was restored. + """ + create_backup = config.data.create_backup + if ( + not create_backup.include_addons + and not create_backup.include_all_addons + and not create_backup.include_folders + and not any(a_id.startswith("hassio.") for a_id in create_backup.agent_ids) + ): + LOGGER.debug("Backup settings don't need to be adjusted") + return + + LOGGER.info( + "Adjusting backup settings to not include addons, folders or supervisor locations" + ) + automatic_agents = [ + agent_id + for agent_id in create_backup.agent_ids + if not agent_id.startswith("hassio.") + ] + if ( + self._local_agent_id not in automatic_agents + and "hassio.local" in create_backup.agent_ids + ): + automatic_agents = [self._local_agent_id, *automatic_agents] + await config.update( + create_backup=CreateBackupParametersDict( + agent_ids=automatic_agents, + include_addons=None, + include_all_addons=False, + include_folders=None, + ) + ) + def _generate_backup_id(date: str, name: str) -> str: """Generate a backup ID.""" diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index ddaa821587f..9c0511a93fe 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -27,6 +27,7 @@ from homeassistant.components.backup import ( AddonInfo, AgentBackup, BackupAgent, + BackupConfig, BackupManagerError, BackupNotFound, BackupReaderWriter, @@ -633,6 +634,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): _LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err) unsub() + async def async_validate_config(self, *, config: BackupConfig) -> None: + """Validate backup config.""" + @callback def _async_listen_job_events( self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 19a85de62ad..d9ed5128e1d 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -251,7 +251,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data0] +# name: test_config_load_config_info[with_hassio-storage_data0] dict({ 'id': 1, 'result': dict({ @@ -288,7 +288,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data1] +# name: test_config_load_config_info[with_hassio-storage_data1] dict({ 'id': 1, 'result': dict({ @@ -337,7 +337,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data2] +# name: test_config_load_config_info[with_hassio-storage_data2] dict({ 'id': 1, 'result': dict({ @@ -375,7 +375,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data3] +# name: test_config_load_config_info[with_hassio-storage_data3] dict({ 'id': 1, 'result': dict({ @@ -413,7 +413,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data4] +# name: test_config_load_config_info[with_hassio-storage_data4] dict({ 'id': 1, 'result': dict({ @@ -452,7 +452,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data5] +# name: test_config_load_config_info[with_hassio-storage_data5] dict({ 'id': 1, 'result': dict({ @@ -490,7 +490,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data6] +# name: test_config_load_config_info[with_hassio-storage_data6] dict({ 'id': 1, 'result': dict({ @@ -530,7 +530,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data7] +# name: test_config_load_config_info[with_hassio-storage_data7] dict({ 'id': 1, 'result': dict({ @@ -576,6 +576,484 @@ 'type': 'result', }) # --- +# name: test_config_load_config_info[with_hassio-storage_data8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'hassio.local', + 'hassio.share', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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_load_config_info[with_hassio-storage_data9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'backup.local', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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_load_config_info[without_hassio-storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_load_config_info[without_hassio-storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00', + 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data3] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00', + 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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_load_config_info[without_hassio-storage_data6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'backup.local', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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_load_config_info[without_hassio-storage_data9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'backup.local', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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[commands0] dict({ 'id': 1, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 5e9d7f3c70a..6d5adb32c01 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -42,10 +42,10 @@ BACKUP_CALL = call( agent_ids=["test.test-agent"], backup_name="test-name", extra_metadata={"instance_id": ANY, "with_automatic_settings": True}, - include_addons=["test-addon"], + include_addons=[], include_all_addons=False, include_database=True, - include_folders=["media"], + include_folders=None, include_homeassistant=True, password="test-password", on_progress=ANY, @@ -1121,25 +1121,96 @@ async def test_agents_info( "minor_version": store.STORAGE_VERSION_MINOR, }, }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["hassio.local", "hassio.share", "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": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["backup.local", "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": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, ], ) +@pytest.mark.parametrize( + ("with_hassio"), + [ + pytest.param(True, id="with_hassio"), + pytest.param(False, id="without_hassio"), + ], +) +@pytest.mark.usefixtures("supervisor_client") @patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) -async def test_config_info( +async def test_config_load_config_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, hass_storage: dict[str, Any], + with_hassio: bool, storage_data: dict[str, Any] | None, ) -> None: - """Test getting backup config info.""" + """Test loading stored backup config and reading it via config/info.""" client = await hass_ws_client(hass) await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-13T12:01:00+01:00") hass_storage.update(storage_data) - await setup_backup_integration(hass) + await setup_backup_integration(hass, with_hassio=with_hassio) await hass.async_block_till_done() await client.send_json_auto_id({"type": "backup/config/info"}) @@ -1705,10 +1776,10 @@ async def test_config_schedule_logic( "agents": {}, "create_backup": { "agent_ids": ["test.test-agent"], - "include_addons": ["test-addon"], + "include_addons": [], "include_all_addons": False, "include_database": True, - "include_folders": ["media"], + "include_folders": [], "name": "test-name", "password": "test-password", }, From fb3b23aef306f8bbe6bdacb99dbcbe94eb1f4dd9 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Wed, 19 Feb 2025 16:55:16 +0100 Subject: [PATCH 0666/1941] Homee switch platform (#137457) --- homeassistant/components/homee/__init__.py | 2 +- homeassistant/components/homee/const.py | 32 +++ homeassistant/components/homee/entity.py | 24 +- homeassistant/components/homee/icons.json | 8 + homeassistant/components/homee/strings.json | 16 +- homeassistant/components/homee/switch.py | 127 +++++++++ .../homee/fixtures/switch_single.json | 74 ++++++ tests/components/homee/fixtures/switches.json | 127 +++++++++ .../homee/snapshots/test_switch.ambr | 241 ++++++++++++++++++ tests/components/homee/test_sensor.py | 4 + tests/components/homee/test_switch.py | 179 +++++++++++++ 11 files changed, 824 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/homee/switch.py create mode 100644 tests/components/homee/fixtures/switch_single.json create mode 100644 tests/components/homee/fixtures/switches.json create mode 100644 tests/components/homee/snapshots/test_switch.ambr create mode 100644 tests/components/homee/test_switch.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 9837d6094ff..7d9db9eb180 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -14,7 +14,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.COVER, Platform.SENSOR] +PLATFORMS = [Platform.COVER, Platform.SENSOR, Platform.SWITCH] type HomeeConfigEntry = ConfigEntry[Homee] diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 1d7ce27335f..54d7773890f 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -1,5 +1,7 @@ """Constants for the homee integration.""" +from pyHomee.const import NodeProfile + from homeassistant.const import ( DEGREE, LIGHT_LUX, @@ -62,3 +64,33 @@ WINDOW_MAP = { 2.0: "tilted", } WINDOW_MAP_REVERSED = {0.0: "open", 1.0: "closed", 2.0: "tilted"} + +# Profile Groups +CLIMATE_PROFILES = [ + NodeProfile.COSI_THERM_CHANNEL, + NodeProfile.HEATING_SYSTEM, + NodeProfile.RADIATOR_THERMOSTAT, + NodeProfile.ROOM_THERMOSTAT, + NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR, + NodeProfile.THERMOSTAT_WITH_HEATING_AND_COOLING, + NodeProfile.WIFI_RADIATOR_THERMOSTAT, + NodeProfile.WIFI_ROOM_THERMOSTAT, +] +LIGHT_PROFILES = [ + NodeProfile.DIMMABLE_COLOR_LIGHT, + NodeProfile.DIMMABLE_COLOR_METERING_PLUG, + NodeProfile.DIMMABLE_COLOR_TEMPERATURE_LIGHT, + NodeProfile.DIMMABLE_EXTENDED_COLOR_LIGHT, + NodeProfile.DIMMABLE_LIGHT, + NodeProfile.DIMMABLE_LIGHT_WITH_BRIGHTNESS_SENSOR, + NodeProfile.DIMMABLE_LIGHT_WITH_BRIGHTNESS_AND_PRESENCE_SENSOR, + NodeProfile.DIMMABLE_LIGHT_WITH_PRESENCE_SENSOR, + NodeProfile.DIMMABLE_METERING_SWITCH, + NodeProfile.DIMMABLE_METERING_PLUG, + NodeProfile.DIMMABLE_PLUG, + NodeProfile.DIMMABLE_RGBWLIGHT, + NodeProfile.DIMMABLE_SWITCH, + NodeProfile.WIFI_DIMMABLE_RGBWLIGHT, + NodeProfile.WIFI_DIMMABLE_LIGHT, + NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH, +] diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 5a46b366d3e..5a7f34b1c37 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -26,10 +26,14 @@ class HomeeEntity(Entity): f"{entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}" ) self._entry = entry + node = entry.runtime_data.get_node_by_id(attribute.node_id) self._attr_device_info = DeviceInfo( identifiers={ (DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}") - } + }, + name=node.name, + model=get_name_for_enum(NodeProfile, node.profile), + via_device=(DOMAIN, entry.runtime_data.settings.uid), ) self._host_connected = entry.runtime_data.connected @@ -50,6 +54,17 @@ 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: + """Set an attribute value on the homee node.""" + homee = self._entry.runtime_data + try: + await homee.set_value(self._attribute.node_id, self._attribute.id, value) + except ConnectionClosed as exception: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_closed", + ) from exception + async def async_update(self) -> None: """Update entity from homee.""" homee = self._entry.runtime_data @@ -129,13 +144,6 @@ class HomeeNodeEntity(Entity): return None - def has_attribute(self, attribute_type: AttributeType) -> bool: - """Check if an attribute of the given type exists.""" - if self._node.attribute_map is None: - return False - - return attribute_type in self._node.attribute_map - async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: """Set an attribute value on the homee node.""" homee = self._entry.runtime_data diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json index 3b1ee17b89c..07ae598095b 100644 --- a/homeassistant/components/homee/icons.json +++ b/homeassistant/components/homee/icons.json @@ -7,6 +7,14 @@ "window_position": { "default": "mdi:window-closed" } + }, + "switch": { + "watchdog_on_off": { + "default": "mdi:dog" + }, + "manual_operation": { + "default": "mdi:hand-back-left" + } } } } diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 025d8df21d6..07f8eb6fb04 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -151,11 +151,25 @@ "tilted": "Tilted" } } + }, + "switch": { + "external_binary_input": { + "name": "Child lock" + }, + "manual_operation": { + "name": "Manual operation" + }, + "on_off_instance": { + "name": "Switch {instance}" + }, + "watchdog": { + "name": "Watchdog" + } } }, "exceptions": { "connection_closed": { - "message": "Could not connect to Homee while setting attribute" + "message": "Could not connect to Homee while setting attribute." } } } diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py new file mode 100644 index 00000000000..e8b87b2b8e0 --- /dev/null +++ b/homeassistant/components/homee/switch.py @@ -0,0 +1,127 @@ +"""The homee switch platform.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pyHomee.const import AttributeType, NodeProfile +from pyHomee.model import HomeeAttribute + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .const import CLIMATE_PROFILES, LIGHT_PROFILES +from .entity import HomeeEntity + + +def get_device_class( + attribute: HomeeAttribute, config_entry: HomeeConfigEntry +) -> SwitchDeviceClass: + """Check device class of Switch according to node profile.""" + node = config_entry.runtime_data.get_node_by_id(attribute.node_id) + if node.profile in [ + NodeProfile.ON_OFF_PLUG, + NodeProfile.METERING_PLUG, + NodeProfile.DOUBLE_ON_OFF_PLUG, + NodeProfile.IMPULSE_PLUG, + ]: + return SwitchDeviceClass.OUTLET + + return SwitchDeviceClass.SWITCH + + +@dataclass(frozen=True, kw_only=True) +class HomeeSwitchEntityDescription(SwitchEntityDescription): + """A class that describes Homee switch entity.""" + + device_class_fn: Callable[[HomeeAttribute, HomeeConfigEntry], SwitchDeviceClass] = ( + lambda attribute, entry: SwitchDeviceClass.SWITCH + ) + + +SWITCH_DESCRIPTIONS: dict[AttributeType, HomeeSwitchEntityDescription] = { + AttributeType.EXTERNAL_BINARY_INPUT: HomeeSwitchEntityDescription( + key="external_binary_input", entity_category=EntityCategory.CONFIG + ), + AttributeType.MANUAL_OPERATION: HomeeSwitchEntityDescription( + key="manual_operation" + ), + AttributeType.ON_OFF: HomeeSwitchEntityDescription( + key="on_off", device_class_fn=get_device_class, name=None + ), + AttributeType.WATCHDOG_ON_OFF: HomeeSwitchEntityDescription( + key="watchdog", entity_category=EntityCategory.CONFIG + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform for the Homee component.""" + + for node in config_entry.runtime_data.nodes: + async_add_devices( + HomeeSwitch(attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type]) + for attribute in node.attributes + if (attribute.type in SWITCH_DESCRIPTIONS and attribute.editable) + and not ( + attribute.type == AttributeType.ON_OFF + and node.profile in LIGHT_PROFILES + ) + and not ( + attribute.type == AttributeType.MANUAL_OPERATION + and node.profile in CLIMATE_PROFILES + ) + ) + + +class HomeeSwitch(HomeeEntity, SwitchEntity): + """Representation of a Homee switch.""" + + entity_description: HomeeSwitchEntityDescription + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: HomeeSwitchEntityDescription, + ) -> None: + """Initialize a Homee switch entity.""" + super().__init__(attribute, entry) + self.entity_description = description + if attribute.instance == 0: + if attribute.type == AttributeType.ON_OFF: + self._attr_name = None + else: + self._attr_translation_key = description.key + else: + self._attr_translation_key = f"{description.key}_instance" + self._attr_translation_placeholders = {"instance": str(attribute.instance)} + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return bool(self._attribute.current_value) + + @property + def device_class(self) -> SwitchDeviceClass: + """Return the device class of the switch.""" + return self.entity_description.device_class_fn(self._attribute, self._entry) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.async_set_value(1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.async_set_value(0) diff --git a/tests/components/homee/fixtures/switch_single.json b/tests/components/homee/fixtures/switch_single.json new file mode 100644 index 00000000000..74b7fae048d --- /dev/null +++ b/tests/components/homee/fixtures/switch_single.json @@ -0,0 +1,74 @@ +{ + "id": 2, + "name": "Test Switch Single", + "profile": 15, + "image": "nodeicon_bulb", + "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": 2, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 2, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 385, + "state": 1, + "last_changed": 1735663169, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 0, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/fixtures/switches.json b/tests/components/homee/fixtures/switches.json new file mode 100644 index 00000000000..333717591a7 --- /dev/null +++ b/tests/components/homee/fixtures/switches.json @@ -0,0 +1,127 @@ +{ + "id": 1, + "name": "Test Switch", + "profile": 10, + "image": "nodeicon_dimmablebulb", + "favorite": 0, + "order": 27, + "protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1736188706, + "added": 1610308228, + "history": 1, + "cube_type": 3, + "note": "All known switches", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 309, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 91, + "state": 1, + "last_changed": 1711796633, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 385, + "state": 1, + "last_changed": 1735663169, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 0, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_switch.ambr b/tests/components/homee/snapshots/test_switch.ambr new file mode 100644 index 00000000000..43c1773cede --- /dev/null +++ b/tests/components/homee/snapshots/test_switch.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_switch_snapshot[switch.test_switch_child_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': 'switch', + 'entity_category': , + 'entity_id': 'switch.test_switch_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Child lock', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'external_binary_input', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Switch Child lock', + }), + 'context': , + 'entity_id': 'switch.test_switch_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_snapshot[switch.test_switch_manual_operation-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.test_switch_manual_operation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Manual operation', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'manual_operation', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_manual_operation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Switch Manual operation', + }), + 'context': , + 'entity_id': 'switch.test_switch_manual_operation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_snapshot[switch.test_switch_switch_1-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.test_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'on_off_instance', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Test Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.test_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_snapshot[switch.test_switch_switch_2-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.test_switch_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'on_off_instance', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Test Switch Switch 2', + }), + 'context': , + 'entity_id': 'switch.test_switch_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_snapshot[switch.test_switch_watchdog-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': , + 'entity_id': 'switch.test_switch_watchdog', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Watchdog', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'watchdog', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_switch_watchdog-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Switch Watchdog', + }), + 'context': , + 'entity_id': 'switch.test_switch_watchdog', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 8ee48d3ea97..0f66709c532 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -28,6 +28,7 @@ async def test_up_down_values( ) -> None: """Test values for up/down sensor.""" 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) assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] @@ -56,6 +57,7 @@ async def test_window_position( ) -> None: """Test values for window handle position.""" 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) assert ( @@ -88,6 +90,7 @@ async def test_brightness_sensor( ) -> 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") @@ -112,6 +115,7 @@ async def test_sensor_snapshot( ) -> 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 diff --git a/tests/components/homee/test_switch.py b/tests/components/homee/test_switch.py new file mode 100644 index 00000000000..bb14313f487 --- /dev/null +++ b/tests/components/homee/test_switch.py @@ -0,0 +1,179 @@ +"""Test Homee switches.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from websockets import frames +from websockets.exceptions import ConnectionClosed + +from homeassistant.components.homee.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + SwitchDeviceClass, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +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_switch_state( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if the correct state is returned.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.test_switch_switch_1").state is not STATE_ON + switch = mock_homee.nodes[0].attributes[2] + switch.current_value = 1 + switch.add_on_changed_listener.call_args_list[0][0][0](switch) + await hass.async_block_till_done() + assert hass.states.get("switch.test_switch_switch_1").state is STATE_ON + + +async def test_switch_turn_on( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turn-on service.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.test_switch_switch_1").state is not STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_switch_switch_1"}, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(1, 3, 1) + + +async def test_switch_turn_off( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turn-off service.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("switch.test_switch_watchdog").state is STATE_ON + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_switch_watchdog"}, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 5, 0) + + +async def test_switch_device_class( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if device class gets set correctly.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("switch.test_switch_switch_1").attributes["device_class"] + == SwitchDeviceClass.OUTLET + ) + assert ( + hass.states.get("switch.test_switch_watchdog").attributes["device_class"] + == SwitchDeviceClass.SWITCH + ) + + +async def test_switch_no_name( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test switch gets no name when it is the main feature of the device.""" + mock_homee.nodes = [build_mock_node("switch_single.json")] + mock_homee.nodes[0].profile = 2002 + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("switch.test_switch_single").attributes["friendly_name"] + == "Test Switch Single" + ) + + +async def test_switch_device_class_no_outlet( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if on_off device class gets set correctly if node-profile is not a plug.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.nodes[0].profile = 2002 + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("switch.test_switch_switch_1").attributes["device_class"] + == SwitchDeviceClass.SWITCH + ) + + +async def test_send_error( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test failed set_value command.""" + mock_homee.nodes = [build_mock_node("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + mock_homee.set_value.side_effect = ConnectionClosed( + rcvd=frames.Close(1002, "Protocol Error"), sent=None + ) + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_switch_switch_1"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "connection_closed" + + +async def test_switch_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("switches.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 85f44fa008d397d9125dfc0d2b96ea53f389613c Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 19 Feb 2025 11:43:13 -0500 Subject: [PATCH 0667/1941] Update play_media parameter description in Media Player (#138855) --- homeassistant/components/media_player/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 2127716cd66..02c0b59e4f0 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -248,7 +248,7 @@ }, "media_content_type": { "name": "Content type", - "description": "The type of the content to play. Such as image, music, tv show, video, episode, channel, or playlist." + "description": "The type of the content to play, such as image, music, tv show, video, episode, channel, or playlist." }, "enqueue": { "name": "Enqueue", From 81c909e8ce2dc56210dd5a72e5ca33bd213879be Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Feb 2025 18:13:36 +0100 Subject: [PATCH 0668/1941] Revert "Add assistant filter to expose entities list command" (#138867) Revert "Add assistant filter to expose entities list command (#138817)" This reverts commit a6bb5dbe2a9a49ae2813e281a95a5ae5033a439f. --- .../homeassistant/exposed_entities.py | 11 +--- .../homeassistant/test_exposed_entities.py | 64 ------------------- 2 files changed, 1 insertion(+), 74 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 0c815502669..7bd9f9ab7bc 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -432,7 +432,6 @@ def ws_expose_entity( @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_entity/list", - vol.Optional("assistant"): vol.In(KNOWN_ASSISTANTS), } ) def ws_list_exposed_entities( @@ -442,18 +441,10 @@ def ws_list_exposed_entities( result: dict[str, Any] = {} exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] - required_assistant = msg.get("assistant") entity_registry = er.async_get(hass) for entity_id in chain(exposed_entities.entities, entity_registry.entities): - entity_settings = async_get_entity_settings(hass, entity_id) - if required_assistant and ( - (required_assistant not in entity_settings) - or (not entity_settings[required_assistant].get("should_expose")) - ): - # Not exposed to required assistant - continue - result[entity_id] = {} + entity_settings = async_get_entity_settings(hass, entity_id) for assistant, settings in entity_settings.items(): if "should_expose" not in settings: continue diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 0c57aad58ea..1f1955c2f82 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -539,70 +539,6 @@ async def test_list_exposed_entities( } -async def test_list_exposed_entities_with_filter( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test list exposed entities with filter.""" - ws_client = await hass_ws_client(hass) - assert await async_setup_component(hass, "homeassistant", {}) - await hass.async_block_till_done() - - entry1 = entity_registry.async_get_or_create("test", "test", "unique1") - entry2 = entity_registry.async_get_or_create("test", "test", "unique2") - - # Expose 1 to Alexa - await ws_client.send_json_auto_id( - { - "type": "homeassistant/expose_entity", - "assistants": ["cloud.alexa"], - "entity_ids": [entry1.entity_id], - "should_expose": True, - } - ) - response = await ws_client.receive_json() - assert response["success"] - - # Expose 2 to Google - await ws_client.send_json_auto_id( - { - "type": "homeassistant/expose_entity", - "assistants": ["cloud.google_assistant"], - "entity_ids": [entry2.entity_id], - "should_expose": True, - } - ) - response = await ws_client.receive_json() - assert response["success"] - - # List with filter - await ws_client.send_json_auto_id( - {"type": "homeassistant/expose_entity/list", "assistant": "cloud.alexa"} - ) - response = await ws_client.receive_json() - assert response["success"] - assert response["result"] == { - "exposed_entities": { - "test.test_unique1": {"cloud.alexa": True}, - }, - } - - await ws_client.send_json_auto_id( - { - "type": "homeassistant/expose_entity/list", - "assistant": "cloud.google_assistant", - } - ) - response = await ws_client.receive_json() - assert response["success"] - assert response["result"] == { - "exposed_entities": { - "test.test_unique2": {"cloud.google_assistant": True}, - }, - } - - async def test_listeners( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: From 6c3a9cb1a8a6aeb9998413d13fde5bb2a6d8dbf7 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 19 Feb 2025 18:18:28 +0100 Subject: [PATCH 0669/1941] Improve reading clarity of steps code in scripts helper part 1 (#138628) --- homeassistant/helpers/script.py | 248 ++++++++++++++++---------------- 1 file changed, 125 insertions(+), 123 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index bd3babc8793..5eef9d90765 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -614,107 +614,6 @@ class _ScriptRun: level=level, ) - def _get_pos_time_period_template(self, key: str) -> timedelta: - try: - return cv.positive_time_period( # type: ignore[no-any-return] - template.render_complex(self._action[key], self._variables) - ) - except (exceptions.TemplateError, vol.Invalid) as ex: - self._log( - "Error rendering %s %s template: %s", - self._script.name, - key, - ex, - level=logging.ERROR, - ) - raise _AbortScript from ex - - async def _async_delay_step(self) -> None: - """Handle delay.""" - delay_delta = self._get_pos_time_period_template(CONF_DELAY) - - self._step_log(f"delay {delay_delta}") - - delay = delay_delta.total_seconds() - self._changed() - if not delay: - # Handle an empty delay - trace_set_result(delay=delay, done=True) - return - - trace_set_result(delay=delay, done=False) - futures, timeout_handle, timeout_future = self._async_futures_with_timeout( - delay - ) - - try: - await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) - finally: - if timeout_future.done(): - trace_set_result(delay=delay, done=True) - else: - timeout_handle.cancel() - - def _get_timeout_seconds_from_action(self) -> float | None: - """Get the timeout from the action.""" - if CONF_TIMEOUT in self._action: - return self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds() - return None - - async def _async_wait_template_step(self) -> None: - """Handle a wait template.""" - timeout = self._get_timeout_seconds_from_action() - self._step_log("wait template", timeout) - - self._variables["wait"] = {"remaining": timeout, "completed": False} - trace_set_result(wait=self._variables["wait"]) - - wait_template = self._action[CONF_WAIT_TEMPLATE] - - # check if condition already okay - if condition.async_template(self._hass, wait_template, self._variables, False): - self._variables["wait"]["completed"] = True - self._changed() - return - - if timeout == 0: - self._changed() - self._async_handle_timeout() - return - - futures, timeout_handle, timeout_future = self._async_futures_with_timeout( - timeout - ) - done = self._hass.loop.create_future() - futures.append(done) - - @callback - def async_script_wait( - entity_id: str, from_s: State | None, to_s: State | None - ) -> None: - """Handle script after template condition is true.""" - self._async_set_remaining_time_var(timeout_handle) - self._variables["wait"]["completed"] = True - _set_result_unless_done(done) - - unsub = async_track_template( - self._hass, wait_template, async_script_wait, self._variables - ) - self._changed() - await self._async_wait_with_optional_timeout( - futures, timeout_handle, timeout_future, unsub - ) - - def _async_set_remaining_time_var( - self, timeout_handle: asyncio.TimerHandle | None - ) -> None: - """Set the remaining time variable for a wait step.""" - wait_var = self._variables["wait"] - if timeout_handle: - wait_var["remaining"] = timeout_handle.when() - self._hass.loop.time() - else: - wait_var["remaining"] = None - async def _async_run_long_action[_T]( self, long_task: asyncio.Task[_T] ) -> _T | None: @@ -1078,6 +977,8 @@ class _ScriptRun: with trace_path("else"): await self._async_run_script(if_data["if_else"]) + ## Time-based steps ## + @overload def _async_futures_with_timeout( self, @@ -1124,6 +1025,88 @@ class _ScriptRun: futures.append(timeout_future) return futures, timeout_handle, timeout_future + def _get_pos_time_period_template(self, key: str) -> timedelta: + try: + return cv.positive_time_period( # type: ignore[no-any-return] + template.render_complex(self._action[key], self._variables) + ) + except (exceptions.TemplateError, vol.Invalid) as ex: + self._log( + "Error rendering %s %s template: %s", + self._script.name, + key, + ex, + level=logging.ERROR, + ) + raise _AbortScript from ex + + async def _async_delay_step(self) -> None: + """Handle delay.""" + delay_delta = self._get_pos_time_period_template(CONF_DELAY) + + self._step_log(f"delay {delay_delta}") + + delay = delay_delta.total_seconds() + self._changed() + if not delay: + # Handle an empty delay + trace_set_result(delay=delay, done=True) + return + + trace_set_result(delay=delay, done=False) + futures, timeout_handle, timeout_future = self._async_futures_with_timeout( + delay + ) + + try: + await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) + finally: + if timeout_future.done(): + trace_set_result(delay=delay, done=True) + else: + timeout_handle.cancel() + + def _get_timeout_seconds_from_action(self) -> float | None: + """Get the timeout from the action.""" + if CONF_TIMEOUT in self._action: + return self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds() + return None + + def _async_handle_timeout(self) -> None: + """Handle timeout.""" + self._variables["wait"]["remaining"] = 0.0 + if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): + self._log(_TIMEOUT_MSG) + trace_set_result(wait=self._variables["wait"], timeout=True) + raise _AbortScript from TimeoutError() + + async def _async_wait_with_optional_timeout( + self, + futures: list[asyncio.Future[None]], + timeout_handle: asyncio.TimerHandle | None, + timeout_future: asyncio.Future[None] | None, + unsub: Callable[[], None], + ) -> None: + try: + await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) + if timeout_future and timeout_future.done(): + self._async_handle_timeout() + finally: + if timeout_future and not timeout_future.done() and timeout_handle: + timeout_handle.cancel() + + unsub() + + def _async_set_remaining_time_var( + self, timeout_handle: asyncio.TimerHandle | None + ) -> None: + """Set the remaining time variable for a wait step.""" + wait_var = self._variables["wait"] + if timeout_handle: + wait_var["remaining"] = timeout_handle.when() - self._hass.loop.time() + else: + wait_var["remaining"] = None + async def _async_wait_for_trigger_step(self) -> None: """Wait for a trigger event.""" timeout = self._get_timeout_seconds_from_action() @@ -1176,30 +1159,49 @@ class _ScriptRun: futures, timeout_handle, timeout_future, remove_triggers ) - def _async_handle_timeout(self) -> None: - """Handle timeout.""" - self._variables["wait"]["remaining"] = 0.0 - if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): - self._log(_TIMEOUT_MSG) - trace_set_result(wait=self._variables["wait"], timeout=True) - raise _AbortScript from TimeoutError() + async def _async_wait_template_step(self) -> None: + """Handle a wait template.""" + timeout = self._get_timeout_seconds_from_action() + self._step_log("wait template", timeout) - async def _async_wait_with_optional_timeout( - self, - futures: list[asyncio.Future[None]], - timeout_handle: asyncio.TimerHandle | None, - timeout_future: asyncio.Future[None] | None, - unsub: Callable[[], None], - ) -> None: - try: - await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) - if timeout_future and timeout_future.done(): - self._async_handle_timeout() - finally: - if timeout_future and not timeout_future.done() and timeout_handle: - timeout_handle.cancel() + self._variables["wait"] = {"remaining": timeout, "completed": False} + trace_set_result(wait=self._variables["wait"]) - unsub() + wait_template = self._action[CONF_WAIT_TEMPLATE] + + # check if condition already okay + if condition.async_template(self._hass, wait_template, self._variables, False): + self._variables["wait"]["completed"] = True + self._changed() + return + + if timeout == 0: + self._changed() + self._async_handle_timeout() + return + + futures, timeout_handle, timeout_future = self._async_futures_with_timeout( + timeout + ) + done = self._hass.loop.create_future() + futures.append(done) + + @callback + def async_script_wait( + entity_id: str, from_s: State | None, to_s: State | None + ) -> None: + """Handle script after template condition is true.""" + self._async_set_remaining_time_var(timeout_handle) + self._variables["wait"]["completed"] = True + _set_result_unless_done(done) + + unsub = async_track_template( + self._hass, wait_template, async_script_wait, self._variables + ) + self._changed() + await self._async_wait_with_optional_timeout( + futures, timeout_handle, timeout_future, unsub + ) async def _async_variables_step(self) -> None: """Set a variable value.""" From 32b854515bef7ad1e20f6de5b3b91a17428932c7 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Wed, 19 Feb 2025 18:23:58 +0100 Subject: [PATCH 0670/1941] Add exception translation for async_set_temperature in integration flexit_bacnet (#138870) --- homeassistant/components/flexit_bacnet/climate.py | 8 +++++++- homeassistant/components/flexit_bacnet/strings.json | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index f611528a6c3..878b63f938f 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -111,7 +111,13 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): else: await self.device.set_air_temp_setpoint_home(temperature) except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: - raise HomeAssistantError from exc + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_temperature", + translation_placeholders={ + "temperature": str(temperature), + }, + ) from exc finally: await self.coordinator.async_refresh() diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index e9acbd46a37..6364d59e4e8 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -130,6 +130,9 @@ "set_preset_mode": { "message": "Failed to set preset mode {preset}." }, + "set_temperature": { + "message": "Failed to set temperature {temperature}." + }, "set_hvac_mode": { "message": "Failed to set HVAC mode {mode}." }, From 1d3fcc67b8fcb60de212e0f33d5e46473766a57a Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 19 Feb 2025 11:51:47 -0600 Subject: [PATCH 0671/1941] Select preferred discovered HEOS host (#138779) * Select preffered host from discovery * Remove invalid test comment --- homeassistant/components/heos/config_flow.py | 69 ++++++++++------ homeassistant/components/heos/const.py | 1 + homeassistant/components/heos/strings.json | 5 ++ tests/components/heos/__init__.py | 1 + tests/components/heos/test_config_flow.py | 83 ++++++++++---------- tests/components/heos/test_diagnostics.py | 17 ++-- 6 files changed, 101 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index db2abee559c..ac09b7ca6bc 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -17,12 +17,9 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector -from homeassistant.helpers.service_info.ssdp import ( - ATTR_UPNP_FRIENDLY_NAME, - SsdpServiceInfo, -) +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo -from .const import DOMAIN +from .const import DOMAIN, ENTRY_TITLE from .coordinator import HeosConfigEntry _LOGGER = logging.getLogger(__name__) @@ -37,11 +34,6 @@ AUTH_SCHEMA = vol.Schema( ) -def format_title(host: str) -> str: - """Format the title for config entries.""" - return f"HEOS System (via {host})" - - async def _validate_host(host: str, errors: dict[str, str]) -> bool: """Validate host is reachable, return True, otherwise populate errors and return False.""" heos = Heos(HeosOptions(host, events=False, heart_beat=False)) @@ -104,6 +96,10 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the HEOS flow.""" + self._discovered_host: str | None = None + @staticmethod @callback def async_get_options_flow(config_entry: HeosConfigEntry) -> OptionsFlow: @@ -117,40 +113,63 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): # Store discovered host if TYPE_CHECKING: assert discovery_info.ssdp_location - hostname = urlparse(discovery_info.ssdp_location).hostname - friendly_name = f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" - self.hass.data.setdefault(DOMAIN, {}) - self.hass.data[DOMAIN][friendly_name] = hostname + await self.async_set_unique_id(DOMAIN) - # Show selection form - return self.async_show_form(step_id="user") + # Connect to discovered host and get system information + hostname = urlparse(discovery_info.ssdp_location).hostname + assert hostname is not None + heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) + try: + await heos.connect() + system_info = await heos.get_system_info() + except HeosError as error: + _LOGGER.debug( + "Failed to retrieve system information from discovered HEOS device %s", + hostname, + exc_info=error, + ) + return self.async_abort(reason="cannot_connect") + finally: + await heos.disconnect() + + # Select the preferred host, if available + if system_info.preferred_hosts: + hostname = system_info.preferred_hosts[0].ip_address + self._discovered_host = hostname + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovered HEOS system.""" + if user_input is not None: + assert self._discovered_host is not None + return self.async_create_entry( + title=ENTRY_TITLE, data={CONF_HOST: self._discovered_host} + ) + + self._set_confirm_only() + return self.async_show_form(step_id="confirm_discovery") async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Obtain host and validate connection.""" - self.hass.data.setdefault(DOMAIN, {}) await self.async_set_unique_id(DOMAIN) # Try connecting to host if provided errors: dict[str, str] = {} host = None if user_input is not None: host = user_input[CONF_HOST] - # Map host from friendly name if in discovered hosts - host = self.hass.data[DOMAIN].get(host, host) if await _validate_host(host, errors): - self.hass.data.pop(DOMAIN) # Remove discovery data return self.async_create_entry( - title=format_title(host), data={CONF_HOST: host} + title=ENTRY_TITLE, data={CONF_HOST: host} ) # Return form - host_type = ( - str if not self.hass.data[DOMAIN] else vol.In(list(self.hass.data[DOMAIN])) - ) return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): host_type}), + data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), errors=errors, ) diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py index e9ab51bf16e..6d603f7ad30 100644 --- a/homeassistant/components/heos/const.py +++ b/homeassistant/components/heos/const.py @@ -3,6 +3,7 @@ ATTR_PASSWORD = "password" ATTR_USERNAME = "username" DOMAIN = "heos" +ENTRY_TITLE = "HEOS System" SERVICE_GROUP_VOLUME_SET = "group_volume_set" SERVICE_GROUP_VOLUME_DOWN = "group_volume_down" SERVICE_GROUP_VOLUME_UP = "group_volume_up" diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index cd3f0b998a1..340eecb9f8b 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -11,6 +11,10 @@ "host": "Host name or IP address of a HEOS-capable product (preferably one connected via wire to the network)." } }, + "confirm_discovery": { + "title": "Discovered HEOS System", + "description": "Do you want to add your HEOS devices to Home Assistant?" + }, "reconfigure": { "title": "Reconfigure HEOS", "description": "Change the host name or IP address of the HEOS-capable product used to access your HEOS System.", @@ -43,6 +47,7 @@ }, "abort": { "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 0b8aed91edf..5b112f2b986 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -22,6 +22,7 @@ class MockHeos(Heos): self.get_players: AsyncMock = AsyncMock() self.group_volume_down: AsyncMock = AsyncMock() self.group_volume_up: AsyncMock = AsyncMock() + self.get_system_info: AsyncMock = AsyncMock() self.load_players: AsyncMock = AsyncMock() self.play_media: AsyncMock = AsyncMock() self.play_preset_station: AsyncMock = AsyncMock() diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index cbc32526958..552b667b6c8 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -2,7 +2,7 @@ from typing import Any -from pyheos import CommandAuthenticationError, CommandFailedError, HeosError +from pyheos import CommandAuthenticationError, CommandFailedError, HeosError, HeosSystem import pytest from homeassistant.components.heos.const import DOMAIN @@ -69,57 +69,46 @@ async def test_create_entry_when_host_valid( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"].unique_id == DOMAIN - assert result["title"] == "HEOS System (via 127.0.0.1)" + assert result["title"] == "HEOS System" assert result["data"] == data assert controller.connect.call_count == 2 # Also called in async_setup_entry assert controller.disconnect.call_count == 1 -async def test_create_entry_when_friendly_name_valid( - hass: HomeAssistant, controller: MockHeos -) -> None: - """Test result type is create entry when friendly name is valid.""" - hass.data[DOMAIN] = {"Office (127.0.0.1)": "127.0.0.1"} - data = {CONF_HOST: "Office (127.0.0.1)"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=data - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"].unique_id == DOMAIN - assert result["title"] == "HEOS System (via 127.0.0.1)" - assert result["data"] == {CONF_HOST: "127.0.0.1"} - assert controller.connect.call_count == 2 # Also called in async_setup_entry - assert controller.disconnect.call_count == 1 - assert DOMAIN not in hass.data - - -async def test_discovery_shows_create_form( +async def test_discovery( hass: HomeAssistant, discovery_data: SsdpServiceInfo, discovery_data_bedroom: SsdpServiceInfo, + controller: MockHeos, + system: HeosSystem, ) -> None: - """Test discovery shows form to confirm setup.""" - - # Single discovered host shows form for user to finish setup. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data - ) - assert hass.data[DOMAIN] == {"Office (127.0.0.1)": "127.0.0.1"} - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - # Subsequent discovered hosts append to discovered hosts and abort. + """Test discovery shows form to confirm, then creates entry.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom ) - assert hass.data[DOMAIN] == { - "Office (127.0.0.1)": "127.0.0.1", - "Bedroom (127.0.0.2)": "127.0.0.2", - } - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_in_progress" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + assert controller.connect.call_count == 1 + assert controller.get_system_info.call_count == 1 + assert controller.disconnect.call_count == 1 + + # Subsequent discovered hosts abort. + subsequent_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert subsequent_result["type"] is FlowResultType.ABORT + assert subsequent_result["reason"] == "already_in_progress" + + # Confirm set up + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System" + assert result["data"] == {CONF_HOST: "127.0.0.1"} async def test_discovery_flow_aborts_already_setup( @@ -136,6 +125,20 @@ async def test_discovery_flow_aborts_already_setup( assert result["reason"] == "single_instance_allowed" +async def test_discovery_fails_to_connect_aborts( + hass: HomeAssistant, discovery_data: SsdpServiceInfo, controller: MockHeos +) -> None: + """Test discovery aborts when trying to connect to host.""" + controller.connect.side_effect = HeosError() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + + async def test_reconfigure_validates_and_updates_config( hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: diff --git a/tests/components/heos/test_diagnostics.py b/tests/components/heos/test_diagnostics.py index fb71682fb48..a5341ef8d83 100644 --- a/tests/components/heos/test_diagnostics.py +++ b/tests/components/heos/test_diagnostics.py @@ -1,8 +1,6 @@ """Tests for the HEOS diagnostics module.""" -from unittest import mock - -from pyheos import HeosSystem +from pyheos import HeosError, HeosSystem import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -33,12 +31,10 @@ async def test_config_entry_diagnostics( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - with mock.patch.object( - controller, controller.get_system_info.__name__, return_value=system - ): - diagnostics = await get_diagnostics_for_config_entry( - hass, hass_client, config_entry - ) + controller.get_system_info.return_value = system + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) assert diagnostics == snapshot( exclude=props("created_at", "modified_at", "entry_id") @@ -50,13 +46,14 @@ async def test_config_entry_diagnostics_error_getting_system( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, + controller: MockHeos, snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics with error during getting system info.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) - # Not patching get_system_info to raise error 'Not connected to device' + controller.get_system_info.side_effect = HeosError("Not connected to device") diagnostics = await get_diagnostics_for_config_entry( hass, hass_client, config_entry From d2ce89882b9a74ed7c5fa1c7db78b888d2da2f1c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 19 Feb 2025 18:52:38 +0100 Subject: [PATCH 0672/1941] Bump onedrive-personal-sdk to 0.0.11 (#138861) --- 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 899a5e77b47..698bc7f5ca4 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["onedrive-personal-sdk==0.0.10"] + "requirements": ["onedrive-personal-sdk==0.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 273e50b0ffd..935c274db5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1559,7 +1559,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.10 +onedrive-personal-sdk==0.0.11 # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a6d473a21a9..f82849acee3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1307,7 +1307,7 @@ omnilogic==0.4.5 ondilo==0.5.0 # homeassistant.components.onedrive -onedrive-personal-sdk==0.0.10 +onedrive-personal-sdk==0.0.11 # homeassistant.components.onvif onvif-zeep-async==3.2.5 From 7117708937ae5810d1efedcd0610f323b3fe46d8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 19 Feb 2025 19:37:36 +0100 Subject: [PATCH 0673/1941] Improve reading clarity of steps code in scripts helper (#134395) * Reorganize steps code in scripts helper * Address feedback * Revert to getattr --- homeassistant/helpers/script.py | 428 +++++++++++++++++--------------- 1 file changed, 221 insertions(+), 207 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 5eef9d90765..38bc96b67ef 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -430,9 +430,6 @@ class _ScriptRun: if not self._stop.done(): self._script._changed() # noqa: SLF001 - async def _async_get_condition(self, config: ConfigType) -> ConditionCheckerType: - return await self._script._async_get_condition(config) # noqa: SLF001 - def _log( self, msg: str, *args: Any, level: int = logging.INFO, **kwargs: Any ) -> None: @@ -521,7 +518,7 @@ class _ScriptRun: trace_set_result(enabled=False) return - handler = f"_async_{action}_step" + handler = f"_async_step_{action}" try: await getattr(self, handler)() except Exception as ex: # noqa: BLE001 @@ -627,111 +624,49 @@ class _ScriptRun: except ScriptStoppedError as ex: raise asyncio.CancelledError from ex - async def _async_call_service_step(self) -> None: - """Call the service specified in the action.""" - self._step_log("call service") - - params = service.async_prepare_call_from_config( - self._hass, self._action, self._variables - ) - - # Validate response data parameters. This check ignores services that do - # not exist which will raise an appropriate error in the service call below. - response_variable = self._action.get(CONF_RESPONSE_VARIABLE) - return_response = response_variable is not None - if self._hass.services.has_service(params[CONF_DOMAIN], params[CONF_SERVICE]): - supports_response = self._hass.services.supports_response( - params[CONF_DOMAIN], params[CONF_SERVICE] - ) - if supports_response == SupportsResponse.ONLY and not return_response: - raise vol.Invalid( - f"Script requires '{CONF_RESPONSE_VARIABLE}' for response data " - f"for service call {params[CONF_DOMAIN]}.{params[CONF_SERVICE]}" - ) - if supports_response == SupportsResponse.NONE and return_response: - raise vol.Invalid( - f"Script does not support '{CONF_RESPONSE_VARIABLE}' for service " - f"'{CONF_RESPONSE_VARIABLE}' which does not support response data." - ) - - running_script = ( - params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger" - ) or params[CONF_DOMAIN] in ("python_script", "script") - trace_set_result(params=params, running_script=running_script) - response_data = await self._async_run_long_action( + async def _async_run_script(self, script: Script) -> None: + """Execute a script.""" + result = await self._async_run_long_action( self._hass.async_create_task_internal( - self._hass.services.async_call( - **params, - blocking=True, - context=self._context, - return_response=return_response, - ), - eager_start=True, + script.async_run(self._variables, self._context), eager_start=True ) ) - if response_variable: - self._variables[response_variable] = response_data + if result and result.conversation_response is not UNDEFINED: + self._conversation_response = result.conversation_response - async def _async_device_step(self) -> None: - """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 + ## Flow control actions ## + + ### Sequence actions ### + + @async_trace_path("parallel") + async def _async_step_parallel(self) -> None: + """Run a sequence in parallel.""" + scripts = await self._script._async_get_parallel_scripts(self._step) # noqa: SLF001 + + async def async_run_with_trace(idx: int, script: Script) -> None: + """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) + + results = await asyncio.gather( + *(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)), + return_exceptions=True, ) + for result in results: + if isinstance(result, Exception): + raise result - async def _async_scene_step(self) -> None: - """Activate the scene specified in the action.""" - self._step_log("activate scene") - trace_set_result(scene=self._action[CONF_SCENE]) - await self._hass.services.async_call( - scene.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: self._action[CONF_SCENE]}, - blocking=True, - context=self._context, - ) + @async_trace_path("sequence") + async def _async_step_sequence(self) -> None: + """Run a sequence.""" + sequence = await self._script._async_get_sequence_script(self._step) # noqa: SLF001 + await self._async_run_script(sequence) - async def _async_event_step(self) -> None: - """Fire an event.""" - self._step_log(self._action.get(CONF_ALIAS, self._action[CONF_EVENT])) - event_data = {} - for conf in (CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE): - if conf not in self._action: - continue + ### Condition actions ### - try: - event_data.update( - template.render_complex(self._action[conf], self._variables) - ) - except exceptions.TemplateError as ex: - self._log( - "Error rendering event data template: %s", ex, level=logging.ERROR - ) - - trace_set_result(event=self._action[CONF_EVENT], event_data=event_data) - self._hass.bus.async_fire_internal( - self._action[CONF_EVENT], event_data, context=self._context - ) - - async def _async_condition_step(self) -> None: - """Test if condition is matching.""" - self._script.last_action = self._action.get( - CONF_ALIAS, self._action[CONF_CONDITION] - ) - cond = await self._async_get_condition(self._action) - try: - trace_element = trace_stack_top(trace_stack_cv) - if trace_element: - trace_element.reuse_by_child = True - check = cond(self._hass, self._variables) - except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'condition' evaluation:\n%s", ex) - check = False - - self._log("Test condition %s: %s", self._script.last_action, check) - trace_update_result(result=check) - if not check: - raise _ConditionFail + async def _async_get_condition(self, config: ConfigType) -> ConditionCheckerType: + return await self._script._async_get_condition(config) # noqa: SLF001 def _test_conditions( self, @@ -760,8 +695,73 @@ class _ScriptRun: return traced_test_conditions(self._hass, self._variables) + async def _async_step_choose(self) -> None: + """Choose a sequence.""" + choose_data = await self._script._async_get_choose_data(self._step) # noqa: SLF001 + + with trace_path("choose"): + for idx, (conditions, script) in enumerate(choose_data["choices"]): + with trace_path(str(idx)): + try: + if self._test_conditions(conditions, "choose", "conditions"): + trace_set_result(choice=idx) + with trace_path("sequence"): + await self._async_run_script(script) + return + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'choose' evaluation:\n%s", ex) + + if choose_data["default"] is not None: + trace_set_result(choice="default") + with trace_path(["default"]): + await self._async_run_script(choose_data["default"]) + + async def _async_step_condition(self) -> None: + """Test if condition is matching.""" + self._script.last_action = self._action.get( + CONF_ALIAS, self._action[CONF_CONDITION] + ) + cond = await self._async_get_condition(self._action) + try: + trace_element = trace_stack_top(trace_stack_cv) + if trace_element: + trace_element.reuse_by_child = True + check = cond(self._hass, self._variables) + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'condition' evaluation:\n%s", ex) + check = False + + self._log("Test condition %s: %s", self._script.last_action, check) + trace_update_result(result=check) + if not check: + raise _ConditionFail + + async def _async_step_if(self) -> None: + """If sequence.""" + if_data = await self._script._async_get_if_data(self._step) # noqa: SLF001 + + test_conditions: bool | None = False + try: + with trace_path("if"): + test_conditions = self._test_conditions( + if_data["if_conditions"], "if", "condition" + ) + except exceptions.ConditionError as ex: + _LOGGER.warning("Error in 'if' evaluation:\n%s", ex) + + if test_conditions: + trace_set_result(choice="then") + with trace_path("then"): + await self._async_run_script(if_data["if_then"]) + return + + if if_data["if_else"] is not None: + trace_set_result(choice="else") + with trace_path("else"): + await self._async_run_script(if_data["if_else"]) + @async_trace_path("repeat") - async def _async_repeat_step(self) -> None: # noqa: C901 + async def _async_step_repeat(self) -> None: # noqa: C901 """Repeat a sequence.""" description = self._action.get(CONF_ALIAS, "sequence") repeat = self._action[CONF_REPEAT] @@ -932,52 +932,128 @@ class _ScriptRun: else: self._variables.pop("repeat", None) # Not set if count = 0 - async def _async_choose_step(self) -> None: - """Choose a sequence.""" - choose_data = await self._script._async_get_choose_data(self._step) # noqa: SLF001 + ### Stop actions ### - with trace_path("choose"): - for idx, (conditions, script) in enumerate(choose_data["choices"]): - with trace_path(str(idx)): - try: - if self._test_conditions(conditions, "choose", "conditions"): - trace_set_result(choice=idx) - with trace_path("sequence"): - await self._async_run_script(script) - return - except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'choose' evaluation:\n%s", ex) + async def _async_step_stop(self) -> None: + """Stop script execution.""" + stop = self._action[CONF_STOP] + error = self._action.get(CONF_ERROR, False) + trace_set_result(stop=stop, error=error) + if error: + self._log("Error script sequence: %s", stop) + raise _AbortScript(stop) - if choose_data["default"] is not None: - trace_set_result(choice="default") - with trace_path(["default"]): - await self._async_run_script(choose_data["default"]) + self._log("Stop script sequence: %s", stop) + if CONF_RESPONSE_VARIABLE in self._action: + try: + response = self._variables[self._action[CONF_RESPONSE_VARIABLE]] + except KeyError as ex: + raise _AbortScript( + f"Response variable '{self._action[CONF_RESPONSE_VARIABLE]}' " + "is not defined" + ) from ex + else: + response = None + raise _StopScript(stop, response) - async def _async_if_step(self) -> None: - """If sequence.""" - if_data = await self._script._async_get_if_data(self._step) # noqa: SLF001 + ## Variable actions ## - test_conditions: bool | None = False - try: - with trace_path("if"): - test_conditions = self._test_conditions( - if_data["if_conditions"], "if", "condition" + 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 + ) + + ## External actions ## + + async def _async_step_call_service(self) -> None: + """Call the service specified in the action.""" + self._step_log("call service") + + params = service.async_prepare_call_from_config( + self._hass, self._action, self._variables + ) + + # Validate response data parameters. This check ignores services that do + # not exist which will raise an appropriate error in the service call below. + response_variable = self._action.get(CONF_RESPONSE_VARIABLE) + return_response = response_variable is not None + if self._hass.services.has_service(params[CONF_DOMAIN], params[CONF_SERVICE]): + supports_response = self._hass.services.supports_response( + params[CONF_DOMAIN], params[CONF_SERVICE] + ) + if supports_response == SupportsResponse.ONLY and not return_response: + raise vol.Invalid( + f"Script requires '{CONF_RESPONSE_VARIABLE}' for response data " + f"for service call {params[CONF_DOMAIN]}.{params[CONF_SERVICE]}" + ) + if supports_response == SupportsResponse.NONE and return_response: + raise vol.Invalid( + f"Script does not support '{CONF_RESPONSE_VARIABLE}' for service " + f"'{CONF_RESPONSE_VARIABLE}' which does not support response data." ) - except exceptions.ConditionError as ex: - _LOGGER.warning("Error in 'if' evaluation:\n%s", ex) - if test_conditions: - trace_set_result(choice="then") - with trace_path("then"): - await self._async_run_script(if_data["if_then"]) - return + running_script = ( + params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger" + ) or params[CONF_DOMAIN] in ("python_script", "script") + trace_set_result(params=params, running_script=running_script) + response_data = await self._async_run_long_action( + self._hass.async_create_task_internal( + self._hass.services.async_call( + **params, + blocking=True, + context=self._context, + return_response=return_response, + ), + eager_start=True, + ) + ) + if response_variable: + self._variables[response_variable] = response_data - if if_data["if_else"] is not None: - trace_set_result(choice="else") - with trace_path("else"): - await self._async_run_script(if_data["if_else"]) + async def _async_step_device(self) -> None: + """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 + ) - ## Time-based steps ## + async def _async_step_event(self) -> None: + """Fire an event.""" + self._step_log(self._action.get(CONF_ALIAS, self._action[CONF_EVENT])) + event_data = {} + for conf in (CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE): + if conf not in self._action: + continue + + try: + event_data.update( + template.render_complex(self._action[conf], self._variables) + ) + except exceptions.TemplateError as ex: + self._log( + "Error rendering event data template: %s", ex, level=logging.ERROR + ) + + trace_set_result(event=self._action[CONF_EVENT], event_data=event_data) + self._hass.bus.async_fire_internal( + self._action[CONF_EVENT], event_data, context=self._context + ) + + async def _async_step_scene(self) -> None: + """Activate the scene specified in the action.""" + self._step_log("activate scene") + trace_set_result(scene=self._action[CONF_SCENE]) + await self._hass.services.async_call( + scene.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self._action[CONF_SCENE]}, + blocking=True, + context=self._context, + ) + + ## Time-based actions ## @overload def _async_futures_with_timeout( @@ -1040,7 +1116,7 @@ class _ScriptRun: ) raise _AbortScript from ex - async def _async_delay_step(self) -> None: + async def _async_step_delay(self) -> None: """Handle delay.""" delay_delta = self._get_pos_time_period_template(CONF_DELAY) @@ -1107,7 +1183,7 @@ class _ScriptRun: else: wait_var["remaining"] = None - async def _async_wait_for_trigger_step(self) -> None: + async def _async_step_wait_for_trigger(self) -> None: """Wait for a trigger event.""" timeout = self._get_timeout_seconds_from_action() @@ -1159,7 +1235,7 @@ class _ScriptRun: futures, timeout_handle, timeout_future, remove_triggers ) - async def _async_wait_template_step(self) -> None: + async def _async_step_wait_template(self) -> None: """Handle a wait template.""" timeout = self._get_timeout_seconds_from_action() self._step_log("wait template", timeout) @@ -1203,14 +1279,9 @@ class _ScriptRun: futures, timeout_handle, timeout_future, unsub ) - async def _async_variables_step(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 - ) + ## Conversation actions ## - async def _async_set_conversation_response_step(self) -> None: + async def _async_step_set_conversation_response(self) -> None: """Set conversation response.""" self._step_log("setting conversation response") resp: template.Template | None = self._action[CONF_SET_CONVERSATION_RESPONSE] @@ -1222,63 +1293,6 @@ class _ScriptRun: ) trace_set_result(conversation_response=self._conversation_response) - async def _async_stop_step(self) -> None: - """Stop script execution.""" - stop = self._action[CONF_STOP] - error = self._action.get(CONF_ERROR, False) - trace_set_result(stop=stop, error=error) - if error: - self._log("Error script sequence: %s", stop) - raise _AbortScript(stop) - - self._log("Stop script sequence: %s", stop) - if CONF_RESPONSE_VARIABLE in self._action: - try: - response = self._variables[self._action[CONF_RESPONSE_VARIABLE]] - except KeyError as ex: - raise _AbortScript( - f"Response variable '{self._action[CONF_RESPONSE_VARIABLE]}' " - "is not defined" - ) from ex - else: - response = None - raise _StopScript(stop, response) - - @async_trace_path("sequence") - async def _async_sequence_step(self) -> None: - """Run a sequence.""" - sequence = await self._script._async_get_sequence_script(self._step) # noqa: SLF001 - await self._async_run_script(sequence) - - @async_trace_path("parallel") - async def _async_parallel_step(self) -> None: - """Run a sequence in parallel.""" - scripts = await self._script._async_get_parallel_scripts(self._step) # noqa: SLF001 - - async def async_run_with_trace(idx: int, script: Script) -> None: - """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) - - results = await asyncio.gather( - *(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)), - return_exceptions=True, - ) - for result in results: - if isinstance(result, Exception): - raise result - - async def _async_run_script(self, script: Script) -> 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 - ) - ) - if result and result.conversation_response is not UNDEFINED: - self._conversation_response = result.conversation_response - class _QueuedScriptRun(_ScriptRun): """Manage queued Script sequence run.""" From e847a8d6a5cec9aae99de493901f858cb76b8d70 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Feb 2025 19:49:30 +0100 Subject: [PATCH 0674/1941] Capitalize all occurrences of "Bond" brand name (#138876) Also makes older action descriptions consistent. --- homeassistant/components/bond/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index 8986905c6ee..d65966d7701 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -31,7 +31,7 @@ "services": { "set_fan_speed_tracked_state": { "name": "Set fan speed tracked state", - "description": "Sets the tracked fan speed for a bond fan.", + "description": "Sets the tracked fan speed for a Bond fan.", "fields": { "entity_id": { "name": "Entity", @@ -45,7 +45,7 @@ }, "set_switch_power_tracked_state": { "name": "Set switch power tracked state", - "description": "Sets the tracked power state of a bond switch.", + "description": "Sets the tracked power state of a Bond switch.", "fields": { "entity_id": { "name": "Entity", @@ -59,7 +59,7 @@ }, "set_light_power_tracked_state": { "name": "Set light power tracked state", - "description": "Sets the tracked power state of a bond light.", + "description": "Sets the tracked power state of a Bond light.", "fields": { "entity_id": { "name": "Entity", @@ -73,7 +73,7 @@ }, "set_light_brightness_tracked_state": { "name": "Set light brightness tracked state", - "description": "Sets the tracked brightness state of a bond light.", + "description": "Sets the tracked brightness state of a Bond light.", "fields": { "entity_id": { "name": "Entity", @@ -87,15 +87,15 @@ }, "start_increasing_brightness": { "name": "Start increasing brightness", - "description": "Start increasing the brightness of the light. (deprecated)." + "description": "Starts increasing the brightness of the light (deprecated)." }, "start_decreasing_brightness": { "name": "Start decreasing brightness", - "description": "Start decreasing the brightness of the light. (deprecated)." + "description": "Starts decreasing the brightness of the light (deprecated)." }, "stop": { "name": "[%key:common::action::stop%]", - "description": "Stop any in-progress action and empty the queue. (deprecated)." + "description": "Stops any in-progress action and empty the queue (deprecated)." } } } From f98e83514d1b9b5aa5b6beb2af3c45a3cb902bb6 Mon Sep 17 00:00:00 2001 From: Maghiel Dijksman Date: Wed, 19 Feb 2025 20:03:32 +0100 Subject: [PATCH 0675/1941] Tuya camera rm duplication (#138794) --- homeassistant/components/tuya/light.py | 18 ++----- homeassistant/components/tuya/number.py | 13 ++--- homeassistant/components/tuya/select.py | 38 ++------------- homeassistant/components/tuya/sensor.py | 27 ++--------- homeassistant/components/tuya/siren.py | 11 ++--- homeassistant/components/tuya/switch.py | 63 ++----------------------- 6 files changed, 24 insertions(+), 146 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 40d0fd73f0e..d94308ebd33 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -261,20 +261,6 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - Low power consumption camera - # Undocumented, see https://github.com/home-assistant/core/issues/132844 - "dghsxj": ( - TuyaLightEntityDescription( - key=DPCode.FLOODLIGHT_SWITCH, - brightness=DPCode.FLOODLIGHT_LIGHTNESS, - name="Floodlight", - ), - TuyaLightEntityDescription( - key=DPCode.BASIC_INDICATOR, - name="Indicator light", - entity_category=EntityCategory.CONFIG, - ), - ), # Smart Gardening system # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 "sz": ( @@ -406,6 +392,10 @@ LIGHTS["cz"] = LIGHTS["kg"] # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s LIGHTS["pc"] = LIGHTS["kg"] +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +LIGHTS["dghsxj"] = LIGHTS["sp"] + # Dimmer (duplicate of `tgq`) # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 LIGHTS["tdq"] = LIGHTS["tgq"] diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index ce1f434bcdd..d4fe7836daa 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -174,15 +174,6 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - Low power consumption camera - # Undocumented, see https://github.com/home-assistant/core/issues/132844 - "dghsxj": ( - NumberEntityDescription( - key=DPCode.BASIC_DEVICE_VOLUME, - translation_key="volume", - entity_category=EntityCategory.CONFIG, - ), - ), # Dimmer Switch # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o "tgkg": ( @@ -314,6 +305,10 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { ), } +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +NUMBERS["dghsxj"] = NUMBERS["sp"] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 0ae49cd127e..553191b7d45 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -128,40 +128,6 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="motion_sensitivity", ), ), - # Smart Camera - Low power consumption camera - # Undocumented, see https://github.com/home-assistant/core/issues/132844 - "dghsxj": ( - SelectEntityDescription( - key=DPCode.IPC_WORK_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="ipc_work_mode", - ), - SelectEntityDescription( - key=DPCode.DECIBEL_SENSITIVITY, - entity_category=EntityCategory.CONFIG, - translation_key="decibel_sensitivity", - ), - SelectEntityDescription( - key=DPCode.RECORD_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="record_mode", - ), - SelectEntityDescription( - key=DPCode.BASIC_NIGHTVISION, - entity_category=EntityCategory.CONFIG, - translation_key="basic_nightvision", - ), - SelectEntityDescription( - key=DPCode.BASIC_ANTI_FLICKER, - entity_category=EntityCategory.CONFIG, - translation_key="basic_anti_flicker", - ), - SelectEntityDescription( - key=DPCode.MOTION_SENSITIVITY, - entity_category=EntityCategory.CONFIG, - translation_key="motion_sensitivity", - ), - ), # IoT Switch? # Note: Undocumented "tdq": ( @@ -360,6 +326,10 @@ SELECTS["cz"] = SELECTS["kg"] # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SELECTS["pc"] = SELECTS["kg"] +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +SELECTS["dghsxj"] = SELECTS["sp"] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 76825e9c814..073202bed94 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -632,29 +632,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Smart Camera - Low power consumption camera - # Undocumented, see https://github.com/home-assistant/core/issues/132844 - "dghsxj": ( - TuyaSensorEntityDescription( - key=DPCode.SENSOR_TEMPERATURE, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.SENSOR_HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.WIRELESS_ELECTRICITY, - translation_key="battery", - device_class=SensorDeviceClass.BATTERY, - entity_category=EntityCategory.DIAGNOSTIC, - state_class=SensorStateClass.MEASUREMENT, - ), - ), # Fingerbot "szjqr": BATTERY_SENSORS, # Solar Light @@ -1243,6 +1220,10 @@ SENSORS["cz"] = SENSORS["kg"] # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SENSORS["pc"] = SENSORS["kg"] +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +SENSORS["dghsxj"] = SENSORS["sp"] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 9c60f7bcaac..039442dafe5 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -44,13 +44,6 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { key=DPCode.SIREN_SWITCH, ), ), - # Smart Camera - Low power consumption camera - # Undocumented, see https://github.com/home-assistant/core/issues/132844 - "dghsxj": ( - SirenEntityDescription( - key=DPCode.SIREN_SWITCH, - ), - ), # CO2 Detector # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy "co2bj": ( @@ -61,6 +54,10 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { ), } +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +SIRENS["dghsxj"] = SIRENS["sp"] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 519a9e83606..76d8b481a90 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -509,65 +509,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - Low power consumption camera - # Undocumented, see https://github.com/home-assistant/core/issues/132844 - "dghsxj": ( - SwitchEntityDescription( - key=DPCode.WIRELESS_BATTERYLOCK, - translation_key="battery_lock", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.CRY_DETECTION_SWITCH, - translation_key="cry_detection", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.DECIBEL_SWITCH, - translation_key="sound_detection", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.RECORD_SWITCH, - translation_key="video_recording", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.MOTION_RECORD, - translation_key="motion_recording", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.BASIC_PRIVATE, - translation_key="privacy_mode", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.BASIC_FLIP, - translation_key="flip", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.BASIC_OSD, - translation_key="time_watermark", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.BASIC_WDR, - translation_key="wide_dynamic_range", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.MOTION_TRACKING, - translation_key="motion_tracking", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.MOTION_SWITCH, - translation_key="motion_alarm", - entity_category=EntityCategory.CONFIG, - ), - ), # Smart Gardening system # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 "sz": ( @@ -785,6 +726,10 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SWITCHES["cz"] = SWITCHES["pc"] +# Smart Camera - Low power consumption camera (duplicate of `sp`) +# Undocumented, see https://github.com/home-assistant/core/issues/132844 +SWITCHES["dghsxj"] = SWITCHES["sp"] + async def async_setup_entry( hass: HomeAssistant, From bc5146db3ce94b19212dd1718df1665ce3e4d915 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Feb 2025 20:21:30 +0100 Subject: [PATCH 0676/1941] Make field description of snips.say_action UI-friendly (#138276) --- homeassistant/components/snips/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/snips/strings.json b/homeassistant/components/snips/strings.json index 724e1a86477..23b255b05a9 100644 --- a/homeassistant/components/snips/strings.json +++ b/homeassistant/components/snips/strings.json @@ -44,7 +44,7 @@ "fields": { "can_be_enqueued": { "name": "Can be enqueued", - "description": "If True, session waits for an open session to end, if False session is dropped if one is running." + "description": "Whether the session should wait for an open session to end. Otherwise it is dropped if another session is already running." }, "custom_data": { "name": "[%key:component::snips::services::say::fields::custom_data::name%]", From 4ed4c2cc5c887d84c2be9268e6615a03656becb6 Mon Sep 17 00:00:00 2001 From: Steven Hartland Date: Wed, 19 Feb 2025 19:23:29 +0000 Subject: [PATCH 0677/1941] Fix scaffolding generations (#138820) --- script/hassfest/manifest.py | 23 +++++----- script/scaffold/__main__.py | 89 ++++++++++++++++++++++++++----------- script/scaffold/model.py | 14 ++++-- script/util.py | 21 +++++++++ 4 files changed, 105 insertions(+), 42 deletions(-) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 6e9cd8bdedc..02c96930bf5 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -19,6 +19,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import Platform from homeassistant.helpers import config_validation as cv +from script.util import sort_manifest as util_sort_manifest from .model import Config, Integration, ScaledQualityScaleTiers @@ -376,20 +377,20 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No validate_version(integration) -_SORT_KEYS = {"domain": ".domain", "name": ".name"} - - -def _sort_manifest_keys(key: str) -> str: - return _SORT_KEYS.get(key, key) - - def sort_manifest(integration: Integration, config: Config) -> bool: """Sort manifest.""" - keys = list(integration.manifest.keys()) - if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys: - manifest = {key: integration.manifest[key] for key in keys_sorted} + if integration.manifest_path is None: + integration.add_error( + "manifest", + "Manifest path not set, unable to sort manifest keys", + ) + return False + + if util_sort_manifest(integration.manifest): if config.action == "generate": - integration.manifest_path.write_text(json.dumps(manifest, indent=2)) + integration.manifest_path.write_text( + json.dumps(integration.manifest, indent=2) + "\n" + ) text = "have been sorted" else: text = "are not sorted correctly" diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 4c102083a74..243ea9507f7 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -8,6 +8,7 @@ import sys from script.util import valid_integration from . import docs, error, gather_info, generate +from .model import Info TEMPLATES = [ p.name for p in (Path(__file__).parent / "templates").glob("*") if p.is_dir() @@ -28,6 +29,40 @@ def get_arguments() -> argparse.Namespace: return parser.parse_args() +def run_process(name: str, cmd: list[str], info: Info) -> None: + """Run a sub process and handle the result. + + :param name: The name of the sub process used in reporting. + :param cmd: The sub process arguments. + :param info: The Info object. + :raises subprocess.CalledProcessError: If the subprocess failed. + + If the sub process was successful print a success message, otherwise + print an error message and raise a subprocess.CalledProcessError. + """ + print(f"Command: {' '.join(cmd)}") + print() + result: subprocess.CompletedProcess = subprocess.run(cmd, check=False) + if result.returncode == 0: + print() + print(f"Completed {name} successfully.") + print() + return + + print() + print(f"Fatal Error: {name} failed with exit code {result.returncode}") + print() + if info.is_new: + print("This is a bug, please report an issue!") + else: + print( + "This may be an existing issue with your integration,", + "if so fix and run `script.scaffold` again,", + "otherwise please report an issue.", + ) + result.check_returncode() + + def main() -> int: """Scaffold an integration.""" if not Path("requirements_all.txt").is_file(): @@ -60,36 +95,36 @@ def main() -> int: generate.generate(template, info) - hassfest_args = [ - "python", - "-m", - "script.hassfest", - ] - # If we wanted a new integration, we've already done our work. if args.template != "integration": generate.generate(args.template, info) - else: - hassfest_args.extend( - [ - "--integration-path", - info.integration_dir, - "--skip-plugins", - "quality_scale", # Skip quality scale as it will fail for newly generated integrations. - ] - ) # Always output sub commands as the output will contain useful information if a command fails. print("Running hassfest to pick up new information.") - subprocess.run(hassfest_args, check=True) - print() + run_process( + "hassfest", + [ + "python", + "-m", + "script.hassfest", + "--integration-path", + str(info.integration_dir), + "--skip-plugins", + "quality_scale", # Skip quality scale as it will fail for newly generated integrations. + ], + info, + ) print("Running gen_requirements_all to pick up new information.") - subprocess.run(["python", "-m", "script.gen_requirements_all"], check=True) - print() + run_process( + "gen_requirements_all", + ["python", "-m", "script.gen_requirements_all"], + info, + ) - print("Running script/translations_develop to pick up new translation strings.") - subprocess.run( + print("Running translations to pick up new translation strings.") + run_process( + "translations", [ "python", "-m", @@ -98,14 +133,13 @@ def main() -> int: "--integration", info.domain, ], - check=True, + info, ) - print() if args.develop: print("Running tests") - print(f"$ python3 -b -m pytest -vvv tests/components/{info.domain}") - subprocess.run( + run_process( + "pytest", [ "python3", "-b", @@ -114,9 +148,8 @@ def main() -> int: "-vvv", f"tests/components/{info.domain}", ], - check=True, + info, ) - print() docs.print_relevant_docs(args.template, info) @@ -126,6 +159,8 @@ def main() -> int: if __name__ == "__main__": try: sys.exit(main()) + except subprocess.CalledProcessError as err: + sys.exit(err.returncode) except error.ExitApp as err: print() print(f"Fatal Error: {err.reason}") diff --git a/script/scaffold/model.py b/script/scaffold/model.py index 3b5a5e50fe4..e3a7be210ab 100644 --- a/script/scaffold/model.py +++ b/script/scaffold/model.py @@ -4,9 +4,12 @@ from __future__ import annotations import json from pathlib import Path +from typing import Any import attr +from script.util import sort_manifest + from .const import COMPONENT_DIR, TESTS_DIR @@ -44,16 +47,19 @@ class Info: """Path to the manifest.""" return COMPONENT_DIR / self.domain / "manifest.json" - def manifest(self) -> dict: + def manifest(self) -> dict[str, Any]: """Return integration manifest.""" return json.loads(self.manifest_path.read_text()) def update_manifest(self, **kwargs) -> None: """Update the integration manifest.""" print(f"Updating {self.domain} manifest: {kwargs}") - self.manifest_path.write_text( - json.dumps({**self.manifest(), **kwargs}, indent=2) + "\n" - ) + + # Sort keys in manifest so we don't trigger hassfest errors. + manifest: dict[str, Any] = {**self.manifest(), **kwargs} + sort_manifest(manifest) + + self.manifest_path.write_text(json.dumps(manifest, indent=2) + "\n") @property def strings_path(self) -> Path: diff --git a/script/util.py b/script/util.py index b7c37c72102..c9fada38c80 100644 --- a/script/util.py +++ b/script/util.py @@ -1,6 +1,7 @@ """Utility functions for the scaffold script.""" import argparse +from typing import Any from .const import COMPONENT_DIR @@ -13,3 +14,23 @@ def valid_integration(integration): ) return integration + + +_MANIFEST_SORT_KEYS = {"domain": ".domain", "name": ".name"} + + +def _sort_manifest_keys(key: str) -> str: + """Sort manifest keys.""" + return _MANIFEST_SORT_KEYS.get(key, key) + + +def sort_manifest(manifest: dict[str, Any]) -> bool: + """Sort manifest.""" + keys = list(manifest) + if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys: + sorted_manifest = {key: manifest[key] for key in keys_sorted} + manifest.clear() + manifest.update(sorted_manifest) + return True + + return False From e360348525ca833579bb2e54d3a6bc3ba1f67a6a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Feb 2025 20:28:09 +0100 Subject: [PATCH 0678/1941] Make description of `input_select.select_next` action consistent (#138877) --- 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 faa47c979a1..c46e3740b68 100644 --- a/homeassistant/components/input_select/strings.json +++ b/homeassistant/components/input_select/strings.json @@ -20,7 +20,7 @@ "services": { "select_next": { "name": "Next", - "description": "Select the next option.", + "description": "Selects the next option.", "fields": { "cycle": { "name": "Cycle", From b2e2ef311943a561782cf2e002dc926961859546 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 19 Feb 2025 21:24:35 +0100 Subject: [PATCH 0679/1941] Bump pyfritzhome to 0.6.15 (#138879) --- 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 2fbb75443b2..7c0f35b591c 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.14"], + "requirements": ["pyfritzhome==0.6.15"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 935c274db5a..29aaca8129e 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.14 +pyfritzhome==0.6.15 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f82849acee3..f86bfa48a44 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.14 +pyfritzhome==0.6.15 # homeassistant.components.ifttt pyfttt==0.3 From 0b6f49fec24856249a7c47a08645e54e8c2667b2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Feb 2025 15:27:42 -0500 Subject: [PATCH 0680/1941] Filter out certain intents from being matched in local fallback (#137763) * Filter out certain intents from being matched in local fallback * Only filter if LLM agent can control HA --- .../components/assist_pipeline/pipeline.py | 27 ++++++- .../components/conversation/__init__.py | 11 ++- .../components/conversation/default_agent.py | 6 +- .../assist_pipeline/test_pipeline.py | 40 ++++++++++ .../conversation/test_default_agent.py | 73 +++++++++++++++++++ 5 files changed, 151 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index cf9fb4c7212..788a207b83a 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -13,7 +13,7 @@ from pathlib import Path from queue import Empty, Queue from threading import Thread import time -from typing import Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast import wave import hass_nabucasa @@ -30,7 +30,7 @@ from homeassistant.components import ( from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) -from homeassistant.const import MATCH_ALL +from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, intent @@ -81,6 +81,9 @@ from .error import ( ) from .vad import AudioBuffer, VoiceActivityTimeout, VoiceCommandSegmenter, chunk_samples +if TYPE_CHECKING: + from hassil.recognize import RecognizeResult + _LOGGER = logging.getLogger(__name__) STORAGE_KEY = f"{DOMAIN}.pipelines" @@ -123,6 +126,12 @@ STORED_PIPELINE_RUNS = 10 SAVE_DELAY = 10 +@callback +def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool: + """Filter out intents that are not local fallback.""" + return result.intent.name in (intent.INTENT_GET_STATE, intent.INTENT_NEVERMIND) + + @callback def _async_resolve_default_pipeline_settings( hass: HomeAssistant, @@ -1084,10 +1093,22 @@ class PipelineRun: ) intent_response.async_set_speech(trigger_response_text) + intent_filter: Callable[[RecognizeResult], bool] | None = None + # If the LLM has API access, we filter out some sentences that are + # interfering with LLM operation. + if ( + intent_agent_state := self.hass.states.get(self.intent_agent) + ) and intent_agent_state.attributes.get( + ATTR_SUPPORTED_FEATURES, 0 + ) & 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 + self.hass, + user_input, + intent_filter=intent_filter, ) ): # Local intent matched diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 11de75801ba..14c5244c18b 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Callable import logging import re from typing import Literal +from hassil.recognize import RecognizeResult import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -241,7 +243,10 @@ async def async_handle_sentence_triggers( async def async_handle_intents( - hass: HomeAssistant, user_input: ConversationInput + hass: HomeAssistant, + user_input: ConversationInput, + *, + intent_filter: Callable[[RecognizeResult], bool] | None = None, ) -> intent.IntentResponse | None: """Try to match input against registered intents and return response. @@ -250,7 +255,9 @@ async def async_handle_intents( default_agent = async_get_agent(hass) assert isinstance(default_agent, DefaultAgent) - return await default_agent.async_handle_intents(user_input) + return await default_agent.async_handle_intents( + user_input, intent_filter=intent_filter + ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index e8bd38f5adf..86c46584faf 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -1324,6 +1324,8 @@ class DefaultAgent(ConversationEntity): async def async_handle_intents( self, user_input: ConversationInput, + *, + intent_filter: Callable[[RecognizeResult], bool] | None = None, ) -> intent.IntentResponse | None: """Try to match sentence against registered intents and return response. @@ -1331,7 +1333,9 @@ class DefaultAgent(ConversationEntity): Returns None if no match or a matching error occurred. """ result = await self.async_recognize_intent(user_input, strict_intents_only=True) - if not isinstance(result, RecognizeResult): + if not isinstance(result, RecognizeResult) or ( + intent_filter is not None and intent_filter(result) + ): # No error message on failed match return None diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index d52e2a762ee..a7f6fbf7553 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator from typing import Any from unittest.mock import ANY, patch +from hassil.recognize import Intent, IntentData, RecognizeResult import pytest from homeassistant.components import conversation @@ -16,6 +17,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( PipelineData, PipelineStorageCollection, PipelineStore, + _async_local_fallback_intent_filter, async_create_default_pipeline, async_get_pipeline, async_get_pipelines, @@ -23,6 +25,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_update_pipeline, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent from homeassistant.setup import async_setup_component from . import MANY_LANGUAGES @@ -657,3 +660,40 @@ async def test_migrate_after_load(hass: HomeAssistant) -> None: assert pipeline_updated.stt_engine == "stt.test" assert pipeline_updated.tts_engine == "tts.test" + + +def test_fallback_intent_filter() -> None: + """Test that we filter the right things.""" + assert ( + _async_local_fallback_intent_filter( + RecognizeResult( + intent=Intent(intent.INTENT_GET_STATE), + intent_data=IntentData([]), + entities={}, + entities_list=[], + ) + ) + is True + ) + assert ( + _async_local_fallback_intent_filter( + RecognizeResult( + intent=Intent(intent.INTENT_NEVERMIND), + intent_data=IntentData([]), + entities={}, + entities_list=[], + ) + ) + is True + ) + assert ( + _async_local_fallback_intent_filter( + RecognizeResult( + intent=Intent(intent.INTENT_TURN_ON), + intent_data=IntentData([]), + entities={}, + entities_list=[], + ) + ) + is False + ) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index d9f9917b9e0..dca4653b480 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -3154,6 +3154,79 @@ async def test_handle_intents_with_response_errors( assert response is None +@pytest.mark.usefixtures("init_components") +async def test_handle_intents_filters_results( + hass: HomeAssistant, + init_components: None, + area_registry: ar.AreaRegistry, +) -> None: + """Test that handle_intents can filter responses.""" + assert await async_setup_component(hass, "climate", {}) + area_registry.async_create("living room") + + agent: default_agent.DefaultAgent = hass.data[DATA_DEFAULT_ENTITY] + + user_input = ConversationInput( + text="What is the temperature in the living room?", + context=Context(), + conversation_id=None, + device_id=None, + language=hass.config.language, + agent_id=None, + ) + + mock_result = RecognizeResult( + intent=Intent("HassTurnOn"), + intent_data=IntentData([]), + entities={}, + entities_list=[], + ) + results = [] + + def _filter_intents(result): + results.append(result) + # We filter first, not 2nd. + return len(results) == 1 + + with ( + patch( + "homeassistant.components.conversation.default_agent.DefaultAgent.async_recognize_intent", + return_value=mock_result, + ) as mock_recognize, + patch( + "homeassistant.components.conversation.default_agent.DefaultAgent._async_process_intent_result", + ) as mock_process, + ): + response = await agent.async_handle_intents( + user_input, intent_filter=_filter_intents + ) + + assert len(mock_recognize.mock_calls) == 1 + assert len(mock_process.mock_calls) == 0 + + # It was ignored + assert response is None + + # Check we filtered things + assert len(results) == 1 + assert results[0] is mock_result + + # Second time it is not filtered + response = await agent.async_handle_intents( + user_input, intent_filter=_filter_intents + ) + + assert len(mock_recognize.mock_calls) == 2 + assert len(mock_process.mock_calls) == 2 + + # Check we filtered things + assert len(results) == 2 + assert results[1] is mock_result + + # It was ignored + assert response is not None + + @pytest.mark.usefixtures("init_components") async def test_state_names_are_not_translated( hass: HomeAssistant, From 8e6f2e6ff251572730d6d32afe15d80375c05ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 19 Feb 2025 20:48:27 +0000 Subject: [PATCH 0681/1941] Add LINAK virtual integration supported by Idasen Desk (#138749) --- homeassistant/components/linak/__init__.py | 1 + homeassistant/components/linak/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/linak/__init__.py create mode 100644 homeassistant/components/linak/manifest.json diff --git a/homeassistant/components/linak/__init__.py b/homeassistant/components/linak/__init__.py new file mode 100644 index 00000000000..4e3c37807ba --- /dev/null +++ b/homeassistant/components/linak/__init__.py @@ -0,0 +1 @@ +"""LINAK virtual integration.""" diff --git a/homeassistant/components/linak/manifest.json b/homeassistant/components/linak/manifest.json new file mode 100644 index 00000000000..db1ddd67bda --- /dev/null +++ b/homeassistant/components/linak/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "linak", + "name": "LINAK", + "integration_type": "virtual", + "supported_by": "idasen_desk" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 05e6a4a78c4..7e7b5272aaa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3392,6 +3392,11 @@ "config_flow": false, "iot_class": "assumed_state" }, + "linak": { + "name": "LINAK", + "integration_type": "virtual", + "supported_by": "idasen_desk" + }, "linear_garage_door": { "name": "Linear Garage Door", "integration_type": "hub", From 354855ff5f1362badcff849061357490e019df5d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Feb 2025 21:51:45 +0100 Subject: [PATCH 0682/1941] Remove some dead code from the conversation integration (#138878) --- .../components/conversation/default_agent.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 86c46584faf..3a7aa0c26e8 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -185,21 +185,6 @@ class IntentCache: self.cache.clear() -def _get_language_variations(language: str) -> Iterable[str]: - """Generate language codes with and without region.""" - yield language - - parts = re.split(r"([-_])", language) - if len(parts) == 3: - lang, sep, region = parts - if sep == "_": - # en_US -> en-US - yield f"{lang}-{region}" - - # en-US -> en - yield lang - - async def async_setup_default_agent( hass: core.HomeAssistant, entity_component: EntityComponent[ConversationEntity], From 0a0a96fb3b05df0b030e9748ff3de437c9be8914 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 19 Feb 2025 21:52:20 +0100 Subject: [PATCH 0683/1941] Add initial basic GitHub Copilot instructions (#137754) Co-authored-by: Martin Hjelmare Co-authored-by: Joost Lekkerkerker --- .github/copilot-instructions.md | 100 ++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..06499d62b9e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,100 @@ +# Instructions for GitHub Copilot + +This repository holds the core of Home Assistant, a Python 3 based home +automation application. + +- Python code must be compatible with Python 3.13 +- Use the newest Python language features if possible: + - Pattern matching + - Type hints + - f-strings for string formatting over `%` or `.format()` + - Dataclasses + - Walrus operator +- Code quality tools: + - Formatting: Ruff + - Linting: PyLint and Ruff + - Type checking: MyPy + - Testing: pytest with plain functions and fixtures +- Inline code documentation: + - File headers should be short and concise: + ```python + """Integration for Peblar EV chargers.""" + ``` + - Every method and function needs a docstring: + ```python + async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool: + """Set up Peblar from a config entry.""" + ... + ``` +- All code and comments and other text are written in American English +- Follow existing code style patterns as much as possible +- Core locations: + - Shared constants: `homeassistant/const.py`, use them instead of hardcoding + strings or creating duplicate integration constants. + - Integration files: + - Constants: `homeassistant/components/{domain}/const.py` + - Models: `homeassistant/components/{domain}/models.py` + - Coordinator: `homeassistant/components/{domain}/coordinator.py` + - Config flow: `homeassistant/components/{domain}/config_flow.py` + - Platform code: `homeassistant/components/{domain}/{platform}.py` +- All external I/O operations must be async +- Async patterns: + - Avoid sleeping in loops + - Avoid awaiting in loops, gather instead + - No blocking calls +- Polling: + - Follow update coordinator pattern, when possible + - Polling interval may not be configurable by the user + - For local network polling, the minimum interval is 5 seconds + - For cloud polling, the minimum interval is 60 seconds +- Error handling: + - Use specific exceptions from `homeassistant.exceptions` + - Setup failures: + - Temporary: Raise `ConfigEntryNotReady` + - Permanent: Use `ConfigEntryError` +- Logging: + - Message format: + - No periods at end + - No integration names or domains (added automatically) + - No sensitive data (keys, tokens, passwords), even when those are incorrect. + - Be very restrictive on the use of logging info messages, use debug for + anything which is not targeting the user. + - Use lazy logging (no f-strings): + ```python + _LOGGER.debug("This is a log message with %s", variable) + ``` +- Entities: + - Ensure unique IDs for state persistence: + - Unique IDs should not contain values that are subject to user or network change. + - An ID needs to be unique per platform, not per integration. + - The ID does not have to contain the integration domain or platform. + - Acceptable examples: + - Serial number of a device + - MAC address of a device formatted using `homeassistant.helpers.device_registry.format_mac` + Do not obtain the MAC address through arp cache of local network access, + only use the MAC address provided by discovery or the device itself. + - Unique identifier that is physically printed on the device or burned into an EEPROM + - Not acceptable examples: + - IP Address + - Device name + - Hostname + - URL + - Email address + - Username + - For entities that are setup by a config entry, the config entry ID + can be used as a last resort if no other Unique ID is available. + For example: `f"{entry.entry_id}-battery"` + - If the state value is unknown, use `None` + - Do not use the `unavailable` string as a state value, + implement the `available()` property method instead + - Do not use the `unknown` string as a state value, use `None` instead +- Extra entity state attributes: + - The keys of all state attributes should always be present + - If the value is unknown, use `None` + - Provide descriptive state attributes +- Testing: + - Test location: `tests/components/{domain}/` + - Use pytest fixtures from `tests.common` + - Mock external dependencies + - Use snapshots for complex data + - Follow existing test patterns From 406f894dc1204152f65fe4b6384eb7bcbb7d508b Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 19 Feb 2025 16:07:53 -0500 Subject: [PATCH 0684/1941] Environment Canada: Add a detailed forecast action (#138806) * Add forecast service. * Add detailed Environment Canada forecast data. * Add icon and translations. * Fix missing commas * Add const. * Add test. --- .../components/environment_canada/const.py | 1 + .../components/environment_canada/icons.json | 3 + .../environment_canada/services.yaml | 6 + .../environment_canada/strings.json | 4 + .../components/environment_canada/weather.py | 36 +- .../components/environment_canada/__init__.py | 1 + .../components/environment_canada/conftest.py | 3 + .../fixtures/current_conditions_data.json | 218 ++++++++++++ .../snapshots/test_weather.ambr | 334 ++++++++++++++++++ .../environment_canada/test_weather.py | 23 ++ 10 files changed, 626 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py index f1f6db2e0df..c2b58d8dcce 100644 --- a/homeassistant/components/environment_canada/const.py +++ b/homeassistant/components/environment_canada/const.py @@ -5,3 +5,4 @@ ATTR_STATION = "station" CONF_STATION = "station" CONF_TITLE = "title" DOMAIN = "environment_canada" +SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts" diff --git a/homeassistant/components/environment_canada/icons.json b/homeassistant/components/environment_canada/icons.json index c3562ce1840..ca55254cc12 100644 --- a/homeassistant/components/environment_canada/icons.json +++ b/homeassistant/components/environment_canada/icons.json @@ -21,6 +21,9 @@ "services": { "set_radar_type": { "service": "mdi:radar" + }, + "get_forecasts": { + "service": "mdi:weather-cloudy-clock" } } } diff --git a/homeassistant/components/environment_canada/services.yaml b/homeassistant/components/environment_canada/services.yaml index 4293b313f5c..0e33aeec933 100644 --- a/homeassistant/components/environment_canada/services.yaml +++ b/homeassistant/components/environment_canada/services.yaml @@ -1,3 +1,9 @@ +get_forecasts: + target: + entity: + integration: environment_canada + domain: weather + set_radar_type: target: entity: diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index 28ca55c6195..1ccff145bb3 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -113,6 +113,10 @@ } }, "services": { + "get_forecasts": { + "name": "Get forecasts", + "description": "Retrieves the forecast from selected weather services." + }, "set_radar_type": { "name": "Set radar type", "description": "Sets the type of radar image to retrieve.", diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index c7e51a32f68..dd7632032ec 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -35,11 +35,16 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.core import ( + HomeAssistant, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, SERVICE_ENVIRONMENT_CANADA_FORECASTS from .coordinator import ECConfigEntry, ECDataUpdateCoordinator # Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ @@ -78,6 +83,14 @@ async def async_setup_entry( async_add_entities([ECWeatherEntity(config_entry.runtime_data.weather_coordinator)]) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_ENVIRONMENT_CANADA_FORECASTS, + None, + "_async_environment_canada_forecasts", + supports_response=SupportsResponse.ONLY, + ) + def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str: """Calculate unique ID.""" @@ -185,6 +198,23 @@ class ECWeatherEntity( """Return the hourly forecast in native units.""" return get_forecast(self.ec_data, True) + def _async_environment_canada_forecasts(self) -> ServiceResponse: + """Return the native Environment Canada forecast.""" + daily = [] + for f in self.ec_data.daily_forecasts: + day = f.copy() + day["timestamp"] = day["timestamp"].isoformat() + daily.append(day) + + hourly = [] + for f in self.ec_data.hourly_forecasts: + hour = f.copy() + hour["timestamp"] = hour["period"].isoformat() + del hour["period"] + hourly.append(hour) + + return {"daily_forecast": daily, "hourly_forecast": hourly} + def get_forecast(ec_data, hourly) -> list[Forecast] | None: """Build the forecast array.""" diff --git a/tests/components/environment_canada/__init__.py b/tests/components/environment_canada/__init__.py index 92c28e09b74..edc7a92a12f 100644 --- a/tests/components/environment_canada/__init__.py +++ b/tests/components/environment_canada/__init__.py @@ -37,6 +37,7 @@ async def init_integration(hass: HomeAssistant, ec_data) -> MockConfigEntry: weather_mock.conditions = ec_data["conditions"] weather_mock.alerts = ec_data["alerts"] weather_mock.daily_forecasts = ec_data["daily_forecasts"] + weather_mock.hourly_forecasts = ec_data["hourly_forecasts"] weather_mock.metadata = ec_data["metadata"] radar_mock = mock_ec() diff --git a/tests/components/environment_canada/conftest.py b/tests/components/environment_canada/conftest.py index 69cec187d11..19180052c93 100644 --- a/tests/components/environment_canada/conftest.py +++ b/tests/components/environment_canada/conftest.py @@ -19,6 +19,9 @@ def ec_data(): if t := weather.get("timestamp"): with contextlib.suppress(ValueError): weather["timestamp"] = datetime.fromisoformat(t) + elif t := weather.get("period"): + with contextlib.suppress(ValueError): + weather["period"] = datetime.fromisoformat(t) return weather return json.loads( diff --git a/tests/components/environment_canada/fixtures/current_conditions_data.json b/tests/components/environment_canada/fixtures/current_conditions_data.json index ceb00028f95..e3b9563ef0b 100644 --- a/tests/components/environment_canada/fixtures/current_conditions_data.json +++ b/tests/components/environment_canada/fixtures/current_conditions_data.json @@ -238,6 +238,224 @@ "timestamp": "2022-10-09 15:00:00+00:00" } ], + "hourly_forecasts": [ + { + "period": "2025-02-19T19:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -11, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-19T20:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -10, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-19T21:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -10, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-19T22:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -11, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-19T23:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -11, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T00:00:00+00:00", + "condition": "Cloudy", + "temperature": -12, + "icon_code": "10", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T01:00:00+00:00", + "condition": "Cloudy", + "temperature": -13, + "icon_code": "10", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T02:00:00+00:00", + "condition": "Cloudy", + "temperature": -13, + "icon_code": "10", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T03:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -14, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T04:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -14, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T05:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -15, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T06:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -15, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T07:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -15, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T08:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -16, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T09:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -16, + "icon_code": "33", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T10:00:00+00:00", + "condition": "Partly cloudy", + "temperature": -16, + "icon_code": "32", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T11:00:00+00:00", + "condition": "Partly cloudy", + "temperature": -16, + "icon_code": "32", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "W" + }, + { + "period": "2025-02-20T12:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -16, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 5, + "wind_direction": "VR" + }, + { + "period": "2025-02-20T13:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -15, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 5, + "wind_direction": "VR" + }, + { + "period": "2025-02-20T14:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -14, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 5, + "wind_direction": "VR" + }, + { + "period": "2025-02-20T15:00:00+00:00", + "condition": "A mix of sun and cloud", + "temperature": -13, + "icon_code": "02", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "NW" + }, + { + "period": "2025-02-20T16:00:00+00:00", + "condition": "Mainly cloudy", + "temperature": -11, + "icon_code": "03", + "precip_probability": 20, + "wind_speed": 10, + "wind_direction": "NW" + }, + { + "period": "2025-02-20T17:00:00+00:00", + "condition": "Periods of light snow", + "temperature": -10, + "icon_code": "16", + "precip_probability": 70, + "wind_speed": 10, + "wind_direction": "NW" + }, + { + "period": "2025-02-20T18:00:00+00:00", + "condition": "Periods of light snow", + "temperature": -8, + "icon_code": "16", + "precip_probability": 70, + "wind_speed": 20, + "wind_direction": "NW" + } + ], "metadata": { "attribution": "Data provided by Environment Canada", "timestamp": "2022/10/3", diff --git a/tests/components/environment_canada/snapshots/test_weather.ambr b/tests/components/environment_canada/snapshots/test_weather.ambr index cfa0ad912a4..46dcacce8a4 100644 --- a/tests/components/environment_canada/snapshots/test_weather.ambr +++ b/tests/components/environment_canada/snapshots/test_weather.ambr @@ -92,3 +92,337 @@ }), }) # --- +# name: test_get_environment_canada_raw_forecast_data + dict({ + 'weather.home_forecast': dict({ + 'daily_forecast': list([ + dict({ + 'icon_code': '30', + 'period': 'Monday night', + 'precip_probability': 0, + 'temperature': -1, + 'temperature_class': 'low', + 'text_summary': 'Clear. Fog patches developing after midnight. Low minus 1 with frost.', + 'timestamp': '2022-10-03T15:00:00+00:00', + }), + dict({ + 'icon_code': '00', + 'period': 'Tuesday', + 'precip_probability': 0, + 'temperature': 18, + 'temperature_class': 'high', + 'text_summary': 'Sunny. Fog patches dissipating in the morning. High 18. UV index 5 or moderate.', + 'timestamp': '2022-10-04T15:00:00+00:00', + }), + dict({ + 'icon_code': '30', + 'period': 'Tuesday night', + 'precip_probability': 0, + 'temperature': 3, + 'temperature_class': 'low', + 'text_summary': 'Clear. Fog patches developing overnight. Low plus 3.', + 'timestamp': '2022-10-04T15:00:00+00:00', + }), + dict({ + 'icon_code': '00', + 'period': 'Wednesday', + 'precip_probability': 0, + 'temperature': 20, + 'temperature_class': 'high', + 'text_summary': 'Sunny. High 20.', + 'timestamp': '2022-10-05T15:00:00+00:00', + }), + dict({ + 'icon_code': '30', + 'period': 'Wednesday night', + 'precip_probability': 0, + 'temperature': 9, + 'temperature_class': 'low', + 'text_summary': 'Clear. Low 9.', + 'timestamp': '2022-10-05T15:00:00+00:00', + }), + dict({ + 'icon_code': '02', + 'period': 'Thursday', + 'precip_probability': 0, + 'temperature': 20, + 'temperature_class': 'high', + 'text_summary': 'A mix of sun and cloud. High 20.', + 'timestamp': '2022-10-06T15:00:00+00:00', + }), + dict({ + 'icon_code': '12', + 'period': 'Thursday night', + 'precip_probability': 0, + 'temperature': 7, + 'temperature_class': 'low', + 'text_summary': 'Showers. Low 7.', + 'timestamp': '2022-10-06T15:00:00+00:00', + }), + dict({ + 'icon_code': '12', + 'period': 'Friday', + 'precip_probability': 40, + 'temperature': 13, + 'temperature_class': 'high', + 'text_summary': 'Cloudy with 40 percent chance of showers. High 13.', + 'timestamp': '2022-10-07T15:00:00+00:00', + }), + dict({ + 'icon_code': '32', + 'period': 'Friday night', + 'precip_probability': 0, + 'temperature': 1, + 'temperature_class': 'low', + 'text_summary': 'Cloudy periods. Low plus 1.', + 'timestamp': '2022-10-07T15:00:00+00:00', + }), + dict({ + 'icon_code': '02', + 'period': 'Saturday', + 'precip_probability': 0, + 'temperature': 10, + 'temperature_class': 'high', + 'text_summary': 'A mix of sun and cloud. High 10.', + 'timestamp': '2022-10-08T15:00:00+00:00', + }), + dict({ + 'icon_code': '32', + 'period': 'Saturday night', + 'precip_probability': 0, + 'temperature': 3, + 'temperature_class': 'low', + 'text_summary': 'Cloudy periods. Low plus 3.', + 'timestamp': '2022-10-08T15:00:00+00:00', + }), + dict({ + 'icon_code': '02', + 'period': 'Sunday', + 'precip_probability': 0, + 'temperature': 12, + 'temperature_class': 'high', + 'text_summary': 'A mix of sun and cloud. High 12.', + 'timestamp': '2022-10-09T15:00:00+00:00', + }), + ]), + 'hourly_forecast': list([ + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -11, + 'timestamp': '2025-02-19T19:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -10, + 'timestamp': '2025-02-19T20:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -10, + 'timestamp': '2025-02-19T21:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -11, + 'timestamp': '2025-02-19T22:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -11, + 'timestamp': '2025-02-19T23:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Cloudy', + 'icon_code': '10', + 'precip_probability': 20, + 'temperature': -12, + 'timestamp': '2025-02-20T00:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Cloudy', + 'icon_code': '10', + 'precip_probability': 20, + 'temperature': -13, + 'timestamp': '2025-02-20T01:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Cloudy', + 'icon_code': '10', + 'precip_probability': 20, + 'temperature': -13, + 'timestamp': '2025-02-20T02:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -14, + 'timestamp': '2025-02-20T03:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -14, + 'timestamp': '2025-02-20T04:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -15, + 'timestamp': '2025-02-20T05:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -15, + 'timestamp': '2025-02-20T06:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -15, + 'timestamp': '2025-02-20T07:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T08:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '33', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T09:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Partly cloudy', + 'icon_code': '32', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T10:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Partly cloudy', + 'icon_code': '32', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T11:00:00+00:00', + 'wind_direction': 'W', + 'wind_speed': 10, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -16, + 'timestamp': '2025-02-20T12:00:00+00:00', + 'wind_direction': 'VR', + 'wind_speed': 5, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -15, + 'timestamp': '2025-02-20T13:00:00+00:00', + 'wind_direction': 'VR', + 'wind_speed': 5, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -14, + 'timestamp': '2025-02-20T14:00:00+00:00', + 'wind_direction': 'VR', + 'wind_speed': 5, + }), + dict({ + 'condition': 'A mix of sun and cloud', + 'icon_code': '02', + 'precip_probability': 20, + 'temperature': -13, + 'timestamp': '2025-02-20T15:00:00+00:00', + 'wind_direction': 'NW', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Mainly cloudy', + 'icon_code': '03', + 'precip_probability': 20, + 'temperature': -11, + 'timestamp': '2025-02-20T16:00:00+00:00', + 'wind_direction': 'NW', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Periods of light snow', + 'icon_code': '16', + 'precip_probability': 70, + 'temperature': -10, + 'timestamp': '2025-02-20T17:00:00+00:00', + 'wind_direction': 'NW', + 'wind_speed': 10, + }), + dict({ + 'condition': 'Periods of light snow', + 'icon_code': '16', + 'precip_probability': 70, + 'temperature': -8, + 'timestamp': '2025-02-20T18:00:00+00:00', + 'wind_direction': 'NW', + 'wind_speed': 20, + }), + ]), + }), + }) +# --- diff --git a/tests/components/environment_canada/test_weather.py b/tests/components/environment_canada/test_weather.py index 8e22f68462f..06166f41bca 100644 --- a/tests/components/environment_canada/test_weather.py +++ b/tests/components/environment_canada/test_weather.py @@ -5,6 +5,10 @@ from typing import Any from syrupy.assertion import SnapshotAssertion +from homeassistant.components.environment_canada.const import ( + DOMAIN, + SERVICE_ENVIRONMENT_CANADA_FORECASTS, +) from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECASTS, @@ -56,3 +60,22 @@ async def test_forecast_daily_with_some_previous_days_data( return_response=True, ) assert response == snapshot + + +async def test_get_environment_canada_raw_forecast_data( + hass: HomeAssistant, snapshot: SnapshotAssertion, ec_data: dict[str, Any] +) -> None: + """Test forecast with half day at start.""" + + await init_integration(hass, ec_data) + + response = await hass.services.async_call( + DOMAIN, + SERVICE_ENVIRONMENT_CANADA_FORECASTS, + { + "entity_id": "weather.home_forecast", + }, + blocking=True, + return_response=True, + ) + assert response == snapshot From eb6993f0a85f7cbc0f262607908aa39d83f67917 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 19 Feb 2025 22:39:17 +0100 Subject: [PATCH 0685/1941] Switch cleanup for Shelly (part 1) (#138791) --- homeassistant/components/shelly/switch.py | 89 ++++++++++------------- 1 file changed, 39 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 9b34b2e079b..41826706945 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast @@ -29,7 +30,7 @@ from .entity import ( ShellyRpcEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, - async_setup_rpc_attribute_entities, + async_setup_entry_rpc, ) from .utils import ( async_remove_orphaned_entities, @@ -60,18 +61,32 @@ MOTION_SWITCH = BlockSwitchDescription( class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): """Class to describe a RPC virtual switch.""" + is_on: Callable[[dict[str, Any]], bool] + method_on: str + method_off: str + method_params_fn: Callable[[int | None, bool], dict] -RPC_VIRTUAL_SWITCH = RpcSwitchDescription( - key="boolean", - sub_key="value", -) -RPC_SCRIPT_SWITCH = RpcSwitchDescription( - key="script", - sub_key="running", - entity_registry_enabled_default=False, - entity_category=EntityCategory.CONFIG, -) +RPC_SWITCHES = { + "boolean": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="Boolean.Set", + method_off="Boolean.Set", + method_params_fn=lambda id, value: {"id": id, "value": value}, + ), + "script": RpcSwitchDescription( + key="script", + sub_key="running", + is_on=lambda status: bool(status["running"]), + method_on="Script.Start", + method_off="Script.Stop", + method_params_fn=lambda id, _: {"id": id}, + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), +} async def async_setup_entry( @@ -174,20 +189,8 @@ def async_setup_rpc_entry( unique_id = f"{coordinator.mac}-switch:{id_}" async_remove_shelly_entity(hass, "light", unique_id) - async_setup_rpc_attribute_entities( - hass, - config_entry, - async_add_entities, - {"boolean": RPC_VIRTUAL_SWITCH}, - RpcVirtualSwitch, - ) - - async_setup_rpc_attribute_entities( - hass, - config_entry, - async_add_entities, - {"script": RPC_SCRIPT_SWITCH}, - RpcScriptSwitch, + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_SWITCHES, RpcSwitch ) # the user can remove virtual components from the device configuration, so we need @@ -324,8 +327,8 @@ class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) -class RpcVirtualSwitch(ShellyRpcAttributeEntity, SwitchEntity): - """Entity that controls a virtual boolean component on RPC based Shelly devices.""" +class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): + """Entity that controls a switch on RPC based Shelly devices.""" entity_description: RpcSwitchDescription _attr_has_entity_name = True @@ -333,32 +336,18 @@ class RpcVirtualSwitch(ShellyRpcAttributeEntity, SwitchEntity): @property def is_on(self) -> bool: """If switch is on.""" - return bool(self.attribute_value) + return self.entity_description.is_on(self.status) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on relay.""" - await self.call_rpc("Boolean.Set", {"id": self._id, "value": True}) + await self.call_rpc( + self.entity_description.method_on, + self.entity_description.method_params_fn(self._id, True), + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off relay.""" - await self.call_rpc("Boolean.Set", {"id": self._id, "value": False}) - - -class RpcScriptSwitch(ShellyRpcAttributeEntity, SwitchEntity): - """Entity that controls a script component on RPC based Shelly devices.""" - - entity_description: RpcSwitchDescription - _attr_has_entity_name = True - - @property - def is_on(self) -> bool: - """If switch is on.""" - return bool(self.status["running"]) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on relay.""" - await self.call_rpc("Script.Start", {"id": self._id}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off relay.""" - await self.call_rpc("Script.Stop", {"id": self._id}) + await self.call_rpc( + self.entity_description.method_off, + self.entity_description.method_params_fn(self._id, False), + ) From ad7780291ef140d5504be36bd4bf9685cafadf07 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Feb 2025 22:40:03 +0100 Subject: [PATCH 0686/1941] Correct backup date when reading a backup created by supervisor (#138860) --- homeassistant/components/backup/util.py | 7 +++++-- tests/components/backup/test_util.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 9d8f6e815dc..bd77880738e 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -104,12 +104,15 @@ def read_backup(backup_path: Path) -> AgentBackup: bool, homeassistant.get("exclude_database", False) ) + extra_metadata = cast(dict[str, bool | str], data.get("extra", {})) + date = extra_metadata.get("supervisor.backup_request_date", data["date"]) + return AgentBackup( addons=addons, backup_id=cast(str, data["slug"]), database_included=database_included, - date=cast(str, data["date"]), - extra_metadata=cast(dict[str, bool | str], data.get("extra", {})), + date=cast(str, date), + extra_metadata=extra_metadata, folders=folders, homeassistant_included=homeassistant_included, homeassistant_version=homeassistant_version, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 504e0d56d58..97e94eafb73 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -89,6 +89,28 @@ from tests.common import get_fixture_path size=1234, ), ), + # Check the backup_request_date is used as date if present + ( + b'{"compressed":true,"date":"2024-12-01T00:00:00.000000-00:00","homeassistant":' + b'{"exclude_database":true,"version":"2024.12.0.dev0"},"name":"test",' + b'"extra":{"supervisor.backup_request_date":"2025-12-01T00:00:00.000000-00:00"},' + b'"protected":true,"slug":"455645fe","type":"partial","version":2}', + AgentBackup( + addons=[], + backup_id="455645fe", + date="2025-12-01T00:00:00.000000-00:00", + database_included=False, + extra_metadata={ + "supervisor.backup_request_date": "2025-12-01T00:00:00.000000-00:00" + }, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=1234, + ), + ), ], ) def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -> None: From 901011de7b2f3c47ef43d0803c8d9555c51a16b9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 19 Feb 2025 22:47:23 +0100 Subject: [PATCH 0687/1941] Use xmod model info for Shelly XMOD devices (#137013) --- .../components/shelly/config_flow.py | 10 +++++++--- .../components/shelly/coordinator.py | 5 +++-- homeassistant/components/shelly/utils.py | 8 ++++++++ tests/components/shelly/conftest.py | 1 + tests/components/shelly/test_coordinator.py | 19 +++++++++++++++++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 45655745403..5c5e187a0f4 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -7,7 +7,12 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info -from aioshelly.const import BLOCK_GENERATIONS, DEFAULT_HTTP_PORT, RPC_GENERATIONS +from aioshelly.const import ( + BLOCK_GENERATIONS, + DEFAULT_HTTP_PORT, + MODEL_WALL_DISPLAY, + RPC_GENERATIONS, +) from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, @@ -41,7 +46,6 @@ from .const import ( CONF_SLEEP_PERIOD, DOMAIN, LOGGER, - MODEL_WALL_DISPLAY, BLEScannerMode, ) from .coordinator import async_reconnect_soon @@ -112,7 +116,7 @@ async def validate_input( return { "title": rpc_device.name, CONF_SLEEP_PERIOD: sleep_period, - "model": rpc_device.shelly.get("model"), + "model": rpc_device.xmod_info.get("p") or rpc_device.shelly.get("model"), CONF_GEN: gen, } diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index ad35ec32299..23d5842f4e4 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -10,7 +10,7 @@ from typing import Any, cast from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner from aioshelly.block_device import BlockDevice, BlockUpdateType -from aioshelly.const import MODEL_NAMES, MODEL_VALVE +from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -68,6 +68,7 @@ from .utils import ( async_create_issue_unsupported_firmware, get_block_device_sleep_period, get_device_entry_gen, + get_device_info_model, get_host, get_http_port, get_rpc_device_wakeup_period, @@ -164,7 +165,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( connections={(CONNECTION_NETWORK_MAC, self.mac)}, identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=MODEL_NAMES.get(self.model), + model=get_device_info_model(self.device), model_id=self.model, sw_version=self.sw_version, hw_version=f"gen{get_device_entry_gen(self.config_entry)}", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index fa310104424..4d3add7b17b 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -315,6 +315,14 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["type"], info["type"])) +def get_device_info_model(device: BlockDevice | RpcDevice) -> str | None: + """Return the device model for deviceinfo.""" + if isinstance(device, RpcDevice) and (model := device.xmod_info.get("n")): + return cast(str, model) + + return cast(str, MODEL_NAMES.get(device.model)) + + def get_rpc_channel_name(device: RpcDevice, key: str) -> str: """Get name based on device and channel name.""" key = key.replace("emdata", "em") diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index b3074742949..56b21701efe 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -476,6 +476,7 @@ def _mock_rpc_device(version: str | None = None): script_getcode=AsyncMock( side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} ), + xmod_info={}, ) type(device).name = PropertyMock(return_value="Test name") return device diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 090c5e7207f..8c011e4ad0d 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -1011,3 +1011,22 @@ async def test_rpc_already_connected( assert "already connected" in caplog.text mock_rpc_device.initialize.assert_called_once() + + +async def test_xmod_model_lookup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test XMOD model look-up.""" + xmod_model = "Test XMOD model name" + monkeypatch.setattr(mock_rpc_device, "xmod_info", {"n": xmod_model}) + entry = await init_integration(hass, 2) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(entry.unique_id))}, + ) + assert device + assert device.model == xmod_model From 5dfd358fc9fd8aab6649a14166d367d1fc245229 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 20 Feb 2025 03:51:13 +0100 Subject: [PATCH 0688/1941] Bump pyloadapi to 1.4.1 (#138894) --- homeassistant/components/pyload/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index e21167cf10b..4490057c8e0 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.3.2"] + "requirements": ["PyLoadAPI==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 29aaca8129e..4f38d9b5198 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.3.2 +PyLoadAPI==1.4.1 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f86bfa48a44..9b819688795 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.3.2 +PyLoadAPI==1.4.1 # homeassistant.components.met_eireann PyMetEireann==2024.11.0 From 5d851b6a567b0b845b6d70be020c695041c85446 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Thu, 20 Feb 2025 06:13:13 +0100 Subject: [PATCH 0689/1941] Add light platform to qbus (#136168) * Add light platform * Add on/off for light * Renamed add_entities to async_add_entities * Revert qbusmqttapi bump * Align dependency version * Use AddConfigEntryEntitiesCallback * Use AddConfigEntryEntitiesCallback --- homeassistant/components/qbus/const.py | 5 +- homeassistant/components/qbus/entity.py | 27 ++++ homeassistant/components/qbus/light.py | 110 ++++++++++++++++ homeassistant/components/qbus/switch.py | 30 ++--- .../qbus/fixtures/payload_config.json | 23 ++++ tests/components/qbus/test_light.py | 118 ++++++++++++++++++ 6 files changed, 293 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/qbus/light.py create mode 100644 tests/components/qbus/test_light.py diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index ddfb8963cb7..b9e42f13766 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -5,7 +5,10 @@ from typing import Final from homeassistant.const import Platform DOMAIN: Final = "qbus" -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.LIGHT, + Platform.SWITCH, +] CONF_SERIAL_NUMBER: Final = "serial" diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index 39bcddaaf4f..4ab1913c4dc 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -1,6 +1,9 @@ """Base class for Qbus entities.""" +from __future__ import annotations + from abc import ABC, abstractmethod +from collections.abc import Callable import re from qbusmqttapi.discovery import QbusMqttOutput @@ -10,12 +13,36 @@ from qbusmqttapi.state import QbusMqttState from homeassistant.components.mqtt import ReceiveMessage, client as mqtt from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER +from .coordinator import QbusControllerCoordinator _REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$") +def add_new_outputs( + coordinator: QbusControllerCoordinator, + added_outputs: list[QbusMqttOutput], + filter_fn: Callable[[QbusMqttOutput], bool], + entity_type: type[QbusEntity], + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Call async_add_entities for new outputs.""" + + added_ref_ids = {k.ref_id for k in added_outputs} + + new_outputs = [ + output + for output in coordinator.data + if filter_fn(output) and output.ref_id not in added_ref_ids + ] + + if new_outputs: + added_outputs.extend(new_outputs) + async_add_entities([entity_type(output) for output in new_outputs]) + + def format_ref_id(ref_id: str) -> str | None: """Format the Qbus ref_id.""" matches: list[str] = re.findall(_REFID_REGEX, ref_id) diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py new file mode 100644 index 00000000000..5ec76f5e807 --- /dev/null +++ b/homeassistant/components/qbus/light.py @@ -0,0 +1,110 @@ +"""Support for Qbus light.""" + +from typing import Any + +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttAnalogState, StateType + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import brightness_to_value, value_to_brightness + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, add_new_outputs + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up light entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "analog", + QbusLight, + async_add_entities, + ) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusLight(QbusEntity, LightEntity): + """Representation of a Qbus light entity.""" + + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_color_mode = ColorMode.BRIGHTNESS + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize light entity.""" + + super().__init__(mqtt_output) + + self._set_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + + percentage: int | None = None + on: bool | None = None + + state = QbusMqttAnalogState(id=self._mqtt_output.id) + + if brightness is None: + on = True + + state.type = StateType.ACTION + state.write_on_off(on) + else: + percentage = round(brightness_to_value((1, 100), brightness)) + + state.type = StateType.STATE + state.write_percentage(percentage) + + await self._async_publish_output_state(state) + self._set_state(percentage=percentage, on=on) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + state = QbusMqttAnalogState(id=self._mqtt_output.id, type=StateType.ACTION) + state.write_on_off(on=False) + + await self._async_publish_output_state(state) + self._set_state(on=False) + + async def _state_received(self, msg: ReceiveMessage) -> None: + output = self._message_factory.parse_output_state( + QbusMqttAnalogState, msg.payload + ) + + if output is not None: + percentage = round(output.read_percentage()) + self._set_state(percentage=percentage) + self.async_schedule_update_ha_state() + + def _set_state( + self, *, percentage: int | None = None, on: bool | None = None + ) -> None: + if percentage is None: + # When turning on without brightness, we don't know the desired + # brightness. It will be set during _state_received(). + if on is True: + self._attr_is_on = True + else: + self._attr_is_on = False + self._attr_brightness = 0 + else: + self._attr_is_on = percentage > 0 + self._attr_brightness = value_to_brightness((1, 100), percentage) diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index 8a932e1e414..002ad43e904 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import QbusConfigEntry -from .entity import QbusEntity +from .entity import QbusEntity, add_new_outputs PARALLEL_UPDATES = 0 @@ -19,26 +19,21 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, entry: QbusConfigEntry, - add_entities: AddConfigEntryEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch entities.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data added_outputs: list[QbusMqttOutput] = [] - # Local function that calls add_entities for new entities def _check_outputs() -> None: - added_output_ids = {k.id for k in added_outputs} - - new_outputs = [ - item - for item in coordinator.data - if item.type == "onoff" and item.id not in added_output_ids - ] - - if new_outputs: - added_outputs.extend(new_outputs) - add_entities([QbusSwitch(output) for output in new_outputs]) + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "onoff", + QbusSwitch, + async_add_entities, + ) _check_outputs() entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) @@ -49,10 +44,7 @@ class QbusSwitch(QbusEntity, SwitchEntity): _attr_device_class = SwitchDeviceClass.SWITCH - def __init__( - self, - mqtt_output: QbusMqttOutput, - ) -> None: + def __init__(self, mqtt_output: QbusMqttOutput) -> None: """Initialize switch entity.""" super().__init__(mqtt_output) diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json index 2ee38a9927e..e2c7f463e4e 100644 --- a/tests/components/qbus/fixtures/payload_config.json +++ b/tests/components/qbus/fixtures/payload_config.json @@ -42,6 +42,29 @@ "write": true } } + }, + { + "id": "UL15", + "location": "Media room", + "locationId": 0, + "name": "MEDIA ROOM", + "originalName": "MEDIA ROOM", + "refId": "000001/28", + "type": "analog", + "actions": { + "off": null, + "on": null + }, + "properties": { + "value": { + "max": 100, + "min": 5, + "read": true, + "step": 0.1, + "type": "number", + "write": true + } + } } ] } diff --git a/tests/components/qbus/test_light.py b/tests/components/qbus/test_light.py new file mode 100644 index 00000000000..c64219f1269 --- /dev/null +++ b/tests/components/qbus/test_light.py @@ -0,0 +1,118 @@ +"""Test Qbus light entities.""" + +import json + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonObjectType + +from .const import TOPIC_CONFIG + +from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.typing import MqttMockHAClient + +# 186 = 73% (rounded) +_BRIGHTNESS = 186 +_BRIGHTNESS_PCT = 73 + +_PAYLOAD_LIGHT_STATE_ON = '{"id":"UL15","properties":{"value":60},"type":"state"}' +_PAYLOAD_LIGHT_STATE_BRIGHTNESS = ( + '{"id":"UL15","properties":{"value":' + str(_BRIGHTNESS_PCT) + '},"type":"state"}' +) +_PAYLOAD_LIGHT_STATE_OFF = '{"id":"UL15","properties":{"value":0},"type":"state"}' + +_PAYLOAD_LIGHT_SET_STATE_ON = '{"id": "UL15", "type": "action", "action": "on"}' +_PAYLOAD_LIGHT_SET_STATE_BRIGHTNESS = ( + '{"id": "UL15", "type": "state", "properties": {"value": ' + + str(_BRIGHTNESS_PCT) + + "}}" +) +_PAYLOAD_LIGHT_SET_STATE_OFF = '{"id": "UL15", "type": "action", "action": "off"}' + +_TOPIC_LIGHT_STATE = "cloudapp/QBUSMQTTGW/UL1/UL15/state" +_TOPIC_LIGHT_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL15/setState" + +_LIGHT_ENTITY_ID = "light.media_room" + + +async def test_light( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Test turning on and off.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() + + # Switch ON + mqtt_mock.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _LIGHT_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_LIGHT_SET_STATE, _PAYLOAD_LIGHT_SET_STATE_ON, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(_LIGHT_ENTITY_ID).state == STATE_ON + + # Set brightness + mqtt_mock.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: _LIGHT_ENTITY_ID, + ATTR_BRIGHTNESS: _BRIGHTNESS, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_LIGHT_SET_STATE, _PAYLOAD_LIGHT_SET_STATE_BRIGHTNESS, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_BRIGHTNESS) + await hass.async_block_till_done() + + entity = hass.states.get(_LIGHT_ENTITY_ID) + assert entity.state == STATE_ON + assert entity.attributes.get(ATTR_BRIGHTNESS) == _BRIGHTNESS + + # Switch OFF + mqtt_mock.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: _LIGHT_ENTITY_ID}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_LIGHT_SET_STATE, _PAYLOAD_LIGHT_SET_STATE_OFF, 0, False + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(_LIGHT_ENTITY_ID).state == STATE_OFF From 5c8fa717bf8f0ff2235cce4f7c83a44344ad3d23 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 20 Feb 2025 08:14:08 +0100 Subject: [PATCH 0690/1941] Move test before setup coordinator `_async_setup` in pyLoad integration (#138893) Move setup test to `async_setup` in the coordinator --- homeassistant/components/pyload/__init__.py | 21 --------------- .../components/pyload/coordinator.py | 26 ++++++++++++++++++- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index 3dd2fd9b2ba..8251722de50 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from aiohttp import CookieJar from pyloadapi.api import PyLoadAPI -from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError from homeassistant.const import ( CONF_HOST, @@ -16,10 +15,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import DOMAIN from .coordinator import PyLoadConfigEntry, PyLoadCoordinator PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] @@ -45,24 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo password=entry.data[CONF_PASSWORD], ) - try: - await pyloadapi.login() - except CannotConnect as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_request_exception", - ) from e - except ParserError as e: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="setup_parse_exception", - ) from e - except InvalidAuth as e: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="setup_authentication_exception", - translation_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]}, - ) from e coordinator = PyLoadCoordinator(hass, entry, pyloadapi) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index 8b2db605c94..0d752e971e5 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -9,7 +9,7 @@ from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -85,3 +85,27 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): ) from e except ParserError as e: raise UpdateFailed("Unable to parse data from pyLoad API") from e + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + + try: + await self.pyload.login() + except CannotConnect as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_request_exception", + ) from e + except ParserError as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_parse_exception", + ) from e + except InvalidAuth as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_authentication_exception", + translation_placeholders={ + CONF_USERNAME: self.config_entry.data[CONF_USERNAME] + }, + ) from e From e5c0183e0f8c5b1eff4b778a6af73d0c4a3bcb3d Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 20 Feb 2025 08:15:14 +0100 Subject: [PATCH 0691/1941] Set parallel_updates in pyLoad integration (#138897) Set parallel_updates --- homeassistant/components/pyload/button.py | 2 ++ homeassistant/components/pyload/sensor.py | 2 ++ homeassistant/components/pyload/switch.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py index 9fcba7e723a..6303ced09f0 100644 --- a/homeassistant/components/pyload/button.py +++ b/homeassistant/components/pyload/button.py @@ -18,6 +18,8 @@ from .const import DOMAIN from .coordinator import PyLoadConfigEntry from .entity import BasePyLoadEntity +PARALLEL_UPDATES = 1 + @dataclass(kw_only=True, frozen=True) class PyLoadButtonEntityDescription(ButtonEntityDescription): diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py index edf7c6a756c..7425c543fe1 100644 --- a/homeassistant/components/pyload/sensor.py +++ b/homeassistant/components/pyload/sensor.py @@ -21,6 +21,8 @@ from .const import UNIT_DOWNLOADS from .coordinator import PyLoadConfigEntry, PyLoadData from .entity import BasePyLoadEntity +PARALLEL_UPDATES = 0 + class PyLoadSensorEntity(StrEnum): """pyLoad Sensor Entities.""" diff --git a/homeassistant/components/pyload/switch.py b/homeassistant/components/pyload/switch.py index d4416666d93..57160cbf5c1 100644 --- a/homeassistant/components/pyload/switch.py +++ b/homeassistant/components/pyload/switch.py @@ -22,6 +22,8 @@ from .const import DOMAIN from .coordinator import PyLoadConfigEntry, PyLoadData from .entity import BasePyLoadEntity +PARALLEL_UPDATES = 1 + class PyLoadSwitch(StrEnum): """PyLoad Switch Entities.""" From 14375e76a35439c898a9994bd5d9c11b1a7c9d84 Mon Sep 17 00:00:00 2001 From: Saswat Padhi Date: Thu, 20 Feb 2025 07:42:09 +0000 Subject: [PATCH 0692/1941] Opower: Fix unavailable "start date" and "end date" sensors (#138694) avoid passing string into date device class --- homeassistant/components/opower/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 61b0e0567b3..46aa9e9b318 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import date from opower import Forecast, MeterType, UnitOfMeasure @@ -28,7 +29,7 @@ from .coordinator import OpowerConfigEntry, OpowerCoordinator class OpowerEntityDescription(SensorEntityDescription): """Class describing Opower sensors entities.""" - value_fn: Callable[[Forecast], str | float] + value_fn: Callable[[Forecast], str | float | date] # suggested_display_precision=0 for all sensors since @@ -96,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: str(data.start_date), + value_fn=lambda data: data.start_date, ), OpowerEntityDescription( key="elec_end_date", @@ -104,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: str(data.end_date), + value_fn=lambda data: data.end_date, ), ) GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( @@ -168,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: str(data.start_date), + value_fn=lambda data: data.start_date, ), OpowerEntityDescription( key="gas_end_date", @@ -176,7 +177,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: str(data.end_date), + value_fn=lambda data: data.end_date, ), ) @@ -246,7 +247,7 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): self.utility_account_id = utility_account_id @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | date: """Return the state.""" if self.coordinator.data is not None: return self.entity_description.value_fn( From 1c3d6b5641d277c1bf4c21693a7056cf12bf1353 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 08:45:36 +0100 Subject: [PATCH 0693/1941] Minor readability improvement of Spotify browse media (#138907) --- homeassistant/components/spotify/browse_media.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index 458525dde28..686431da249 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -226,17 +226,17 @@ async def async_browse_media( if media_content_id is None or not media_content_id.startswith(MEDIA_PLAYER_PREFIX): raise BrowseError("Invalid Spotify URL specified") - # Check for config entry specifier, and extract Spotify URI + # The config entry id is the host name of the URL, the Spotify URI is the name parsed_url = yarl.URL(media_content_id) - host = parsed_url.host + config_entry_id = parsed_url.host if ( - host is None + config_entry_id is None # config entry ids can be upper or lower case. Yarl always returns host # names in lower case, so we need to look for the config entry in both or ( - entry := hass.config_entries.async_get_entry(host) - or hass.config_entries.async_get_entry(host.upper()) + entry := hass.config_entries.async_get_entry(config_entry_id) + or hass.config_entries.async_get_entry(config_entry_id.upper()) ) is None or entry.state is not ConfigEntryState.LOADED From a2ceeb19dcd706d3a73222bec8441a19c3bca72c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Feb 2025 08:47:37 +0100 Subject: [PATCH 0694/1941] Bump docker/build-push-action from 6.13.0 to 6.14.0 (#138902) --- .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 cdffcbe4d5b..ccd1fb22eb9 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 + uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0 + uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From 0949f7d0baca77e6ef011fb45048c3e3a7deb850 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 08:57:55 +0100 Subject: [PATCH 0695/1941] Adjust config entry state checks in qbus (#138911) --- homeassistant/components/qbus/__init__.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/qbus/__init__.py b/homeassistant/components/qbus/__init__.py index da9dcfe69be..f77f439ecc1 100644 --- a/homeassistant/components/qbus/__init__.py +++ b/homeassistant/components/qbus/__init__.py @@ -71,17 +71,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> boo if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): entry.runtime_data.shutdown() - cleanup(hass, entry) + _cleanup(hass, entry) return unload_ok -def cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None: +def _cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None: """Shutdown if no more entries are loaded.""" - entries = hass.config_entries.async_loaded_entries(DOMAIN) - count = len(entries) - - # During unloading of the entry, it is not marked as unloaded yet. So - # count can be 1 if it is the last one. - if count <= 1 and (config_coordinator := hass.data.get(QBUS_KEY)): + if not hass.config_entries.async_loaded_entries(DOMAIN) and ( + config_coordinator := hass.data.get(QBUS_KEY) + ): config_coordinator.shutdown() From 2f7a8b4d9d270df5c005744c6ffdd42237cbd62a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 08:58:37 +0100 Subject: [PATCH 0696/1941] Adjust config entry state checks in reolink (#138909) --- homeassistant/components/reolink/media_source.py | 5 +---- homeassistant/components/reolink/services.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 3505b4093ae..39514d58cb7 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -18,7 +18,6 @@ from homeassistant.components.media_source import ( Unresolvable, ) from homeassistant.components.stream import create_stream -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -151,9 +150,7 @@ class ReolinkVODMediaSource(MediaSource): entity_reg = er.async_get(self.hass) device_reg = dr.async_get(self.hass) - for config_entry in self.hass.config_entries.async_entries(DOMAIN): - if config_entry.state != ConfigEntryState.LOADED: - continue + for config_entry in self.hass.config_entries.async_loaded_entries(DOMAIN): channels: list[str] = [] host = config_entry.runtime_data.host entities = er.async_entries_for_config_entry( diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index acd31fe0d7d..d170aa32379 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -40,7 +40,7 @@ def async_setup_services(hass: HomeAssistant) -> None: if ( config_entry is None or device is None - or config_entry.state == ConfigEntryState.NOT_LOADED + or config_entry.state != ConfigEntryState.LOADED ): raise ServiceValidationError( translation_domain=DOMAIN, From 1bf7e5d749e27c3a356a17affe05f7a73e9596f1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 09:01:15 +0100 Subject: [PATCH 0697/1941] Adjust config entry state check in yolink (#138904) --- homeassistant/components/yolink/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index 8d622de70e7..f17408a7005 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -39,7 +39,7 @@ def async_register_services(hass: HomeAssistant) -> None: continue if entry.domain == DOMAIN: break - if entry is None or entry.state == ConfigEntryState.NOT_LOADED: + if entry is None or entry.state != ConfigEntryState.LOADED: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_config_entry", From 872cca9935bbdc18a884a1dbaf1bed6110f7de89 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Feb 2025 09:03:54 +0100 Subject: [PATCH 0698/1941] Bump actions/cache from 4.2.0 to 4.2.1 (#138901) --- .github/workflows/ci.yaml | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2a9f1571830..6eafa360e83 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -240,7 +240,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.1 with: path: venv key: >- @@ -256,7 +256,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.1 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -286,7 +286,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -295,7 +295,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -326,7 +326,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -335,7 +335,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -366,7 +366,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -375,7 +375,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -482,7 +482,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.1 with: path: venv key: >- @@ -490,7 +490,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.1 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -578,7 +578,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -611,7 +611,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -649,7 +649,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -692,7 +692,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -739,7 +739,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -791,7 +791,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -799,7 +799,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.0 + uses: actions/cache@v4.2.1 with: path: .mypy_cache key: >- @@ -865,7 +865,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -929,7 +929,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -1051,7 +1051,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -1181,7 +1181,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true @@ -1328,7 +1328,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.0 + uses: actions/cache/restore@v4.2.1 with: path: venv fail-on-cache-miss: true From e79a1a52c3382ebacb78deb4f5ceb5122071c66c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 09:08:46 +0100 Subject: [PATCH 0699/1941] Adjust config entry state checks in esphome (#138914) --- homeassistant/components/esphome/dashboard.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 334c16e5730..290feec1e2a 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -6,7 +6,7 @@ import asyncio import logging from typing import Any -from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -108,8 +108,7 @@ class ESPHomeDashboardManager: reloads = [ hass.config_entries.async_reload(entry.entry_id) - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state is ConfigEntryState.LOADED + for entry in hass.config_entries.async_loaded_entries(DOMAIN) ] # Re-auth flows will check the dashboard for encryption key when the form is requested # but we only trigger reauth if the dashboard is available. From 1392bab4d5185794f0fbaf9835114180272c4ec1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 09:11:15 +0100 Subject: [PATCH 0700/1941] Adjust config entry state checks in renault (#138910) --- homeassistant/components/renault/services.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index 80fb2363b1e..df65d16b0b8 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -178,9 +177,8 @@ def setup_services(hass: HomeAssistant) -> None: loaded_entries: list[RenaultConfigEntry] = [ entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - and entry.entry_id in device_entry.config_entries + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries ] for entry in loaded_entries: for vin, vehicle in entry.runtime_data.vehicles.items(): From 08358514b4884919b337b9ae5585086de07b55ca Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 09:14:17 +0100 Subject: [PATCH 0701/1941] Adjust config entry state checks in mcp_server (#138913) --- homeassistant/components/mcp_server/http.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 433d978cef7..bc8fdbd56c8 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -25,7 +25,6 @@ from mcp import types from homeassistant.components import conversation from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm @@ -56,11 +55,9 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry: Will raise an HTTP error if the expected configuration is not present. """ - config_entries: list[MCPServerConfigEntry] = [ - config_entry - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.state == ConfigEntryState.LOADED - ] + config_entries: list[MCPServerConfigEntry] = ( + hass.config_entries.async_loaded_entries(DOMAIN) + ) if not config_entries: raise HTTPNotFound(text="Model Context Protocol server is not configured") if len(config_entries) > 1: From c7169a4ed797afe403eeb7ae15ae0dcb9d496db3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 09:14:45 +0100 Subject: [PATCH 0702/1941] Adjust config entry state checks in nest (#138912) --- homeassistant/components/nest/device_info.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index facd429b139..8241b8aa5f8 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -7,7 +7,6 @@ from collections.abc import Mapping from google_nest_sdm.device import Device from google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -84,8 +83,7 @@ def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]: """Return a mapping of all nest devices for all config entries.""" return { device.name: device - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.state == ConfigEntryState.LOADED + for config_entry in hass.config_entries.async_loaded_entries(DOMAIN) for device in config_entry.runtime_data.device_manager.devices.values() } From d24a14442fdc58e06049817877af82aa051ffc66 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 09:38:15 +0100 Subject: [PATCH 0703/1941] Adjust cleanup of removed integration aladdin_connect (#138917) --- .../components/aladdin_connect/__init__.py | 18 ++++++----- tests/components/aladdin_connect/test_init.py | 31 ++++++++++++++++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 6d3f1d642b5..af50147a8ef 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -28,11 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index b01af287b7b..b2ef0a722fd 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -1,7 +1,11 @@ """Tests for the Aladdin Connect integration.""" from homeassistant.components.aladdin_connect import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_aladdin_connect_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_aladdin_connect_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From 66af5ca1e98a9441a7a48defc279f4a1a741f4e6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Feb 2025 10:04:05 +0100 Subject: [PATCH 0704/1941] Improve action descriptions of ness_alarm integration (#138921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - for the panic action change the description to "Triggers a panic _alarm_" as we don't want to trigger a panic ;-) - for the aux action replace "Trigger …" with "Changes the state of an aux output" as it can turn this off as well - clarify the description of the state field, dropping "true" for a UI-friendly wording --- homeassistant/components/ness_alarm/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ness_alarm/strings.json b/homeassistant/components/ness_alarm/strings.json index ec4e39a6128..f4490ac98db 100644 --- a/homeassistant/components/ness_alarm/strings.json +++ b/homeassistant/components/ness_alarm/strings.json @@ -2,7 +2,7 @@ "services": { "aux": { "name": "Aux", - "description": "Trigger an aux output.", + "description": "Changes the state of an aux output.", "fields": { "output_id": { "name": "Output ID", @@ -10,17 +10,17 @@ }, "state": { "name": "State", - "description": "The On/Off State. If P14xE 8E is enabled then a value of true will pulse output x for the time specified in P14(x+4)E." + "description": "The on/off state of the output. If P14xE 8E is enabled then turning on will pulse the output for the time specified in P14(x+4)E." } } }, "panic": { "name": "Panic", - "description": "Triggers a panic.", + "description": "Triggers a panic alarm.", "fields": { "code": { "name": "Code", - "description": "The user code to use to trigger the panic." + "description": "The user code to use to trigger the panic alarm." } } } From 1a56dcfdafa521f78a3ef078a53707623a78a202 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 20 Feb 2025 10:46:24 +0100 Subject: [PATCH 0705/1941] Fix Reolink callback id collision (#138918) --- homeassistant/components/reolink/entity.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index e3a84579865..55ce4ce891e 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -107,10 +107,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] """Handle incoming TCP push event.""" self.async_write_ha_state() - def register_callback(self, unique_id: str, cmd_id: int) -> None: + def register_callback(self, callback_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" self._host.api.baichuan.register_callback( # pragma: no cover - unique_id, self._push_callback, cmd_id + callback_id, self._push_callback, cmd_id ) async def async_added_to_hass(self) -> None: @@ -118,23 +118,25 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] await super().async_added_to_hass() cmd_key = self.entity_description.cmd_key cmd_id = self.entity_description.cmd_id + callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_register_update_cmd(cmd_key) if cmd_id is not None: - self.register_callback(self._attr_unique_id, cmd_id) + self.register_callback(callback_id, cmd_id) # Privacy mode - self.register_callback(f"{self._attr_unique_id}_623", 623) + self.register_callback(f"{callback_id}_623", 623) async def async_will_remove_from_hass(self) -> None: """Entity removed.""" cmd_key = self.entity_description.cmd_key cmd_id = self.entity_description.cmd_id + callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_unregister_update_cmd(cmd_key) if cmd_id is not None: - self._host.api.baichuan.unregister_callback(self._attr_unique_id) + self._host.api.baichuan.unregister_callback(callback_id) # Privacy mode - self._host.api.baichuan.unregister_callback(f"{self._attr_unique_id}_623") + self._host.api.baichuan.unregister_callback(f"{callback_id}_623") await super().async_will_remove_from_hass() @@ -193,10 +195,10 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Return True if entity is available.""" return super().available and self._host.api.camera_online(self._channel) - def register_callback(self, unique_id: str, cmd_id: int) -> None: + def register_callback(self, callback_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" self._host.api.baichuan.register_callback( - unique_id, self._push_callback, cmd_id, self._channel + callback_id, self._push_callback, cmd_id, self._channel ) async def async_added_to_hass(self) -> None: From b3e245687cf26c97c68d4a9a3479d4ca00abf51a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Feb 2025 03:48:01 -0600 Subject: [PATCH 0706/1941] Bump bluetooth-auto-recovery to 1.4.4 (#138895) --- 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 a21b7126a8e..b77beb64ea0 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak==0.22.3", "bleak-retry-connector==3.8.1", "bluetooth-adapters==0.21.4", - "bluetooth-auto-recovery==1.4.2", + "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.23.4", "dbus-fast==2.33.0", "habluetooth==3.22.0" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 03da649b32f..88be0a47025 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ bcrypt==4.2.0 bleak-retry-connector==3.8.1 bleak==0.22.3 bluetooth-adapters==0.21.4 -bluetooth-auto-recovery==1.4.2 +bluetooth-auto-recovery==1.4.4 bluetooth-data-tools==1.23.4 cached-ipaddress==0.8.0 certifi>=2021.5.30 diff --git a/requirements_all.txt b/requirements_all.txt index 4f38d9b5198..17c55015bdd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -628,7 +628,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.2 +bluetooth-auto-recovery==1.4.4 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b819688795..eb2d3a65bc4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -552,7 +552,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.2 +bluetooth-auto-recovery==1.4.4 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble From 6aae319b1ae13d2f26e78de0b9fbb598da53188c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 20 Feb 2025 10:48:45 +0100 Subject: [PATCH 0707/1941] Allow use of insecure ciphers in rest_command (#138886) --- homeassistant/components/rest_command/__init__.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index fe3702510af..f4c84bf72b5 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -34,6 +34,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType +from homeassistant.util.ssl import SSLCipherList DOMAIN = "rest_command" @@ -46,6 +47,7 @@ DEFAULT_VERIFY_SSL = True SUPPORT_REST_METHODS = ["get", "patch", "post", "put", "delete"] CONF_CONTENT_TYPE = "content_type" +CONF_INSECURE_CIPHER = "insecure_cipher" COMMAND_SCHEMA = vol.Schema( { @@ -60,6 +62,7 @@ COMMAND_SCHEMA = vol.Schema( vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), vol.Optional(CONF_CONTENT_TYPE): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_INSECURE_CIPHER, default=False): cv.boolean, } ) @@ -91,7 +94,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback def async_register_rest_command(name: str, command_config: dict[str, Any]) -> None: """Create service for rest command.""" - websession = async_get_clientsession(hass, command_config[CONF_VERIFY_SSL]) + websession = async_get_clientsession( + hass, + command_config[CONF_VERIFY_SSL], + ssl_cipher=( + SSLCipherList.INSECURE + if command_config[CONF_INSECURE_CIPHER] + else SSLCipherList.PYTHON_DEFAULT + ), + ) timeout = command_config[CONF_TIMEOUT] method = command_config[CONF_METHOD] From 20f273f06abda1041343ae9fe1ba971a20e1f197 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Thu, 20 Feb 2025 12:07:12 +0100 Subject: [PATCH 0708/1941] Add button platform to Homee (#138923) --- homeassistant/components/homee/__init__.py | 2 +- homeassistant/components/homee/button.py | 78 +++ homeassistant/components/homee/strings.json | 35 ++ tests/components/homee/fixtures/buttons.json | 274 +++++++++ .../homee/snapshots/test_button.ambr | 566 ++++++++++++++++++ tests/components/homee/test_button.py | 50 ++ 6 files changed, 1004 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/homee/button.py create mode 100644 tests/components/homee/fixtures/buttons.json create mode 100644 tests/components/homee/snapshots/test_button.ambr create mode 100644 tests/components/homee/test_button.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 7d9db9eb180..530c7920b27 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -14,7 +14,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.COVER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR, Platform.SWITCH] type HomeeConfigEntry = ConfigEntry[Homee] diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py new file mode 100644 index 00000000000..f39ee3f5a87 --- /dev/null +++ b/homeassistant/components/homee/button.py @@ -0,0 +1,78 @@ +"""The homee button platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = { + AttributeType.AUTOMATIC_MODE_IMPULSE: ButtonEntityDescription(key="automatic_mode"), + AttributeType.BRIEFLY_OPEN_IMPULSE: ButtonEntityDescription(key="briefly_open"), + AttributeType.IDENTIFICATION_MODE: ButtonEntityDescription( + key="identification_mode", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=ButtonDeviceClass.IDENTIFY, + ), + AttributeType.IMPULSE: ButtonEntityDescription(key="impulse"), + AttributeType.LIGHT_IMPULSE: ButtonEntityDescription(key="light"), + AttributeType.OPEN_PARTIAL_IMPULSE: ButtonEntityDescription(key="open_partial"), + AttributeType.PERMANENTLY_OPEN_IMPULSE: ButtonEntityDescription( + key="permanently_open" + ), + AttributeType.RESET_METER: ButtonEntityDescription( + key="reset_meter", + entity_category=EntityCategory.CONFIG, + ), + AttributeType.VENTILATE_IMPULSE: ButtonEntityDescription(key="ventilate"), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the button component.""" + + async_add_entities( + HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable + ) + + +class HomeeButton(HomeeEntity, ButtonEntity): + """Representation of a Homee button.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: ButtonEntityDescription, + ) -> None: + """Initialize a Homee button entity.""" + super().__init__(attribute, entry) + self.entity_description = description + if attribute.instance == 0: + if attribute.type == AttributeType.IMPULSE: + self._attr_name = None + else: + self._attr_translation_key = description.key + else: + self._attr_translation_key = f"{description.key}_instance" + self._attr_translation_placeholders = {"instance": str(attribute.instance)} + + async def async_press(self) -> None: + """Handle the button press.""" + await self.async_set_value(1) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 07f8eb6fb04..fabe02a0377 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -26,6 +26,41 @@ } }, "entity": { + "button": { + "automatic_mode": { + "name": "Automatic mode" + }, + "briefly_open": { + "name": "Briefly open" + }, + "identification_mode": { + "name": "Identification mode" + }, + "impulse_instance": { + "name": "Impulse {instance}" + }, + "light": { + "name": "Light" + }, + "light_instance": { + "name": "Light {instance}" + }, + "open_partial": { + "name": "Open partially" + }, + "permanently_open": { + "name": "Open permanently" + }, + "reset_meter": { + "name": "Reset meter" + }, + "reset_meter_instance": { + "name": "Reset meter {instance}" + }, + "ventilate": { + "name": "Ventilate" + } + }, "sensor": { "brightness_instance": { "name": "Illuminance {instance}" diff --git a/tests/components/homee/fixtures/buttons.json b/tests/components/homee/fixtures/buttons.json new file mode 100644 index 00000000000..306aed39f65 --- /dev/null +++ b/tests/components/homee/fixtures/buttons.json @@ -0,0 +1,274 @@ +{ + "id": 1, + "name": "Test Button", + "profile": 2015, + "image": "default", + "favorite": 0, + "order": 1, + "protocol": 19, + "routing": 0, + "state": 1, + "state_changed": 1676561556, + "added": 1675835814, + "history": 1, + "cube_type": 17, + "note": "# Hörmann Garagentor Serie 3", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 326, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 327, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 170, + "state": 1, + "last_changed": 1672148539, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 304, + "state": 1, + "last_changed": 1739125922, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "" + }, + { + "id": 5, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 304, + "state": 1, + "last_changed": 1739125922, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "" + }, + { + "id": 6, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 304, + "state": 1, + "last_changed": 1739125922, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "" + }, + { + "id": 7, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 305, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 306, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 328, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 10, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 347, + "state": 1, + "last_changed": 1682166450, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 11, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 347, + "state": 1, + "last_changed": 1682166450, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 378, + "state": 1, + "last_changed": 1677692134, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_button.ambr b/tests/components/homee/snapshots/test_button.ambr new file mode 100644 index 00000000000..be2bbae539b --- /dev/null +++ b/tests/components/homee/snapshots/test_button.ambr @@ -0,0 +1,566 @@ +# serializer version: 1 +# name: test_button_snapshot[button.test_button-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button', + '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': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button', + }), + 'context': , + 'entity_id': 'button.test_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_automatic_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': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_automatic_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Automatic mode', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'automatic_mode', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_automatic_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Automatic mode', + }), + 'context': , + 'entity_id': 'button.test_button_automatic_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_briefly_open-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_briefly_open', + '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': 'Briefly open', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'briefly_open', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_briefly_open-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Briefly open', + }), + 'context': , + 'entity_id': 'button.test_button_briefly_open', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_identification_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': 'button', + 'entity_category': , + 'entity_id': 'button.test_button_identification_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identification mode', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'identification_mode', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_identification_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Test Button Identification mode', + }), + 'context': , + 'entity_id': 'button.test_button_identification_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_impulse_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_impulse_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': 'Impulse 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'impulse_instance', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_impulse_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Impulse 1', + }), + 'context': , + 'entity_id': 'button.test_button_impulse_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_impulse_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_impulse_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': 'Impulse 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'impulse_instance', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_impulse_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Impulse 2', + }), + 'context': , + 'entity_id': 'button.test_button_impulse_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Light', + }), + 'context': , + 'entity_id': 'button.test_button_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_open_partially-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_open_partially', + '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': 'Open partially', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'open_partial', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_open_partially-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Open partially', + }), + 'context': , + 'entity_id': 'button.test_button_open_partially', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_open_permanently-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_open_permanently', + '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': 'Open permanently', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'permanently_open', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_open_permanently-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Open permanently', + }), + 'context': , + 'entity_id': 'button.test_button_open_permanently', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_reset_meter_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_button_reset_meter_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': 'Reset meter 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_meter_instance', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_reset_meter_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Reset meter 1', + }), + 'context': , + 'entity_id': 'button.test_button_reset_meter_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_reset_meter_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_button_reset_meter_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': 'Reset meter 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_meter_instance', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_reset_meter_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Reset meter 2', + }), + 'context': , + 'entity_id': 'button.test_button_reset_meter_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_snapshot[button.test_button_ventilate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_button_ventilate', + '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': 'Ventilate', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ventilate', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_button_ventilate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button Ventilate', + }), + 'context': , + 'entity_id': 'button.test_button_ventilate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/homee/test_button.py b/tests/components/homee/test_button.py new file mode 100644 index 00000000000..fc7b018805f --- /dev/null +++ b/tests/components/homee/test_button.py @@ -0,0 +1,50 @@ +"""Test Homee buttons.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +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_button_press( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test press button service.""" + mock_homee.nodes = [build_mock_node("buttons.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( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_button_impulse_1"}, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(1, 5, 1) + + +async def test_button_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("buttons.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 119b296c26d1aa6ea64967d9e2c9d40117a52765 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Feb 2025 12:11:34 +0100 Subject: [PATCH 0709/1941] Make backup config update a callback (#138925) --- homeassistant/components/backup/config.py | 3 ++- homeassistant/components/backup/manager.py | 2 +- homeassistant/components/backup/websocket.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 4d0cd82bc44..f34c1b8887d 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -154,7 +154,8 @@ class BackupConfig: self.data.retention.apply(self._manager) self.data.schedule.apply(self._manager) - async def update( + @callback + def update( self, *, agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 5a1bcde2b3b..0f79cd79e0c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1870,7 +1870,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): and "hassio.local" in create_backup.agent_ids ): automatic_agents = [self._local_agent_id, *automatic_agents] - await config.update( + config.update( create_backup=CreateBackupParametersDict( agent_ids=automatic_agents, include_addons=None, diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 8453046cabb..b36343c7634 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -346,6 +346,7 @@ async def handle_config_info( ) +@callback @websocket_api.require_admin @websocket_api.websocket_command( { @@ -387,8 +388,7 @@ async def handle_config_info( ), } ) -@websocket_api.async_response -async def handle_config_update( +def handle_config_update( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], @@ -398,7 +398,7 @@ async def handle_config_update( changes = dict(msg) changes.pop("id") changes.pop("type") - await manager.config.update(**changes) + manager.config.update(**changes) connection.send_result(msg["id"]) From e916b57714a56e7717ce1e95425fef8f595058db Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 12:16:23 +0100 Subject: [PATCH 0710/1941] Adjust cleanup of removed integration eight_sleep (#138926) --- .../components/eight_sleep/__init__.py | 18 +++++----- tests/components/eight_sleep/test_init.py | 33 +++++++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 9df39bbe314..cfb2cfba845 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -28,11 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/tests/components/eight_sleep/test_init.py b/tests/components/eight_sleep/test_init.py index 6b94ff31139..2a1845191d3 100644 --- a/tests/components/eight_sleep/test_init.py +++ b/tests/components/eight_sleep/test_init.py @@ -1,14 +1,18 @@ """Tests for the Eight Sleep integration.""" from homeassistant.components.eight_sleep import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from tests.common import MockConfigEntry -async def test_mazda_repair_issue( +async def test_eight_sleep_repair_issue( hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test the Eight Sleep configuration entry loading/unloading handles the repair.""" @@ -33,6 +37,28 @@ async def test_mazda_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_mazda_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From e53617a788cd5f22926a563b455fbb0198ecb37f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 12:16:39 +0100 Subject: [PATCH 0711/1941] Adjust cleanup of removed integration life360 (#138928) --- homeassistant/components/life360/__init__.py | 19 +++++++----- tests/components/life360/test_init.py | 31 +++++++++++++++++++- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 5c2d62545d6..60c1ac753e6 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -26,11 +26,14 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) + """Unload a config entry.""" return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/tests/components/life360/test_init.py b/tests/components/life360/test_init.py index 0a781f6f2b2..6bdea177e61 100644 --- a/tests/components/life360/test_init.py +++ b/tests/components/life360/test_init.py @@ -1,7 +1,11 @@ """Tests for the MyQ Connected Services integration.""" from homeassistant.components.life360 import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_life360_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_life360_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From 94869f32102f76ff34f2c9fec590807b9c5fedf6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 12:17:10 +0100 Subject: [PATCH 0712/1941] Adjust cleanup of removed integration linear_garage_door (#138929) --- .../components/linear_garage_door/__init__.py | 18 +++-- .../linear_garage_door/test_init.py | 76 +++++++++++++++++-- 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py index 5e524fbb512..c2a6c6a7ed1 100644 --- a/homeassistant/components/linear_garage_door/__init__.py +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -43,14 +43,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/tests/components/linear_garage_door/test_init.py b/tests/components/linear_garage_door/test_init.py index 8f1e85f28ff..2693eda60bb 100644 --- a/tests/components/linear_garage_door/test_init.py +++ b/tests/components/linear_garage_door/test_init.py @@ -6,7 +6,12 @@ from linear_garage_door import InvalidLoginError import pytest from homeassistant.components.linear_garage_door.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -58,18 +63,73 @@ async def test_setup_failure( async def test_repair_issue( hass: HomeAssistant, mock_linear: AsyncMock, - mock_config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry, ) -> None: - """Test reauth trigger setup.""" - - await setup_integration(hass, mock_config_entry, []) - assert mock_config_entry.state is ConfigEntryState.LOADED + """Test the Linear Garage Door configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + domain=DOMAIN, + entry_id="acefdd4b3a4a0911067d1cf51414201e", + title="test-site-name", + data={ + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + await setup_integration(hass, config_entry_1, []) + assert config_entry_1.state is ConfigEntryState.LOADED + # Add a second one + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + entry_id="acefdd4b3a4a0911067d1cf51414201f", + title="test-site-name", + data={ + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + "site_id": "test-site-id", + "device_id": "test-uuid", + }, + ) + await setup_integration(hass, config_entry_2, []) + assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - await hass.config_entries.async_remove(mock_config_entry.entry_id) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From affec21a6a0725c03aae5faba0fd86fec169f69b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 12:17:58 +0100 Subject: [PATCH 0713/1941] Adjust cleanup of removed integration mazda (#138930) --- homeassistant/components/mazda/__init__.py | 18 +++++++------ tests/components/mazda/test_init.py | 31 +++++++++++++++++++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index fd323060ac0..ccbb331573e 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py index 5d15f01389b..b024c214888 100644 --- a/tests/components/mazda/test_init.py +++ b/tests/components/mazda/test_init.py @@ -1,7 +1,11 @@ """Tests for the Mazda Connected Services integration.""" from homeassistant.components.mazda import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_mazda_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_mazda_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From d9a18c29941dcbf510f2afe2b0f5c8267c5f670e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 12:18:40 +0100 Subject: [PATCH 0714/1941] Adjust cleanup of removed integration myq (#138931) --- homeassistant/components/myq/__init__.py | 18 ++++++++------ tests/components/myq/test_init.py | 31 +++++++++++++++++++++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py index 41b36a34c20..47629006887 100644 --- a/homeassistant/components/myq/__init__.py +++ b/homeassistant/components/myq/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/tests/components/myq/test_init.py b/tests/components/myq/test_init.py index 24e03f56075..61ec0273f76 100644 --- a/tests/components/myq/test_init.py +++ b/tests/components/myq/test_init.py @@ -1,7 +1,11 @@ """Tests for the MyQ Connected Services integration.""" from homeassistant.components.myq import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_myq_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_myq_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From 6d6dfce7d13195eed0ae79a485ea392f444aae71 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 12:19:00 +0100 Subject: [PATCH 0715/1941] Adjust cleanup of removed integration spider (#138932) --- homeassistant/components/spider/__init__.py | 18 ++++++------ tests/components/spider/test_init.py | 31 ++++++++++++++++++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/spider/__init__.py b/homeassistant/components/spider/__init__.py index 4b138ec77a8..c0d85c02dd4 100644 --- a/homeassistant/components/spider/__init__.py +++ b/homeassistant/components/spider/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if all( - config_entry.state is ConfigEntryState.NOT_LOADED - for config_entry in hass.config_entries.async_entries(DOMAIN) - if config_entry.entry_id != entry.entry_id - ): - ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/tests/components/spider/test_init.py b/tests/components/spider/test_init.py index 6d1d87cfa6a..f28fc9d5871 100644 --- a/tests/components/spider/test_init.py +++ b/tests/components/spider/test_init.py @@ -1,7 +1,11 @@ """Tests for the Spider integration.""" from homeassistant.components.spider import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigEntryDisabler, + ConfigEntryState, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -33,6 +37,28 @@ async def test_spider_repair_issue( assert config_entry_2.state is ConfigEntryState.LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Add an ignored entry + config_entry_3 = MockConfigEntry( + source=SOURCE_IGNORE, + domain=DOMAIN, + ) + config_entry_3.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_3.entry_id) + await hass.async_block_till_done() + + assert config_entry_3.state is ConfigEntryState.NOT_LOADED + + # Add a disabled entry + config_entry_4 = MockConfigEntry( + disabled_by=ConfigEntryDisabler.USER, + domain=DOMAIN, + ) + config_entry_4.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_4.entry_id) + await hass.async_block_till_done() + + assert config_entry_4.state is ConfigEntryState.NOT_LOADED + # Remove the first one await hass.config_entries.async_remove(config_entry_1.entry_id) await hass.async_block_till_done() @@ -48,3 +74,6 @@ async def test_spider_repair_issue( assert config_entry_1.state is ConfigEntryState.NOT_LOADED assert config_entry_2.state is ConfigEntryState.NOT_LOADED assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None + + # Check the ignored and disabled entries are removed + assert not hass.config_entries.async_entries(DOMAIN) From d2bd45099b072e65626b3a826812c06b448d7632 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Feb 2025 06:11:14 -0600 Subject: [PATCH 0716/1941] Bump habluetooth to 3.22.1 and bleak-retry-connector to 3.9.0 (#138898) --- homeassistant/components/bluetooth/manifest.json | 4 ++-- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index b77beb64ea0..9cdaaaa2e16 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,11 +16,11 @@ "quality_scale": "internal", "requirements": [ "bleak==0.22.3", - "bleak-retry-connector==3.8.1", + "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.23.4", "dbus-fast==2.33.0", - "habluetooth==3.22.0" + "habluetooth==3.22.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 88be0a47025..5de22bf698e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ audioop-lts==0.2.1 av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 -bleak-retry-connector==3.8.1 +bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.4 @@ -33,7 +33,7 @@ dbus-fast==2.33.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.22.0 +habluetooth==3.22.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 17c55015bdd..dac373757df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -600,7 +600,7 @@ bizkaibus==0.1.1 bleak-esphome==2.7.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.1 +bleak-retry-connector==3.9.0 # homeassistant.components.bluetooth bleak==0.22.3 @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.22.0 +habluetooth==3.22.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb2d3a65bc4..de7290c37b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -531,7 +531,7 @@ bimmer-connected[china]==0.17.2 bleak-esphome==2.7.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.8.1 +bleak-retry-connector==3.9.0 # homeassistant.components.bluetooth bleak==0.22.3 @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.22.0 +habluetooth==3.22.1 # homeassistant.components.cloud hass-nabucasa==0.92.0 From 2d0967994e72a8e8bf063523f671c18328fe8e59 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Thu, 20 Feb 2025 06:14:57 -0600 Subject: [PATCH 0717/1941] Fix ability to set HEOS options (#138235) --- homeassistant/components/heos/config_flow.py | 39 +++-- tests/components/heos/__init__.py | 6 +- tests/components/heos/test_config_flow.py | 142 ++++++++++++++++++- 3 files changed, 170 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index ac09b7ca6bc..aee9bf4c47e 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -5,15 +5,16 @@ import logging from typing import TYPE_CHECKING, Any from urllib.parse import urlparse -from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions +from pyheos import ( + CommandAuthenticationError, + ConnectionState, + Heos, + HeosError, + HeosOptions, +) import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntryState, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector @@ -48,13 +49,19 @@ async def _validate_host(host: str, errors: dict[str, str]) -> bool: async def _validate_auth( - user_input: dict[str, str], heos: Heos, errors: dict[str, str] + user_input: dict[str, str], entry: HeosConfigEntry, errors: dict[str, str] ) -> bool: """Validate authentication by signing in or out, otherwise populate errors if needed.""" + can_validate = ( + hasattr(entry, "runtime_data") + and entry.runtime_data.heos.connection_state is ConnectionState.CONNECTED + ) if not user_input: # Log out (neither username nor password provided) + if not can_validate: + return True try: - await heos.sign_out() + await entry.runtime_data.heos.sign_out() except HeosError: errors["base"] = "unknown" _LOGGER.exception("Unexpected error occurred during sign-out") @@ -73,8 +80,12 @@ async def _validate_auth( return False # Attempt to login (both username and password provided) + if not can_validate: + return True try: - await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + await entry.runtime_data.heos.sign_in( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) except CommandAuthenticationError as err: errors["base"] = "invalid_auth" _LOGGER.warning("Failed to sign-in to HEOS Account: %s", err) @@ -86,7 +97,7 @@ async def _validate_auth( else: _LOGGER.debug( "Successfully signed-in to HEOS Account: %s", - heos.signed_in_username, + entry.runtime_data.heos.signed_in_username, ) return True @@ -205,8 +216,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} entry: HeosConfigEntry = self._get_reauth_entry() if user_input is not None: - assert entry.state is ConfigEntryState.LOADED - if await _validate_auth(user_input, entry.runtime_data.heos, errors): + if await _validate_auth(user_input, entry, errors): return self.async_update_reload_and_abort(entry, options=user_input) return self.async_show_form( @@ -227,8 +237,7 @@ class HeosOptionsFlowHandler(OptionsFlow): """Manage the options.""" errors: dict[str, str] = {} if user_input is not None: - entry: HeosConfigEntry = self.config_entry - if await _validate_auth(user_input, entry.runtime_data.heos, errors): + if await _validate_auth(user_input, self.config_entry, errors): return self.async_create_entry(data=user_input) return self.async_show_form( diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 5b112f2b986..016cc7b3580 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from pyheos import Heos, HeosGroup, HeosOptions, HeosPlayer +from pyheos import ConnectionState, Heos, HeosGroup, HeosOptions, HeosPlayer class MockHeos(Heos): @@ -60,3 +60,7 @@ class MockHeos(Heos): def mock_set_signed_in_username(self, signed_in_username: str | None) -> None: """Set the signed in status on the mock instance.""" self._signed_in_username = signed_in_username + + def mock_set_connection_state(self, connection_state: ConnectionState) -> None: + """Set the connection state on the mock instance.""" + self._connection._state = connection_state diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 552b667b6c8..a78fc456100 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -2,7 +2,13 @@ from typing import Any -from pyheos import CommandAuthenticationError, CommandFailedError, HeosError, HeosSystem +from pyheos import ( + CommandAuthenticationError, + CommandFailedError, + ConnectionState, + HeosError, + HeosSystem, +) import pytest from homeassistant.components.heos.const import DOMAIN @@ -232,6 +238,7 @@ async def test_options_flow_signs_in( """Test options flow signs-in with entered credentials.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) # Start the options flow. Entry has not current options. assert CONF_USERNAME not in config_entry.options @@ -271,6 +278,7 @@ async def test_options_flow_signs_out( """Test options flow signs-out when credentials cleared.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) # Start the options flow. Entry has not current options. result = await hass.config_entries.options.async_init(config_entry.entry_id) @@ -319,6 +327,7 @@ async def test_options_flow_missing_one_param_recovers( """Test options flow signs-in after recovering from only username or password being entered.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) # Start the options flow. Entry has not current options. assert CONF_USERNAME not in config_entry.options @@ -347,6 +356,86 @@ async def test_options_flow_missing_one_param_recovers( assert result["type"] is FlowResultType.CREATE_ENTRY +async def test_options_flow_sign_in_setup_error_saves( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test options can still be updated when the integration failed to set up.""" + config_entry.add_to_hass(hass) + controller.get_players.side_effect = ValueError("Unexpected error") + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Enter valid credentials + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == user_input + assert result["data"] == user_input + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_options_flow_sign_out_setup_error_saves( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test options can still be cleared when the integration failed to set up.""" + config_entry.add_to_hass(hass) + controller.get_players.side_effect = ValueError("Unexpected error") + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Enter valid credentials + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == {} + assert result["data"] == {} + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_options_flow_sign_in_not_connected_saves( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test options can still be updated when not connected to the HEOS device.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.RECONNECTING) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Enter valid credentials + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == user_input + assert result["data"] == user_input + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_options_flow_sign_out_not_connected_saves( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test options can still be cleared when not connected to the HEOS device.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.RECONNECTING) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + # Enter valid credentials + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == {} + assert result["data"] == {} + assert result["type"] is FlowResultType.CREATE_ENTRY + + @pytest.mark.parametrize( ("error", "expected_error_key"), [ @@ -368,6 +457,7 @@ async def test_reauth_signs_in_aborts( """Test reauth flow signs-in with entered credentials and aborts.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) result = await config_entry.start_reauth_flow(hass) assert config_entry.state is ConfigEntryState.LOADED @@ -407,6 +497,7 @@ async def test_reauth_signs_out( """Test reauth flow signs-out when credentials cleared and aborts.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) result = await config_entry.start_reauth_flow(hass) assert config_entry.state is ConfigEntryState.LOADED @@ -457,6 +548,7 @@ async def test_reauth_flow_missing_one_param_recovers( """Test reauth flow signs-in after recovering from only username or password being entered.""" config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.CONNECTED) # Start the options flow. Entry has not current options. result = await config_entry.start_reauth_flow(hass) @@ -484,3 +576,51 @@ async def test_reauth_flow_missing_one_param_recovers( assert config_entry.options[CONF_PASSWORD] == user_input[CONF_PASSWORD] assert result["reason"] == "reauth_successful" assert result["type"] is FlowResultType.ABORT + + +async def test_reauth_updates_when_not_connected( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test reauth flow signs-in with entered credentials and aborts.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.RECONNECTING) + + result = await config_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Valid credentials signs-in, updates options, and aborts + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options[CONF_USERNAME] == user_input[CONF_USERNAME] + assert config_entry.options[CONF_PASSWORD] == user_input[CONF_PASSWORD] + assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + + +async def test_reauth_clears_when_not_connected( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test reauth flow signs-out with entered credentials and aborts.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_connection_state(ConnectionState.RECONNECTING) + + result = await config_entry.start_reauth_flow(hass) + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Valid credentials signs-out, updates options, and aborts + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 0 + assert config_entry.options == {} + assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT From 9f7c4648a209b135b593f1210df10a3730296b7c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 20 Feb 2025 13:35:29 +0100 Subject: [PATCH 0718/1941] Allow files to be directly deleted in onedrive (#138908) * Allow files to be directly deleted in onedrive * let options flow reload * update description --- homeassistant/components/onedrive/__init__.py | 5 +++ homeassistant/components/onedrive/backup.py | 10 +++-- .../components/onedrive/config_flow.py | 44 ++++++++++++++++++- homeassistant/components/onedrive/const.py | 2 + .../components/onedrive/quality_scale.yaml | 5 +-- .../components/onedrive/strings.json | 13 ++++++ tests/components/onedrive/test_config_flow.py | 27 ++++++++++++ 7 files changed, 97 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index c82757dca31..4aa11daf39d 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -99,6 +99,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> _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: + await hass.config_entries.async_reload(entry.entry_id) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 674708b0cb3..f8a2a6699c4 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -31,7 +31,7 @@ from homeassistant.components.backup import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN +from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN from .coordinator import OneDriveConfigEntry _LOGGER = logging.getLogger(__name__) @@ -205,8 +205,12 @@ class OneDriveBackupAgent(BackupAgent): backup = backups[backup_id] - await self._client.delete_drive_item(backup.backup_file_id) - await self._client.delete_drive_item(backup.metadata_file_id) + delete_permanently = self._entry.options.get(CONF_DELETE_PERMANENTLY, False) + + await self._client.delete_drive_item(backup.backup_file_id, delete_permanently) + await self._client.delete_drive_item( + backup.metadata_file_id, delete_permanently + ) self._cache_expiration = time() @handle_backup_errors diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 900db0177d9..06c9ec253e3 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -1,18 +1,23 @@ """Config flow for OneDrive.""" +from __future__ import annotations + from collections.abc import Mapping import logging from typing import Any, cast from onedrive_personal_sdk.clients.client import OneDriveClient from onedrive_personal_sdk.exceptions import OneDriveException +import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, 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 .const import DOMAIN, OAUTH_SCOPES +from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES +from .coordinator import OneDriveConfigEntry class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): @@ -86,3 +91,38 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + + @staticmethod + @callback + def async_get_options_flow( + config_entry: OneDriveConfigEntry, + ) -> OneDriveOptionsFlowHandler: + """Create the options flow.""" + return OneDriveOptionsFlowHandler() + + +class OneDriveOptionsFlowHandler(OptionsFlow): + """Handles options flow for the component.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options for OneDrive.""" + if user_input: + return self.async_create_entry(title="", data=user_input) + + options_schema = vol.Schema( + { + vol.Optional( + CONF_DELETE_PERMANENTLY, + default=self.config_entry.options.get( + CONF_DELETE_PERMANENTLY, False + ), + ): bool, + } + ) + + return self.async_show_form( + step_id="init", + data_schema=options_schema, + ) diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py index f9d49b141e5..7aefa26ea81 100644 --- a/homeassistant/components/onedrive/const.py +++ b/homeassistant/components/onedrive/const.py @@ -7,6 +7,8 @@ from homeassistant.util.hass_dict import HassKey DOMAIN: Final = "onedrive" +CONF_DELETE_PERMANENTLY: Final = "delete_permanently" + # replace "consumers" with "common", when adding SharePoint or OneDrive for Business support OAUTH2_AUTHORIZE: Final = ( "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index 84b980c5e01..44754e76f2c 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -30,10 +30,7 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: - status: exempt - comment: | - No Options flow. + docs-configuration-parameters: done docs-installation-parameters: done entity-unavailable: done integration-owner: done diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 20d139a4bc0..27afe3e8a9b 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -29,6 +29,19 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "options": { + "step": { + "init": { + "description": "By default, files are put into the Recycle Bin when deleted, where they remain available for another 30 days. If you enable this option, files will be deleted immediately when they are cleaned up by the backup system.", + "data": { + "delete_permanently": "Delete files permanently" + }, + "data_description": { + "delete_permanently": "Delete files without moving them to the Recycle Bin" + } + } + } + }, "issues": { "drive_full": { "title": "OneDrive data cap exceeded", diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index fb0d58b86c6..1ae92332075 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.onedrive.const import ( + CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, @@ -223,3 +224,29 @@ async def test_reauth_flow_id_changed( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "wrong_drive" + + +async def test_options_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_DELETE_PERMANENTLY: True, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { + CONF_DELETE_PERMANENTLY: True, + } From b856de225dbab2c058f2b8621c9e946d3f7a7eb5 Mon Sep 17 00:00:00 2001 From: Dmitry Kuzmenko Date: Thu, 20 Feb 2025 17:18:19 +0300 Subject: [PATCH 0719/1941] Catch zeep fault as well on GetSystemDateAndTime call. (#138916) --- homeassistant/components/onvif/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 6d1a340fc7b..3f37ba42397 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -235,7 +235,7 @@ class ONVIFDevice: LOGGER.debug("%s: Retrieving current device date/time", self.name) try: device_time = await device_mgmt.GetSystemDateAndTime() - except RequestError as err: + except (RequestError, Fault) as err: LOGGER.warning( "Couldn't get device '%s' date/time. Error: %s", self.name, err ) From fb5728456107831bc935a89497596dc37affb9f0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 16:02:22 +0100 Subject: [PATCH 0720/1941] Remove helper.recorder.async_wait_recorder (#138935) --- homeassistant/helpers/recorder.py | 10 ---------- tests/components/recorder/common.py | 6 ++++++ tests/components/recorder/test_init.py | 9 +++++---- tests/components/recorder/test_migrate.py | 5 ++--- tests/components/recorder/test_websocket_api.py | 3 ++- tests/conftest.py | 2 +- 6 files changed, 16 insertions(+), 19 deletions(-) diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 59604944eeb..8b210874313 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -60,16 +60,6 @@ def async_initialize_recorder(hass: HomeAssistant) -> None: async_setup(hass) -async def async_wait_recorder(hass: HomeAssistant) -> bool: - """Wait for recorder to initialize and return connection status. - - Returns False immediately if the recorder is not enabled. - """ - if DOMAIN not in hass.data: - return False - return await hass.data[DOMAIN].db_connected - - @functools.lru_cache(maxsize=1) def get_instance(hass: HomeAssistant) -> Recorder: """Get the recorder instance.""" diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 792000c3725..fbcf97b6079 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -37,6 +37,7 @@ from homeassistant.components.recorder.db_schema import ( from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask from homeassistant.const import UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State +from homeassistant.helpers import recorder as recorder_helper from homeassistant.util import dt as dt_util from . import db_schema_0 @@ -79,6 +80,11 @@ async def async_block_recorder(hass: HomeAssistant, seconds: float) -> None: await event.wait() +async def async_wait_recorder(hass: HomeAssistant) -> bool: + """Wait for recorder to initialize and return connection status.""" + return await hass.data[recorder_helper.DOMAIN].db_connected + + def get_start_time(start: datetime) -> datetime: """Calculate a valid start time for statistics.""" start_minutes = start.minute - start.minute % 5 diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index f8d1ac4af57..95cd959db3b 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -85,6 +85,7 @@ from homeassistant.util.json import json_loads from .common import ( async_block_recorder, async_recorder_block_till_done, + async_wait_recorder, async_wait_recording_done, convert_pending_states_to_meta, corrupt_db_file, @@ -155,7 +156,7 @@ async def test_shutdown_before_startup_finishes( recorder_helper.async_initialize_recorder(hass) hass.async_create_task(async_setup_recorder_instance(hass, config)) - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) instance = get_instance(hass) session = await instance.async_add_executor_job(instance.get_session) @@ -188,7 +189,7 @@ async def test_canceled_before_startup_finishes( hass.set_state(CoreState.not_running) recorder_helper.async_initialize_recorder(hass) hass.async_create_task(async_setup_recorder_instance(hass)) - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) instance = get_instance(hass) instance._hass_started.cancel() @@ -240,7 +241,7 @@ async def test_state_gets_saved_when_set_before_start_event( recorder_helper.async_initialize_recorder(hass) hass.async_create_task(async_setup_recorder_instance(hass)) - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) entity_id = "test.recorder" state = "restoring_from_db" @@ -2724,7 +2725,7 @@ async def test_commit_before_commits_pending_writes( recorder_helper.async_initialize_recorder(hass) hass.async_create_task(async_setup_recorder_instance(hass, config)) - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) instance = get_instance(hass) assert instance.commit_interval == 60 verify_states_in_queue_future = hass.loop.create_future() diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 081394c780c..035fd9b4440 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -30,10 +30,9 @@ from homeassistant.components.recorder.db_schema import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant, State -from homeassistant.helpers import recorder as recorder_helper from homeassistant.util import dt as dt_util -from .common import async_wait_recording_done, create_engine_test +from .common import async_wait_recorder, async_wait_recording_done, create_engine_test from .conftest import InstrumentedMigration from tests.common import async_fire_time_changed @@ -641,7 +640,7 @@ async def test_schema_migrate( ) await hass.async_add_executor_job(instrument_migration.migration_started.wait) assert recorder.util.async_migration_in_progress(hass) is True - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) assert recorder.util.async_migration_in_progress(hass) is True assert recorder.util.async_migration_is_live(hass) == live diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 9e5172ae1f0..8cbbb7a711b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -32,6 +32,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .common import ( async_recorder_block_till_done, + async_wait_recorder, async_wait_recording_done, create_engine_test, do_adhoc_statistics, @@ -2650,7 +2651,7 @@ async def test_recorder_info_migration_queue_exhausted( instrument_migration.migration_started.wait ) assert recorder.util.async_migration_in_progress(hass) is True - await recorder_helper.async_wait_recorder(hass) + await async_wait_recorder(hass) hass.states.async_set("my.entity", "on", {}) await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index 6bc346eb3b9..64bbac11a1f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1557,7 +1557,7 @@ async def _async_init_recorder_component( assert (recorder.DOMAIN in hass.config.components) == expected_setup_result else: # Wait for recorder to connect to the database - await recorder_helper.async_wait_recorder(hass) + await hass.data[recorder_helper.DOMAIN].db_connected _LOGGER.info( "Test recorder successfully started, database location: %s", config[recorder.CONF_DB_URL], From 0d8c449ff4b25f3e8c60f31128ca76078fbc82ed Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 16:06:33 +0100 Subject: [PATCH 0721/1941] Validate hassio backup settings (#138880) * Validate hassio backup settings * Add snapshots * Don't reset addon and folder settings * Adapt to changes in BackupConfig.update --- homeassistant/components/backup/__init__.py | 3 +- homeassistant/components/hassio/backup.py | 21 ++- .../backup/snapshots/test_websocket.ambr | 2 +- tests/components/conftest.py | 1 + .../hassio/snapshots/test_backup.ambr | 130 ++++++++++++++++++ tests/components/hassio/test_backup.py | 93 +++++++++++++ 6 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 tests/components/hassio/snapshots/test_backup.ambr diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 1b19b185b4f..a5159086945 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -16,7 +16,7 @@ from .agent import ( BackupAgentPlatformProtocol, LocalBackupAgent, ) -from .config import BackupConfig +from .config import BackupConfig, CreateBackupParametersDict from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views from .manager import ( @@ -55,6 +55,7 @@ __all__ = [ "BackupReaderWriter", "BackupReaderWriterError", "CreateBackupEvent", + "CreateBackupParametersDict", "CreateBackupStage", "CreateBackupState", "Folder", diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 9c0511a93fe..e7d169c142c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -33,6 +33,7 @@ from homeassistant.components.backup import ( BackupReaderWriter, BackupReaderWriterError, CreateBackupEvent, + CreateBackupParametersDict, CreateBackupStage, CreateBackupState, Folder, @@ -635,7 +636,25 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): unsub() async def async_validate_config(self, *, config: BackupConfig) -> None: - """Validate backup config.""" + """Validate backup config. + + Replace the core backup agent with the hassio default agent. + """ + core_agent_id = "backup.local" + create_backup = config.data.create_backup + if core_agent_id not in create_backup.agent_ids: + _LOGGER.debug("Backup settings don't need to be adjusted") + return + + default_agent = await _default_agent(self._client) + _LOGGER.info("Adjusting backup settings to not include core backup location") + automatic_agents = [ + agent_id if agent_id != core_agent_id else default_agent + for agent_id in create_backup.agent_ids + ] + config.update( + create_backup=CreateBackupParametersDict(agent_ids=automatic_agents) + ) @callback def _async_listen_job_events( diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index d9ed5128e1d..742fec4c3f3 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -625,7 +625,7 @@ }), 'create_backup': dict({ 'agent_ids': list([ - 'backup.local', + 'hassio.local', 'test-agent', ]), 'include_addons': None, diff --git a/tests/components/conftest.py b/tests/components/conftest.py index ebf390e30d7..dd6776a1cad 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -529,6 +529,7 @@ def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> As def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" mounts_info_mock = AsyncMock(spec_set=["default_backup_mount", "mounts"]) + mounts_info_mock.default_backup_mount = None mounts_info_mock.mounts = [] supervisor_client = AsyncMock() supervisor_client.addons = AsyncMock() diff --git a/tests/components/hassio/snapshots/test_backup.ambr b/tests/components/hassio/snapshots/test_backup.ambr new file mode 100644 index 00000000000..a2f33bf9624 --- /dev/null +++ b/tests/components/hassio/snapshots/test_backup.ambr @@ -0,0 +1,130 @@ +# serializer version: 1 +# name: test_config_load_config_info[storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_load_config_info[storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent1', + 'hassio.local', + 'test-agent2', + ]), + 'include_addons': list([ + 'addon1', + 'addon2', + ]), + 'include_all_addons': True, + 'include_database': True, + 'include_folders': list([ + 'media', + 'share', + ]), + '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_load_config_info[storage_data2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent1', + 'hassio.local', + 'test-agent2', + ]), + 'include_addons': list([ + 'addon1', + 'addon2', + ]), + 'include_all_addons': False, + 'include_database': True, + 'include_folders': list([ + 'media', + 'share', + ]), + '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', + }) +# --- diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 7547e3e3586..6a66d249dd1 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -30,6 +30,7 @@ from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL from aiohasupervisor.models.mounts import MountsInfo from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -38,6 +39,7 @@ from homeassistant.components.backup import ( BackupAgent, BackupAgentPlatformProtocol, Folder, + store as backup_store, ) from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV @@ -2466,3 +2468,94 @@ async def test_restore_progress_after_restart_unknown_job( assert response["success"] assert response["result"]["last_non_idle_event"] is None assert response["result"]["state"] == "idle" + + +@pytest.mark.parametrize( + "storage_data", + [ + {}, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["test-agent1", "hassio.local", "test-agent2"], + "include_addons": ["addon1", "addon2"], + "include_all_addons": True, + "include_database": True, + "include_folders": ["media", "share"], + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": backup_store.STORAGE_VERSION, + "minor_version": backup_store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["test-agent1", "backup.local", "test-agent2"], + "include_addons": ["addon1", "addon2"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media", "share"], + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": backup_store.STORAGE_VERSION, + "minor_version": backup_store.STORAGE_VERSION_MINOR, + }, + }, + ], +) +@pytest.mark.usefixtures("hassio_client") +async def test_config_load_config_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + hass_storage: dict[str, Any], + storage_data: dict[str, Any] | None, +) -> None: + """Test loading stored backup config and reading it via config/info.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + + hass_storage.update(storage_data) + + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot From 73442e84432e4bc6a0fda17fcc9797625ac3721f Mon Sep 17 00:00:00 2001 From: Steven Stallion Date: Thu, 20 Feb 2025 09:15:47 -0600 Subject: [PATCH 0722/1941] Add SensorPush Cloud integration (#134223) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/sensorpush.json | 5 + .../components/sensorpush_cloud/__init__.py | 28 + .../sensorpush_cloud/config_flow.py | 64 + .../components/sensorpush_cloud/const.py | 12 + .../sensorpush_cloud/coordinator.py | 45 + .../components/sensorpush_cloud/manifest.json | 11 + .../sensorpush_cloud/quality_scale.yaml | 68 + .../components/sensorpush_cloud/sensor.py | 158 ++ .../components/sensorpush_cloud/strings.json | 40 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 17 +- mypy.ini | 10 + requirements_all.txt | 6 + requirements_test_all.txt | 6 + tests/components/sensorpush_cloud/__init__.py | 1 + tests/components/sensorpush_cloud/conftest.py | 60 + tests/components/sensorpush_cloud/const.py | 32 + .../snapshots/test_sensor.ambr | 1267 +++++++++++++++++ .../sensorpush_cloud/test_config_flow.py | 95 ++ .../sensorpush_cloud/test_sensor.py | 29 + 22 files changed, 1955 insertions(+), 3 deletions(-) create mode 100644 homeassistant/brands/sensorpush.json create mode 100644 homeassistant/components/sensorpush_cloud/__init__.py create mode 100644 homeassistant/components/sensorpush_cloud/config_flow.py create mode 100644 homeassistant/components/sensorpush_cloud/const.py create mode 100644 homeassistant/components/sensorpush_cloud/coordinator.py create mode 100644 homeassistant/components/sensorpush_cloud/manifest.json create mode 100644 homeassistant/components/sensorpush_cloud/quality_scale.yaml create mode 100644 homeassistant/components/sensorpush_cloud/sensor.py create mode 100644 homeassistant/components/sensorpush_cloud/strings.json create mode 100644 tests/components/sensorpush_cloud/__init__.py create mode 100644 tests/components/sensorpush_cloud/conftest.py create mode 100644 tests/components/sensorpush_cloud/const.py create mode 100644 tests/components/sensorpush_cloud/snapshots/test_sensor.ambr create mode 100644 tests/components/sensorpush_cloud/test_config_flow.py create mode 100644 tests/components/sensorpush_cloud/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 9543ccc3989..682e2c920ce 100644 --- a/.strict-typing +++ b/.strict-typing @@ -438,6 +438,7 @@ homeassistant.components.select.* homeassistant.components.sensibo.* homeassistant.components.sensirion_ble.* homeassistant.components.sensor.* +homeassistant.components.sensorpush_cloud.* homeassistant.components.sensoterra.* homeassistant.components.senz.* homeassistant.components.sfr_box.* diff --git a/CODEOWNERS b/CODEOWNERS index 3d8159560bc..6a66c24c7e8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1342,6 +1342,8 @@ build.json @home-assistant/supervisor /tests/components/sensorpro/ @bdraco /homeassistant/components/sensorpush/ @bdraco /tests/components/sensorpush/ @bdraco +/homeassistant/components/sensorpush_cloud/ @sstallion +/tests/components/sensorpush_cloud/ @sstallion /homeassistant/components/sensoterra/ @markruys /tests/components/sensoterra/ @markruys /homeassistant/components/sentry/ @dcramer @frenck diff --git a/homeassistant/brands/sensorpush.json b/homeassistant/brands/sensorpush.json new file mode 100644 index 00000000000..b7e528948f8 --- /dev/null +++ b/homeassistant/brands/sensorpush.json @@ -0,0 +1,5 @@ +{ + "domain": "sensorpush", + "name": "SensorPush", + "integrations": ["sensorpush", "sensorpush_cloud"] +} diff --git a/homeassistant/components/sensorpush_cloud/__init__.py b/homeassistant/components/sensorpush_cloud/__init__.py new file mode 100644 index 00000000000..2d9d299c132 --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/__init__.py @@ -0,0 +1,28 @@ +"""The SensorPush Cloud integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SensorPushCloudConfigEntry, SensorPushCloudCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: SensorPushCloudConfigEntry +) -> bool: + """Set up SensorPush Cloud from a config entry.""" + coordinator = SensorPushCloudCoordinator(hass, entry) + entry.runtime_data = coordinator + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: SensorPushCloudConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sensorpush_cloud/config_flow.py b/homeassistant/components/sensorpush_cloud/config_flow.py new file mode 100644 index 00000000000..d06fde2eba1 --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow for the SensorPush Cloud integration.""" + +from __future__ import annotations + +from typing import Any + +from sensorpush_ha import SensorPushCloudApi, SensorPushCloudAuthError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN, LOGGER + + +class SensorPushCloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for SensorPush Cloud.""" + + 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: + email, password = user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + await self.async_set_unique_id(email) + self._abort_if_unique_id_configured() + clientsession = async_get_clientsession(self.hass) + api = SensorPushCloudApi(email, password, clientsession) + try: + await api.async_authorize() + except SensorPushCloudAuthError: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=email, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, autocomplete="username" + ) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/sensorpush_cloud/const.py b/homeassistant/components/sensorpush_cloud/const.py new file mode 100644 index 00000000000..9e66dacfaba --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/const.py @@ -0,0 +1,12 @@ +"""Constants for the SensorPush Cloud integration.""" + +from datetime import timedelta +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "sensorpush_cloud" + +UPDATE_INTERVAL: Final = timedelta(seconds=60) +MAX_TIME_BETWEEN_UPDATES: Final = UPDATE_INTERVAL * 60 diff --git a/homeassistant/components/sensorpush_cloud/coordinator.py b/homeassistant/components/sensorpush_cloud/coordinator.py new file mode 100644 index 00000000000..9885538b55a --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/coordinator.py @@ -0,0 +1,45 @@ +"""Coordinator for the SensorPush Cloud integration.""" + +from __future__ import annotations + +from sensorpush_ha import ( + SensorPushCloudApi, + SensorPushCloudData, + SensorPushCloudError, + SensorPushCloudHelper, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, UPDATE_INTERVAL + +type SensorPushCloudConfigEntry = ConfigEntry[SensorPushCloudCoordinator] + + +class SensorPushCloudCoordinator(DataUpdateCoordinator[dict[str, SensorPushCloudData]]): + """SensorPush Cloud coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: SensorPushCloudConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=entry.title, + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + email, password = entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD] + clientsession = async_get_clientsession(hass) + api = SensorPushCloudApi(email, password, clientsession) + self.helper = SensorPushCloudHelper(api) + + async def _async_update_data(self) -> dict[str, SensorPushCloudData]: + """Fetch data from API endpoints.""" + try: + return await self.helper.async_get_data() + except SensorPushCloudError as e: + raise UpdateFailed(e) from e diff --git a/homeassistant/components/sensorpush_cloud/manifest.json b/homeassistant/components/sensorpush_cloud/manifest.json new file mode 100644 index 00000000000..ad817251fa1 --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "sensorpush_cloud", + "name": "SensorPush Cloud", + "codeowners": ["@sstallion"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sensorpush_cloud", + "iot_class": "cloud_polling", + "loggers": ["sensorpush_api", "sensorpush_ha"], + "quality_scale": "bronze", + "requirements": ["sensorpush-api==2.1.1", "sensorpush-ha==1.3.2"] +} diff --git a/homeassistant/components/sensorpush_cloud/quality_scale.yaml b/homeassistant/components/sensorpush_cloud/quality_scale.yaml new file mode 100644 index 00000000000..96816e1d50d --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom 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: + status: exempt + comment: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not support options flow. + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + 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: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/sensorpush_cloud/sensor.py b/homeassistant/components/sensorpush_cloud/sensor.py new file mode 100644 index 00000000000..d2855f63a62 --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/sensor.py @@ -0,0 +1,158 @@ +"""Support for SensorPush Cloud sensors.""" + +from __future__ import annotations + +from typing import Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfElectricPotential, + UnitOfLength, + UnitOfPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .const import DOMAIN, MAX_TIME_BETWEEN_UPDATES +from .coordinator import SensorPushCloudConfigEntry, SensorPushCloudCoordinator + +ATTR_ALTITUDE: Final = "altitude" +ATTR_ATMOSPHERIC_PRESSURE: Final = "atmospheric_pressure" +ATTR_BATTERY_VOLTAGE: Final = "battery_voltage" +ATTR_DEWPOINT: Final = "dewpoint" +ATTR_HUMIDITY: Final = "humidity" +ATTR_SIGNAL_STRENGTH: Final = "signal_strength" +ATTR_VAPOR_PRESSURE: Final = "vapor_pressure" + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + key=ATTR_ALTITUDE, + device_class=SensorDeviceClass.DISTANCE, + entity_registry_enabled_default=False, + translation_key="altitude", + native_unit_of_measurement=UnitOfLength.FEET, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_ATMOSPHERIC_PRESSURE, + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfPressure.INHG, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BATTERY_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + translation_key="battery_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_DEWPOINT, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + translation_key="dewpoint", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_registry_enabled_default=False, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_VAPOR_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + entity_registry_enabled_default=False, + translation_key="vapor_pressure", + native_unit_of_measurement=UnitOfPressure.KPA, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SensorPushCloudConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SensorPush Cloud sensors.""" + coordinator = entry.runtime_data + async_add_entities( + SensorPushCloudSensor(coordinator, entity_description, device_id) + for entity_description in SENSORS + for device_id in coordinator.data + ) + + +class SensorPushCloudSensor( + CoordinatorEntity[SensorPushCloudCoordinator], SensorEntity +): + """SensorPush Cloud sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SensorPushCloudCoordinator, + entity_description: SensorEntityDescription, + device_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self.device_id = device_id + + device = coordinator.data[device_id] + self._attr_unique_id = f"{device.device_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + manufacturer=device.manufacturer, + model=device.model, + name=device.name, + ) + + @property + def available(self) -> bool: + """Return true if entity is available.""" + if self.device_id in self.coordinator.data: + last_update = self.coordinator.data[self.device_id].last_update + if dt_util.utcnow() >= (last_update + MAX_TIME_BETWEEN_UPDATES): + return False + return super().available + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data[self.device_id][self.entity_description.key] diff --git a/homeassistant/components/sensorpush_cloud/strings.json b/homeassistant/components/sensorpush_cloud/strings.json new file mode 100644 index 00000000000..8467a123b6f --- /dev/null +++ b/homeassistant/components/sensorpush_cloud/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "user": { + "description": "To activate API access, log in to the [Gateway Cloud Dashboard](https://dashboard.sensorpush.com/) and agree to the terms of service. Devices are not available until activated with the SensorPush app on iOS or Android.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "The email address used to log in to the SensorPush Gateway Cloud Dashboard", + "password": "The password used to log in to the SensorPush Gateway Cloud Dashboard" + } + } + }, + "error": { + "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_account%]" + } + }, + "entity": { + "sensor": { + "altitude": { + "name": "Altitude" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "dewpoint": { + "name": "Dew point" + }, + "vapor_pressure": { + "name": "Vapor pressure" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 01aa2d8f236..40af1df86cd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -546,6 +546,7 @@ FLOWS = { "sensirion_ble", "sensorpro", "sensorpush", + "sensorpush_cloud", "sensoterra", "sentry", "senz", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7e7b5272aaa..2d28d4f46d7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5587,9 +5587,20 @@ }, "sensorpush": { "name": "SensorPush", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "integrations": { + "sensorpush": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "SensorPush" + }, + "sensorpush_cloud": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "SensorPush Cloud" + } + } }, "sensoterra": { "name": "Sensoterra", diff --git a/mypy.ini b/mypy.ini index f15ad433a52..4c062c99aec 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4136,6 +4136,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sensorpush_cloud.*] +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.sensoterra.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index dac373757df..c7006b9049a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2696,9 +2696,15 @@ sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 +# homeassistant.components.sensorpush_cloud +sensorpush-api==2.1.1 + # homeassistant.components.sensorpush sensorpush-ble==1.7.1 +# homeassistant.components.sensorpush_cloud +sensorpush-ha==1.3.2 + # homeassistant.components.sensoterra sensoterra==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de7290c37b6..8c1be927b55 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2175,9 +2175,15 @@ sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 +# homeassistant.components.sensorpush_cloud +sensorpush-api==2.1.1 + # homeassistant.components.sensorpush sensorpush-ble==1.7.1 +# homeassistant.components.sensorpush_cloud +sensorpush-ha==1.3.2 + # homeassistant.components.sensoterra sensoterra==2.0.1 diff --git a/tests/components/sensorpush_cloud/__init__.py b/tests/components/sensorpush_cloud/__init__.py new file mode 100644 index 00000000000..2a5d148692c --- /dev/null +++ b/tests/components/sensorpush_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the SensorPush Cloud integration.""" diff --git a/tests/components/sensorpush_cloud/conftest.py b/tests/components/sensorpush_cloud/conftest.py new file mode 100644 index 00000000000..ac434b04353 --- /dev/null +++ b/tests/components/sensorpush_cloud/conftest.py @@ -0,0 +1,60 @@ +"""Common fixtures for the SensorPush Cloud tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from sensorpush_ha import SensorPushCloudApi + +from homeassistant.components.sensorpush_cloud.const import DOMAIN +from homeassistant.const import CONF_EMAIL + +from .const import CONF_DATA, MOCK_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_api() -> Generator[AsyncMock]: + """Override SensorPushCloudApi.""" + mock_api = AsyncMock(SensorPushCloudApi) + with ( + patch( + "homeassistant.components.sensorpush_cloud.config_flow.SensorPushCloudApi", + return_value=mock_api, + ), + ): + yield mock_api + + +@pytest.fixture +def mock_helper() -> Generator[AsyncMock]: + """Override SensorPushCloudHelper.""" + with ( + patch( + "homeassistant.components.sensorpush_cloud.coordinator.SensorPushCloudHelper", + autospec=True, + ) as mock_helper, + ): + helper = mock_helper.return_value + helper.async_get_data.return_value = MOCK_DATA + yield helper + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """ConfigEntry mock.""" + return MockConfigEntry( + domain=DOMAIN, data=CONF_DATA, unique_id=CONF_DATA[CONF_EMAIL] + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sensorpush_cloud.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sensorpush_cloud/const.py b/tests/components/sensorpush_cloud/const.py new file mode 100644 index 00000000000..1efc4ea445a --- /dev/null +++ b/tests/components/sensorpush_cloud/const.py @@ -0,0 +1,32 @@ +"""Constants for the SensorPush Cloud tests.""" + +from sensorpush_ha import SensorPushCloudData + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.util import dt as dt_util + +CONF_DATA = { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", +} + +NUM_MOCK_DEVICES = 3 + +MOCK_DATA = { + f"test-sensor-device-id-{i}": SensorPushCloudData( + device_id=f"test-sensor-device-id-{i}", + manufacturer=f"test-sensor-manufacturer-{i}", + model=f"test-sensor-model-{i}", + name=f"test-sensor-name-{i}", + altitude=0.0, + atmospheric_pressure=0.0, + battery_voltage=0.0, + dewpoint=0.0, + humidity=0.0, + last_update=dt_util.utcnow(), + signal_strength=0.0, + temperature=0.0, + vapor_pressure=0.0, + ) + for i in range(NUM_MOCK_DEVICES) +} diff --git a/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr b/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a78b012ac02 --- /dev/null +++ b/tests/components/sensorpush_cloud/snapshots/test_sensor.ambr @@ -0,0 +1,1267 @@ +# serializer version: 1 +# name: test_sensors[sensor.test_sensor_name_0_altitude-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_sensor_name_0_altitude', + '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': 'Altitude', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'altitude', + 'unique_id': 'test-sensor-device-id-0_altitude', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_altitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'test-sensor-name-0 Altitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_altitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_atmospheric_pressure-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_sensor_name_0_atmospheric_pressure', + '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': 'Atmospheric pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-0_atmospheric_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'test-sensor-name-0 Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_battery_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.test_sensor_name_0_battery_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': 'Battery voltage', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'test-sensor-device-id-0_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test-sensor-name-0 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_dew_point-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_sensor_name_0_dew_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': 'Dew point', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dewpoint', + 'unique_id': 'test-sensor-device-id-0_dewpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-0 Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_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.test_sensor_name_0_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': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-0_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'test-sensor-name-0 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_signal_strength-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_sensor_name_0_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-0_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'test-sensor-name-0 Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_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.test_sensor_name_0_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': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-0_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-0 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_vapor_pressure-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_sensor_name_0_vapor_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vapor pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vapor_pressure', + 'unique_id': 'test-sensor-device-id-0_vapor_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_0_vapor_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'test-sensor-name-0 Vapor pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_0_vapor_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_altitude-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_sensor_name_1_altitude', + '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': 'Altitude', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'altitude', + 'unique_id': 'test-sensor-device-id-1_altitude', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_altitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'test-sensor-name-1 Altitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_altitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_atmospheric_pressure-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_sensor_name_1_atmospheric_pressure', + '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': 'Atmospheric pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-1_atmospheric_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'test-sensor-name-1 Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_battery_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.test_sensor_name_1_battery_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': 'Battery voltage', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'test-sensor-device-id-1_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test-sensor-name-1 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_dew_point-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_sensor_name_1_dew_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': 'Dew point', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dewpoint', + 'unique_id': 'test-sensor-device-id-1_dewpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-1 Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_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.test_sensor_name_1_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-1_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'test-sensor-name-1 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_signal_strength-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_sensor_name_1_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-1_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'test-sensor-name-1 Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_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.test_sensor_name_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_vapor_pressure-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_sensor_name_1_vapor_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vapor pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vapor_pressure', + 'unique_id': 'test-sensor-device-id-1_vapor_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_1_vapor_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'test-sensor-name-1 Vapor pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_1_vapor_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_altitude-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_sensor_name_2_altitude', + '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': 'Altitude', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'altitude', + 'unique_id': 'test-sensor-device-id-2_altitude', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_altitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'test-sensor-name-2 Altitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_altitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_atmospheric_pressure-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_sensor_name_2_atmospheric_pressure', + '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': 'Atmospheric pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-2_atmospheric_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_atmospheric_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'atmospheric_pressure', + 'friendly_name': 'test-sensor-name-2 Atmospheric pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_atmospheric_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_battery_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.test_sensor_name_2_battery_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': 'Battery voltage', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': 'test-sensor-device-id-2_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test-sensor-name-2 Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_dew_point-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_sensor_name_2_dew_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': 'Dew point', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dewpoint', + 'unique_id': 'test-sensor-device-id-2_dewpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-2 Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_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.test_sensor_name_2_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': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-2_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'test-sensor-name-2 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_signal_strength-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_sensor_name_2_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-2_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'test-sensor-name-2 Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_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.test_sensor_name_2_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': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-sensor-device-id-2_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test-sensor-name-2 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-17.8', + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_vapor_pressure-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_sensor_name_2_vapor_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Vapor pressure', + 'platform': 'sensorpush_cloud', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vapor_pressure', + 'unique_id': 'test-sensor-device-id-2_vapor_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.test_sensor_name_2_vapor_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'test-sensor-name-2 Vapor pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_name_2_vapor_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/sensorpush_cloud/test_config_flow.py b/tests/components/sensorpush_cloud/test_config_flow.py new file mode 100644 index 00000000000..dc88c638b9b --- /dev/null +++ b/tests/components/sensorpush_cloud/test_config_flow.py @@ -0,0 +1,95 @@ +"""Test the SensorPush Cloud config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from sensorpush_ha import SensorPushCloudAuthError + +from homeassistant.components.sensorpush_cloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import CONF_DATA, CONF_EMAIL + +from tests.common import MockConfigEntry + + +async def test_user( + hass: HomeAssistant, + mock_api: AsyncMock, + mock_helper: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user initialized flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONF_DATA, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == CONF_DATA + assert result["result"].unique_id == CONF_DATA[CONF_EMAIL] + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_already_configured( + hass: HomeAssistant, + mock_api: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we fail on a duplicate entry in the user flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("error", "expected"), + [(SensorPushCloudAuthError, "invalid_auth"), (Exception, "unknown")], +) +async def test_user_error( + hass: HomeAssistant, + mock_api: AsyncMock, + mock_setup_entry: AsyncMock, + error: Exception, + expected: str, +) -> None: + """Test we display errors in the user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_api.async_authorize.side_effect = error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONF_DATA + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected} + + # Show we can recover from errors: + mock_api.async_authorize.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONF_DATA + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == CONF_DATA + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/sensorpush_cloud/test_sensor.py b/tests/components/sensorpush_cloud/test_sensor.py new file mode 100644 index 00000000000..c35d40f1bc2 --- /dev/null +++ b/tests/components/sensorpush_cloud/test_sensor.py @@ -0,0 +1,29 @@ +"""Test SensorPush Cloud sensors.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_helper: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test we can read sensors.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From f828b4e0b9a615d7219364d10f9cb3a904836046 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 16:18:57 +0100 Subject: [PATCH 0723/1941] Adjust config entry state check in vizio (#138905) --- homeassistant/components/vizio/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 27a7fa2cd97..10a71695e05 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any from homeassistant.components.media_player import MediaPlayerDeviceClass -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store @@ -39,12 +39,9 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) - # Exclude this config entry because its not unloaded yet if not any( - entry.state is ConfigEntryState.LOADED - and entry.entry_id != config_entry.entry_id - and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV - for entry in hass.config_entries.async_entries(DOMAIN) + entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV + for entry in hass.config_entries.async_loaded_entries(DOMAIN) ): hass.data[DOMAIN].pop(CONF_APPS, None) From 8826714704d281845805c78c6f07e16a40b6662a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 20 Feb 2025 16:23:21 +0100 Subject: [PATCH 0724/1941] Bump ruff to 0.9.7 (#138939) --- .pre-commit-config.yaml | 2 +- homeassistant/components/gtfs/sensor.py | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a059710d3d7..5b701b21b9e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.1 + rev: v0.9.7 hooks: - id: ruff args: diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 2637a55f772..8c624e2cdd6 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -341,7 +341,7 @@ def get_next_departure( {tomorrow_order} origin_stop_time.departure_time LIMIT :limit - """ + """ # noqa: S608 result = schedule.engine.connect().execute( text(sql_query), { diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 1cf3d91defa..8c9308e739b 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.9.1 +ruff==0.9.7 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 2eeb19fb547..b2e4005cf79 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.1 \ + 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 LABEL "name"="hassfest" From 66f293c8f34232af681c633b723a75c1e21ae019 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Thu, 20 Feb 2025 16:30:50 +0100 Subject: [PATCH 0725/1941] Add climate entity tests for flexit_bacnet and mark test coverage done (99%) (#138887) --- .../flexit_bacnet/quality_scale.yaml | 2 +- .../components/flexit_bacnet/test_climate.py | 142 +++++++++++++++++- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml index eb649656c9d..7a98eda4eb3 100644 --- a/homeassistant/components/flexit_bacnet/quality_scale.yaml +++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml @@ -52,7 +52,7 @@ rules: status: exempt comment: | Integration doesn't require any form of authentication. - test-coverage: todo + test-coverage: done # Gold entity-translations: done entity-device-class: done diff --git a/tests/components/flexit_bacnet/test_climate.py b/tests/components/flexit_bacnet/test_climate.py index 5baac1c5077..be361541c39 100644 --- a/tests/components/flexit_bacnet/test_climate.py +++ b/tests/components/flexit_bacnet/test_climate.py @@ -1,19 +1,32 @@ """Tests for the Flexit Nordic (BACnet) climate entity.""" +import asyncio from unittest.mock import AsyncMock -from flexit_bacnet import VENTILATION_MODE_AWAY, VENTILATION_MODE_HOME +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, +) +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, ATTR_PRESET_MODE, PRESET_AWAY, PRESET_HOME, + SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, ) from homeassistant.components.flexit_bacnet.const import PRESET_TO_VENTILATION_MODE_MAP -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_with_selected_platforms @@ -81,3 +94,128 @@ async def test_set_hvac_preset_mode( mock_flexit_bacnet.set_ventilation_mode.assert_called_with( PRESET_TO_VENTILATION_MODE_MAP[PRESET_HOME] ) + + mock_flexit_bacnet.set_ventilation_mode.side_effect = asyncio.TimeoutError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_PRESET_MODE: PRESET_AWAY, + }, + blocking=True, + ) + + mock_flexit_bacnet.set_ventilation_mode.assert_called_with( + PRESET_TO_VENTILATION_MODE_MAP[PRESET_AWAY] + ) + + +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_STOP + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.OFF + mock_flexit_bacnet.set_ventilation_mode.assert_called_once_with( + VENTILATION_MODE_STOP + ) + + mock_flexit_bacnet.set_ventilation_mode.side_effect = asyncio.TimeoutError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + mock_flexit_bacnet.set_ventilation_mode.assert_called_with(VENTILATION_MODE_STOP) + + +async def test_hvac_action( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test hvac_action property.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Simulate electric heater being ON + mock_flexit_bacnet.electric_heater = True + await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + + # Simulate electric heater being OFF + mock_flexit_bacnet.electric_heater = False + await hass.helpers.entity_component.async_update_entity(ENTITY_ID) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.FAN + + +async def test_set_temperature( + hass: HomeAssistant, + mock_flexit_bacnet: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the temperature.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Set ventilation mode to HOME and set temperature to 22.5°C + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_HOME + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 22.5, + }, + blocking=True, + ) + + # Ensure that the correct method was called + mock_flexit_bacnet.set_air_temp_setpoint_home.assert_called_once_with(22.5) + + # Change ventilation mode to AWAY and set temperature + mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_AWAY + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 18.0, + }, + blocking=True, + ) + + # Ensure that the correct method was called + mock_flexit_bacnet.set_air_temp_setpoint_away.assert_called_once_with(18.0) + + # Test handling of connection errors + mock_flexit_bacnet.set_air_temp_setpoint_away.side_effect = ConnectionError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 20.0, + }, + blocking=True, + ) From ff4f4111d00c516dcf7287b017471a5cf66cd48e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 17:28:39 +0100 Subject: [PATCH 0726/1941] Minor adjustment of recorder helper (#138941) --- homeassistant/components/recorder/core.py | 3 ++- homeassistant/components/recorder/statistics.py | 13 ++++++++----- homeassistant/components/recorder/tasks.py | 4 ++-- homeassistant/helpers/recorder.py | 11 ++++++++--- tests/components/recorder/common.py | 2 +- tests/conftest.py | 2 +- 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 05a5731e791..eaf72b74cdc 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -43,6 +43,7 @@ from homeassistant.helpers.event import ( async_track_time_interval, async_track_utc_time_change, ) +from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util @@ -183,7 +184,7 @@ class Recorder(threading.Thread): self.db_retry_wait = db_retry_wait self.database_engine: DatabaseEngine | None = None # Database connection is ready, but non-live migration may be in progress - db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected + db_connected: asyncio.Future[bool] = hass.data[DATA_RECORDER].db_connected self.async_db_connected: asyncio.Future[bool] = db_connected # Database is ready to use but live migration may be in progress self.async_db_ready: asyncio.Future[bool] = hass.loop.create_future() diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2b6640270ed..c42a0f77c39 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -24,6 +24,7 @@ import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError +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 @@ -561,7 +562,9 @@ def _compile_statistics( platform_stats: list[StatisticResult] = [] current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} # Collect statistics from all platforms implementing support - for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): + for domain, platform in instance.hass.data[ + DATA_RECORDER + ].recorder_platforms.items(): if not ( platform_compile_statistics := getattr( platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None @@ -599,7 +602,7 @@ def _compile_statistics( if start.minute == 50: # Once every hour, update issues - for platform in instance.hass.data[DOMAIN].recorder_platforms.values(): + for platform in instance.hass.data[DATA_RECORDER].recorder_platforms.values(): if not ( platform_update_issues := getattr( platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None @@ -882,7 +885,7 @@ def list_statistic_ids( # the integrations for the missing ones. # # Query all integrations with a registered recorder platform - for platform in hass.data[DOMAIN].recorder_platforms.values(): + for platform in hass.data[DATA_RECORDER].recorder_platforms.values(): if not ( platform_list_statistic_ids := getattr( platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None @@ -2232,7 +2235,7 @@ def _sorted_statistics_to_dict( def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]: """Validate statistics.""" platform_validation: dict[str, list[ValidationIssue]] = {} - for platform in hass.data[DOMAIN].recorder_platforms.values(): + for platform in hass.data[DATA_RECORDER].recorder_platforms.values(): if platform_validate_statistics := getattr( platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None ): @@ -2243,7 +2246,7 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]] def update_statistics_issues(hass: HomeAssistant) -> None: """Update statistics issues.""" with session_scope(hass=hass, read_only=True) as session: - for platform in hass.data[DOMAIN].recorder_platforms.values(): + for platform in hass.data[DATA_RECORDER].recorder_platforms.values(): if platform_update_statistics_issues := getattr( platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None ): diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index fa10c12aa68..4eb9547ee9d 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -11,11 +11,11 @@ import logging import threading from typing import TYPE_CHECKING, Any +from homeassistant.helpers.recorder import DATA_RECORDER from homeassistant.helpers.typing import UndefinedType from homeassistant.util.event_type import EventType from . import entity_registry, purge, statistics -from .const import DOMAIN from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData from .util import periodic_db_cleanups, session_scope @@ -308,7 +308,7 @@ class AddRecorderPlatformTask(RecorderTask): hass = instance.hass domain = self.domain platform = self.platform - platforms: dict[str, Any] = hass.data[DOMAIN].recorder_platforms + platforms: dict[str, Any] = hass.data[DATA_RECORDER].recorder_platforms platforms[domain] = platform diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 8b210874313..7ad319419c1 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -DOMAIN: HassKey[RecorderData] = HassKey("recorder") +DATA_RECORDER: HassKey[RecorderData] = HassKey("recorder") DATA_INSTANCE: HassKey[Recorder] = HassKey("recorder_instance") @@ -52,11 +52,16 @@ def async_migration_is_live(hass: HomeAssistant) -> bool: @callback def async_initialize_recorder(hass: HomeAssistant) -> None: - """Initialize recorder data.""" + """Initialize recorder data. + + This creates the RecorderData instance stored in hass.data[DATA_RECORDER] and + registers the basic recorder websocket API which is used by frontend to determine + if the recorder is migrating the database. + """ # pylint: disable-next=import-outside-toplevel from homeassistant.components.recorder.basic_websocket_api import async_setup - hass.data[DOMAIN] = RecorderData() + hass.data[DATA_RECORDER] = RecorderData() async_setup(hass) diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index fbcf97b6079..5e1f02baeed 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -82,7 +82,7 @@ async def async_block_recorder(hass: HomeAssistant, seconds: float) -> None: async def async_wait_recorder(hass: HomeAssistant) -> bool: """Wait for recorder to initialize and return connection status.""" - return await hass.data[recorder_helper.DOMAIN].db_connected + return await hass.data[recorder_helper.DATA_RECORDER].db_connected def get_start_time(start: datetime) -> datetime: diff --git a/tests/conftest.py b/tests/conftest.py index 64bbac11a1f..2f7330ebf22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1557,7 +1557,7 @@ async def _async_init_recorder_component( assert (recorder.DOMAIN in hass.config.components) == expected_setup_result else: # Wait for recorder to connect to the database - await hass.data[recorder_helper.DOMAIN].db_connected + await hass.data[recorder_helper.DATA_RECORDER].db_connected _LOGGER.info( "Test recorder successfully started, database location: %s", config[recorder.CONF_DB_URL], From ec7ec993b09d99b4cfc4fb3b8c8014b11bc11aee Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Feb 2025 18:26:14 +0100 Subject: [PATCH 0727/1941] Improve names and descriptions of `media_player.xxx_set` actions (#138773) Co-authored-by: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> --- homeassistant/components/media_player/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 02c0b59e4f0..87b5ec692af 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -299,22 +299,22 @@ "description": "Removes all items from the playlist." }, "shuffle_set": { - "name": "Shuffle", - "description": "Playback mode that selects the media in randomized order.", + "name": "Set shuffle", + "description": "Enables or disables the shuffle mode.", "fields": { "shuffle": { - "name": "Shuffle", - "description": "Whether or not shuffle mode is enabled." + "name": "Shuffle mode", + "description": "Whether the media should be played in randomized order or not." } } }, "repeat_set": { - "name": "Repeat", - "description": "Playback mode that plays the media in a loop.", + "name": "Set repeat", + "description": "Sets the repeat mode.", "fields": { "repeat": { "name": "Repeat mode", - "description": "Repeat mode to set." + "description": "Whether the media (one or all) should be played in a loop or not." } } }, From 5d1eb6928192298ec4f8f3b5efdf2a70bd5cc71e Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Thu, 20 Feb 2025 19:31:31 +0100 Subject: [PATCH 0728/1941] Add light platform to Homee (#138776) --- homeassistant/components/homee/__init__.py | 8 +- homeassistant/components/homee/const.py | 1 + homeassistant/components/homee/light.py | 213 +++++++++++ homeassistant/components/homee/strings.json | 5 + .../homee/fixtures/light_single.json | 102 +++++ tests/components/homee/fixtures/lights.json | 333 +++++++++++++++++ .../homee/snapshots/test_light.ambr | 348 ++++++++++++++++++ tests/components/homee/test_light.py | 158 ++++++++ 8 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/homee/light.py create mode 100644 tests/components/homee/fixtures/light_single.json create mode 100644 tests/components/homee/fixtures/lights.json create mode 100644 tests/components/homee/snapshots/test_light.ambr create mode 100644 tests/components/homee/test_light.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 530c7920b27..0e4959c35ac 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -14,7 +14,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] type HomeeConfigEntry = ConfigEntry[Homee] diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 54d7773890f..2c614d3f5eb 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -76,6 +76,7 @@ CLIMATE_PROFILES = [ NodeProfile.WIFI_RADIATOR_THERMOSTAT, NodeProfile.WIFI_ROOM_THERMOSTAT, ] + LIGHT_PROFILES = [ NodeProfile.DIMMABLE_COLOR_LIGHT, NodeProfile.DIMMABLE_COLOR_METERING_PLUG, diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py new file mode 100644 index 00000000000..12d127c0945 --- /dev/null +++ b/homeassistant/components/homee/light.py @@ -0,0 +1,213 @@ +"""The Homee light platform.""" + +from typing import Any + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import ( + brightness_to_value, + color_hs_to_RGB, + color_RGB_to_hs, + value_to_brightness, +) + +from . import HomeeConfigEntry +from .const import LIGHT_PROFILES +from .entity import HomeeNodeEntity + +LIGHT_ATTRIBUTES = [ + AttributeType.COLOR, + AttributeType.COLOR_MODE, + AttributeType.COLOR_TEMPERATURE, + AttributeType.DIMMING_LEVEL, +] + + +def is_light_node(node: HomeeNode) -> bool: + """Determine if a node is controllable as a homee light based on its profile and attributes.""" + assert node.attribute_map is not None + return node.profile in LIGHT_PROFILES and AttributeType.ON_OFF in node.attribute_map + + +def get_color_mode(supported_modes: set[ColorMode]) -> ColorMode: + """Determine the color mode from the supported modes.""" + if ColorMode.HS in supported_modes: + return ColorMode.HS + if ColorMode.COLOR_TEMP in supported_modes: + return ColorMode.COLOR_TEMP + if ColorMode.BRIGHTNESS in supported_modes: + return ColorMode.BRIGHTNESS + + return ColorMode.ONOFF + + +def get_light_attribute_sets( + node: HomeeNode, +) -> list[dict[AttributeType, HomeeAttribute]]: + """Return the lights with their attributes as found in the node.""" + lights: list[dict[AttributeType, HomeeAttribute]] = [] + on_off_attributes = [ + i for i in node.attributes if i.type == AttributeType.ON_OFF and i.editable + ] + for a in on_off_attributes: + attribute_dict: dict[AttributeType, HomeeAttribute] = {a.type: a} + for attribute in node.attributes: + if attribute.instance == a.instance and attribute.type in LIGHT_ATTRIBUTES: + attribute_dict[attribute.type] = attribute + lights.append(attribute_dict) + + return lights + + +def rgb_list_to_decimal(color: tuple[int, int, int]) -> int: + """Convert an rgb color from list to decimal representation.""" + return int(int(color[0]) << 16) + (int(color[1]) << 8) + (int(color[2])) + + +def decimal_to_rgb_list(color: float) -> list[int]: + """Convert an rgb color from decimal to list representation.""" + return [ + (int(color) & 0xFF0000) >> 16, + (int(color) & 0x00FF00) >> 8, + (int(color) & 0x0000FF), + ] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the light entity.""" + + async_add_entities( + HomeeLight(node, light, config_entry) + for node in config_entry.runtime_data.nodes + for light in get_light_attribute_sets(node) + if is_light_node(node) + ) + + +class HomeeLight(HomeeNodeEntity, LightEntity): + """Representation of a Homee light.""" + + def __init__( + self, + node: HomeeNode, + light: dict[AttributeType, HomeeAttribute], + entry: HomeeConfigEntry, + ) -> None: + """Initialize a Homee light.""" + super().__init__(node, entry) + + self._on_off_attr: HomeeAttribute = light[AttributeType.ON_OFF] + self._dimmer_attr: HomeeAttribute | None = light.get( + AttributeType.DIMMING_LEVEL + ) + self._col_attr: HomeeAttribute | None = light.get(AttributeType.COLOR) + self._temp_attr: HomeeAttribute | None = light.get( + AttributeType.COLOR_TEMPERATURE + ) + self._mode_attr: HomeeAttribute | None = light.get(AttributeType.COLOR_MODE) + + self._attr_supported_color_modes = self._get_supported_color_modes() + self._attr_color_mode = get_color_mode(self._attr_supported_color_modes) + + if self._temp_attr is not None: + self._attr_min_color_temp_kelvin = int(self._temp_attr.minimum) + self._attr_max_color_temp_kelvin = int(self._temp_attr.maximum) + + if self._on_off_attr.instance > 0: + self._attr_translation_key = "light_instance" + self._attr_translation_placeholders = { + "instance": str(self._on_off_attr.instance) + } + else: + # If a device has only one light, it will get its name. + self._attr_name = None + self._attr_unique_id = ( + f"{entry.runtime_data.settings.uid}-{self._node.id}-{self._on_off_attr.id}" + ) + + @property + def brightness(self) -> int: + """Return the brightness of the light.""" + assert self._dimmer_attr is not None + return value_to_brightness( + (self._dimmer_attr.minimum + 1, self._dimmer_attr.maximum), + self._dimmer_attr.current_value, + ) + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the color of the light.""" + assert self._col_attr is not None + rgb = decimal_to_rgb_list(self._col_attr.current_value) + return color_RGB_to_hs(*rgb) + + @property + def color_temp_kelvin(self) -> int: + """Return the color temperature of the light.""" + assert self._temp_attr is not None + return int(self._temp_attr.current_value) + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return bool(self._on_off_attr.current_value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on.""" + if ATTR_BRIGHTNESS in kwargs and self._dimmer_attr is not None: + target_value = round( + brightness_to_value( + (self._dimmer_attr.minimum, self._dimmer_attr.maximum), + kwargs[ATTR_BRIGHTNESS], + ) + ) + await self.async_set_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) + + 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]) + if ATTR_HS_COLOR in kwargs: + color = kwargs[ATTR_HS_COLOR] + if self._col_attr is not None: + await self.async_set_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) + + def _get_supported_color_modes(self) -> set[ColorMode]: + """Determine the supported color modes from the available attributes.""" + color_modes: set[ColorMode] = set() + + if self._temp_attr is not None and self._temp_attr.editable: + color_modes.add(ColorMode.COLOR_TEMP) + if self._col_attr is not None: + color_modes.add(ColorMode.HS) + + # If no other color modes are available, set one of those. + if len(color_modes) == 0: + if self._dimmer_attr is not None: + color_modes.add(ColorMode.BRIGHTNESS) + else: + color_modes.add(ColorMode.ONOFF) + + return color_modes diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index fabe02a0377..f7e24acff99 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -61,6 +61,11 @@ "name": "Ventilate" } }, + "light": { + "light_instance": { + "name": "Light {instance}" + } + }, "sensor": { "brightness_instance": { "name": "Illuminance {instance}" diff --git a/tests/components/homee/fixtures/light_single.json b/tests/components/homee/fixtures/light_single.json new file mode 100644 index 00000000000..30932da8679 --- /dev/null +++ b/tests/components/homee/fixtures/light_single.json @@ -0,0 +1,102 @@ +{ + "id": 2, + "name": "Another Test Light", + "profile": 1002, + "image": "default", + "favorite": 0, + "order": 48, + "protocol": 21, + "sub_protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1694024544, + "added": 1679551927, + "history": 1, + "cube_type": 8, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 12, + "node_id": 2, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 13, + "node_id": 2, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 14, + "node_id": 2, + "instance": 0, + "minimum": 2000, + "maximum": 7000, + "current_value": 3700.0, + "target_value": 3700.0, + "last_value": 3700.0, + "unit": "K", + "step_value": 1.0, + "editable": 1, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/lights.json b/tests/components/homee/fixtures/lights.json new file mode 100644 index 00000000000..3363b93fd77 --- /dev/null +++ b/tests/components/homee/fixtures/lights.json @@ -0,0 +1,333 @@ +{ + "id": 1, + "name": "Test Light", + "profile": 1002, + "image": "default", + "favorite": 0, + "order": 48, + "protocol": 21, + "sub_protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1694024544, + "added": 1679551927, + "history": 1, + "cube_type": 8, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 3, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1073741824, + "current_value": 16763000, + "target_value": 16763000, + "last_value": 16763000, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 23, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "7001020;16419669;12026363;16525995", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 1, + "minimum": 153, + "maximum": 500, + "current_value": 366.0, + "target_value": 366.0, + "last_value": 366.0, + "unit": "K", + "step_value": 1.0, + "editable": 1, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 5, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 6, + "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": 1, + "type": 2, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 7, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1073741824, + "current_value": 16763000, + "target_value": 16763000, + "last_value": 16763000, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 23, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "7001020;16419669;12026363;16525995", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 2, + "minimum": 2202, + "maximum": 4000, + "current_value": 3000.0, + "target_value": 3000.0, + "last_value": 3000.0, + "unit": "K", + "step_value": 1.0, + "editable": 1, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 9, + "node_id": 1, + "instance": 3, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 10, + "node_id": 1, + "instance": 3, + "minimum": 0, + "maximum": 100, + "current_value": 40.0, + "target_value": 40.0, + "last_value": 40.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1736743291, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 11, + "node_id": 1, + "instance": 4, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 12, + "node_id": 1, + "instance": 4, + "minimum": 2200, + "maximum": 4000, + "current_value": 3000.0, + "target_value": 3000.0, + "last_value": 3000.0, + "unit": "K", + "step_value": 1.0, + "editable": 0, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_light.ambr b/tests/components/homee/snapshots/test_light.ambr new file mode 100644 index 00000000000..3c766552467 --- /dev/null +++ b/tests/components/homee/snapshots/test_light.ambr @@ -0,0 +1,348 @@ +# serializer version: 1 +# name: test_light_snapshot[light.another_test_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + '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.another_test_light', + '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': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00055511EECC-2-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.another_test_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 270, + 'color_temp_kelvin': 3700, + 'friendly_name': 'Another Test Light', + 'hs_color': tuple( + 26.996, + 40.593, + ), + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'rgb_color': tuple( + 255, + 198, + 151, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.44, + 0.371, + ), + }), + 'context': , + 'entity_id': 'light.another_test_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_snapshot[light.test_light_light_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 500, + 'max_mireds': 6535, + 'min_color_temp_kelvin': 153, + 'min_mireds': 2000, + '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.test_light_light_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': 'Light 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Test Light Light 1', + 'hs_color': tuple( + 35.556, + 52.941, + ), + 'max_color_temp_kelvin': 500, + 'max_mireds': 6535, + 'min_color_temp_kelvin': 153, + 'min_mireds': 2000, + 'rgb_color': tuple( + 255, + 200, + 120, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.464, + 0.402, + ), + }), + 'context': , + 'entity_id': 'light.test_light_light_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_snapshot[light.test_light_light_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 4000, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 250, + '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.test_light_light_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': 'Light 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Test Light Light 2', + 'hs_color': None, + 'max_color_temp_kelvin': 4000, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 250, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.test_light_light_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_light_snapshot[light.test_light_light_3-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.test_light_light_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': 'Light 3', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 102, + 'color_mode': , + 'friendly_name': 'Test Light Light 3', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_light_light_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_snapshot[light.test_light_light_4-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.test_light_light_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': 'Light 4', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Test Light Light 4', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_light_light_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/homee/test_light.py b/tests/components/homee/test_light.py new file mode 100644 index 00000000000..c8af4f6b23d --- /dev/null +++ b/tests/components/homee/test_light.py @@ -0,0 +1,158 @@ +"""Test homee lights.""" + +from typing import Any +from unittest.mock import MagicMock, call, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +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 + + +def mock_attribute_map(attributes) -> dict: + """Mock the attribute map of a Homee node.""" + attribute_map = {} + for a in attributes: + attribute_map[a.type] = a + + return attribute_map + + +async def setup_mock_light( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + file: str, +) -> None: + """Setups the light node for the tests.""" + mock_homee.nodes = [build_mock_node(file)] + mock_homee.nodes[0].attribute_map = mock_attribute_map( + mock_homee.nodes[0].attributes + ) + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("data", "calls"), + [ + ({}, [call(1, 1, 1)]), + ({ATTR_BRIGHTNESS: 255}, [call(1, 2, 100)]), + ( + { + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_TEMP_KELVIN: 4300, + }, + [call(1, 2, 100), call(1, 4, 4300)], + ), + ({ATTR_HS_COLOR: (100, 100)}, [call(1, 1, 1), call(1, 3, 5635840)]), + ], +) +async def test_turn_on( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test turning on the light.""" + await setup_mock_light(hass, mock_homee, mock_config_entry, "lights.json") + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_light_light_1"} | data, + blocking=True, + ) + assert mock_homee.set_value.call_args_list == calls + + +async def test_turn_off( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning off a light.""" + await setup_mock_light(hass, mock_homee, mock_config_entry, "lights.json") + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "light.test_light_light_1", + }, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 0) + + +async def test_toggle( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test toggling a light.""" + await setup_mock_light(hass, mock_homee, mock_config_entry, "lights.json") + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TOGGLE, + { + ATTR_ENTITY_ID: "light.test_light_light_1", + }, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 0) + + mock_homee.nodes[0].attributes[0].current_value = 0.0 + mock_homee.nodes[0].add_on_changed_listener.call_args_list[0][0][0]( + mock_homee.nodes[0] + ) + await hass.async_block_till_done() + mock_homee.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TOGGLE, + { + ATTR_ENTITY_ID: "light.test_light_light_1", + }, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 1) + + +async def test_light_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test snapshot of lights.""" + mock_homee.nodes = [ + build_mock_node("lights.json"), + build_mock_node("light_single.json"), + ] + for i in range(2): + mock_homee.nodes[i].attribute_map = mock_attribute_map( + mock_homee.nodes[i].attributes + ) + with patch("homeassistant.components.homee.PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 5f98d5a65a7f6e8203fc3c9c1e74c64dbaff01c2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2025 19:42:11 +0100 Subject: [PATCH 0729/1941] Revert Python 3.13.2 requirement for now (#138948) --- homeassistant/const.py | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 84f16cd08b7..7775b618795 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -28,8 +28,8 @@ MINOR_VERSION: Final = 3 PATCH_VERSION: Final = "0.dev0" __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, 2) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) # Truthy date string triggers showing related deprecation warning messages. REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" diff --git a/pyproject.toml b/pyproject.toml index d090d897716..e4eae2e4647 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] -requires-python = ">=3.13.2" +requires-python = ">=3.13.0" dependencies = [ "aiodns==3.2.0", # Integrations may depend on hassio integration without listing it to From e8ff31b792f15cee90b66eadae721550ca02f6f7 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 20 Feb 2025 22:23:59 +0100 Subject: [PATCH 0730/1941] Add error handling to enphase_envoy number platform action (#136812) --- .../components/enphase_envoy/number.py | 4 +- tests/components/enphase_envoy/test_number.py | 83 ++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index a88c282281b..91e93d9c59b 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator -from .entity import EnvoyBaseEntity +from .entity import EnvoyBaseEntity, exception_handler PARALLEL_UPDATES = 1 @@ -132,6 +132,7 @@ class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity): self.data.dry_contact_settings[self._relay_id] ) + @exception_handler async def async_set_native_value(self, value: float) -> None: """Update the relay.""" await self.envoy.update_dry_contact( @@ -185,6 +186,7 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity): 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_set_native_value(self, value: float) -> None: """Update the storage setting.""" await self.entity_description.update_fn(self.envoy, value) diff --git a/tests/components/enphase_envoy/test_number.py b/tests/components/enphase_envoy/test_number.py index 7f9293eef7c..07826174c7d 100644 --- a/tests/components/enphase_envoy/test_number.py +++ b/tests/components/enphase_envoy/test_number.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -13,6 +14,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -99,6 +101,43 @@ async def test_number_operation_storage( mock_envoy.set_reserve_soc.assert_awaited_once_with(test_value) +@pytest.mark.parametrize( + ("mock_envoy", "use_serial", "target", "test_value"), + [ + ("envoy_metered_batt_relay", "enpower_654321", "reserve_battery_level", 30.0), + ], + indirect=["mock_envoy"], +) +async def test_number_operation_storage_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: bool, + target: str, + test_value: float, +) -> None: + """Test enphase_envoy number storage entities operation.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry) + + test_entity = f"number.{use_serial}_{target}" + + mock_envoy.set_reserve_soc.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_set_native_value for {test_entity}, host", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: test_entity, + ATTR_VALUE: test_value, + }, + blocking=True, + ) + + @pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True) @pytest.mark.parametrize( ("relay", "target", "expected_value", "test_value", "test_field"), @@ -125,12 +164,10 @@ async def test_number_operation_relays( with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): await setup_integration(hass, config_entry) - entity_base = f"{Platform.NUMBER}." - assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) assert (name := dry_contact.load_name.lower().replace(" ", "_")) - test_entity = f"{entity_base}{name}_{target}" + test_entity = f"number.{name}_{target}" assert (entity_state := hass.states.get(test_entity)) assert float(entity_state.state) == expected_value @@ -148,3 +185,43 @@ async def test_number_operation_relays( mock_envoy.update_dry_contact.assert_awaited_once_with( {"id": relay, test_field: int(test_value)} ) + + +@pytest.mark.parametrize( + ("mock_envoy", "relay", "target", "test_value"), + [ + ("envoy_metered_batt_relay", "NC1", "cutoff_battery_level", 15.0), + ], + indirect=["mock_envoy"], +) +async def test_number_operation_relays_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + relay: str, + target: str, + test_value: float, +) -> None: + """Test enphase_envoy number relay entities operation with error returned.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, config_entry) + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"number.{name}_{target}" + + mock_envoy.update_dry_contact.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_set_native_value for {test_entity}, host", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: test_entity, + ATTR_VALUE: test_value, + }, + blocking=True, + ) From 490e012e5471b309ee2367406cd6a671de4c68e9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 20 Feb 2025 22:38:43 +0100 Subject: [PATCH 0731/1941] Fix handling of min/max temperature presets in AVM Fritz!SmartHome (#138954) --- homeassistant/components/fritzbox/climate.py | 26 +++++------ tests/components/fritzbox/test_climate.py | 45 +++++++++++++++++--- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index c25113f1bca..118e03c391f 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -85,6 +85,8 @@ async def async_setup_entry( class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): """The thermostat class for FRITZ!SmartHome thermostats.""" + _attr_max_temp = MAX_TEMPERATURE + _attr_min_temp = MIN_TEMPERATURE _attr_precision = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "thermostat" @@ -135,11 +137,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - target_temp = kwargs.get(ATTR_TEMPERATURE) - hvac_mode = kwargs.get(ATTR_HVAC_MODE) - if hvac_mode == HVACMode.OFF: + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF: await self.async_set_hvac_mode(hvac_mode) - elif target_temp is not None: + elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None: + if target_temp == OFF_API_TEMPERATURE: + target_temp = OFF_REPORT_SET_TEMPERATURE + elif target_temp == ON_API_TEMPERATURE: + target_temp = ON_REPORT_SET_TEMPERATURE await self.hass.async_add_executor_job( self.data.set_target_temperature, target_temp, True ) @@ -169,12 +173,12 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): translation_domain=DOMAIN, translation_key="change_hvac_while_active_mode", ) - if self.hvac_mode == hvac_mode: + if self.hvac_mode is hvac_mode: LOGGER.debug( "%s is already in requested hvac mode %s", self.name, hvac_mode ) return - if hvac_mode == HVACMode.OFF: + if hvac_mode is HVACMode.OFF: await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) else: if value_scheduled_preset(self.data) == PRESET_ECO: @@ -208,16 +212,6 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): elif preset_mode == PRESET_ECO: await self.async_set_temperature(temperature=self.data.eco_temperature) - @property - def min_temp(self) -> int: - """Return the minimum temperature.""" - return MIN_TEMPERATURE - - @property - def max_temp(self) -> int: - """Return the maximum temperature.""" - return MAX_TEMPERATURE - @property def extra_state_attributes(self) -> ClimateExtraAttributes: """Return the device specific state attributes.""" diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 87e6d36e3b6..f170836fa9b 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -23,7 +23,12 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.fritzbox.climate import PRESET_HOLIDAY, PRESET_SUMMER +from homeassistant.components.fritzbox.climate import ( + OFF_API_TEMPERATURE, + ON_API_TEMPERATURE, + PRESET_HOLIDAY, + PRESET_SUMMER, +) from homeassistant.components.fritzbox.const import ( ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, @@ -367,9 +372,23 @@ async def test_set_hvac_mode( assert device.set_target_temperature.call_args_list == expected_call_args -async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None: +@pytest.mark.parametrize( + ("comfort_temperature", "expected_call_args"), + [ + (20, [call(20, True)]), + (28, [call(28, True)]), + (ON_API_TEMPERATURE, [call(30, True)]), + ], +) +async def test_set_preset_mode_comfort( + hass: HomeAssistant, + fritz: Mock, + comfort_temperature: int, + expected_call_args: list[_Call], +) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() + device.comfort_temperature = comfort_temperature assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -380,12 +399,27 @@ async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, True, ) - assert device.set_target_temperature.call_args_list == [call(22, True)] + assert device.set_target_temperature.call_count == len(expected_call_args) + assert device.set_target_temperature.call_args_list == expected_call_args -async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: +@pytest.mark.parametrize( + ("eco_temperature", "expected_call_args"), + [ + (20, [call(20, True)]), + (16, [call(16, True)]), + (OFF_API_TEMPERATURE, [call(0, True)]), + ], +) +async def test_set_preset_mode_eco( + hass: HomeAssistant, + fritz: Mock, + eco_temperature: int, + expected_call_args: list[_Call], +) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() + device.eco_temperature = eco_temperature assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -396,7 +430,8 @@ async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, True, ) - assert device.set_target_temperature.call_args_list == [call(16, True)] + assert device.set_target_temperature.call_count == len(expected_call_args) + assert device.set_target_temperature.call_args_list == expected_call_args async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: From ab299d2bf717f1f924bc0716246c280c4850755a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Feb 2025 15:39:33 -0600 Subject: [PATCH 0732/1941] Bump propcache to 0.3.0 (#138949) --- 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 5de22bf698e..8318a7305e1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -48,7 +48,7 @@ orjson==3.10.12 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.1.0 -propcache==0.2.1 +propcache==0.3.0 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 diff --git a/pyproject.toml b/pyproject.toml index e4eae2e4647..4ea1e1e0481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ dependencies = [ # PyJWT has loose dependency. We want the latest one. "cryptography==44.0.1", "Pillow==11.1.0", - "propcache==0.2.1", + "propcache==0.3.0", "pyOpenSSL==25.0.0", "orjson==3.10.12", "packaging>=23.1", diff --git a/requirements.txt b/requirements.txt index bd92428465d..b2d519e7992 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ lru-dict==1.3.0 PyJWT==2.10.1 cryptography==44.0.1 Pillow==11.1.0 -propcache==0.2.1 +propcache==0.3.0 pyOpenSSL==25.0.0 orjson==3.10.12 packaging>=23.1 From aec7fc183595f9b530208b016acc3be4bcac7c5e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Feb 2025 22:42:29 +0100 Subject: [PATCH 0733/1941] Use capitalized "Modbus" as name, replace "slave" with "server" (#138945) --- homeassistant/components/modbus/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 7b55022645e..347549dc837 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -2,11 +2,11 @@ "services": { "reload": { "name": "[%key:common::action::reload%]", - "description": "Reloads all modbus entities." + "description": "Reloads all Modbus entities." }, "write_coil": { "name": "Write coil", - "description": "Writes to a modbus coil.", + "description": "Writes to a Modbus coil.", "fields": { "address": { "name": "Address", @@ -17,8 +17,8 @@ "description": "State to write." }, "slave": { - "name": "Slave", - "description": "Address of the modbus unit/slave." + "name": "Server", + "description": "Address of the Modbus unit/server." }, "hub": { "name": "Hub", @@ -28,7 +28,7 @@ }, "write_register": { "name": "Write register", - "description": "Writes to a modbus holding register.", + "description": "Writes to a Modbus holding register.", "fields": { "address": { "name": "[%key:component::modbus::services::write_coil::fields::address::name%]", From 97bf557b32190d955d9c4d76bcfb35b5e8243302 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Feb 2025 15:49:26 -0600 Subject: [PATCH 0734/1941] Restore `PaddleSwitchPico` (Pico Paddle Remote) device trigger to Lutron Caseta (#137689) --- .../lutron_caseta/device_trigger.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 0b432f88045..31c9a0e171d 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -277,6 +277,21 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( } ) +# See mappings at https://github.com/home-assistant/core/issues/137548#issuecomment-2643440119 +PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = { + "on": 2, # 'Number': 2 in LIP + "off": 4, # 'Number': 4 in LIP +} +PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = { + "on": 0, # 'ButtonNumber': 0 in LEAP + "off": 2, # 'ButtonNumber': 2 in LEAP +} +PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP), + } +) + DEVICE_TYPE_SCHEMA_MAP = { "Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA, @@ -288,6 +303,7 @@ DEVICE_TYPE_SCHEMA_MAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, "FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, } DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { @@ -300,6 +316,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP, } DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { @@ -312,6 +329,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP, "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP, + "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP, } LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = { @@ -326,6 +344,7 @@ TRIGGER_SCHEMA = vol.Any( PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + PADDLE_SWITCH_PICO_TRIGGER_SCHEMA, ) From 509add8e5ca7fb47e0c02a645c92861a989cf8c5 Mon Sep 17 00:00:00 2001 From: Petr V Date: Thu, 20 Feb 2025 22:51:49 +0100 Subject: [PATCH 0735/1941] Adjust Tuya Water Detector to support 1 as an alarm state (#135933) --- homeassistant/components/tuya/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 1487a80248c..1e13f101110 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -256,7 +256,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=DPCode.WATERSENSOR_STATE, device_class=BinarySensorDeviceClass.MOISTURE, - on_value="alarm", + on_value={"1", "alarm"}, ), TAMPER_BINARY_SENSOR, ), From 1cae504cfe36874a1a3bde0dfc390e427294254b Mon Sep 17 00:00:00 2001 From: cro Date: Thu, 20 Feb 2025 22:52:03 +0100 Subject: [PATCH 0736/1941] Fix bug in set_preset_mode_with_end_datetime (wrong typo of frost_guard) (#138402) --- homeassistant/components/netatmo/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index cab0528199d..c130d8e96e3 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -39,7 +39,7 @@ set_preset_mode_with_end_datetime: select: options: - "away" - - "Frost Guard" + - "frost_guard" end_datetime: required: true example: '"2019-04-20 05:04:20"' From 9d241a77b78e8f49f84aa8ecc819806c15ad3027 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 20 Feb 2025 23:14:17 +0100 Subject: [PATCH 0737/1941] Adjust DSL line status options in SFR Box integration (#136425) --- homeassistant/components/sfr_box/sensor.py | 2 +- homeassistant/components/sfr_box/strings.json | 10 +++++----- tests/components/sfr_box/snapshots/test_sensor.ambr | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 8f50b6acd90..8b495da56c3 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -123,7 +123,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( entity_registry_enabled_default=False, options=[ "no_defect", - "of_frame", + "loss_of_frame", "loss_of_signal", "loss_of_power", "loss_of_signal_quality", diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 6f0001e97ce..35e9b1869ff 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -64,11 +64,11 @@ "dsl_line_status": { "name": "DSL line status", "state": { - "no_defect": "No Defect", - "of_frame": "Of Frame", - "loss_of_signal": "Loss Of Signal", - "loss_of_power": "Loss Of Power", - "loss_of_signal_quality": "Loss Of Signal Quality", + "no_defect": "No defect", + "loss_of_frame": "Loss of frame", + "loss_of_signal": "Loss of signal", + "loss_of_power": "Loss of power", + "loss_of_signal_quality": "Loss of signal quality", "unknown": "Unknown" } }, diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 6376ef24ce2..56745c8be8e 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -486,7 +486,7 @@ 'capabilities': dict({ 'options': list([ 'no_defect', - 'of_frame', + 'loss_of_frame', 'loss_of_signal', 'loss_of_power', 'loss_of_signal_quality', @@ -755,7 +755,7 @@ 'friendly_name': 'SFR Box DSL line status', 'options': list([ 'no_defect', - 'of_frame', + 'loss_of_frame', 'loss_of_signal', 'loss_of_power', 'loss_of_signal_quality', From 97b853e2ea051e467e8348c3121e8859b995e446 Mon Sep 17 00:00:00 2001 From: Josh Gustafson Date: Thu, 20 Feb 2025 15:16:25 -0700 Subject: [PATCH 0738/1941] Bump arcam-fmj to 1.8.1 (#138959) --- homeassistant/components/arcam_fmj/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 944c70c1217..41396eca5d6 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.8.0"], + "requirements": ["arcam-fmj==1.8.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/requirements_all.txt b/requirements_all.txt index c7006b9049a..0e87093f4b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -497,7 +497,7 @@ aqualogic==2.6 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.0 +arcam-fmj==1.8.1 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c1be927b55..fcbe6702623 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -467,7 +467,7 @@ apsystems-ez1==2.4.0 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.0 +arcam-fmj==1.8.1 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From c687f3753998ca387c9091d11410e2945c6c7ef9 Mon Sep 17 00:00:00 2001 From: Luke Hines Date: Thu, 20 Feb 2025 22:56:37 +0000 Subject: [PATCH 0739/1941] Jellyfin - Improve media image quality (#138958) --- homeassistant/components/jellyfin/media_player.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index a8744b3e725..e0fcc8a559b 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -17,7 +17,7 @@ from homeassistant.util.dt import parse_datetime from .browse_media import build_item_response, build_root_response from .client_wrapper import get_artwork_url -from .const import CONTENT_TYPE_MAP, LOGGER +from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator from .entity import JellyfinClientEntity @@ -169,7 +169,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): if self.now_playing is None: return None - return get_artwork_url(self.coordinator.api_client, self.now_playing, 150) + return get_artwork_url( + self.coordinator.api_client, self.now_playing, MAX_IMAGE_WIDTH + ) @property def supported_features(self) -> MediaPlayerEntityFeature: From 9cbed483fb053faffadd73204934f9ed202c29cb Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 20 Feb 2025 23:12:27 +0000 Subject: [PATCH 0740/1941] Bump pyprosegur to 0.0.13 (#138960) --- 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 adf5e985fe9..6419b81aa7f 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.9"] + "requirements": ["pyprosegur==0.0.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0e87093f4b2..fa48f76da85 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.9 +pyprosegur==0.0.13 # homeassistant.components.prusalink pyprusalink==2.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fcbe6702623..264fdfb65c7 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.9 +pyprosegur==0.0.13 # homeassistant.components.prusalink pyprusalink==2.1.1 From 9105542bab661c4c58781851388c8208fb520c60 Mon Sep 17 00:00:00 2001 From: proohit <46965017+proohit@users.noreply.github.com> Date: Fri, 21 Feb 2025 00:32:17 +0100 Subject: [PATCH 0741/1941] Add debug launch configuration for current open test file (#137177) --- .vscode/launch.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 7b77a1c9bfd..15cdb9fb625 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -42,6 +42,14 @@ "--picked" ], }, + { + "name": "Home Assistant: Debug Current Test File", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "console": "integratedTerminal", + "args": ["-vv", "${file}"] + }, { // Debug by attaching to local Home Assistant server using Remote Python Debugger. // See https://www.home-assistant.io/integrations/debugpy/ @@ -77,4 +85,4 @@ ] } ] -} \ No newline at end of file +} From 71bdd0e237bee140dd54a7a8fbd91f5981cb3ef9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Feb 2025 18:53:04 -0600 Subject: [PATCH 0742/1941] Bump inkbird-ble to 0.7.0 (#138964) --- 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 c1922004317..1a251f52582 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.5.8"] + "requirements": ["inkbird-ble==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fa48f76da85..abaf65a54dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1220,7 +1220,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.5.8 +inkbird-ble==0.7.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 264fdfb65c7..47c6e83454c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1034,7 +1034,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.inkbird -inkbird-ble==0.5.8 +inkbird-ble==0.7.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From b35d252549c5f0964b1abfcf825ea21e5c7eafd2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 20 Feb 2025 23:03:26 -0500 Subject: [PATCH 0743/1941] Bump universal-silabs-flasher to v0.0.29 (#138970) * Bump flasher from 0.0.25 to 0.0.29 * Add new application type --- homeassistant/components/homeassistant_hardware/manifest.json | 2 +- homeassistant/components/homeassistant_hardware/util.py | 1 + requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index 2efa12ccfda..8f59ab61600 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -5,5 +5,5 @@ "codeowners": ["@home-assistant/core"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", - "requirements": ["universal-silabs-flasher==0.0.25"] + "requirements": ["universal-silabs-flasher==0.0.29"] } diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index bd1ff642d10..0e1b56b406e 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -42,6 +42,7 @@ class ApplicationType(StrEnum): CPC = "cpc" EZSP = "ezsp" SPINEL = "spinel" + ROUTER = "router" @classmethod def from_flasher_application_type( diff --git a/requirements_all.txt b/requirements_all.txt index abaf65a54dc..2705e3cd859 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2968,7 +2968,7 @@ unifi_ap==0.0.2 unifiled==0.11 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.25 +universal-silabs-flasher==0.0.29 # homeassistant.components.upb upb-lib==0.6.0 From e59ec8f867450c40f7abd72686014309151514bc Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 21 Feb 2025 11:55:56 +0100 Subject: [PATCH 0744/1941] Add ability to get callback when a config entry state changes (#138943) * Add entry_on_state_change_helper * undo black * remove unload * no coro * Add tests * Don't accept coro * Review feedback * Add error test * Make it callback type * Make it callback type * Removal test * change type --- homeassistant/config_entries.py | 28 +++++++ tests/test_config_entries.py | 130 ++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 871b476227c..2639c429e71 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -402,6 +402,7 @@ class ConfigEntry[_DataT = Any]: update_listeners: list[UpdateListenerType] _async_cancel_retry_setup: Callable[[], Any] | None _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None + _on_state_change: list[CALLBACK_TYPE] | None setup_lock: asyncio.Lock _reauth_lock: asyncio.Lock _tasks: set[asyncio.Future[Any]] @@ -526,6 +527,9 @@ class ConfigEntry[_DataT = Any]: # Hold list for actions to call on unload. _setter(self, "_on_unload", None) + # Hold list for actions to call on state change. + _setter(self, "_on_state_change", None) + # Reload lock to prevent conflicting reloads _setter(self, "setup_lock", asyncio.Lock()) # Reauth lock to prevent concurrent reauth flows @@ -1058,6 +1062,8 @@ class ConfigEntry[_DataT = Any]: hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self ) + self._async_process_on_state_change() + async def async_migrate(self, hass: HomeAssistant) -> bool: """Migrate an entry. @@ -1172,6 +1178,28 @@ class ConfigEntry[_DataT = Any]: task, ) + @callback + def async_on_state_change(self, func: CALLBACK_TYPE) -> CALLBACK_TYPE: + """Add a function to call when a config entry changes its state.""" + if self._on_state_change is None: + self._on_state_change = [] + self._on_state_change.append(func) + return lambda: cast(list, self._on_state_change).remove(func) + + def _async_process_on_state_change(self) -> None: + """Process the on_state_change callbacks and wait for pending tasks.""" + if self._on_state_change is None: + return + for func in self._on_state_change: + try: + func() + except Exception: + _LOGGER.exception( + "Error calling on_state_change callback for %s (%s)", + self.title, + self.domain, + ) + @callback def async_start_reauth( self, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index acc79deb538..7066417bfee 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4796,6 +4796,136 @@ async def test_entry_reload_calls_on_unload_listeners( assert entry.state is config_entries.ConfigEntryState.LOADED +@pytest.mark.parametrize( + ("source_state", "target_state", "transition_method_name", "call_count"), + [ + ( + config_entries.ConfigEntryState.NOT_LOADED, + config_entries.ConfigEntryState.LOADED, + "async_setup", + 2, + ), + ( + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.NOT_LOADED, + "async_unload", + 2, + ), + ( + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.LOADED, + "async_reload", + 4, + ), + ], +) +async def test_entry_state_change_calls_listener( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + source_state: config_entries.ConfigEntryState, + target_state: config_entries.ConfigEntryState, + transition_method_name: str, + call_count: int, +) -> None: + """Test listeners get called on entry state changes.""" + entry = MockConfigEntry(domain="comp", state=source_state) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=AsyncMock(return_value=True), + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + + mock_state_change_callback = Mock() + entry.async_on_state_change(mock_state_change_callback) + + transition_method = getattr(manager, transition_method_name) + await transition_method(entry.entry_id) + + assert len(mock_state_change_callback.mock_calls) == call_count + assert entry.state is target_state + + +async def test_entry_state_change_listener_removed( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, +) -> None: + """Test state_change listener can be removed.""" + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=AsyncMock(return_value=True), + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + + mock_state_change_callback = Mock() + remove = entry.async_on_state_change(mock_state_change_callback) + + await manager.async_setup(entry.entry_id) + + assert len(mock_state_change_callback.mock_calls) == 2 + assert entry.state is config_entries.ConfigEntryState.LOADED + + remove() + + await manager.async_unload(entry.entry_id) + + # the listener should no longer be called + assert len(mock_state_change_callback.mock_calls) == 2 + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_entry_state_change_error_does_not_block_transition( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we transition states normally even if the callback throws in on_state_change.""" + entry = MockConfigEntry( + title="test", domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED + ) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=AsyncMock(return_value=True), + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + + mock_state_change_callback = Mock(side_effect=Exception()) + + entry.async_on_state_change(mock_state_change_callback) + + await manager.async_setup(entry.entry_id) + + assert len(mock_state_change_callback.mock_calls) == 2 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert "Error calling on_state_change callback for test (comp)" in caplog.text + + async def test_setup_raise_entry_error( hass: HomeAssistant, manager: config_entries.ConfigEntries, From 113e703d5c77eef5a4d2d4d1a4b8a6f87d9d4fe2 Mon Sep 17 00:00:00 2001 From: Jonas Fors Lellky Date: Fri, 21 Feb 2025 12:31:03 +0100 Subject: [PATCH 0745/1941] =?UTF-8?q?Mark=20flexit=5Fbacnet=20as=20silver?= =?UTF-8?q?=20on=20the=20quality=20scale=20=F0=9F=A5=88=20(#138951)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/flexit_bacnet/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json index 5ef3f11a7b7..2e94dd2f4c7 100644 --- a/homeassistant/components/flexit_bacnet/manifest.json +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["flexit_bacnet==2.2.3"] } From 4f43c971cdcfea46621c1b85118019419a200f4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Feb 2025 06:22:34 -0600 Subject: [PATCH 0746/1941] Remember inkbird device type in the config entry (#138967) --- homeassistant/components/inkbird/__init__.py | 48 +++++++++++++------- homeassistant/components/inkbird/const.py | 2 + tests/components/inkbird/__init__.py | 11 +++++ tests/components/inkbird/test_sensor.py | 36 ++++++++++++++- 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index c715c64599a..9dd058e841a 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -4,17 +4,20 @@ from __future__ import annotations import logging -from inkbird_ble import INKBIRDBluetoothDeviceData +from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate -from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfo, +) from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN +from .const import CONF_DEVICE_TYPE, DOMAIN PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -25,20 +28,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" address = entry.unique_id assert address is not None - data = INKBIRDBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=data.update, - ) + device_type: str | None = entry.data.get(CONF_DEVICE_TYPE) + data = INKBIRDBluetoothDeviceData(device_type) + + @callback + def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate: + """Handle update callback from the passive BLE processor.""" + nonlocal device_type + update = data.update(service_info) + if device_type is None and data.device_type is not None: + device_type_str = str(data.device_type) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str} + ) + device_type = device_type_str + return update + + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=_async_on_update, ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload( - coordinator.async_start() - ) # only start after all platforms have had a chance to subscribe + # only start after all platforms have had a chance to subscribe + entry.async_on_unload(coordinator.async_start()) return True diff --git a/homeassistant/components/inkbird/const.py b/homeassistant/components/inkbird/const.py index 9d0e1638958..93fdcc7519c 100644 --- a/homeassistant/components/inkbird/const.py +++ b/homeassistant/components/inkbird/const.py @@ -1,3 +1,5 @@ """Constants for the INKBIRD Bluetooth integration.""" DOMAIN = "inkbird" + +CONF_DEVICE_TYPE = "device_type" diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 30ca369672c..01ae0bf8efc 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -22,6 +22,17 @@ SPS_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +SPS_WITH_CORRUPT_NAME_SERVICE_INFO = BluetoothServiceInfo( + name="XXXXcorruptXXXX", + address="AA:BB:CC:DD:EE:FF", + rssi=-63, + service_data={}, + manufacturer_data={2096: b"\x0f\x12\x00Z\xc7W\x06"}, + service_uuids=["0000fff0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + IBBQ_SERVICE_INFO = BluetoothServiceInfo( name="iBBQ", address="4125DDBA-2774-4851-9889-6AADDD4CAC3D", diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 822136b9021..0f3d6497c2b 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -1,11 +1,11 @@ """Test the INKBIRD config flow.""" -from homeassistant.components.inkbird.const import DOMAIN +from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -from . import SPS_SERVICE_INFO +from . import SPS_SERVICE_INFO, SPS_WITH_CORRUPT_NAME_SERVICE_INFO from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -34,5 +34,37 @@ async def test_sensors(hass: HomeAssistant) -> None: assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + # Make sure we remember the device type + # in case the name is corrupted later + assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_device_with_corrupt_name(hass: HomeAssistant) -> None: + """Test setting up a known device type with a corrupt name.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="AA:BB:CC:DD:EE:FF", + data={CONF_DEVICE_TYPE: "IBS-TH"}, + ) + 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()) == 0 + inject_bluetooth_service_info(hass, SPS_WITH_CORRUPT_NAME_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + temp_sensor = hass.states.get("sensor.ibs_th_eeff_battery") + temp_sensor_attribtes = temp_sensor.attributes + assert temp_sensor.state == "87" + assert temp_sensor_attribtes[ATTR_FRIENDLY_NAME] == "IBS-TH EEFF Battery" + assert temp_sensor_attribtes[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert temp_sensor_attribtes[ATTR_STATE_CLASS] == "measurement" + + assert entry.data[CONF_DEVICE_TYPE] == "IBS-TH" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() From 56e36cb1ff4fba089c95072022792cc430a3c90e Mon Sep 17 00:00:00 2001 From: Sam Wright Date: Fri, 21 Feb 2025 23:24:38 +1100 Subject: [PATCH 0747/1941] Bump aiounifi to v82 (#138975) --- 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 ce573592153..f5ad99b72f7 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==81"], + "requirements": ["aiounifi==82"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 2705e3cd859..20f1a20c584 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==81 +aiounifi==82 # homeassistant.components.usb aiousbwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47c6e83454c..7da8cdccf7c 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==81 +aiounifi==82 # homeassistant.components.usb aiousbwatcher==1.1.1 From 1d43cb3f295c2176ca1d7552a741a9ad06756af6 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Fri, 21 Feb 2025 07:25:22 -0500 Subject: [PATCH 0748/1941] Media Player tests patch demo object (#138854) --- tests/components/media_player/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 38486fe5911..1878d7372f6 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -283,7 +283,7 @@ async def test_media_browse( client = await hass_ws_client(hass) with patch( - "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", + "homeassistant.components.demo.media_player.DemoBrowsePlayer.async_browse_media", return_value=BrowseMedia( media_class=MediaClass.DIRECTORY, media_content_id="mock-id", @@ -323,7 +323,7 @@ async def test_media_browse( assert mock_browse_media.mock_calls[0][1] == ("album", "abcd") with patch( - "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", + "homeassistant.components.demo.media_player.DemoBrowsePlayer.async_browse_media", return_value={"bla": "yo"}, ): await client.send_json( From b73c6ed7681ed5e3ad2fdf9c66127708aaa33675 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 21 Feb 2025 06:32:36 -0600 Subject: [PATCH 0749/1941] Update HEOS host from discovery (#138950) --- homeassistant/components/heos/config_flow.py | 41 +++++++++++-- homeassistant/components/heos/manifest.json | 1 - .../components/heos/quality_scale.yaml | 4 +- tests/components/heos/test_config_flow.py | 57 +++++++++++++++++-- 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index aee9bf4c47e..a2f9671c94b 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -102,6 +102,18 @@ async def _validate_auth( return True +def _get_current_hosts(entry: HeosConfigEntry) -> set[str]: + """Get a set of current hosts from the entry.""" + hosts = set(entry.data[CONF_HOST]) + if hasattr(entry, "runtime_data"): + hosts.update( + player.ip_address + for player in entry.runtime_data.heos.players.values() + if player.ip_address is not None + ) + return hosts + + class HeosFlowHandler(ConfigFlow, domain=DOMAIN): """Define a flow for HEOS.""" @@ -125,10 +137,15 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): if TYPE_CHECKING: assert discovery_info.ssdp_location - await self.async_set_unique_id(DOMAIN) - # Connect to discovered host and get system information + entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None + + # Abort early when discovered host is part of the current system + if entry and hostname in _get_current_hosts(entry): + return self.async_abort(reason="single_instance_allowed") + + # Connect to discovered host and get system information heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) try: await heos.connect() @@ -146,8 +163,23 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): # Select the preferred host, if available if system_info.preferred_hosts: hostname = system_info.preferred_hosts[0].ip_address - self._discovered_host = hostname - return await self.async_step_confirm_discovery() + + # Move to confirmation when not configured + if entry is None: + self._discovered_host = hostname + return await self.async_step_confirm_discovery() + + # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload + if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: + _LOGGER.debug( + "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname + ) + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_HOST: hostname}, + reason="reconfigure_successful", + ) + return self.async_abort(reason="single_instance_allowed") async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None @@ -167,6 +199,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Obtain host and validate connection.""" await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured(error="single_instance_allowed") # Try connecting to host if provided errors: dict[str, str] = {} host = None diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 72472760951..d19b8cfd5ad 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -9,7 +9,6 @@ "loggers": ["pyheos"], "quality_scale": "silver", "requirements": ["pyheos==1.0.2"], - "single_config_entry": true, "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 6ade4e6ffb9..5f5062b6a82 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -38,9 +38,7 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: - status: todo - comment: Explore if this is possible. + discovery-update-info: done discovery: done docs-data-update: done docs-examples: done diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index a78fc456100..396c3743663 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -7,7 +7,9 @@ from pyheos import ( CommandFailedError, ConnectionState, HeosError, + HeosHost, HeosSystem, + NetworkType, ) import pytest @@ -118,17 +120,44 @@ async def test_discovery( async def test_discovery_flow_aborts_already_setup( - hass: HomeAssistant, discovery_data: SsdpServiceInfo, config_entry: MockConfigEntry + hass: HomeAssistant, + discovery_data_bedroom: SsdpServiceInfo, + config_entry: MockConfigEntry, + controller: MockHeos, ) -> None: - """Test discovery flow aborts when entry already setup.""" + """Test discovery flow aborts when entry already setup and hosts didn't change.""" config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom ) - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 0 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_discovery_aborts_same_system( + hass: HomeAssistant, + discovery_data_bedroom: SsdpServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, + system: HeosSystem, +) -> None: + """Test discovery does not update when current host is part of discovered's system.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 1 + assert config_entry.data[CONF_HOST] == "127.0.0.1" async def test_discovery_fails_to_connect_aborts( @@ -145,6 +174,26 @@ async def test_discovery_fails_to_connect_aborts( assert controller.disconnect.call_count == 1 +async def test_discovery_updates( + hass: HomeAssistant, + discovery_data_bedroom: SsdpServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, +) -> None: + """Test discovery updates existing entry.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + host = HeosHost("Player", "Model", None, None, "127.0.0.2", NetworkType.WIRED, True) + controller.get_system_info.return_value = HeosSystem(None, host, [host]) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data_bedroom + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.2" + + async def test_reconfigure_validates_and_updates_config( hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: From 800749728b3061f7ca93d2fb4773122eac6383d6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 21 Feb 2025 13:37:08 +0100 Subject: [PATCH 0750/1941] Extend initial IQS state for ViCare (#138952) --- .../components/vicare/quality_scale.yaml | 69 +++++++++++++------ 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/vicare/quality_scale.yaml b/homeassistant/components/vicare/quality_scale.yaml index 55b7590a092..81b03364142 100644 --- a/homeassistant/components/vicare/quality_scale.yaml +++ b/homeassistant/components/vicare/quality_scale.yaml @@ -1,43 +1,70 @@ rules: # Bronze - config-flow: done - test-before-configure: done - unique-config-entry: - status: todo - comment: Uniqueness is not checked yet. - config-flow-test-coverage: done - runtime-data: done - test-before-setup: done - appropriate-polling: done - entity-unique-id: done - has-entity-name: done - entity-event-setup: - status: exempt - comment: Entities of this integration does not explicitly subscribe to events. - dependency-transparency: done action-setup: status: todo comment: service registered in climate async_setup_entry. + appropriate-polling: done + brands: done common-modules: status: done comment: No coordinator is used, data update is centrally handled by the library. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - docs-actions: done - brands: done + entity-event-setup: + status: exempt + comment: Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: + status: todo + comment: Uniqueness is not checked yet. + # Silver - integration-owner: done - reauthentication-flow: done + 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: done + test-coverage: todo + # Gold devices: done diagnostics: done - entity-category: done + 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: done + entity-category: done entity-device-class: done - entity-translations: done entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo repair-issues: status: exempt comment: This integration does not raise any repairable issues. + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo From 97a124b28a6ba4be9acc4a88ac4ceaee26f1b709 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 21 Feb 2025 14:10:45 +0100 Subject: [PATCH 0751/1941] Homee: fix state_class of rain sensors. (#138310) --- homeassistant/components/homee/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index 237b80915aa..86733aae778 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -157,7 +157,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = { AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription( key="rainfall_day", device_class=SensorDeviceClass.PRECIPITATION, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription( key="humidity", From 508b6c8db04fa38489248761774be62e22827f26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Feb 2025 14:50:21 +0100 Subject: [PATCH 0752/1941] Bump sigstore/cosign-installer from 3.8.0 to 3.8.1 (#138973) --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ccd1fb22eb9..ffefee0d84e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.8.0 + uses: sigstore/cosign-installer@v3.8.1 with: cosign-release: "v2.2.3" From debee2508628f00c88ec299eaee9b6022f1c660b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 21 Feb 2025 09:26:35 -0500 Subject: [PATCH 0753/1941] Migrate `homeassistant_hardware` to use `FirmwareInfo` instead of just the application type (#138874) * Migrate `self._probed_firmware_type` to `self._probed_firmware_info` * Migrate from `firmware_type` to the full `firmware_info` * Implement `probe_silabs_firmware_type` via `probe_silabs_firmware_info` * Fix unit tests * Increase coverage * Bring test coverage to 100% * Simplify test per review comment --- .../firmware_config_flow.py | 74 +++++---- .../components/homeassistant_hardware/util.py | 30 +++- .../homeassistant_sky_connect/config_flow.py | 21 ++- .../homeassistant_yellow/config_flow.py | 30 +++- .../test_config_flow.py | 152 +++++++++++------- .../test_config_flow_failures.py | 78 +++++++++ .../homeassistant_hardware/test_util.py | 98 +++++++++++ .../test_config_flow.py | 44 ++++- .../homeassistant_yellow/test_config_flow.py | 35 +++- 9 files changed, 451 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 8d7a302e786..83031587712 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -28,12 +28,13 @@ from . import silabs_multiprotocol_addon from .const import OTBR_DOMAIN, ZHA_DOMAIN from .util import ( ApplicationType, + FirmwareInfo, OwningAddon, OwningIntegration, get_otbr_addon_manager, get_zigbee_flasher_addon_manager, guess_hardware_owners, - probe_silabs_firmware_type, + probe_silabs_firmware_info, ) _LOGGER = logging.getLogger(__name__) @@ -52,7 +53,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Instantiate base flow.""" super().__init__(*args, **kwargs) - self._probed_firmware_type: ApplicationType | None = None + self._probed_firmware_info: FirmwareInfo | None = None self._device: str | None = None # To be set in a subclass self._hardware_name: str = "unknown" # To be set in a subclass @@ -64,8 +65,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Shared translation placeholders.""" placeholders = { "firmware_type": ( - self._probed_firmware_type.value - if self._probed_firmware_type is not None + self._probed_firmware_info.firmware_type.value + if self._probed_firmware_info is not None else "unknown" ), "model": self._hardware_name, @@ -120,39 +121,49 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): description_placeholders=self._get_translation_placeholders(), ) - async def _probe_firmware_type(self) -> bool: - """Probe the firmware currently on the device.""" - assert self._device is not None - - self._probed_firmware_type = await probe_silabs_firmware_type( - self._device, - probe_methods=( - # We probe in order of frequency: Zigbee, Thread, then multi-PAN - ApplicationType.GECKO_BOOTLOADER, - ApplicationType.EZSP, - ApplicationType.SPINEL, - ApplicationType.CPC, - ), - ) - - return self._probed_firmware_type in ( + async def _probe_firmware_info( + self, + probe_methods: tuple[ApplicationType, ...] = ( + # We probe in order of frequency: Zigbee, Thread, then multi-PAN + ApplicationType.GECKO_BOOTLOADER, ApplicationType.EZSP, ApplicationType.SPINEL, ApplicationType.CPC, + ), + ) -> bool: + """Probe the firmware currently on the device.""" + assert self._device is not None + + self._probed_firmware_info = await probe_silabs_firmware_info( + self._device, + probe_methods=probe_methods, + ) + + return ( + self._probed_firmware_info is not None + and self._probed_firmware_info.firmware_type + in ( + ApplicationType.EZSP, + ApplicationType.SPINEL, + ApplicationType.CPC, + ) ) async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Pick Zigbee firmware.""" - if not await self._probe_firmware_type(): + if not await self._probe_firmware_info(): return self.async_abort( reason="unsupported_firmware", description_placeholders=self._get_translation_placeholders(), ) # Allow the stick to be used with ZHA without flashing - if self._probed_firmware_type == ApplicationType.EZSP: + if ( + self._probed_firmware_info is not None + and self._probed_firmware_info.firmware_type == ApplicationType.EZSP + ): return await self.async_step_confirm_zigbee() if not is_hassio(self.hass): @@ -338,7 +349,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Confirm Zigbee setup.""" assert self._device is not None assert self._hardware_name is not None - self._probed_firmware_type = ApplicationType.EZSP + + if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) if user_input is not None: await self.hass.config_entries.flow.async_init( @@ -366,7 +382,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Pick Thread firmware.""" - if not await self._probe_firmware_type(): + if not await self._probe_firmware_info(): return self.async_abort( reason="unsupported_firmware", description_placeholders=self._get_translation_placeholders(), @@ -458,7 +474,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Confirm OTBR setup.""" assert self._device is not None - self._probed_firmware_type = ApplicationType.SPINEL + if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) if user_input is not None: # OTBR discovery is done automatically via hassio @@ -497,14 +517,14 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow): class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow): """Zigbee and Thread options flow handlers.""" + _probed_firmware_info: FirmwareInfo + def __init__(self, config_entry: ConfigEntry, *args: Any, **kwargs: Any) -> None: """Instantiate options flow.""" super().__init__(*args, **kwargs) self._config_entry = config_entry - self._probed_firmware_type = ApplicationType(self.config_entry.data["firmware"]) - # Make `context` a regular dictionary self.context = {} diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 0e1b56b406e..1afb786369e 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -249,10 +249,10 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware return guesses[-1][0] -async def probe_silabs_firmware_type( +async def probe_silabs_firmware_info( device: str, *, probe_methods: Iterable[ApplicationType] | None = None -) -> ApplicationType | None: - """Probe the running firmware on a Silabs device.""" +) -> FirmwareInfo | None: + """Probe the running firmware on a SiLabs device.""" flasher = Flasher( device=device, **( @@ -270,4 +270,26 @@ async def probe_silabs_firmware_type( if flasher.app_type is None: return None - return ApplicationType.from_flasher_application_type(flasher.app_type) + return FirmwareInfo( + device=device, + firmware_type=ApplicationType.from_flasher_application_type(flasher.app_type), + firmware_version=( + flasher.app_version.orig_version + if flasher.app_version is not None + else None + ), + source="probe", + owners=[], + ) + + +async def probe_silabs_firmware_type( + device: str, *, probe_methods: Iterable[ApplicationType] | None = None +) -> ApplicationType | None: + """Probe the running firmware type on a SiLabs device.""" + + fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods) + if fw_info is None: + return None + + return fw_info.firmware_type diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index b3b4f68ba96..d8446c2d3f9 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -10,7 +10,10 @@ from homeassistant.components.homeassistant_hardware import ( firmware_config_flow, silabs_multiprotocol_addon, ) -from homeassistant.components.homeassistant_hardware.util import ApplicationType +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) from homeassistant.config_entries import ( ConfigEntry, ConfigEntryBaseFlow, @@ -118,7 +121,7 @@ class HomeAssistantSkyConnectConfigFlow( """Create the config entry.""" assert self._usb_info is not None assert self._hw_variant is not None - assert self._probed_firmware_type is not None + assert self._probed_firmware_info is not None return self.async_create_entry( title=self._hw_variant.full_name, @@ -130,7 +133,7 @@ class HomeAssistantSkyConnectConfigFlow( "description": self._usb_info.description, # For backwards compatibility "product": self._usb_info.description, "device": self._usb_info.device, - "firmware": self._probed_firmware_type.value, + "firmware": self._probed_firmware_info.firmware_type.value, }, ) @@ -203,18 +206,26 @@ class HomeAssistantSkyConnectOptionsFlowHandler( self._hardware_name = self._hw_variant.full_name self._device = self._usb_info.device + self._probed_firmware_info = FirmwareInfo( + device=self._device, + firmware_type=ApplicationType(self.config_entry.data["firmware"]), + firmware_version=None, + source="guess", + owners=[], + ) + # Regenerate the translation placeholders self._get_translation_placeholders() def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" - assert self._probed_firmware_type is not None + assert self._probed_firmware_info is not None self.hass.config_entries.async_update_entry( entry=self.config_entry, data={ **self.config_entry.data, - "firmware": self._probed_firmware_type.value, + "firmware": self._probed_firmware_info.firmware_type.value, }, options=self.config_entry.options, ) diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 502a20db07c..b916c6e46ca 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -24,7 +24,10 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon OptionsFlowHandler as MultiprotocolOptionsFlowHandler, SerialPortSettings as MultiprotocolSerialPortSettings, ) -from homeassistant.components.homeassistant_hardware.util import ApplicationType +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) from homeassistant.config_entries import ( SOURCE_HARDWARE, ConfigEntry, @@ -79,10 +82,13 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" # We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this - await self._probe_firmware_type() + await self._probe_firmware_info() # Kick off ZHA hardware discovery automatically if Zigbee firmware is running - if self._probed_firmware_type is ApplicationType.EZSP: + if ( + self._probed_firmware_info is not None + and self._probed_firmware_info.firmware_type is ApplicationType.EZSP + ): discovery_flow.async_create_flow( self.hass, ZHA_DOMAIN, @@ -98,7 +104,11 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): title=BOARD_NAME, data={ # Assume the firmware type is EZSP if we cannot probe it - FIRMWARE: (self._probed_firmware_type or ApplicationType.EZSP).value, + FIRMWARE: ( + self._probed_firmware_info.firmware_type + if self._probed_firmware_info is not None + else ApplicationType.EZSP + ).value, }, ) @@ -264,6 +274,14 @@ class HomeAssistantYellowOptionsFlowHandler( self._hardware_name = BOARD_NAME self._device = RADIO_DEVICE + self._probed_firmware_info = FirmwareInfo( + device=self._device, + firmware_type=ApplicationType(self.config_entry.data["firmware"]), + firmware_version=None, + source="guess", + owners=[], + ) + # Regenerate the translation placeholders self._get_translation_placeholders() @@ -285,13 +303,13 @@ class HomeAssistantYellowOptionsFlowHandler( def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" - assert self._probed_firmware_type is not None + assert self._probed_firmware_info is not None self.hass.config_entries.async_update_entry( entry=self.config_entry, data={ **self.config_entry.data, - FIRMWARE: self._probed_firmware_type.value, + FIRMWARE: self._probed_firmware_info.firmware_type.value, }, ) diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 3696ea66c03..32c5a381233 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -17,6 +17,7 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import ) from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, + FirmwareInfo, get_otbr_addon_manager, get_zigbee_flasher_addon_manager, ) @@ -65,13 +66,13 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): """Create the config entry.""" assert self._device is not None assert self._hardware_name is not None - assert self._probed_firmware_type is not None + assert self._probed_firmware_info is not None return self.async_create_entry( title=self._hardware_name, data={ "device": self._device, - "firmware": self._probed_firmware_type.value, + "firmware": self._probed_firmware_info.firmware_type.value, "hardware": self._hardware_name, }, ) @@ -87,18 +88,26 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): self._device = self.config_entry.data["device"] self._hardware_name = self.config_entry.data["hardware"] + self._probed_firmware_info = FirmwareInfo( + device=self._device, + firmware_type=ApplicationType(self.config_entry.data["firmware"]), + firmware_version=None, + source="guess", + owners=[], + ) + # Regenerate the translation placeholders self._get_translation_placeholders() def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" - assert self._probed_firmware_type is not None + assert self._probed_firmware_info is not None self.hass.config_entries.async_update_entry( entry=self.config_entry, data={ **self.config_entry.data, - "firmware": self._probed_firmware_type.value, + "firmware": self._probed_firmware_info.firmware_type.value, }, options=self.config_entry.options, ) @@ -142,7 +151,7 @@ def mock_addon_info( hass: HomeAssistant, *, is_hassio: bool = True, - app_type: ApplicationType = ApplicationType.EZSP, + app_type: ApplicationType | None = ApplicationType.EZSP, otbr_addon_info: AddonInfo = AddonInfo( available=True, hostname=None, @@ -187,6 +196,17 @@ def mock_addon_info( ) mock_otbr_manager.async_get_addon_info.return_value = otbr_addon_info + if app_type is None: + firmware_info_result = None + else: + firmware_info_result = FirmwareInfo( + device="/dev/ttyUSB0", # Not used + firmware_type=app_type, + firmware_version=None, + owners=[], + source="probe", + ) + with ( patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", @@ -209,8 +229,8 @@ def mock_addon_info( return_value=is_hassio, ), patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_type", - return_value=app_type, + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=firmware_info_result, ), ): yield mock_otbr_manager, mock_flasher_manager @@ -274,10 +294,14 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_zigbee" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.data == { @@ -347,10 +371,14 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> result = await hass.config_entries.flow.async_configure(result["flow_id"]) # Done - await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ): + await hass.async_block_till_done(wait_background_tasks=True) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_zigbee" async def test_config_flow_thread(hass: HomeAssistant) -> None: @@ -419,17 +447,21 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_otbr" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY - config_entry = result["result"] - assert config_entry.data == { - "firmware": "spinel", - "device": TEST_DEVICE, - "hardware": TEST_HARDWARE_NAME, - } + config_entry = result["result"] + assert config_entry.data == { + "firmware": "spinel", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -> None: @@ -477,10 +509,14 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_otbr" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: @@ -501,10 +537,10 @@ async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_zigbee" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.data == { @@ -538,17 +574,17 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) - # First step is confirmation - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" - assert result["description_placeholders"]["firmware_type"] == "ezsp" - assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME - with mock_addon_info( hass, app_type=ApplicationType.EZSP, ) as (mock_otbr_manager, mock_flasher_manager): + # First step is confirmation + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + assert result["description_placeholders"]["firmware_type"] == "ezsp" + assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME + result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, @@ -599,14 +635,18 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_otbr" - # We are now done - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.SPINEL, + ): + # We are now done + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY - # The firmware type has been updated - assert config_entry.data["firmware"] == "spinel" + # The firmware type has been updated + assert config_entry.data["firmware"] == "spinel" async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: @@ -680,11 +720,15 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_zigbee" - # We are now done - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ): + # We are now done + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY - # The firmware type has been updated - assert config_entry.data["firmware"] == "ezsp" + # The firmware type has been updated + assert config_entry.data["firmware"] == "ezsp" diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index c240d0198ca..8c2ee4b90ba 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -309,6 +309,42 @@ async def test_config_flow_zigbee_flasher_uninstall_fails(hass: HomeAssistant) - assert result["step_id"] == "confirm_zigbee" +@pytest.mark.parametrize( + "ignore_translations", + ["component.test_firmware_domain.config.abort.unsupported_firmware"], +) +async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> None: + """Test the config flow failing due to Zigbee firmware not being detected.""" + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "pick_firmware" + + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): + # Pick the menu option: we are now installing the addon + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_zigbee" + + with mock_addon_info( + hass, + app_type=None, # Probing fails + ) as (mock_otbr_manager, mock_flasher_manager): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_firmware" + + @pytest.mark.parametrize( "ignore_translations", ["component.test_firmware_domain.config.abort.not_hassio_thread"], @@ -530,6 +566,48 @@ async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) - assert result["step_id"] == "confirm_otbr" +@pytest.mark.parametrize( + "ignore_translations", + ["component.test_firmware_domain.config.abort.unsupported_firmware"], +) +async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> None: + """Test the config flow failing due to OpenThread firmware not being detected.""" + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with mock_addon_info( + hass, + app_type=ApplicationType.EZSP, + ) as (mock_otbr_manager, mock_flasher_manager): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_otbr" + + with mock_addon_info( + hass, + app_type=None, # Probing fails + ) as (mock_otbr_manager, mock_flasher_manager): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unsupported_firmware" + + @pytest.mark.parametrize( "ignore_translations", ["component.test_firmware_domain.options.abort.zha_still_using_stick"], diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 52739f16886..b467380c431 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -2,6 +2,10 @@ from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from universal_silabs_flasher.common import Version as FlasherVersion +from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType + from homeassistant.components.hassio import ( AddonError, AddonInfo, @@ -18,6 +22,8 @@ from homeassistant.components.homeassistant_hardware.util import ( OwningIntegration, get_otbr_addon_firmware_info, guess_firmware_info, + probe_silabs_firmware_info, + probe_silabs_firmware_type, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant @@ -280,3 +286,95 @@ async def test_get_otbr_addon_firmware_info_failure_bad_options( ) assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None + + +@pytest.mark.parametrize( + ("app_type", "firmware_version", "expected_fw_info"), + [ + ( + FlasherApplicationType.EZSP, + FlasherVersion("1.0.0"), + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version="1.0.0", + source="probe", + owners=[], + ), + ), + ( + FlasherApplicationType.EZSP, + None, + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="probe", + owners=[], + ), + ), + ( + FlasherApplicationType.SPINEL, + FlasherVersion("2.0.0"), + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.SPINEL, + firmware_version="2.0.0", + source="probe", + owners=[], + ), + ), + (None, None, None), + ], +) +async def test_probe_silabs_firmware_info( + app_type: FlasherApplicationType | None, + firmware_version: FlasherVersion | None, + expected_fw_info: FirmwareInfo | None, +) -> None: + """Test getting the firmware info.""" + + def probe_app_type() -> None: + mock_flasher.app_type = app_type + mock_flasher.app_version = firmware_version + + mock_flasher = MagicMock() + mock_flasher.app_type = None + mock_flasher.app_version = None + mock_flasher.probe_app_type = AsyncMock(side_effect=probe_app_type) + + with patch( + "homeassistant.components.homeassistant_hardware.util.Flasher", + return_value=mock_flasher, + ): + result = await probe_silabs_firmware_info("/dev/ttyUSB0") + assert result == expected_fw_info + + +@pytest.mark.parametrize( + ("probe_result", "expected"), + [ + ( + FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[], + ), + ApplicationType.EZSP, + ), + (None, None), + ], +) +async def test_probe_silabs_firmware_type( + probe_result: FirmwareInfo | None, expected: ApplicationType | None +) -> None: + """Test getting the firmware type from the probe result.""" + with patch( + "homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info", + autospec=True, + return_value=probe_result, + ): + result = await probe_silabs_firmware_type("/dev/ttyUSB0") + assert result == expected diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 904fcac321c..d8542002ae8 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -13,6 +13,10 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon get_flasher_addon_manager, get_multiprotocol_addon_manager, ) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -61,10 +65,22 @@ async def test_config_flow( async def mock_async_step_pick_firmware_zigbee(self, data): return await self.async_step_confirm_zigbee(user_input={}) - with patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow.async_step_pick_firmware_zigbee", - autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + owners=[], + source="probe", + ), + ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -134,10 +150,22 @@ async def test_options_flow( async def mock_async_step_pick_firmware_zigbee(self, data): return await self.async_step_confirm_zigbee(user_input={}) - with patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", - autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=usb_data.device, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + owners=[], + source="probe", + ), + ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 1067be7b56e..78fd45c6b5b 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -18,7 +18,10 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon get_flasher_addon_manager, get_multiprotocol_addon_manager, ) -from homeassistant.components.homeassistant_hardware.util import ApplicationType +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) from homeassistant.components.homeassistant_yellow.const import DOMAIN, RADIO_DEVICE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -82,8 +85,14 @@ async def test_config_flow(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_type", - return_value=ApplicationType.EZSP, + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + owners=[], + source="probe", + ), ), ): result = await hass.config_entries.flow.async_init( @@ -330,10 +339,22 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: async def mock_async_step_pick_firmware_zigbee(self, data): return await self.async_step_confirm_zigbee(user_input={}) - with patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", - autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version=None, + owners=[], + source="probe", + ), + ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], From d522571308d548cc0ad3b291a68e3990b33e14c7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 21 Feb 2025 16:05:14 +0100 Subject: [PATCH 0754/1941] Bump deebot-client to 12.2.0 (#138986) --- 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 79e0c34e4b9..b31fa7f347d 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.1.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 20f1a20c584..ca5e10a9f92 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==12.1.0 +deebot-client==12.2.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7da8cdccf7c..3dd578a2d86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.1.0 +deebot-client==12.2.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 8068f82888f7d20cfcf01fd840a9d9767056955a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 21 Feb 2025 16:16:55 +0100 Subject: [PATCH 0755/1941] Don't fail on successful relogin in pyLoad integration (#138936) * Don't fail on successful relogin * logging --- .../components/pyload/coordinator.py | 14 +++++----- .../pyload/snapshots/test_sensor.ambr | 10 +++---- tests/components/pyload/test_init.py | 27 ++++++++++++++++++- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index 0d752e971e5..937d8d71291 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -59,14 +59,11 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): async def _async_update_data(self) -> PyLoadData: """Fetch data from API endpoint.""" try: - if not self.version: - self.version = await self.pyload.version() return PyLoadData( **await self.pyload.get_status(), free_space=await self.pyload.free_space(), ) - - except InvalidAuth as e: + except InvalidAuth: try: await self.pyload.login() except InvalidAuth as exc: @@ -75,10 +72,10 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): translation_key="setup_authentication_exception", translation_placeholders={CONF_USERNAME: self.pyload.username}, ) from exc - - raise UpdateFailed( - "Unable to retrieve data due to cookie expiration" - ) from e + _LOGGER.debug( + "Unable to retrieve data due to cookie expiration, retrying after 20 seconds" + ) + return self.data except CannotConnect as e: raise UpdateFailed( "Unable to connect and retrieve data from pyLoad API" @@ -91,6 +88,7 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): try: await self.pyload.login() + self.version = await self.pyload.version() except CannotConnect as e: raise ConfigEntryNotReady( translation_domain=DOMAIN, diff --git a/tests/components/pyload/snapshots/test_sensor.ambr b/tests/components/pyload/snapshots/test_sensor.ambr index 25abe62017d..d9948f4273a 100644 --- a/tests/components/pyload/snapshots/test_sensor.ambr +++ b/tests/components/pyload/snapshots/test_sensor.ambr @@ -310,7 +310,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '1', }) # --- # name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_downloads_in_queue-entry] @@ -361,7 +361,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '6', }) # --- # name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_free_space-entry] @@ -416,7 +416,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '93.1322574606165', }) # --- # name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_speed-entry] @@ -471,7 +471,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '43.247704', }) # --- # name: test_sensor_update_exceptions[InvalidAuth][sensor.pyload_total_downloads-entry] @@ -522,7 +522,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '37', }) # --- # name: test_sensor_update_exceptions[ParserError][sensor.pyload_active_downloads-entry] diff --git a/tests/components/pyload/test_init.py b/tests/components/pyload/test_init.py index 12713ef2e54..00b1f0aa3a8 100644 --- a/tests/components/pyload/test_init.py +++ b/tests/components/pyload/test_init.py @@ -1,14 +1,16 @@ """Test pyLoad init.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_entry_setup_unload( @@ -63,3 +65,26 @@ async def test_config_entry_setup_invalid_auth( assert config_entry.state is ConfigEntryState.SETUP_ERROR assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +async def test_coordinator_update_invalid_auth( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pyloadapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator authentication.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_pyloadapi.login.side_effect = InvalidAuth + mock_pyloadapi.get_status.side_effect = InvalidAuth + + freezer.tick(timedelta(seconds=20)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) From 0f7cb6b757ad8399261dbd2627cb4f968bab50a4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 21 Feb 2025 16:36:48 +0100 Subject: [PATCH 0756/1941] Bump reolink-aio to 0.12.0 (#138985) --- 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 505358a07f7..37e448aa820 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.10"] + "requirements": ["reolink-aio==0.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ca5e10a9f92..88eeaafd223 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2612,7 +2612,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.10 +reolink-aio==0.12.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3dd578a2d86..b37274817c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2115,7 +2115,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.10 +reolink-aio==0.12.0 # homeassistant.components.rflink rflink==0.0.66 From 059a6dddbea817863cca94062c64a1302ecbd1dd Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 21 Feb 2025 16:39:24 +0100 Subject: [PATCH 0757/1941] Fix off by one bug when sorting tasks in Habitica integration (#138993) * Fix off-by-one bug when sorting dailies and to-dos in Habitica * Add test --- homeassistant/components/habitica/todo.py | 11 +++++----- tests/components/habitica/test_todo.py | 26 +++++++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py index fd93f551916..29b98e90b04 100644 --- a/homeassistant/components/habitica/todo.py +++ b/homeassistant/components/habitica/todo.py @@ -119,12 +119,13 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity): assert self.todo_items if previous_uid: - pos = ( - self.todo_items.index( - next(item for item in self.todo_items if item.uid == previous_uid) - ) - + 1 + 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 += 1 else: pos = 0 diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index 8f20b3e685a..01c033fcf95 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -601,17 +601,19 @@ async def test_delete_completed_todo_items_exception( @pytest.mark.parametrize( - ("entity_id", "uid", "previous_uid"), + ("entity_id", "uid", "second_pos", "third_pos"), [ ( "todo.test_user_to_do_s", "1aa3137e-ef72-4d1f-91ee-41933602f438", "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", ), ( "todo.test_user_dailies", "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1", "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", ), ], ids=["todo", "daily"], @@ -623,7 +625,8 @@ async def test_move_todo_item( hass_ws_client: WebSocketGenerator, entity_id: str, uid: str, - previous_uid: str, + second_pos: str, + third_pos: str, ) -> None: """Test move todo items.""" @@ -634,13 +637,13 @@ async def test_move_todo_item( assert config_entry.state is ConfigEntryState.LOADED client = await hass_ws_client() - # move to second position + # move up to second position data = { "id": id, "type": "todo/item/move", "entity_id": entity_id, "uid": uid, - "previous_uid": previous_uid, + "previous_uid": second_pos, } await client.send_json_auto_id(data) resp = await client.receive_json() @@ -649,6 +652,21 @@ async def test_move_todo_item( habitica.reorder_task.assert_awaited_once_with(UUID(uid), 1) habitica.reorder_task.reset_mock() + # move down to third position + data = { + "id": id, + "type": "todo/item/move", + "entity_id": entity_id, + "uid": uid, + "previous_uid": third_pos, + } + await client.send_json_auto_id(data) + resp = await client.receive_json() + assert resp.get("success") + + habitica.reorder_task.assert_awaited_once_with(UUID(uid), 2) + habitica.reorder_task.reset_mock() + # move to top position data = { "id": id, From 26c60880e41044add00de700cc7269b1066652a5 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 21 Feb 2025 16:45:00 +0100 Subject: [PATCH 0758/1941] Add remember the milk entity tests (#138991) * Add remember the milk entity tests * Fix docstring --- .../components/remember_the_milk/entity.py | 17 +- .../components/remember_the_milk/conftest.py | 40 +++ .../remember_the_milk/test_entity.py | 282 ++++++++++++++++++ 3 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 tests/components/remember_the_milk/conftest.py create mode 100644 tests/components/remember_the_milk/test_entity.py diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index 8fa52b6c06c..5f618a96c11 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -60,20 +60,21 @@ class RememberTheMilkEntity(Entity): result = self._rtm_api.rtm.timelines.create() timeline = result.timeline.value - if hass_id is None or rtm_id is None: + if rtm_id is None: result = self._rtm_api.rtm.tasks.add( timeline=timeline, name=task_name, parse="1" ) _LOGGER.debug( "Created new task '%s' in account %s", task_name, self.name ) - self._rtm_config.set_rtm_id( - self._name, - hass_id, - result.list.id, - result.list.taskseries.id, - result.list.taskseries.task.id, - ) + if hass_id is not None: + self._rtm_config.set_rtm_id( + self._name, + hass_id, + result.list.id, + result.list.taskseries.id, + result.list.taskseries.task.id, + ) else: self._rtm_api.rtm.tasks.setName( name=task_name, diff --git a/tests/components/remember_the_milk/conftest.py b/tests/components/remember_the_milk/conftest.py new file mode 100644 index 00000000000..f7257f35c64 --- /dev/null +++ b/tests/components/remember_the_milk/conftest.py @@ -0,0 +1,40 @@ +"""Provide common pytest fixtures.""" + +from collections.abc import AsyncGenerator, Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.core import HomeAssistant + +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.token_valid.return_value = True + timelines = MagicMock() + timelines.timeline.value = "1234" + client.rtm.timelines.create.return_value = timelines + add_response = MagicMock() + add_response.list.id = "1" + add_response.list.taskseries.id = "2" + add_response.list.taskseries.task.id = "3" + client.rtm.tasks.add.return_value = add_response + + yield client + + +@pytest.fixture +async def storage(hass: HomeAssistant, client) -> AsyncGenerator[MagicMock]: + """Mock the config storage.""" + with patch( + "homeassistant.components.remember_the_milk.RememberTheMilkConfiguration" + ) as storage_class: + storage = storage_class.return_value + storage.get_token.return_value = TOKEN + storage.get_rtm_id.return_value = None + yield storage diff --git a/tests/components/remember_the_milk/test_entity.py b/tests/components/remember_the_milk/test_entity.py new file mode 100644 index 00000000000..e9d7a16d7ab --- /dev/null +++ b/tests/components/remember_the_milk/test_entity.py @@ -0,0 +1,282 @@ +"""Test the Remember The Milk entity.""" + +from typing import Any +from unittest.mock import MagicMock, call + +import pytest +from rtmapi import RtmRequestFailedException + +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", +} + + +@pytest.mark.parametrize( + ("valid_token", "entity_state"), [(True, "ok"), (False, "API token invalid")] +) +async def test_entity_state( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + valid_token: bool, + entity_state: str, +) -> None: + """Test the entity state.""" + client.token_valid.return_value = valid_token + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + entity_id = f"{DOMAIN}.{PROFILE}" + state = hass.states.get(entity_id) + + assert state + assert state.state == entity_state + + +@pytest.mark.parametrize( + ( + "get_rtm_id_return_value", + "service", + "service_data", + "get_rtm_id_call_count", + "get_rtm_id_call_args", + "timelines_call_count", + "api_method", + "api_method_call_count", + "api_method_call_args", + "storage_method", + "storage_method_call_count", + "storage_method_call_args", + ), + [ + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1"}, + 0, + None, + 1, + "rtm.tasks.add", + 1, + call( + timeline="1234", + name="Test 1", + parse="1", + ), + "set_rtm_id", + 0, + None, + ), + ( + None, + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + 1, + call(PROFILE, "test_1"), + 1, + "rtm.tasks.add", + 1, + call( + timeline="1234", + name="Test 1", + parse="1", + ), + "set_rtm_id", + 1, + call(PROFILE, "test_1", "1", "2", "3"), + ), + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + 1, + call(PROFILE, "test_1"), + 1, + "rtm.tasks.setName", + 1, + call( + name="Test 1", + list_id="1", + taskseries_id="2", + task_id="3", + timeline="1234", + ), + "set_rtm_id", + 0, + None, + ), + ( + ("1", "2", "3"), + f"{PROFILE}_complete_task", + {"id": "test_1"}, + 1, + call(PROFILE, "test_1"), + 1, + "rtm.tasks.complete", + 1, + call( + list_id="1", + taskseries_id="2", + task_id="3", + timeline="1234", + ), + "delete_rtm_id", + 1, + call(PROFILE, "test_1"), + ), + ], +) +async def test_services( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + get_rtm_id_return_value: Any, + service: str, + service_data: dict[str, Any], + get_rtm_id_call_count: int, + get_rtm_id_call_args: tuple[tuple, dict] | None, + timelines_call_count: int, + api_method: str, + api_method_call_count: int, + api_method_call_args: tuple[tuple, dict], + storage_method: str, + storage_method_call_count: int, + storage_method_call_args: tuple[tuple, dict] | None, +) -> None: + """Test create and complete task service.""" + storage.get_rtm_id.return_value = get_rtm_id_return_value + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + + await hass.services.async_call(DOMAIN, service, service_data, blocking=True) + + assert storage.get_rtm_id.call_count == get_rtm_id_call_count + assert storage.get_rtm_id.call_args == get_rtm_id_call_args + assert client.rtm.timelines.create.call_count == timelines_call_count + client_method = client + for name in api_method.split("."): + client_method = getattr(client_method, name) + assert client_method.call_count == api_method_call_count + assert client_method.call_args == api_method_call_args + storage_method_attribute = getattr(storage, storage_method) + assert storage_method_attribute.call_count == storage_method_call_count + assert storage_method_attribute.call_args == storage_method_call_args + + +@pytest.mark.parametrize( + ( + "get_rtm_id_return_value", + "service", + "service_data", + "method", + "exception", + "error_message", + ), + [ + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1"}, + "rtm.timelines.create", + RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), + "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + ), + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1"}, + "rtm.tasks.add", + RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), + "Request rtm.tasks.add failed. Status: 400, reason: Bad request.", + ), + ( + None, + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.timelines.create", + RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), + "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + ), + ( + None, + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.tasks.add", + RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"), + "Request rtm.tasks.add failed. Status: 400, reason: Bad request.", + ), + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.timelines.create", + RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), + "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + ), + ( + ("1", "2", "3"), + f"{PROFILE}_create_task", + {"name": "Test 1", "id": "test_1"}, + "rtm.tasks.setName", + RtmRequestFailedException("rtm.tasks.setName", "400", "Bad request"), + "Request rtm.tasks.setName failed. Status: 400, reason: Bad request.", + ), + ( + None, + f"{PROFILE}_complete_task", + {"id": "test_1"}, + "rtm.timelines.create", + None, + ( + f"Could not find task with ID test_1 in account {PROFILE}. " + "So task could not be closed" + ), + ), + ( + ("1", "2", "3"), + f"{PROFILE}_complete_task", + {"id": "test_1"}, + "rtm.timelines.create", + RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"), + "Request rtm.timelines.create failed. Status: 400, reason: Bad request.", + ), + ( + ("1", "2", "3"), + f"{PROFILE}_complete_task", + {"id": "test_1"}, + "rtm.tasks.complete", + RtmRequestFailedException("rtm.tasks.complete", "400", "Bad request"), + "Request rtm.tasks.complete failed. Status: 400, reason: Bad request.", + ), + ], +) +async def test_services_errors( + hass: HomeAssistant, + client: MagicMock, + storage: MagicMock, + caplog: pytest.LogCaptureFixture, + get_rtm_id_return_value: Any, + service: str, + service_data: dict[str, Any], + method: str, + exception: Exception, + error_message: str, +) -> None: + """Test create and complete task service errors.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG}) + storage.get_rtm_id.return_value = get_rtm_id_return_value + + client_method = client + for name in method.split("."): + client_method = getattr(client_method, name) + + client_method.side_effect = exception + + await hass.services.async_call(DOMAIN, service, service_data, blocking=True) + + assert error_message in caplog.text From 800f680bd5730dc7a44d3825184fbfce077518cd Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 21 Feb 2025 17:53:43 +0200 Subject: [PATCH 0759/1941] Fix Shelly model name for xmod devices (#138984) --- .../components/shelly/coordinator.py | 4 +- homeassistant/components/shelly/utils.py | 23 ++++- tests/components/shelly/conftest.py | 95 +++++++++++++++++++ tests/components/shelly/test_init.py | 5 +- 4 files changed, 116 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 23d5842f4e4..7b4da241043 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -68,11 +68,11 @@ from .utils import ( async_create_issue_unsupported_firmware, get_block_device_sleep_period, get_device_entry_gen, - get_device_info_model, get_host, get_http_port, get_rpc_device_wakeup_period, get_rpc_ws_url, + get_shelly_model_name, update_device_fw_info, ) @@ -165,7 +165,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( connections={(CONNECTION_NETWORK_MAC, self.mac)}, identifiers={(DOMAIN, self.mac)}, manufacturer="Shelly", - model=get_device_info_model(self.device), + model=get_shelly_model_name(self.model, self.sleep_period, self.device), model_id=self.model, sw_version=self.sw_version, hw_version=f"gen{get_device_entry_gen(self.config_entry)}", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 4d3add7b17b..2e81f745819 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -315,12 +315,25 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["type"], info["type"])) -def get_device_info_model(device: BlockDevice | RpcDevice) -> str | None: - """Return the device model for deviceinfo.""" - if isinstance(device, RpcDevice) and (model := device.xmod_info.get("n")): - return cast(str, model) +def get_shelly_model_name( + model: str, + sleep_period: int, + device: BlockDevice | RpcDevice, +) -> str | None: + """Get Shelly model name. - return cast(str, MODEL_NAMES.get(device.model)) + Assume that XMOD devices are not sleepy devices. + """ + if ( + sleep_period == 0 + and isinstance(device, RpcDevice) + and (model_name := device.xmod_info.get("n")) + ): + # Use the model name from XMOD data + return cast(str, model_name) + + # Use the model name from aioshelly + return cast(str, MODEL_NAMES.get(model)) def get_rpc_channel_name(device: RpcDevice, key: str) -> str: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 56b21701efe..b643979f9a6 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -13,6 +13,7 @@ from aioshelly.ble.const import ( ) from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.const import MODEL_1, MODEL_25, MODEL_PLUS_2PM +from aioshelly.exceptions import NotInitialized from aioshelly.rpc_device import RpcDevice, RpcUpdateType import pytest @@ -568,3 +569,97 @@ async def mock_blu_trv(): blu_trv_device_mock.return_value.mock_update = Mock(side_effect=update) yield blu_trv_device_mock.return_value + + +def _mock_sleepy_not_initialized_rpc_device(): + """Mock sleepy NotInitialized rpc (Gen2+, Websocket) device.""" + device = Mock(spec=RpcDevice, initialized=False, connected=False) + type(device).requires_auth = PropertyMock(side_effect=NotInitialized) + type(device).status = PropertyMock(side_effect=NotInitialized) + type(device).event = PropertyMock(side_effect=NotInitialized) + type(device).config = PropertyMock(side_effect=NotInitialized) + type(device).shelly = PropertyMock(side_effect=NotInitialized) + type(device).gen = PropertyMock(side_effect=NotInitialized) + type(device).firmware_version = PropertyMock(side_effect=NotInitialized) + type(device).version = PropertyMock(side_effect=NotInitialized) + type(device).model = PropertyMock(side_effect=NotInitialized) + type(device).xmod_info = PropertyMock(side_effect=NotInitialized) + type(device).hostname = PropertyMock(side_effect=NotInitialized) + type(device).name = PropertyMock(side_effect=NotInitialized) + type(device).firmware_supported = PropertyMock(side_effect=NotInitialized) + return device + + +def initialize_sleepy_rpc_device(device): + """Initialize a sleepy RPC (Gen2+, Websocket) device.""" + type(device).requires_auth = PropertyMock() + type(device).status = PropertyMock(return_value=MOCK_STATUS_RPC) + type(device).event = PropertyMock(return_value={}) + type(device).config = PropertyMock(return_value=MOCK_CONFIG) + type(device).shelly = PropertyMock(return_value=MOCK_SHELLY_RPC) + type(device).gen = PropertyMock(return_value=2) + type(device).firmware_version = PropertyMock( + return_value="20240425-141520/1.3.0-ga3fdd3d" + ) + type(device).version = PropertyMock("1.3.0") + type(device).model = PropertyMock("SPSW-201PE16EU") + type(device).xmod_info = PropertyMock(return_value={}) + type(device).hostname = PropertyMock(return_value="hostname") + type(device).name = PropertyMock(return_value="Test Name") + type(device).firmware_supported = PropertyMock(return_value=True) + + device.status["sys"]["wakeup_period"] = 1000 + device.connected = True + device.initialized = True + + +@pytest.fixture +async def mock_sleepy_rpc_device(): + """Mock sleepy RPC (Gen2+, Websocket) device. + + Mock a RPC device that is not initialized and raises NotInitialized + when aioshelly properties are accessed. + + Initialize the device when initialize() method is called. + """ + with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: + + def update(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.STATUS + ) + + def event(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.EVENT + ) + + def online(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.ONLINE + ) + + def disconnected(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.DISCONNECTED + ) + + def initialized(): + rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, RpcUpdateType.INITIALIZED + ) + + def _initialize(): + initialize_sleepy_rpc_device(device) + + device = _mock_sleepy_not_initialized_rpc_device() + device.initialize = AsyncMock(side_effect=_initialize) + rpc_device_mock.return_value = device + + rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) + rpc_device_mock.return_value.mock_update = Mock(side_effect=update) + rpc_device_mock.return_value.mock_event = Mock(side_effect=event) + rpc_device_mock.return_value.mock_online = Mock(side_effect=online) + rpc_device_mock.return_value.mock_initialized = Mock(side_effect=initialized) + + yield rpc_device_mock.return_value diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 270e2163635..b05bce76728 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -312,13 +312,10 @@ async def test_sleeping_rpc_device_online_new_firmware( async def test_sleeping_rpc_device_online_during_setup( hass: HomeAssistant, - mock_rpc_device: Mock, - monkeypatch: pytest.MonkeyPatch, + mock_sleepy_rpc_device: Mock, caplog: pytest.LogCaptureFixture, ) -> None: """Test sleeping device Gen2 woke up by user during setup.""" - monkeypatch.setattr(mock_rpc_device, "connected", False) - monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 1000) await init_integration(hass, 2, sleep_period=1000) await hass.async_block_till_done(wait_background_tasks=True) From a92c52e65b4852893b5faaf2776d7a3c9f55366e Mon Sep 17 00:00:00 2001 From: Sam Wright Date: Sat, 22 Feb 2025 04:14:52 +1100 Subject: [PATCH 0760/1941] Unifi zone based rules (#138974) * Add support for controlling zone based firewall policies * Add test * Address Kane's comments + add real repo * Add firewall icon --- .../components/unifi/hub/entity_loader.py | 1 + homeassistant/components/unifi/icons.json | 3 + homeassistant/components/unifi/switch.py | 35 +++++++ tests/components/unifi/conftest.py | 10 ++ tests/components/unifi/test_switch.py | 97 +++++++++++++++++++ 5 files changed, 146 insertions(+) diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 64403152b0c..84948a92e98 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -46,6 +46,7 @@ class UnifiEntityLoader: hub.api.port_forwarding.update, hub.api.sites.update, hub.api.system_information.update, + hub.api.firewall_policies.update, hub.api.traffic_rules.update, hub.api.traffic_routes.update, hub.api.wlans.update, diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json index 6874bb5ae03..616d7cb185f 100644 --- a/homeassistant/components/unifi/icons.json +++ b/homeassistant/components/unifi/icons.json @@ -55,6 +55,9 @@ "off": "mdi:network-off" } }, + "firewall_policy_control": { + "default": "mdi:security-network" + }, "port_forward_control": { "default": "mdi:upload-network" }, diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index de0e8d3f412..282d0c9ae93 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -4,6 +4,7 @@ Support for controlling power supply of clients which are powered over Ethernet Support for controlling network access of clients selected in option flow. Support for controlling deep packet inspection (DPI) restriction groups. Support for controlling WLAN availability. +Support for controlling zone based traffic rules. """ from __future__ import annotations @@ -17,6 +18,7 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups +from aiounifi.interfaces.firewall_policies import FirewallPolicies from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.port_forwarding import PortForwarding from aiounifi.interfaces.ports import Ports @@ -29,6 +31,7 @@ from aiounifi.models.device import DeviceSetOutletRelayRequest from aiounifi.models.dpi_restriction_app import DPIRestrictionAppEnableRequest from aiounifi.models.dpi_restriction_group import DPIRestrictionGroup from aiounifi.models.event import Event, EventKey +from aiounifi.models.firewall_policy import FirewallPolicy, FirewallPolicyUpdateRequest from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest @@ -129,6 +132,24 @@ async def async_dpi_group_control_fn(hub: UnifiHub, obj_id: str, target: bool) - ) +async def async_firewall_policy_control_fn( + hub: UnifiHub, obj_id: str, target: bool +) -> None: + """Control firewall policy state.""" + policy = hub.api.firewall_policies[obj_id].raw + policy["enabled"] = target + await hub.api.request(FirewallPolicyUpdateRequest.create(policy)) + # Update the policies so the UI is updated appropriately + await hub.api.firewall_policies.update() + + +@callback +def async_firewall_policy_supported_fn(hub: UnifiHub, obj_id: str) -> bool: + """Check if firewall policy is able to be controlled. Predefined policies are unable to be turned off.""" + policy = hub.api.firewall_policies[obj_id] + return not policy.predefined + + @callback def async_outlet_switching_supported_fn(hub: UnifiHub, obj_id: str) -> bool: """Determine if an outlet supports switching.""" @@ -236,6 +257,20 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( supported_fn=lambda hub, obj_id: bool(hub.api.dpi_groups[obj_id].dpiapp_ids), unique_id_fn=lambda hub, obj_id: obj_id, ), + UnifiSwitchEntityDescription[FirewallPolicies, FirewallPolicy]( + key="Firewall policy control", + translation_key="firewall_policy_control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + api_handler_fn=lambda api: api.firewall_policies, + control_fn=async_firewall_policy_control_fn, + device_info_fn=async_unifi_network_device_info_fn, + is_on_fn=lambda hub, firewall_policy: firewall_policy.enabled, + name_fn=lambda firewall_policy: firewall_policy.name, + object_fn=lambda api, obj_id: api.firewall_policies[obj_id], + unique_id_fn=lambda hub, obj_id: f"firewall_policy-{obj_id}", + supported_fn=async_firewall_policy_supported_fn, + ), UnifiSwitchEntityDescription[Outlets, Outlet]( key="Outlet control", device_class=SwitchDeviceClass.OUTLET, diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index ec7a0595731..4075aa0ad59 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -172,6 +172,7 @@ def fixture_request( device_payload: list[dict[str, Any]], dpi_app_payload: list[dict[str, Any]], dpi_group_payload: list[dict[str, Any]], + firewall_policy_payload: list[dict[str, Any]], port_forward_payload: list[dict[str, Any]], traffic_rule_payload: list[dict[str, Any]], traffic_route_payload: list[dict[str, Any]], @@ -211,6 +212,9 @@ def fixture_request( mock_get_request(f"/api/s/{site_id}/stat/device", device_payload) mock_get_request(f"/api/s/{site_id}/rest/dpiapp", dpi_app_payload) mock_get_request(f"/api/s/{site_id}/rest/dpigroup", dpi_group_payload) + mock_get_request( + f"/v2/api/site/{site_id}/firewall-policies", firewall_policy_payload + ) mock_get_request(f"/api/s/{site_id}/rest/portforward", port_forward_payload) mock_get_request(f"/api/s/{site_id}/stat/sysinfo", system_information_payload) mock_get_request(f"/api/s/{site_id}/rest/wlanconf", wlan_payload) @@ -253,6 +257,12 @@ def fixture_dpi_group_data() -> list[dict[str, Any]]: return [] +@pytest.fixture(name="firewall_policy_payload") +def firewall_policy_payload_data() -> list[dict[str, Any]]: + """Firewall policy data.""" + return [] + + @pytest.fixture(name="port_forward_payload") def fixture_port_forward_data() -> list[dict[str, Any]]: """Port forward data.""" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index e4765d1181e..c8ee786895c 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -827,6 +827,45 @@ TRAFFIC_ROUTE = { ], } +FIREWALL_POLICY = { + "_id": "678ceb9fe3849d293243405c", + "action": "ALLOW", + "connection_state_type": "ALL", + "connection_states": [], + "create_allow_respond": True, + "description": "", + "destination": { + "match_opposite_ports": False, + "matching_target": "ANY", + "port_matching_type": "ANY", + "zone_id": "678ccc26e3849d2932432e26", + }, + "enabled": True, + "icmp_typename": "ANY", + "icmp_v6_typename": "ANY", + "index": 10000, + "ip_version": "BOTH", + "logging": False, + "match_ip_sec": False, + "match_opposite_protocol": False, + "name": "Allow internal to IoT", + "predefined": False, + "protocol": "all", + "schedule": { + "mode": "EVERY_DAY", + "repeat_on_days": [], + "time_all_day": False, + "time_range_end": "12:00", + "time_range_start": "09:00", + }, + "source": { + "match_opposite_ports": False, + "matching_target": "ANY", + "port_matching_type": "ANY", + "zone_id": "678c63bc2d97692f08adcdfa", + }, +} + @pytest.mark.parametrize( "config_entry_options", [{CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}] @@ -1226,6 +1265,62 @@ async def test_traffic_routes( assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call +@pytest.mark.parametrize(("firewall_policy_payload"), [([FIREWALL_POLICY])]) +async def test_firewall_policies( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + config_entry_setup: MockConfigEntry, + firewall_policy_payload: list[dict[str, Any]], +) -> None: + """Test control of UniFi firewall policies.""" + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + # Validate state object + assert ( + hass.states.get("switch.unifi_network_allow_internal_to_iot").state == STATE_ON + ) + + firewall_policy = deepcopy(firewall_policy_payload[0]) + + # Disable firewall policy + aioclient_mock.put( + f"https://{config_entry_setup.data[CONF_HOST]}:1234" + f"/v2/api/site/{config_entry_setup.data[CONF_SITE_ID]}" + f"/firewall-policies/{firewall_policy['_id']}", + ) + + call_count = aioclient_mock.call_count + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.unifi_network_allow_internal_to_iot"}, + blocking=True, + ) + # Updating the value for firewall policies will make another call to retrieve the values + assert aioclient_mock.call_count == call_count + 2 + expected_disable_call = deepcopy(firewall_policy) + expected_disable_call["enabled"] = False + + assert aioclient_mock.mock_calls[call_count][2] == expected_disable_call + + call_count = aioclient_mock.call_count + + # Enable firewall policy + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.unifi_network_allow_internal_to_iot"}, + blocking=True, + ) + + expected_enable_call = deepcopy(firewall_policy) + expected_enable_call["enabled"] = True + + assert aioclient_mock.call_count == call_count + 2 + assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call + + @pytest.mark.parametrize( ("device_payload", "entity_id", "outlet_index", "expected_switches"), [ @@ -1677,6 +1772,7 @@ async def test_updating_unique_id( @pytest.mark.parametrize("dpi_group_payload", [DPI_GROUPS]) @pytest.mark.parametrize("port_forward_payload", [[PORT_FORWARD_PLEX]]) @pytest.mark.parametrize(("traffic_rule_payload"), [([TRAFFIC_RULE])]) +@pytest.mark.parametrize("firewall_policy_payload", [[FIREWALL_POLICY]]) @pytest.mark.parametrize("wlan_payload", [[WLAN]]) @pytest.mark.usefixtures("config_entry_setup") @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -1691,6 +1787,7 @@ async def test_hub_state_change( "switch.block_media_streaming", "switch.unifi_network_plex", "switch.unifi_network_test_traffic_rule", + "switch.unifi_network_allow_internal_to_iot", "switch.ssid_1", ) for entity_id in entity_ids: From 42ab3228a05160a6d0ed75b72d392be77536098a Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+mtielen@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:28:47 +0100 Subject: [PATCH 0761/1941] Bump wolf-comm to 0.0.19 (#138997) Co-authored-by: Shay Levy --- homeassistant/components/wolflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 4bfc0e6dd83..964d192d279 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wolflink", "iot_class": "cloud_polling", "loggers": ["wolf_comm"], - "requirements": ["wolf-comm==0.0.15"] + "requirements": ["wolf-comm==0.0.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index 88eeaafd223..1fc098f5f78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3070,7 +3070,7 @@ wirelesstagpy==0.8.1 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.15 +wolf-comm==0.0.19 # homeassistant.components.wyoming wyoming==1.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b37274817c5..a30256c1e18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2471,7 +2471,7 @@ wiffi==1.1.2 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.15 +wolf-comm==0.0.19 # homeassistant.components.wyoming wyoming==1.5.4 From 7495ea2cc8898b23eb2fef829dc59b926a3d9a7d Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:29:50 +0100 Subject: [PATCH 0762/1941] Bump qbusmqttapi to 1.3.0 (#139000) --- homeassistant/components/qbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index b7d277f3953..17101da7c33 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -13,5 +13,5 @@ "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.2.4"] + "requirements": ["qbusmqttapi==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1fc098f5f78..4684b94c654 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2573,7 +2573,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.2.59 # homeassistant.components.qbus -qbusmqttapi==1.2.4 +qbusmqttapi==1.3.0 # homeassistant.components.qingping qingping-ble==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a30256c1e18..cd4469bb524 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2085,7 +2085,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.2.59 # homeassistant.components.qbus -qbusmqttapi==1.2.4 +qbusmqttapi==1.3.0 # homeassistant.components.qingping qingping-ble==0.10.0 From 672df7355cb6b8598e0bba10391fe764b3f7a5e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Feb 2025 19:30:48 +0100 Subject: [PATCH 0763/1941] Omit unknown hue effects (#138992) --- homeassistant/components/hue/v2/light.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index fc3e000ab75..4b00299bc9d 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -107,7 +107,9 @@ class HueLight(HueBaseEntity, LightEntity): self._attr_effect_list = [] if effects := resource.effects: self._attr_effect_list = [ - x.value for x in effects.status_values if x != EffectStatus.NO_EFFECT + x.value + for x in effects.status_values + if x not in (EffectStatus.NO_EFFECT, EffectStatus.UNKNOWN) ] if timed_effects := resource.timed_effects: self._attr_effect_list += [ From fb5af9acd05d85af43c2afa8e54aec0fa7c9c9e8 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 21 Feb 2025 20:52:10 +0200 Subject: [PATCH 0764/1941] Fix Shelly mock initialization for sleepy RPC device in tests (#139003) --- tests/components/shelly/conftest.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index b643979f9a6..a332d16f95d 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -1,5 +1,6 @@ """Test configuration for Shelly.""" +from copy import deepcopy from unittest.mock import AsyncMock, Mock, PropertyMock, patch from aioshelly.ble.const import ( @@ -592,8 +593,11 @@ def _mock_sleepy_not_initialized_rpc_device(): def initialize_sleepy_rpc_device(device): """Initialize a sleepy RPC (Gen2+, Websocket) device.""" - type(device).requires_auth = PropertyMock() - type(device).status = PropertyMock(return_value=MOCK_STATUS_RPC) + status = deepcopy(MOCK_STATUS_RPC) + status["sys"]["wakeup_period"] = 1000 + + type(device).requires_auth = PropertyMock(return_value=False) + type(device).status = PropertyMock(return_value=status) type(device).event = PropertyMock(return_value={}) type(device).config = PropertyMock(return_value=MOCK_CONFIG) type(device).shelly = PropertyMock(return_value=MOCK_SHELLY_RPC) @@ -601,14 +605,13 @@ def initialize_sleepy_rpc_device(device): type(device).firmware_version = PropertyMock( return_value="20240425-141520/1.3.0-ga3fdd3d" ) - type(device).version = PropertyMock("1.3.0") - type(device).model = PropertyMock("SPSW-201PE16EU") + type(device).version = PropertyMock(return_value="1.3.0") + type(device).model = PropertyMock(return_value="SPSW-201PE16EU") type(device).xmod_info = PropertyMock(return_value={}) type(device).hostname = PropertyMock(return_value="hostname") type(device).name = PropertyMock(return_value="Test Name") type(device).firmware_supported = PropertyMock(return_value=True) - device.status["sys"]["wakeup_period"] = 1000 device.connected = True device.initialized = True From 58274160a0e9fb22be3fde8ab6c6b9e2f5e5e98b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 21 Feb 2025 20:00:31 +0100 Subject: [PATCH 0765/1941] Update frontend to 20250221.0 (#139006) --- 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 c8506335e16..499e1fbddb2 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==20250214.0"] + "requirements": ["home-assistant-frontend==20250221.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8318a7305e1..ba61ba109c0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.22.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250214.0 +home-assistant-frontend==20250221.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 4684b94c654..7c619b7c12e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250214.0 +home-assistant-frontend==20250221.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd4469bb524..35b358b9071 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250214.0 +home-assistant-frontend==20250221.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From 98ab16cf998b5ce4c4a104097e84d540aa74da0f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 21 Feb 2025 13:06:56 -0600 Subject: [PATCH 0766/1941] Bump HEOS quality scale to platinum (#138995) --- homeassistant/components/heos/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index d19b8cfd5ad..573deda2132 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyheos"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["pyheos==1.0.2"], "ssdp": [ { From 2bd9918ee87d807bb2df5594cd4b0da85450806d Mon Sep 17 00:00:00 2001 From: Niv Steingarten Date: Fri, 21 Feb 2025 21:13:22 +0200 Subject: [PATCH 0767/1941] Add daily and monthly consumption sensors to the rympro integration (#137953) --- homeassistant/components/rympro/coordinator.py | 6 ++++++ homeassistant/components/rympro/sensor.py | 14 ++++++++++++++ homeassistant/components/rympro/strings.json | 6 ++++++ 3 files changed, 26 insertions(+) diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index 55e5f0f90df..6b49a065d35 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -42,6 +42,12 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): try: meters = await self.rympro.last_read() for meter_id, meter in meters.items(): + meter["monthly_consumption"] = await self.rympro.monthly_consumption( + meter_id + ) + meter["daily_consumption"] = await self.rympro.daily_consumption( + meter_id + ) meter["consumption_forecast"] = await self.rympro.consumption_forecast( meter_id ) diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 250e942fb4f..66ed41a4ce9 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -36,6 +36,20 @@ SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = ( suggested_display_precision=3, value_key="read", ), + RymProSensorEntityDescription( + key="monthly_consumption", + translation_key="monthly_consumption", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + value_key="monthly_consumption", + ), + RymProSensorEntityDescription( + key="daily_consumption", + translation_key="daily_consumption", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + value_key="daily_consumption", + ), RymProSensorEntityDescription( key="monthly_forecast", translation_key="monthly_forecast", diff --git a/homeassistant/components/rympro/strings.json b/homeassistant/components/rympro/strings.json index 2c1e2ad93c9..589e91a6c6f 100644 --- a/homeassistant/components/rympro/strings.json +++ b/homeassistant/components/rympro/strings.json @@ -23,6 +23,12 @@ "total_consumption": { "name": "Total consumption" }, + "monthly_consumption": { + "name": "Monthly consumption" + }, + "daily_consumption": { + "name": "Daily consumption" + }, "monthly_forecast": { "name": "Monthly forecast" } From c9a0814142368bb797803b8557c4e97209d658e7 Mon Sep 17 00:00:00 2001 From: Petr V Date: Thu, 20 Feb 2025 22:51:49 +0100 Subject: [PATCH 0768/1941] Adjust Tuya Water Detector to support 1 as an alarm state (#135933) --- homeassistant/components/tuya/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 12661a26fd1..b634bfa3162 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -256,7 +256,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=DPCode.WATERSENSOR_STATE, device_class=BinarySensorDeviceClass.MOISTURE, - on_value="alarm", + on_value={"1", "alarm"}, ), TAMPER_BINARY_SENSOR, ), From 417ac56bd60ac9ce2ca4d9c99fb06decba24a3ec Mon Sep 17 00:00:00 2001 From: cro Date: Thu, 20 Feb 2025 22:52:03 +0100 Subject: [PATCH 0769/1941] Fix bug in set_preset_mode_with_end_datetime (wrong typo of frost_guard) (#138402) --- homeassistant/components/netatmo/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index cab0528199d..c130d8e96e3 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -39,7 +39,7 @@ set_preset_mode_with_end_datetime: select: options: - "away" - - "Frost Guard" + - "frost_guard" end_datetime: required: true example: '"2019-04-20 05:04:20"' From b40daf0152e42a041acd71dd40809855ca53054b Mon Sep 17 00:00:00 2001 From: Khole <29937485+KJonline@users.noreply.github.com> Date: Sat, 15 Feb 2025 13:13:16 +0000 Subject: [PATCH 0770/1941] Bump pyhive-integration to 1.0.2 (#138569) --- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index f68478516ab..712ccf09cae 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhive-integration==1.0.1"] + "requirements": ["pyhive-integration==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a601084b26..77425a41bad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pyhaversion==22.8.0 pyheos==1.0.2 # homeassistant.components.hive -pyhive-integration==1.0.1 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af81f78c6ab..8e58f3ed70f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1619,7 +1619,7 @@ pyhaversion==22.8.0 pyheos==1.0.2 # homeassistant.components.hive -pyhive-integration==1.0.1 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 From 8078e41cad84bf6052b44ffffa35080f2ede91c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Feb 2025 13:22:06 -0600 Subject: [PATCH 0771/1941] Allow ignored thermobeacon devices to be set up from the user flow (#139009) Every few days we get an issue report about a device a user ignored and forgot about, and than can no longer get set up. Sometimes its a govee device, sometimes its a switchbot device, but the pattern is consistent. Allow ignored devices to be selected in the user step and replace the ignored entry. Same as #137056 and #137052 but for thermobeacon --- .../components/thermobeacon/config_flow.py | 2 +- .../thermobeacon/test_config_flow.py | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/thermobeacon/config_flow.py b/homeassistant/components/thermobeacon/config_flow.py index 08994a41008..6fa502716ca 100644 --- a/homeassistant/components/thermobeacon/config_flow.py +++ b/homeassistant/components/thermobeacon/config_flow.py @@ -72,7 +72,7 @@ class ThermoBeaconConfigFlow(ConfigFlow, domain=DOMAIN): title=self._discovered_devices[address], data={} ) - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery_info in async_discovered_service_info(self.hass, False): address = discovery_info.address if address in current_addresses or address in self._discovered_devices: diff --git a/tests/components/thermobeacon/test_config_flow.py b/tests/components/thermobeacon/test_config_flow.py index a26a2b70c5e..2194168c25d 100644 --- a/tests/components/thermobeacon/test_config_flow.py +++ b/tests/components/thermobeacon/test_config_flow.py @@ -79,6 +79,38 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None: assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" +async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None: + """Test setup from service info can replace an ignored entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=THERMOBEACON_SERVICE_INFO.address, + data={}, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.thermobeacon.config_flow.async_discovered_service_info", + return_value=[THERMOBEACON_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.thermobeacon.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "aa:bb:cc:dd:ee:ff"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Lanyard/mini hygrometer EEFF" + assert result2["data"] == {} + assert result2["result"].unique_id == "aa:bb:cc:dd:ee:ff" + + async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: """Test the device gets added via another flow between steps.""" with patch( From 7b82781f4cabdd38fdc5f5de5c7ebd0f7c9a4de3 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 16 Feb 2025 01:20:51 +1000 Subject: [PATCH 0772/1941] Bump tesla-fleet-api to v0.9.10 (#138575) bump --- 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 330745316d7..bb8f6041771 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.8"] + "requirements": ["tesla-fleet-api==0.9.10"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 136990e5347..e8f0bb98b27 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.8", "teslemetry-stream==0.6.6"] + "requirements": ["tesla-fleet-api==0.9.10", "teslemetry-stream==0.6.6"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index ef4d366c779..d777cf5051e 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.8"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 77425a41bad..60619a6ccb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2854,7 +2854,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.8 +tesla-fleet-api==0.9.10 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e58f3ed70f..24d70d71f66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2294,7 +2294,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.8 +tesla-fleet-api==0.9.10 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From e60b6482ab4cc5d31e4151bedb3ba8e70a98b5ff Mon Sep 17 00:00:00 2001 From: Luca Bensi <130408125+lucab-91@users.noreply.github.com> Date: Sun, 16 Feb 2025 13:09:15 +0100 Subject: [PATCH 0773/1941] Bump pysmarty2 to 0.10.2 (#138625) --- homeassistant/components/smarty/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index ca3133d8add..c295647b8e5 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pymodbus", "pysmarty2"], - "requirements": ["pysmarty2==0.10.1"] + "requirements": ["pysmarty2==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 60619a6ccb3..bcabec7a9a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2304,7 +2304,7 @@ pysmartapp==0.3.5 pysmartthings==0.7.8 # homeassistant.components.smarty -pysmarty2==0.10.1 +pysmarty2==0.10.2 # homeassistant.components.edl21 pysml==0.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24d70d71f66..18f705475e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1876,7 +1876,7 @@ pysmartapp==0.3.5 pysmartthings==0.7.8 # homeassistant.components.smarty -pysmarty2==0.10.1 +pysmarty2==0.10.2 # homeassistant.components.edl21 pysml==0.0.12 From 1e49e04491105d689b3e0156541d520acac6d777 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Sun, 16 Feb 2025 22:33:32 +0200 Subject: [PATCH 0774/1941] Rename "returned" state to "alert" (#138676) Rename "returned" state to "alert" in icons, services, and strings files --- homeassistant/components/seventeentrack/icons.json | 2 +- homeassistant/components/seventeentrack/services.yaml | 2 +- homeassistant/components/seventeentrack/strings.json | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/seventeentrack/icons.json b/homeassistant/components/seventeentrack/icons.json index a5cac0a9f84..c48e147e973 100644 --- a/homeassistant/components/seventeentrack/icons.json +++ b/homeassistant/components/seventeentrack/icons.json @@ -19,7 +19,7 @@ "delivered": { "default": "mdi:package" }, - "returned": { + "alert": { "default": "mdi:package" }, "package": { diff --git a/homeassistant/components/seventeentrack/services.yaml b/homeassistant/components/seventeentrack/services.yaml index d4592dc8aab..45d7c0a530a 100644 --- a/homeassistant/components/seventeentrack/services.yaml +++ b/homeassistant/components/seventeentrack/services.yaml @@ -11,7 +11,7 @@ get_packages: - "ready_to_be_picked_up" - "undelivered" - "delivered" - - "returned" + - "alert" translation_key: package_state config_entry_id: required: true diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index 982b15ab629..70fea2e2735 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -57,8 +57,8 @@ "delivered": { "name": "Delivered" }, - "returned": { - "name": "Returned" + "alert": { + "name": "Alert" }, "package": { "name": "Package {name}" @@ -104,7 +104,7 @@ "ready_to_be_picked_up": "[%key:component::seventeentrack::entity::sensor::ready_to_be_picked_up::name%]", "undelivered": "[%key:component::seventeentrack::entity::sensor::undelivered::name%]", "delivered": "[%key:component::seventeentrack::entity::sensor::delivered::name%]", - "returned": "[%key:component::seventeentrack::entity::sensor::returned::name%]" + "alert": "[%key:component::seventeentrack::entity::sensor::alert::name%]" } } } From 2b7543aca2c50b948fededc1b387f3e51df8ed60 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sun, 16 Feb 2025 20:33:48 -0700 Subject: [PATCH 0775/1941] Bump pyvesync for vesync (#138681) * bump pyvesync * fix tests * Test fix --- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vesync/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index b3697844f19..9e2fbcc1782 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.17"] + "requirements": ["pyvesync==2.1.18"] } diff --git a/requirements_all.txt b/requirements_all.txt index bcabec7a9a4..a408bb084b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2513,7 +2513,7 @@ pyvera==0.3.15 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.17 +pyvesync==2.1.18 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18f705475e4..74507a26fb0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2031,7 +2031,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.15 # homeassistant.components.vesync -pyvesync==2.1.17 +pyvesync==2.1.18 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 1c409dbab00..407e18d65b6 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -171,6 +171,7 @@ 'models': list([ 'LV-PUR131S', 'LV-RH131S', + 'LV-RH131S-WM', ]), 'modes': list([ 'manual', From 179ba8309d65c8836f634d8696a056384ede48db Mon Sep 17 00:00:00 2001 From: Saswat Padhi Date: Thu, 20 Feb 2025 07:42:09 +0000 Subject: [PATCH 0776/1941] Opower: Fix unavailable "start date" and "end date" sensors (#138694) avoid passing string into date device class --- homeassistant/components/opower/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index f9d0fe62332..18518c9e21e 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import date from opower import Forecast, MeterType, UnitOfMeasure @@ -29,7 +30,7 @@ from .coordinator import OpowerCoordinator class OpowerEntityDescription(SensorEntityDescription): """Class describing Opower sensors entities.""" - value_fn: Callable[[Forecast], str | float] + value_fn: Callable[[Forecast], str | float | date] # suggested_display_precision=0 for all sensors since @@ -97,7 +98,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: str(data.start_date), + value_fn=lambda data: data.start_date, ), OpowerEntityDescription( key="elec_end_date", @@ -105,7 +106,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: str(data.end_date), + value_fn=lambda data: data.end_date, ), ) GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( @@ -169,7 +170,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: str(data.start_date), + value_fn=lambda data: data.start_date, ), OpowerEntityDescription( key="gas_end_date", @@ -177,7 +178,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data: str(data.end_date), + value_fn=lambda data: data.end_date, ), ) @@ -247,7 +248,7 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): self.utility_account_id = utility_account_id @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | date: """Return the state.""" if self.coordinator.data is not None: return self.entity_description.value_fn( From 66bb5016219f6ebf3ed784281aa3fdbf02efdad0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 17 Feb 2025 15:38:28 +0100 Subject: [PATCH 0777/1941] Correct backup filename on delete or download of cloud backup (#138704) * Correct backup filename on delete or download of cloud backup * Improve tests * Address review comments --- homeassistant/components/cloud/backup.py | 43 +++++++++++------ tests/components/cloud/test_backup.py | 61 +++++++++++++++++++++--- 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index 61edeccdd9c..b31fe16fbe9 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -11,7 +11,11 @@ from typing import Any from aiohttp import ClientError from hass_nabucasa import Cloud, CloudError from hass_nabucasa.api import CloudApiNonRetryableError -from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list +from hass_nabucasa.cloud_api import ( + FilesHandlerListEntry, + async_files_delete_file, + async_files_list, +) from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError @@ -76,11 +80,6 @@ class CloudBackupAgent(BackupAgent): self._cloud = cloud self._hass = hass - @callback - def _get_backup_filename(self) -> str: - """Return the backup filename.""" - return f"{self._cloud.client.prefs.instance_id}.tar" - async def async_download_backup( self, backup_id: str, @@ -91,13 +90,13 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ - if not await self.async_get_backup(backup_id): + if not (backup := await self._async_get_backup(backup_id)): raise BackupAgentError("Backup not found") try: content = await self._cloud.files.download( storage_type=StorageType.BACKUP, - filename=self._get_backup_filename(), + filename=backup["Key"], ) except CloudError as err: raise BackupAgentError(f"Failed to download backup: {err}") from err @@ -124,7 +123,7 @@ class CloudBackupAgent(BackupAgent): base64md5hash = await calculate_b64md5(open_stream, size) except FilesError as err: raise BackupAgentError(err) from err - filename = self._get_backup_filename() + filename = f"{self._cloud.client.prefs.instance_id}.tar" metadata = backup.as_dict() tries = 1 @@ -172,29 +171,34 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - if not await self.async_get_backup(backup_id): + if not (backup := await self._async_get_backup(backup_id)): return try: await async_files_delete_file( self._cloud, storage_type=StorageType.BACKUP, - filename=self._get_backup_filename(), + filename=backup["Key"], ) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to delete backup") from err async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + backups = await self._async_list_backups() + return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] + + async def _async_list_backups(self) -> list[FilesHandlerListEntry]: """List backups.""" try: backups = await async_files_list( self._cloud, storage_type=StorageType.BACKUP ) - _LOGGER.debug("Cloud backups: %s", backups) except (ClientError, CloudError) as err: raise BackupAgentError("Failed to list backups") from err - return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups] + _LOGGER.debug("Cloud backups: %s", backups) + return backups async def async_get_backup( self, @@ -202,10 +206,19 @@ class CloudBackupAgent(BackupAgent): **kwargs: Any, ) -> AgentBackup | None: """Return a backup.""" - backups = await self.async_list_backups() + if not (backup := await self._async_get_backup(backup_id)): + return None + return AgentBackup.from_dict(backup["Metadata"]) + + async def _async_get_backup( + self, + backup_id: str, + ) -> FilesHandlerListEntry | None: + """Return a backup.""" + backups = await self._async_list_backups() for backup in backups: - if backup.backup_id == backup_id: + if backup["Metadata"]["backup_id"] == backup_id: return backup return None diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index c6bb0bdad54..18793cc00bb 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -3,12 +3,12 @@ from collections.abc import AsyncGenerator, Generator from io import StringIO from typing import Any -from unittest.mock import Mock, PropertyMock, patch +from unittest.mock import ANY, Mock, PropertyMock, patch from aiohttp import ClientError from hass_nabucasa import CloudError from hass_nabucasa.api import CloudApiNonRetryableError -from hass_nabucasa.files import FilesError +from hass_nabucasa.files import FilesError, StorageType import pytest from homeassistant.components.backup import ( @@ -90,7 +90,26 @@ def mock_list_files() -> Generator[MagicMock]: "size": 34519040, "storage-type": "backup", }, - } + }, + { + "Key": "462e16810d6841228828d9dd2f9e341f.tar", + "LastModified": "2024-11-22T10:49:01.182Z", + "Size": 34519040, + "Metadata": { + "addons": [], + "backup_id": "23e64aed", + "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, + "storage-type": "backup", + }, + }, ] yield list_files @@ -148,7 +167,21 @@ async def test_agents_list_backups( "name": "Core 2024.12.0.dev0", "failed_agent_ids": [], "with_automatic_settings": None, - } + }, + { + "addons": [], + "agents": {"cloud.cloud": {"protected": False, "size": 34519040}}, + "backup_id": "23e64aed", + "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", + "failed_agent_ids": [], + "with_automatic_settings": None, + }, ] @@ -242,6 +275,10 @@ async def test_agents_download( resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud") assert resp.status == 200 assert await resp.content.read() == b"backup data" + cloud.files.download.assert_called_once_with( + filename="462e16810d6841228828d9dd2f9e341e.tar", + storage_type=StorageType.BACKUP, + ) @pytest.mark.usefixtures("cloud_logged_in", "mock_list_files") @@ -317,7 +354,14 @@ async def test_agents_upload( data={"file": StringIO(backup_data)}, ) - assert len(cloud.files.upload.mock_calls) == 1 + cloud.files.upload.assert_called_once_with( + storage_type=StorageType.BACKUP, + open_stream=ANY, + filename=f"{cloud.client.prefs.instance_id}.tar", + base64md5hash=ANY, + metadata=ANY, + size=ANY, + ) metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"] assert metadata["backup_id"] == backup_id @@ -552,6 +596,7 @@ async def test_agents_upload_wrong_size( async def test_agents_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + cloud: Mock, mock_delete_file: Mock, ) -> None: """Test agent delete backup.""" @@ -568,7 +613,11 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_delete_file.assert_called_once() + mock_delete_file.assert_called_once_with( + cloud, + filename="462e16810d6841228828d9dd2f9e341e.tar", + storage_type=StorageType.BACKUP, + ) @pytest.mark.parametrize("side_effect", [ClientError, CloudError]) From 35bcf826273c812a730ac2427418dc59f11c4d9d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Feb 2025 16:24:30 +0100 Subject: [PATCH 0778/1941] Correct invalid automatic backup settings when loading from store (#138716) * Correct invalid automatic backup settings when loading from store * Improve docstring * Improve tests --- homeassistant/components/backup/__init__.py | 2 + homeassistant/components/backup/manager.py | 49 +- homeassistant/components/hassio/backup.py | 4 + .../backup/snapshots/test_websocket.ambr | 494 +++++++++++++++++- tests/components/backup/test_websocket.py | 85 ++- 5 files changed, 618 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 71a4f5ea41a..1b19b185b4f 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -16,6 +16,7 @@ from .agent import ( BackupAgentPlatformProtocol, LocalBackupAgent, ) +from .config import BackupConfig from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views from .manager import ( @@ -47,6 +48,7 @@ __all__ = [ "BackupAgent", "BackupAgentError", "BackupAgentPlatformProtocol", + "BackupConfig", "BackupManagerError", "BackupNotFound", "BackupPlatformProtocol", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 81826ffcb24..5a1bcde2b3b 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -43,7 +43,11 @@ from .agent import ( BackupAgentPlatformProtocol, LocalBackupAgent, ) -from .config import BackupConfig, delete_backups_exceeding_configured_count +from .config import ( + BackupConfig, + CreateBackupParametersDict, + delete_backups_exceeding_configured_count, +) from .const import ( BUF_SIZE, DATA_MANAGER, @@ -282,6 +286,10 @@ class BackupReaderWriter(abc.ABC): ) -> None: """Get restore events after core restart.""" + @abc.abstractmethod + async def async_validate_config(self, *, config: BackupConfig) -> None: + """Validate backup config.""" + class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" @@ -333,6 +341,7 @@ class BackupManager: self.config.load(stored["config"]) self.known_backups.load(stored["backups"]) + await self._reader_writer.async_validate_config(config=self.config) await self._reader_writer.async_resume_restore_progress_after_restart( on_progress=self.async_on_backup_event ) @@ -1832,6 +1841,44 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) on_progress(IdleEvent()) + async def async_validate_config(self, *, config: BackupConfig) -> None: + """Validate backup config. + + Update automatic backup settings to not include addons or folders and remove + hassio agents in case a backup created by supervisor was restored. + """ + create_backup = config.data.create_backup + if ( + not create_backup.include_addons + and not create_backup.include_all_addons + and not create_backup.include_folders + and not any(a_id.startswith("hassio.") for a_id in create_backup.agent_ids) + ): + LOGGER.debug("Backup settings don't need to be adjusted") + return + + LOGGER.info( + "Adjusting backup settings to not include addons, folders or supervisor locations" + ) + automatic_agents = [ + agent_id + for agent_id in create_backup.agent_ids + if not agent_id.startswith("hassio.") + ] + if ( + self._local_agent_id not in automatic_agents + and "hassio.local" in create_backup.agent_ids + ): + automatic_agents = [self._local_agent_id, *automatic_agents] + await config.update( + create_backup=CreateBackupParametersDict( + agent_ids=automatic_agents, + include_addons=None, + include_all_addons=False, + include_folders=None, + ) + ) + def _generate_backup_id(date: str, name: str) -> str: """Generate a backup ID.""" diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index ddaa821587f..9c0511a93fe 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -27,6 +27,7 @@ from homeassistant.components.backup import ( AddonInfo, AgentBackup, BackupAgent, + BackupConfig, BackupManagerError, BackupNotFound, BackupReaderWriter, @@ -633,6 +634,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): _LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err) unsub() + async def async_validate_config(self, *, config: BackupConfig) -> None: + """Validate backup config.""" + @callback def _async_listen_job_events( self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None] diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 2f063262f34..572ed9b06fa 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -251,7 +251,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data0] +# name: test_config_load_config_info[with_hassio-storage_data0] dict({ 'id': 1, 'result': dict({ @@ -288,7 +288,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data1] +# name: test_config_load_config_info[with_hassio-storage_data1] dict({ 'id': 1, 'result': dict({ @@ -337,7 +337,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data2] +# name: test_config_load_config_info[with_hassio-storage_data2] dict({ 'id': 1, 'result': dict({ @@ -375,7 +375,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data3] +# name: test_config_load_config_info[with_hassio-storage_data3] dict({ 'id': 1, 'result': dict({ @@ -413,7 +413,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data4] +# name: test_config_load_config_info[with_hassio-storage_data4] dict({ 'id': 1, 'result': dict({ @@ -452,7 +452,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data5] +# name: test_config_load_config_info[with_hassio-storage_data5] dict({ 'id': 1, 'result': dict({ @@ -490,7 +490,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data6] +# name: test_config_load_config_info[with_hassio-storage_data6] dict({ 'id': 1, 'result': dict({ @@ -530,7 +530,7 @@ 'type': 'result', }) # --- -# name: test_config_info[storage_data7] +# name: test_config_load_config_info[with_hassio-storage_data7] dict({ 'id': 1, 'result': dict({ @@ -576,6 +576,484 @@ 'type': 'result', }) # --- +# name: test_config_load_config_info[with_hassio-storage_data8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'hassio.local', + 'hassio.share', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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_load_config_info[with_hassio-storage_data9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'backup.local', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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_load_config_info[without_hassio-storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_load_config_info[without_hassio-storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': 'test-name', + 'password': 'test-password', + }), + 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00', + 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': 3, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + 'sat', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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': 3, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data3] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00', + 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': None, + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': 7, + }), + 'schedule': dict({ + 'days': list([ + ]), + 'recurrence': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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_load_config_info[without_hassio-storage_data6] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data7] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + 'test-agent1': dict({ + 'protected': True, + }), + 'test-agent2': dict({ + 'protected': False, + }), + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-17T04:55:00+01:00', + 'next_automatic_backup_additional': False, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'days': list([ + 'mon', + 'sun', + ]), + 'recurrence': 'custom_days', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_load_config_info[without_hassio-storage_data8] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'backup.local', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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_load_config_info[without_hassio-storage_data9] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'backup.local', + 'test-agent', + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': False, + '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[commands0] dict({ 'id': 1, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 773256bdd0b..82d2c0a921d 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -46,10 +46,10 @@ BACKUP_CALL = call( agent_ids=["test.test-agent"], backup_name="test-name", extra_metadata={"instance_id": ANY, "with_automatic_settings": True}, - include_addons=["test-addon"], + include_addons=[], include_all_addons=False, include_database=True, - include_folders=["media"], + include_folders=None, include_homeassistant=True, password="test-password", on_progress=ANY, @@ -1126,25 +1126,96 @@ async def test_agents_info( "minor_version": store.STORAGE_VERSION_MINOR, }, }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["hassio.local", "hassio.share", "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": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["backup.local", "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": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, + }, + }, ], ) +@pytest.mark.parametrize( + ("with_hassio"), + [ + pytest.param(True, id="with_hassio"), + pytest.param(False, id="without_hassio"), + ], +) +@pytest.mark.usefixtures("supervisor_client") @patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) -async def test_config_info( +async def test_config_load_config_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, hass_storage: dict[str, Any], + with_hassio: bool, storage_data: dict[str, Any] | None, ) -> None: - """Test getting backup config info.""" + """Test loading stored backup config and reading it via config/info.""" client = await hass_ws_client(hass) await hass.config.async_set_time_zone("Europe/Amsterdam") freezer.move_to("2024-11-13T12:01:00+01:00") hass_storage.update(storage_data) - await setup_backup_integration(hass) + await setup_backup_integration(hass, with_hassio=with_hassio) await hass.async_block_till_done() await client.send_json_auto_id({"type": "backup/config/info"}) @@ -1702,10 +1773,10 @@ async def test_config_schedule_logic( "agents": {}, "create_backup": { "agent_ids": ["test.test-agent"], - "include_addons": ["test-addon"], + "include_addons": [], "include_all_addons": False, "include_database": True, - "include_folders": ["media"], + "include_folders": [], "name": "test-name", "password": "test-password", }, From 167881e434e3be697cd279a9087803e05ea3e6c3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 17 Feb 2025 17:45:26 +0100 Subject: [PATCH 0779/1941] Bump airgradient to 0.9.2 (#138725) * Bump airgradient to 0.9.2 * Bump airgradient to 0.9.2 --- homeassistant/components/airgradient/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../airgradient/snapshots/test_diagnostics.ambr | 6 +++--- .../components/airgradient/snapshots/test_sensor.ambr | 10 +++++----- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index 13764142697..afaf2698ced 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/airgradient", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["airgradient==0.9.1"], + "requirements": ["airgradient==0.9.2"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index a408bb084b6..a719fe8060d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ aiowithings==3.1.5 aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.9.1 +airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74507a26fb0..d1db6c02eac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -410,7 +410,7 @@ aiowithings==3.1.5 aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.9.1 +airgradient==0.9.2 # homeassistant.components.airly airly==1.1.0 diff --git a/tests/components/airgradient/snapshots/test_diagnostics.ambr b/tests/components/airgradient/snapshots/test_diagnostics.ambr index a96dfb95382..624a6f76f8d 100644 --- a/tests/components/airgradient/snapshots/test_diagnostics.ambr +++ b/tests/components/airgradient/snapshots/test_diagnostics.ambr @@ -25,13 +25,13 @@ 'nitrogen_index': 1, 'pm003_count': 270, 'pm01': 22, - 'pm02': 34, + 'pm02': 34.0, 'pm10': 41, 'raw_ambient_temperature': 27.96, - 'raw_nitrogen': 16931, + 'raw_nitrogen': 16931.0, 'raw_pm02': 34, 'raw_relative_humidity': 48.0, - 'raw_total_volatile_organic_component': 31792, + 'raw_total_volatile_organic_component': 31792.0, 'rco2': 778, 'relative_humidity': 47.0, 'serial_number': '84fce612f5b8', diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 3db188bed95..353424eabbe 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -710,7 +710,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '34', + 'state': '34.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_raw_nox-entry] @@ -760,7 +760,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '16931', + 'state': '16931.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-entry] @@ -861,7 +861,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '31792', + 'state': '31792.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_signal_strength-entry] @@ -1255,7 +1255,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '16359', + 'state': '16359.0', }) # --- # name: test_all_entities[outdoor][sensor.airgradient_raw_voc-entry] @@ -1305,7 +1305,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '30802', + 'state': '30802.0', }) # --- # name: test_all_entities[outdoor][sensor.airgradient_signal_strength-entry] From 6070feea7330c4474c5a6009ef50e0ea00417034 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Feb 2025 13:49:31 +0100 Subject: [PATCH 0780/1941] Clean up translations for mocked integrations inbetween tests (#138732) * Clean up translations for mocked integrations inbetween tests * Adjust code, add test * Fix docstring * Improve cleanup, add test * Fix test --- tests/common.py | 17 ----------- tests/components/stt/test_init.py | 4 --- tests/components/tts/test_init.py | 4 --- tests/conftest.py | 33 ++++++++++++++++++--- tests/test_test_fixtures.py | 48 +++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 29 deletions(-) diff --git a/tests/common.py b/tests/common.py index 0315ee6d845..87e377c8fc7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1821,23 +1821,6 @@ async def snapshot_platform( assert state == snapshot(name=f"{entity_entry.entity_id}-state") -def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None: - """Reset translation cache for specified components. - - Use this if you are mocking a core component (for example via - mock_integration), to ensure that the mocked translations are not - persisted in the shared session cache. - """ - translations_cache = translation._async_get_translations_cache(hass) - for loaded_components in translations_cache.cache_data.loaded.values(): - for component_to_unload in components: - loaded_components.discard(component_to_unload) - for loaded_categories in translations_cache.cache_data.cache.values(): - for loaded_components in loaded_categories.values(): - for component_to_unload in components: - loaded_components.pop(component_to_unload, None) - - @lru_cache def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: """Load quality scale for integration.""" diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 3d5daab2bec..92225123995 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -34,7 +34,6 @@ from tests.common import ( mock_integration, mock_platform, mock_restore_cache, - reset_translation_cache, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -519,9 +518,6 @@ async def test_default_engine_prefer_cloud_entity( assert provider_engine.name == "test" assert async_default_engine(hass) == "stt.cloud_stt_entity" - # Reset the `cloud` translations cache to avoid flaky translation checks - reset_translation_cache(hass, ["cloud"]) - async def test_get_engine_legacy( hass: HomeAssistant, tmp_path: Path, mock_provider: MockSTTProvider diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index d115546c9bc..4d0767cddf3 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -44,7 +44,6 @@ from tests.common import ( mock_integration, mock_platform, mock_restore_cache, - reset_translation_cache, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -1987,6 +1986,3 @@ async def test_default_engine_prefer_cloud_entity( provider_engine = tts.async_resolve_engine(hass, "test") assert provider_engine == "test" assert tts.async_default_engine(hass) == "tts.cloud_tts_entity" - - # Reset the `cloud` translations cache to avoid flaky translation checks - reset_translation_cache(hass, ["cloud"]) diff --git a/tests/conftest.py b/tests/conftest.py index de627925941..cac06409fef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ import gc import itertools import logging import os +import pathlib import reprlib from shutil import rmtree import sqlite3 @@ -49,7 +50,7 @@ from . import patch_recorder # Setup patching of dt_util time functions before any other Home Assistant imports from . import patch_time # noqa: F401, isort:skip -from homeassistant import core as ha, loader, runner +from homeassistant import components, core as ha, loader, runner from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant @@ -85,6 +86,7 @@ from homeassistant.helpers import ( issue_registry as ir, label_registry as lr, recorder as recorder_helper, + translation as translation_helper, ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.translation import _TranslationsCacheData @@ -1211,9 +1213,8 @@ def mock_get_source_ip() -> Generator[_patch]: def translations_once() -> Generator[_patch]: """Only load translations once per session. - Warning: having this as a session fixture can cause issues with tests that - create mock integrations, overriding the real integration translations - with empty ones. Translations should be reset after such tests (see #131628) + Note: To avoid issues with tests that mock integrations, translations for + mocked integrations are cleaned up by the evict_faked_translations fixture. """ cache = _TranslationsCacheData({}, {}) patcher = patch( @@ -1227,6 +1228,30 @@ def translations_once() -> Generator[_patch]: patcher.stop() +@pytest.fixture(autouse=True, scope="module") +def evict_faked_translations(translations_once) -> Generator[_patch]: + """Clear translations for mocked integrations from the cache after each module.""" + real_component_strings = translation_helper._async_get_component_strings + with patch( + "homeassistant.helpers.translation._async_get_component_strings", + wraps=real_component_strings, + ) as mock_component_strings: + yield + cache: _TranslationsCacheData = translations_once.kwargs["return_value"] + component_paths = components.__path__ + + for call in mock_component_strings.mock_calls: + integrations: dict[str, loader.Integration] = call.args[3] + for domain, integration in integrations.items(): + if any( + pathlib.Path(f"{component_path}/{domain}") == integration.file_path + for component_path in component_paths + ): + continue + for loaded_for_lang in cache.loaded.values(): + loaded_for_lang.discard(domain) + + @pytest.fixture def disable_translations_once( translations_once: _patch, diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 78f66ceb549..0b8fd20a7c0 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -1,6 +1,8 @@ """Test test fixture configuration.""" +from collections.abc import Generator from http import HTTPStatus +import pathlib import socket from aiohttp import web @@ -9,8 +11,11 @@ import pytest_socket from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.helpers import translation from homeassistant.setup import async_setup_component +from .common import MockModule, mock_integration +from .conftest import evict_faked_translations from .typing import ClientSessionGenerator @@ -70,3 +75,46 @@ async def test_aiohttp_client_frozen_router_view( assert response.status == HTTPStatus.OK result = await response.json() assert result["test"] is True + + +async def test_evict_faked_translations_assumptions(hass: HomeAssistant) -> None: + """Test assumptions made when detecting translations for mocked integrations. + + If this test fails, the evict_faked_translations may need to be updated. + """ + integration = mock_integration(hass, MockModule("test"), built_in=True) + assert integration.file_path == pathlib.Path("") + + +async def test_evict_faked_translations(hass: HomeAssistant, translations_once) -> None: + """Test the evict_faked_translations fixture.""" + cache: translation._TranslationsCacheData = translations_once.kwargs["return_value"] + fake_domain = "test" + real_domain = "homeassistant" + + # Evict the real domain from the cache in case it's been loaded before + cache.loaded["en"].discard(real_domain) + + assert fake_domain not in cache.loaded["en"] + assert real_domain not in cache.loaded["en"] + + # The evict_faked_translations fixture has module scope, so we set it up and + # tear it down manually + real_func = evict_faked_translations.__pytest_wrapped__.obj + gen: Generator = real_func(translations_once) + + # Set up the evict_faked_translations fixture + next(gen) + + mock_integration(hass, MockModule(fake_domain), built_in=True) + await translation.async_load_integrations(hass, {fake_domain, real_domain}) + assert fake_domain in cache.loaded["en"] + assert real_domain in cache.loaded["en"] + + # Tear down the evict_faked_translations fixture + with pytest.raises(StopIteration): + next(gen) + + # The mock integration should be removed from the cache, the real domain should still be there + assert fake_domain not in cache.loaded["en"] + assert real_domain in cache.loaded["en"] From ac21d2855ce116c449beadcc3c2053b1c531ab95 Mon Sep 17 00:00:00 2001 From: Niv Steingarten Date: Tue, 18 Feb 2025 15:23:25 +0200 Subject: [PATCH 0781/1941] Bump pyrympro from 0.0.8 to 0.0.9 (#138753) --- homeassistant/components/rympro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index 046e778f05b..51c26b312fb 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.8"] + "requirements": ["pyrympro==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index a719fe8060d..48bdc5e213e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2253,7 +2253,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.8 +pyrympro==0.0.9 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1db6c02eac..3a3f0aae9fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1837,7 +1837,7 @@ pyrituals==0.0.6 pyroute2==0.7.5 # homeassistant.components.rympro -pyrympro==0.0.8 +pyrympro==0.0.9 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 From 59651c6f103607670ebd44542e8307954826319b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 18 Feb 2025 15:16:44 +0100 Subject: [PATCH 0782/1941] Don't allow setting backup retention to 0 days or copies (#138771) * Don't allow setting backup retention to 0 days or copies * Add tests --- homeassistant/components/backup/store.py | 9 +- homeassistant/components/backup/websocket.py | 6 +- .../backup/snapshots/test_store.ambr | 99 +++++++++- .../backup/snapshots/test_websocket.ambr | 176 ++++++++++++++++-- tests/components/backup/test_store.py | 32 ++++ tests/components/backup/test_websocket.py | 16 +- 6 files changed, 312 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 9b4af823c77..8287080b5a2 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 = 3 +STORAGE_VERSION_MINOR = 4 class StoredBackupData(TypedDict): @@ -60,6 +60,13 @@ class _BackupStore(Store[StoredBackupData]): else: data["config"]["schedule"]["days"] = [state] data["config"]["schedule"]["recurrence"] = "custom_days" + if old_minor_version < 4: + # Workaround for a bug in frontend which incorrectly set days to 0 + # instead of to None for unlimited retention. + if data["config"]["retention"]["copies"] == 0: + data["config"]["retention"]["copies"] = None + if data["config"]["retention"]["days"] == 0: + data["config"]["retention"]["days"] = 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 b6d092e1913..8453046cabb 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -368,8 +368,10 @@ async def handle_config_info( ), vol.Optional("retention"): vol.Schema( { - vol.Optional("copies"): vol.Any(int, None), - vol.Optional("days"): vol.Any(int, None), + # Note: We can't use cv.positive_int because it allows 0 even + # though 0 is not positive. + vol.Optional("copies"): vol.Any(vol.All(int, vol.Range(min=1)), None), + vol.Optional("days"): vol.Any(vol.All(int, vol.Range(min=1)), None), }, ), vol.Optional("schedule"): vol.Schema( diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr index 2fd81d6841a..04f88b84a97 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': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -84,11 +84,100 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- # name: test_store_migration[store_data1] + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + }), + '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': 4, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data1].1 + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'agents': dict({ + }), + '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': 4, + 'version': 1, + }) +# --- +# name: test_store_migration[store_data2] dict({ 'data': dict({ 'backups': list([ @@ -131,11 +220,11 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- -# name: test_store_migration[store_data1].1 +# name: test_store_migration[store_data2].1 dict({ 'data': dict({ 'backups': list([ @@ -179,7 +268,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 572ed9b06fa..b580f6295f2 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -1164,7 +1164,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1278,7 +1278,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1392,7 +1392,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1516,7 +1516,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1683,7 +1683,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1797,7 +1797,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -1913,7 +1913,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2027,7 +2027,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2145,7 +2145,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2267,7 +2267,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2381,7 +2381,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2495,7 +2495,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2609,7 +2609,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2723,7 +2723,7 @@ }), }), 'key': 'backup', - 'minor_version': 3, + 'minor_version': 4, 'version': 1, }) # --- @@ -2801,6 +2801,154 @@ 'type': 'result', }) # --- +# name: test_config_update_errors[command10] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_errors[command10].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_errors[command11] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_errors[command11].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_errors[command1] dict({ 'id': 1, diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py index f05afbea9ec..eff53bda777 100644 --- a/tests/components/backup/test_store.py +++ b/tests/components/backup/test_store.py @@ -57,6 +57,38 @@ def mock_delay_save() -> Generator[None]: "key": DOMAIN, "version": 1, }, + { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "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": 0, + "days": 0, + }, + "schedule": { + "state": "never", + }, + }, + }, + "key": DOMAIN, + "version": 1, + }, { "data": { "backups": [ diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 82d2c0a921d..496f035e708 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1437,6 +1437,14 @@ async def test_config_update( "type": "backup/config/update", "agents": {"test-agent1": {"favorite": True}}, }, + { + "type": "backup/config/update", + "retention": {"copies": 0}, + }, + { + "type": "backup/config/update", + "retention": {"days": 0}, + }, ], ) async def test_config_update_errors( @@ -2234,7 +2242,7 @@ async def test_config_schedule_logic( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "retention": {"copies": 0, "days": None}, + "retention": {"copies": 1, "days": None}, "schedule": {"recurrence": "daily"}, }, { @@ -2308,7 +2316,7 @@ async def test_config_schedule_logic( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "retention": {"copies": 0, "days": None}, + "retention": {"copies": 1, "days": None}, "schedule": {"recurrence": "daily"}, }, { @@ -2377,7 +2385,7 @@ async def test_config_schedule_logic( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "retention": {"copies": 0, "days": None}, + "retention": {"copies": 1, "days": None}, "schedule": {"recurrence": "daily"}, }, { @@ -3098,7 +3106,7 @@ async def test_config_retention_copies_logic_manual_backup( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "retention": {"copies": None, "days": 0}, + "retention": {"copies": None, "days": 1}, "schedule": {"recurrence": "never"}, } ], From 12e530dc75ff3cf72ac4c173a991aee61a3e0e22 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 18 Feb 2025 09:43:00 -0500 Subject: [PATCH 0783/1941] Fix TV input source option for Sonos Arc Ultra (#138778) initial commit --- homeassistant/components/sonos/const.py | 1 + tests/components/sonos/conftest.py | 10 +++++++-- tests/components/sonos/test_media_player.py | 25 +++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 610a68afedf..8fb704cbfbc 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -170,6 +170,7 @@ MODELS_TV_ONLY = ( "BEAM", "PLAYBAR", "PLAYBASE", + "ULTRA", ) MODELS_LINEIN_AND_TV = ("AMP",) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 0f56794b9f2..e22f18c6d77 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -580,13 +580,19 @@ def alarm_clock_fixture_extended(): return alarm_clock +@pytest.fixture(name="speaker_model") +def speaker_model_fixture(request: pytest.FixtureRequest): + """Create fixture for the speaker model.""" + return getattr(request, "param", "Model Name") + + @pytest.fixture(name="speaker_info") -def speaker_info_fixture(): +def speaker_info_fixture(speaker_model): """Create speaker_info fixture.""" return { "zone_name": "Zone A", "uid": "RINCON_test", - "model_name": "Model Name", + "model_name": speaker_model, "model_number": "S12", "hardware_version": "1.20.1.6-1.1", "software_version": "49.2-64250", diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 63b2c8889ec..cec40c997a7 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -10,6 +10,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, @@ -1205,3 +1206,27 @@ async def test_media_get_queue( ) soco_mock.get_queue.assert_called_with(max_items=0) assert result == snapshot + + +@pytest.mark.parametrize( + ("speaker_model", "source_list"), + [ + ("Sonos Arc Ultra", [SOURCE_TV]), + ("Sonos Arc", [SOURCE_TV]), + ("Sonos Playbar", [SOURCE_TV]), + ("Sonos Connect", [SOURCE_LINEIN]), + ("Sonos Play:5", [SOURCE_LINEIN]), + ("Sonos Amp", [SOURCE_LINEIN, SOURCE_TV]), + ("Sonos Era", None), + ], + indirect=["speaker_model"], +) +async def test_media_source_list( + hass: HomeAssistant, + async_autosetup_sonos, + speaker_model: str, + source_list: list[str] | None, +) -> None: + """Test the mapping between the speaker model name and source_list.""" + state = hass.states.get("media_player.zone_a") + assert state.attributes.get(ATTR_INPUT_SOURCE_LIST) == source_list From 441917706b586b154ed3633f950f2c54dcf8740c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 18 Feb 2025 19:39:44 -0600 Subject: [PATCH 0784/1941] Add assistant filter to expose entities list command (#138817) --- .../homeassistant/exposed_entities.py | 11 +++- .../homeassistant/test_exposed_entities.py | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index 7bd9f9ab7bc..0c815502669 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -432,6 +432,7 @@ def ws_expose_entity( @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_entity/list", + vol.Optional("assistant"): vol.In(KNOWN_ASSISTANTS), } ) def ws_list_exposed_entities( @@ -441,10 +442,18 @@ def ws_list_exposed_entities( result: dict[str, Any] = {} exposed_entities = hass.data[DATA_EXPOSED_ENTITIES] + required_assistant = msg.get("assistant") entity_registry = er.async_get(hass) for entity_id in chain(exposed_entities.entities, entity_registry.entities): - result[entity_id] = {} entity_settings = async_get_entity_settings(hass, entity_id) + if required_assistant and ( + (required_assistant not in entity_settings) + or (not entity_settings[required_assistant].get("should_expose")) + ): + # Not exposed to required assistant + continue + + result[entity_id] = {} for assistant, settings in entity_settings.items(): if "should_expose" not in settings: continue diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 1f1955c2f82..0c57aad58ea 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -539,6 +539,70 @@ async def test_list_exposed_entities( } +async def test_list_exposed_entities_with_filter( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test list exposed entities with filter.""" + ws_client = await hass_ws_client(hass) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + entry1 = entity_registry.async_get_or_create("test", "test", "unique1") + entry2 = entity_registry.async_get_or_create("test", "test", "unique2") + + # Expose 1 to Alexa + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.alexa"], + "entity_ids": [entry1.entity_id], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # Expose 2 to Google + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity", + "assistants": ["cloud.google_assistant"], + "entity_ids": [entry2.entity_id], + "should_expose": True, + } + ) + response = await ws_client.receive_json() + assert response["success"] + + # List with filter + await ws_client.send_json_auto_id( + {"type": "homeassistant/expose_entity/list", "assistant": "cloud.alexa"} + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "exposed_entities": { + "test.test_unique1": {"cloud.alexa": True}, + }, + } + + await ws_client.send_json_auto_id( + { + "type": "homeassistant/expose_entity/list", + "assistant": "cloud.google_assistant", + } + ) + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == { + "exposed_entities": { + "test.test_unique2": {"cloud.google_assistant": True}, + }, + } + + async def test_listeners( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: From d42e31b5e702a8eb4481f8247a85b8e25c63e649 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 19 Feb 2025 13:42:53 +0100 Subject: [PATCH 0785/1941] Fix playback for encrypted Reolink files (#138852) --- homeassistant/components/reolink/media_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index e912bfb5100..740ba21baa9 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -71,7 +71,7 @@ class ReolinkVODMediaSource(MediaSource): host = get_host(self.hass, config_entry_id) def get_vod_type() -> VodRequestType: - if filename.endswith(".mp4"): + if filename.endswith((".mp4", ".vref")): if host.api.is_nvr: return VodRequestType.DOWNLOAD return VodRequestType.PLAYBACK From 6da33a88833d490cf980c8718027bb1f5d19e03d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Feb 2025 22:40:03 +0100 Subject: [PATCH 0786/1941] Correct backup date when reading a backup created by supervisor (#138860) --- homeassistant/components/backup/util.py | 7 +++++-- tests/components/backup/test_util.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 9d8f6e815dc..bd77880738e 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -104,12 +104,15 @@ def read_backup(backup_path: Path) -> AgentBackup: bool, homeassistant.get("exclude_database", False) ) + extra_metadata = cast(dict[str, bool | str], data.get("extra", {})) + date = extra_metadata.get("supervisor.backup_request_date", data["date"]) + return AgentBackup( addons=addons, backup_id=cast(str, data["slug"]), database_included=database_included, - date=cast(str, data["date"]), - extra_metadata=cast(dict[str, bool | str], data.get("extra", {})), + date=cast(str, date), + extra_metadata=extra_metadata, folders=folders, homeassistant_included=homeassistant_included, homeassistant_version=homeassistant_version, diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 504e0d56d58..97e94eafb73 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -89,6 +89,28 @@ from tests.common import get_fixture_path size=1234, ), ), + # Check the backup_request_date is used as date if present + ( + b'{"compressed":true,"date":"2024-12-01T00:00:00.000000-00:00","homeassistant":' + b'{"exclude_database":true,"version":"2024.12.0.dev0"},"name":"test",' + b'"extra":{"supervisor.backup_request_date":"2025-12-01T00:00:00.000000-00:00"},' + b'"protected":true,"slug":"455645fe","type":"partial","version":2}', + AgentBackup( + addons=[], + backup_id="455645fe", + date="2025-12-01T00:00:00.000000-00:00", + database_included=False, + extra_metadata={ + "supervisor.backup_request_date": "2025-12-01T00:00:00.000000-00:00" + }, + folders=[], + homeassistant_included=True, + homeassistant_version="2024.12.0.dev0", + name="test", + protected=True, + size=1234, + ), + ), ], ) def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -> None: From 94555f533bd10440cb52a8bbd1b248e0392f9808 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 19 Feb 2025 21:24:35 +0100 Subject: [PATCH 0787/1941] Bump pyfritzhome to 0.6.15 (#138879) --- 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 2fbb75443b2..7c0f35b591c 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.14"], + "requirements": ["pyfritzhome==0.6.15"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 48bdc5e213e..df87b50a9e6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1969,7 +1969,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.14 +pyfritzhome==0.6.15 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a3f0aae9fe..7d32ea03ea2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1604,7 +1604,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.14 +pyfritzhome==0.6.15 # homeassistant.components.ifttt pyfttt==0.3 From 8c3ee80203535458999ab14a37e3052779fd81ec Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 20 Feb 2025 16:06:33 +0100 Subject: [PATCH 0788/1941] Validate hassio backup settings (#138880) * Validate hassio backup settings * Add snapshots * Don't reset addon and folder settings * Adapt to changes in BackupConfig.update --- homeassistant/components/backup/__init__.py | 3 +- homeassistant/components/hassio/backup.py | 21 ++- .../backup/snapshots/test_websocket.ambr | 2 +- tests/components/conftest.py | 1 + .../hassio/snapshots/test_backup.ambr | 130 ++++++++++++++++++ tests/components/hassio/test_backup.py | 93 +++++++++++++ 6 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 tests/components/hassio/snapshots/test_backup.ambr diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 1b19b185b4f..a5159086945 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -16,7 +16,7 @@ from .agent import ( BackupAgentPlatformProtocol, LocalBackupAgent, ) -from .config import BackupConfig +from .config import BackupConfig, CreateBackupParametersDict from .const import DATA_MANAGER, DOMAIN from .http import async_register_http_views from .manager import ( @@ -55,6 +55,7 @@ __all__ = [ "BackupReaderWriter", "BackupReaderWriterError", "CreateBackupEvent", + "CreateBackupParametersDict", "CreateBackupStage", "CreateBackupState", "Folder", diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 9c0511a93fe..e7d169c142c 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -33,6 +33,7 @@ from homeassistant.components.backup import ( BackupReaderWriter, BackupReaderWriterError, CreateBackupEvent, + CreateBackupParametersDict, CreateBackupStage, CreateBackupState, Folder, @@ -635,7 +636,25 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): unsub() async def async_validate_config(self, *, config: BackupConfig) -> None: - """Validate backup config.""" + """Validate backup config. + + Replace the core backup agent with the hassio default agent. + """ + core_agent_id = "backup.local" + create_backup = config.data.create_backup + if core_agent_id not in create_backup.agent_ids: + _LOGGER.debug("Backup settings don't need to be adjusted") + return + + default_agent = await _default_agent(self._client) + _LOGGER.info("Adjusting backup settings to not include core backup location") + automatic_agents = [ + agent_id if agent_id != core_agent_id else default_agent + for agent_id in create_backup.agent_ids + ] + config.update( + create_backup=CreateBackupParametersDict(agent_ids=automatic_agents) + ) @callback def _async_listen_job_events( diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index b580f6295f2..a5657ecc137 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -625,7 +625,7 @@ }), 'create_backup': dict({ 'agent_ids': list([ - 'backup.local', + 'hassio.local', 'test-agent', ]), 'include_addons': None, diff --git a/tests/components/conftest.py b/tests/components/conftest.py index ebf390e30d7..dd6776a1cad 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -529,6 +529,7 @@ def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> As def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" mounts_info_mock = AsyncMock(spec_set=["default_backup_mount", "mounts"]) + mounts_info_mock.default_backup_mount = None mounts_info_mock.mounts = [] supervisor_client = AsyncMock() supervisor_client.addons = AsyncMock() diff --git a/tests/components/hassio/snapshots/test_backup.ambr b/tests/components/hassio/snapshots/test_backup.ambr new file mode 100644 index 00000000000..a2f33bf9624 --- /dev/null +++ b/tests/components/hassio/snapshots/test_backup.ambr @@ -0,0 +1,130 @@ +# serializer version: 1 +# name: test_config_load_config_info[storage_data0] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + '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_load_config_info[storage_data1] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent1', + 'hassio.local', + 'test-agent2', + ]), + 'include_addons': list([ + 'addon1', + 'addon2', + ]), + 'include_all_addons': True, + 'include_database': True, + 'include_folders': list([ + 'media', + 'share', + ]), + '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_load_config_info[storage_data2] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'agents': dict({ + }), + 'create_backup': dict({ + 'agent_ids': list([ + 'test-agent1', + 'hassio.local', + 'test-agent2', + ]), + 'include_addons': list([ + 'addon1', + 'addon2', + ]), + 'include_all_addons': False, + 'include_database': True, + 'include_folders': list([ + 'media', + 'share', + ]), + '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', + }) +# --- diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 7547e3e3586..6a66d249dd1 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -30,6 +30,7 @@ from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL from aiohasupervisor.models.mounts import MountsInfo from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from homeassistant.components.backup import ( DOMAIN as BACKUP_DOMAIN, @@ -38,6 +39,7 @@ from homeassistant.components.backup import ( BackupAgent, BackupAgentPlatformProtocol, Folder, + store as backup_store, ) from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV @@ -2466,3 +2468,94 @@ async def test_restore_progress_after_restart_unknown_job( assert response["success"] assert response["result"]["last_non_idle_event"] is None assert response["result"]["state"] == "idle" + + +@pytest.mark.parametrize( + "storage_data", + [ + {}, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["test-agent1", "hassio.local", "test-agent2"], + "include_addons": ["addon1", "addon2"], + "include_all_addons": True, + "include_database": True, + "include_folders": ["media", "share"], + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": backup_store.STORAGE_VERSION, + "minor_version": backup_store.STORAGE_VERSION_MINOR, + }, + }, + { + "backup": { + "data": { + "backups": [], + "config": { + "agents": {}, + "create_backup": { + "agent_ids": ["test-agent1", "backup.local", "test-agent2"], + "include_addons": ["addon1", "addon2"], + "include_all_addons": False, + "include_database": True, + "include_folders": ["media", "share"], + "name": None, + "password": None, + }, + "retention": {"copies": None, "days": None}, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "schedule": { + "days": [], + "recurrence": "never", + "state": "never", + "time": None, + }, + }, + }, + "key": DOMAIN, + "version": backup_store.STORAGE_VERSION, + "minor_version": backup_store.STORAGE_VERSION_MINOR, + }, + }, + ], +) +@pytest.mark.usefixtures("hassio_client") +async def test_config_load_config_info( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + hass_storage: dict[str, Any], + storage_data: dict[str, Any] | None, +) -> None: + """Test loading stored backup config and reading it via config/info.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + + hass_storage.update(storage_data) + + assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) + await hass.async_block_till_done() + + await client.send_json_auto_id({"type": "backup/config/info"}) + assert await client.receive_json() == snapshot From d752a3a24cb5641c9a978ff1ee26fdd944e84594 Mon Sep 17 00:00:00 2001 From: Dmitry Kuzmenko Date: Thu, 20 Feb 2025 17:18:19 +0300 Subject: [PATCH 0789/1941] Catch zeep fault as well on GetSystemDateAndTime call. (#138916) --- homeassistant/components/onvif/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 6d1a340fc7b..3f37ba42397 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -235,7 +235,7 @@ class ONVIFDevice: LOGGER.debug("%s: Retrieving current device date/time", self.name) try: device_time = await device_mgmt.GetSystemDateAndTime() - except RequestError as err: + except (RequestError, Fault) as err: LOGGER.warning( "Couldn't get device '%s' date/time. Error: %s", self.name, err ) From dc7cba60bdde7b0e78d4bba4f5c9ed2a484aad2b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 20 Feb 2025 10:46:24 +0100 Subject: [PATCH 0790/1941] Fix Reolink callback id collision (#138918) --- homeassistant/components/reolink/entity.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 63c95c25025..7b39a8bafc9 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -103,10 +103,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] """Handle incoming TCP push event.""" self.async_write_ha_state() - def register_callback(self, unique_id: str, cmd_id: int) -> None: + def register_callback(self, callback_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" self._host.api.baichuan.register_callback( # pragma: no cover - unique_id, self._push_callback, cmd_id + callback_id, self._push_callback, cmd_id ) async def async_added_to_hass(self) -> None: @@ -114,23 +114,25 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] await super().async_added_to_hass() cmd_key = self.entity_description.cmd_key cmd_id = self.entity_description.cmd_id + callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_register_update_cmd(cmd_key) if cmd_id is not None: - self.register_callback(self._attr_unique_id, cmd_id) + self.register_callback(callback_id, cmd_id) # Privacy mode - self.register_callback(f"{self._attr_unique_id}_623", 623) + self.register_callback(f"{callback_id}_623", 623) async def async_will_remove_from_hass(self) -> None: """Entity removed.""" cmd_key = self.entity_description.cmd_key cmd_id = self.entity_description.cmd_id + callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_unregister_update_cmd(cmd_key) if cmd_id is not None: - self._host.api.baichuan.unregister_callback(self._attr_unique_id) + self._host.api.baichuan.unregister_callback(callback_id) # Privacy mode - self._host.api.baichuan.unregister_callback(f"{self._attr_unique_id}_623") + self._host.api.baichuan.unregister_callback(f"{callback_id}_623") await super().async_will_remove_from_hass() @@ -189,10 +191,10 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Return True if entity is available.""" return super().available and self._host.api.camera_online(self._channel) - def register_callback(self, unique_id: str, cmd_id: int) -> None: + def register_callback(self, callback_id: str, cmd_id: int) -> None: """Register callback for TCP push events.""" self._host.api.baichuan.register_callback( - unique_id, self._push_callback, cmd_id, self._channel + callback_id, self._push_callback, cmd_id, self._channel ) async def async_added_to_hass(self) -> None: From 266612e4d9f51a9cb0b86bfa9605c1c734b4da99 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 20 Feb 2025 22:38:43 +0100 Subject: [PATCH 0791/1941] Fix handling of min/max temperature presets in AVM Fritz!SmartHome (#138954) --- homeassistant/components/fritzbox/climate.py | 26 +++++------ tests/components/fritzbox/test_climate.py | 45 +++++++++++++++++--- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 87a87ac691f..3c3d90da151 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -85,6 +85,8 @@ async def async_setup_entry( class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): """The thermostat class for FRITZ!SmartHome thermostats.""" + _attr_max_temp = MAX_TEMPERATURE + _attr_min_temp = MIN_TEMPERATURE _attr_precision = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "thermostat" @@ -135,11 +137,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - target_temp = kwargs.get(ATTR_TEMPERATURE) - hvac_mode = kwargs.get(ATTR_HVAC_MODE) - if hvac_mode == HVACMode.OFF: + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF: await self.async_set_hvac_mode(hvac_mode) - elif target_temp is not None: + elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None: + if target_temp == OFF_API_TEMPERATURE: + target_temp = OFF_REPORT_SET_TEMPERATURE + elif target_temp == ON_API_TEMPERATURE: + target_temp = ON_REPORT_SET_TEMPERATURE await self.hass.async_add_executor_job( self.data.set_target_temperature, target_temp, True ) @@ -169,12 +173,12 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): translation_domain=DOMAIN, translation_key="change_hvac_while_active_mode", ) - if self.hvac_mode == hvac_mode: + if self.hvac_mode is hvac_mode: LOGGER.debug( "%s is already in requested hvac mode %s", self.name, hvac_mode ) return - if hvac_mode == HVACMode.OFF: + if hvac_mode is HVACMode.OFF: await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) else: if value_scheduled_preset(self.data) == PRESET_ECO: @@ -208,16 +212,6 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): elif preset_mode == PRESET_ECO: await self.async_set_temperature(temperature=self.data.eco_temperature) - @property - def min_temp(self) -> int: - """Return the minimum temperature.""" - return MIN_TEMPERATURE - - @property - def max_temp(self) -> int: - """Return the maximum temperature.""" - return MAX_TEMPERATURE - @property def extra_state_attributes(self) -> ClimateExtraAttributes: """Return the device specific state attributes.""" diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 87e6d36e3b6..f170836fa9b 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -23,7 +23,12 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.fritzbox.climate import PRESET_HOLIDAY, PRESET_SUMMER +from homeassistant.components.fritzbox.climate import ( + OFF_API_TEMPERATURE, + ON_API_TEMPERATURE, + PRESET_HOLIDAY, + PRESET_SUMMER, +) from homeassistant.components.fritzbox.const import ( ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, @@ -367,9 +372,23 @@ async def test_set_hvac_mode( assert device.set_target_temperature.call_args_list == expected_call_args -async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None: +@pytest.mark.parametrize( + ("comfort_temperature", "expected_call_args"), + [ + (20, [call(20, True)]), + (28, [call(28, True)]), + (ON_API_TEMPERATURE, [call(30, True)]), + ], +) +async def test_set_preset_mode_comfort( + hass: HomeAssistant, + fritz: Mock, + comfort_temperature: int, + expected_call_args: list[_Call], +) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() + device.comfort_temperature = comfort_temperature assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -380,12 +399,27 @@ async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, True, ) - assert device.set_target_temperature.call_args_list == [call(22, True)] + assert device.set_target_temperature.call_count == len(expected_call_args) + assert device.set_target_temperature.call_args_list == expected_call_args -async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: +@pytest.mark.parametrize( + ("eco_temperature", "expected_call_args"), + [ + (20, [call(20, True)]), + (16, [call(16, True)]), + (OFF_API_TEMPERATURE, [call(0, True)]), + ], +) +async def test_set_preset_mode_eco( + hass: HomeAssistant, + fritz: Mock, + eco_temperature: int, + expected_call_args: list[_Call], +) -> None: """Test setting preset mode.""" device = FritzDeviceClimateMock() + device.eco_temperature = eco_temperature assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -396,7 +430,8 @@ async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, True, ) - assert device.set_target_temperature.call_args_list == [call(16, True)] + assert device.set_target_temperature.call_count == len(expected_call_args) + assert device.set_target_temperature.call_args_list == expected_call_args async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None: From 83d9c000d35317459901ff797bb28736f646ca81 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 20 Feb 2025 23:12:27 +0000 Subject: [PATCH 0792/1941] Bump pyprosegur to 0.0.13 (#138960) --- 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 adf5e985fe9..6419b81aa7f 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.9"] + "requirements": ["pyprosegur==0.0.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index df87b50a9e6..4916ff817af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2217,7 +2217,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.9 +pyprosegur==0.0.13 # homeassistant.components.prusalink pyprusalink==2.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d32ea03ea2..05df4a4d594 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1810,7 +1810,7 @@ pypoint==3.0.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.9 +pyprosegur==0.0.13 # homeassistant.components.prusalink pyprusalink==2.1.1 From 3ea1d2823e1950b4cc288b04601f5b909552295e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 21 Feb 2025 16:36:48 +0100 Subject: [PATCH 0793/1941] Bump reolink-aio to 0.12.0 (#138985) --- 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 505358a07f7..37e448aa820 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.11.10"] + "requirements": ["reolink-aio==0.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4916ff817af..4a2e4255062 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2603,7 +2603,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.10 +reolink-aio==0.12.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05df4a4d594..f9f8d88da7c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2106,7 +2106,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.11.10 +reolink-aio==0.12.0 # homeassistant.components.rflink rflink==0.0.66 From 325022ec777857c7e5aabd59c611eeb1cf8b130a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 21 Feb 2025 16:05:14 +0100 Subject: [PATCH 0794/1941] Bump deebot-client to 12.2.0 (#138986) --- 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 79e0c34e4b9..b31fa7f347d 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.1.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==12.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4a2e4255062..8e259b3d160 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ debugpy==1.8.11 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==12.1.0 +deebot-client==12.2.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9f8d88da7c..6f7a0a410fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ dbus-fast==2.33.0 debugpy==1.8.11 # homeassistant.components.ecovacs -deebot-client==12.1.0 +deebot-client==12.2.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 0dbdb42947eecda4a1e0304c681a32e92f6cf5df Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Feb 2025 19:30:48 +0100 Subject: [PATCH 0795/1941] Omit unknown hue effects (#138992) --- homeassistant/components/hue/v2/light.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 86d8cc93e54..ce599a5a1d8 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -107,7 +107,9 @@ class HueLight(HueBaseEntity, LightEntity): self._attr_effect_list = [] if effects := resource.effects: self._attr_effect_list = [ - x.value for x in effects.status_values if x != EffectStatus.NO_EFFECT + x.value + for x in effects.status_values + if x not in (EffectStatus.NO_EFFECT, EffectStatus.UNKNOWN) ] if timed_effects := resource.timed_effects: self._attr_effect_list += [ From df5f6fc1e69e4c654268ca2c8b4182911d802ba8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 21 Feb 2025 20:00:31 +0100 Subject: [PATCH 0796/1941] Update frontend to 20250221.0 (#139006) --- 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 c8506335e16..499e1fbddb2 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==20250214.0"] + "requirements": ["home-assistant-frontend==20250221.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1c44651b9ec..5854420136b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20250214.0 +home-assistant-frontend==20250221.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 8e259b3d160..4ad08d6a8d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250214.0 +home-assistant-frontend==20250221.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f7a0a410fb..c9737b3beba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250214.0 +home-assistant-frontend==20250221.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From ba1650bd05bbdebc2b76ccad013eaee7987daa4a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 21 Feb 2025 19:32:37 +0000 Subject: [PATCH 0797/1941] Bump version to 2025.2.5 --- 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 05438c9ce26..99ead85ad5d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "4" +PATCH_VERSION: Final = "5" __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 ffa6d8cb6bd..9852ed00b4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.2.4" +version = "2025.2.5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6e71893b50857367fc7973879827202ee17521cf Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 21 Feb 2025 21:28:01 +0100 Subject: [PATCH 0798/1941] Bump pyfritzhome 0.6.16 (#139011) bump pyfritzhome 0.6.16 --- 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 7c0f35b591c..92405a977ee 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.15"], + "requirements": ["pyfritzhome==0.6.16"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 7c619b7c12e..4ccd6d25719 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.15 +pyfritzhome==0.6.16 # homeassistant.components.ifttt pyfttt==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35b358b9071..e42a970c2a0 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.15 +pyfritzhome==0.6.16 # homeassistant.components.ifttt pyfttt==0.3 From 3d2ab3b59e616ddda2b0054f470fe07cde290de0 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Feb 2025 12:11:34 +0100 Subject: [PATCH 0799/1941] Make backup config update a callback (#138925) --- homeassistant/components/backup/config.py | 3 ++- homeassistant/components/backup/manager.py | 2 +- homeassistant/components/backup/websocket.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 4d0cd82bc44..f34c1b8887d 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -154,7 +154,8 @@ class BackupConfig: self.data.retention.apply(self._manager) self.data.schedule.apply(self._manager) - async def update( + @callback + def update( self, *, agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 5a1bcde2b3b..0f79cd79e0c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1870,7 +1870,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): and "hassio.local" in create_backup.agent_ids ): automatic_agents = [self._local_agent_id, *automatic_agents] - await config.update( + config.update( create_backup=CreateBackupParametersDict( agent_ids=automatic_agents, include_addons=None, diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 8453046cabb..b36343c7634 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -346,6 +346,7 @@ async def handle_config_info( ) +@callback @websocket_api.require_admin @websocket_api.websocket_command( { @@ -387,8 +388,7 @@ async def handle_config_info( ), } ) -@websocket_api.async_response -async def handle_config_update( +def handle_config_update( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], @@ -398,7 +398,7 @@ async def handle_config_update( changes = dict(msg) changes.pop("id") changes.pop("type") - await manager.config.update(**changes) + manager.config.update(**changes) connection.send_result(msg["id"]) From 463d9617acbe3bd6e46a0054f152104f91a8fb23 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Sat, 22 Feb 2025 08:49:17 +0900 Subject: [PATCH 0800/1941] Add target_temp_step attribute to water_heater (#138920) Co-authored-by: yunseon.park --- homeassistant/components/demo/water_heater.py | 11 +++++++++-- .../components/water_heater/__init__.py | 17 ++++++++++++++++- tests/components/demo/test_water_heater.py | 1 + 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py index 7bc558a2ae4..9e12bb9e1d5 100644 --- a/homeassistant/components/demo/water_heater.py +++ b/homeassistant/components/demo/water_heater.py @@ -30,10 +30,15 @@ async def async_setup_entry( async_add_entities( [ DemoWaterHeater( - "Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco" + "Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco", 1 ), DemoWaterHeater( - "Demo Water Heater Celsius", 45, UnitOfTemperature.CELSIUS, True, "eco" + "Demo Water Heater Celsius", + 45, + UnitOfTemperature.CELSIUS, + True, + "eco", + 1, ), ] ) @@ -52,6 +57,7 @@ class DemoWaterHeater(WaterHeaterEntity): unit_of_measurement: str, away: bool, current_operation: str, + target_temperature_step: float, ) -> None: """Initialize the water_heater device.""" self._attr_name = name @@ -74,6 +80,7 @@ class DemoWaterHeater(WaterHeaterEntity): "gas", "off", ] + self._attr_target_temperature_step = target_temperature_step def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index c9155950680..f2038def79c 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -77,6 +77,7 @@ ATTR_OPERATION_MODE = "operation_mode" ATTR_OPERATION_LIST = "operation_list" ATTR_TARGET_TEMP_HIGH = "target_temp_high" ATTR_TARGET_TEMP_LOW = "target_temp_low" +ATTR_TARGET_TEMP_STEP = "target_temp_step" ATTR_CURRENT_TEMPERATURE = "current_temperature" CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE] @@ -154,6 +155,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = { "target_temperature", "target_temperature_high", "target_temperature_low", + "target_temperature_step", "is_away_mode_on", } @@ -162,7 +164,12 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for water heater entities.""" _entity_component_unrecorded_attributes = frozenset( - {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} + { + ATTR_OPERATION_LIST, + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_TARGET_TEMP_STEP, + } ) entity_description: WaterHeaterEntityDescription @@ -179,6 +186,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_target_temperature_low: float | None = None _attr_target_temperature: float | None = None _attr_temperature_unit: str + _attr_target_temperature_step: float | None = None @final @property @@ -206,6 +214,8 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self.hass, self.max_temp, self.temperature_unit, self.precision ), } + if target_temperature_step := self.target_temperature_step: + data[ATTR_TARGET_TEMP_STEP] = target_temperature_step if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features: data[ATTR_OPERATION_LIST] = self.operation_list @@ -289,6 +299,11 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the lowbound target temperature we try to reach.""" return self._attr_target_temperature_low + @cached_property + def target_temperature_step(self) -> float | None: + """Return the supported step of target temperature.""" + return self._attr_target_temperature_step + @cached_property def is_away_mode_on(self) -> bool | None: """Return true if away mode is on.""" diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index 48859610d39..257e1ab5ffb 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -43,6 +43,7 @@ async def test_setup_params(hass: HomeAssistant) -> None: assert state.attributes.get("temperature") == 119 assert state.attributes.get("away_mode") == "off" assert state.attributes.get("operation_mode") == "eco" + assert state.attributes.get("target_temp_step") == 1 async def test_default_setup_params(hass: HomeAssistant) -> None: From bf83f5a671da2ad008f11868f97a4fb27a30b525 Mon Sep 17 00:00:00 2001 From: Stephan Jauernick Date: Sat, 22 Feb 2025 02:40:55 +0100 Subject: [PATCH 0801/1941] Add button to set date and time for thermopro TP358/TP393 (#135740) Co-authored-by: J. Nick Koston --- .../components/thermopro/__init__.py | 37 ++++- homeassistant/components/thermopro/button.py | 157 ++++++++++++++++++ homeassistant/components/thermopro/const.py | 3 + homeassistant/components/thermopro/sensor.py | 4 +- .../components/thermopro/strings.json | 7 + tests/components/thermopro/__init__.py | 10 ++ tests/components/thermopro/conftest.py | 56 +++++++ tests/components/thermopro/test_button.py | 135 +++++++++++++++ 8 files changed, 399 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/thermopro/button.py create mode 100644 tests/components/thermopro/test_button.py diff --git a/homeassistant/components/thermopro/__init__.py b/homeassistant/components/thermopro/__init__.py index 2cd207818c5..742449cffbe 100644 --- a/homeassistant/components/thermopro/__init__.py +++ b/homeassistant/components/thermopro/__init__.py @@ -2,25 +2,47 @@ from __future__ import annotations +from functools import partial import logging -from thermopro_ble import ThermoProBluetoothDeviceData +from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData -from homeassistant.components.bluetooth import BluetoothScanningMode +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_DATA_UPDATED -PLATFORMS: list[Platform] = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +def process_service_info( + hass: HomeAssistant, + entry: ConfigEntry, + data: ThermoProBluetoothDeviceData, + service_info: BluetoothServiceInfoBleak, +) -> SensorUpdate: + """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" + update = data.update(service_info) + async_dispatcher_send( + hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", data, service_info, update + ) + return update + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up ThermoPro BLE device from a config entry.""" address = entry.unique_id @@ -32,13 +54,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, address=address, mode=BluetoothScanningMode.ACTIVE, - update_method=data.update, + update_method=partial(process_service_info, hass, entry, data), ) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload( - coordinator.async_start() - ) # only start after all platforms have had a chance to subscribe + # only start after all platforms have had a chance to subscribe + entry.async_on_unload(coordinator.async_start()) return True diff --git a/homeassistant/components/thermopro/button.py b/homeassistant/components/thermopro/button.py new file mode 100644 index 00000000000..9faa9f22c4c --- /dev/null +++ b/homeassistant/components/thermopro/button.py @@ -0,0 +1,157 @@ +"""Thermopro button platform.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData, ThermoProDevice + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_ble_device_from_address, + async_track_unavailable, +) +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import now + +from .const import DOMAIN, SIGNAL_AVAILABILITY_UPDATED, SIGNAL_DATA_UPDATED + +PARALLEL_UPDATES = 1 # one connection at a time + + +@dataclass(kw_only=True, frozen=True) +class ThermoProButtonEntityDescription(ButtonEntityDescription): + """Describe a ThermoPro button entity.""" + + press_action_fn: Callable[[HomeAssistant, str], Coroutine[None, Any, Any]] + + +async def _async_set_datetime(hass: HomeAssistant, address: str) -> None: + """Set Date&Time for a given device.""" + ble_device = async_ble_device_from_address(hass, address, connectable=True) + assert ble_device is not None + await ThermoProDevice(ble_device).set_datetime(now(), am_pm=False) + + +BUTTON_ENTITIES: tuple[ThermoProButtonEntityDescription, ...] = ( + ThermoProButtonEntityDescription( + key="datetime", + translation_key="set_datetime", + icon="mdi:calendar-clock", + entity_category=EntityCategory.CONFIG, + press_action_fn=_async_set_datetime, + ), +) + +MODELS_THAT_SUPPORT_BUTTONS = {"TP358", "TP393"} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the thermopro button platform.""" + address = entry.unique_id + assert address is not None + availability_signal = f"{SIGNAL_AVAILABILITY_UPDATED}_{entry.entry_id}" + entity_added = False + + @callback + def _async_on_data_updated( + data: ThermoProBluetoothDeviceData, + service_info: BluetoothServiceInfoBleak, + update: SensorUpdate, + ) -> None: + nonlocal entity_added + sensor_device_info = update.devices[data.primary_device_id] + if sensor_device_info.model not in MODELS_THAT_SUPPORT_BUTTONS: + return + + if not entity_added: + name = sensor_device_info.name + assert name is not None + entity_added = True + async_add_entities( + ThermoProButtonEntity( + description=description, + data=data, + availability_signal=availability_signal, + address=address, + ) + for description in BUTTON_ENTITIES + ) + + if service_info.connectable: + async_dispatcher_send(hass, availability_signal, True) + + entry.async_on_unload( + async_dispatcher_connect( + hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", _async_on_data_updated + ) + ) + + +class ThermoProButtonEntity(ButtonEntity): + """Representation of a ThermoPro button entity.""" + + _attr_has_entity_name = True + entity_description: ThermoProButtonEntityDescription + + def __init__( + self, + description: ThermoProButtonEntityDescription, + data: ThermoProBluetoothDeviceData, + availability_signal: str, + address: str, + ) -> None: + """Initialize the thermopro button entity.""" + self.entity_description = description + self._address = address + self._availability_signal = availability_signal + self._attr_unique_id = f"{address}-{description.key}" + self._attr_device_info = dr.DeviceInfo( + name=data.get_device_name(), + identifiers={(DOMAIN, address)}, + connections={(dr.CONNECTION_BLUETOOTH, address)}, + ) + + async def async_added_to_hass(self) -> None: + """Connect availability dispatcher.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._availability_signal, + self._async_on_availability_changed, + ) + ) + self.async_on_remove( + async_track_unavailable( + self.hass, self._async_on_unavailable, self._address, connectable=True + ) + ) + + @callback + def _async_on_unavailable(self, _: BluetoothServiceInfoBleak) -> None: + self._async_on_availability_changed(False) + + @callback + def _async_on_availability_changed(self, available: bool) -> None: + self._attr_available = available + self.async_write_ha_state() + + async def async_press(self) -> None: + """Execute the press action for the entity.""" + await self.entity_description.press_action_fn(self.hass, self._address) diff --git a/homeassistant/components/thermopro/const.py b/homeassistant/components/thermopro/const.py index 343729442cf..7d2170f8cf9 100644 --- a/homeassistant/components/thermopro/const.py +++ b/homeassistant/components/thermopro/const.py @@ -1,3 +1,6 @@ """Constants for the ThermoPro Bluetooth integration.""" DOMAIN = "thermopro" + +SIGNAL_DATA_UPDATED = f"{DOMAIN}_service_info_updated" +SIGNAL_AVAILABILITY_UPDATED = f"{DOMAIN}_availability_updated" diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py index 4c9c6a4e42a..853f00f2dd5 100644 --- a/homeassistant/components/thermopro/sensor.py +++ b/homeassistant/components/thermopro/sensor.py @@ -9,7 +9,6 @@ from thermopro_ble import ( Units, ) -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, @@ -23,6 +22,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -110,7 +110,7 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ThermoPro BLE sensors.""" diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 4e12a84b653..5789de410b2 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -17,5 +17,12 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "button": { + "set_datetime": { + "name": "Set Date&Time" + } + } } } diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py index 264e556756c..d3cba26858f 100644 --- a/tests/components/thermopro/__init__.py +++ b/tests/components/thermopro/__init__.py @@ -23,6 +23,16 @@ TP357_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +TP358_SERVICE_INFO = BluetoothServiceInfo( + name="TP358 (4221)", + manufacturer_data={61890: b"\x00\x1d\x02,"}, + service_uuids=[], + address="aa:bb:cc:dd:ee:ff", + rssi=-65, + service_data={}, + source="local", +) + TP962R_SERVICE_INFO = BluetoothServiceInfo( name="TP962R (0000)", manufacturer_data={14081: b"\x00;\x0b7\x00"}, diff --git a/tests/components/thermopro/conftest.py b/tests/components/thermopro/conftest.py index 445f52b7844..0dcc03ae7f4 100644 --- a/tests/components/thermopro/conftest.py +++ b/tests/components/thermopro/conftest.py @@ -1,8 +1,64 @@ """ThermoPro session fixtures.""" +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + import pytest +from thermopro_ble import ThermoProDevice + +from homeassistant.components.thermopro.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import now + +from tests.common import MockConfigEntry @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" + + +@pytest.fixture +def dummy_thermoprodevice(monkeypatch: pytest.MonkeyPatch) -> ThermoProDevice: + """Mock for downstream library.""" + client = ThermoProDevice("") + monkeypatch.setattr(client, "set_datetime", AsyncMock()) + return client + + +@pytest.fixture +def mock_thermoprodevice( + monkeypatch: pytest.MonkeyPatch, dummy_thermoprodevice: ThermoProDevice +) -> ThermoProDevice: + """Return downstream library mock.""" + monkeypatch.setattr( + "homeassistant.components.thermopro.button.ThermoProDevice", + MagicMock(return_value=dummy_thermoprodevice), + ) + return dummy_thermoprodevice + + +@pytest.fixture +def mock_now(monkeypatch: pytest.MonkeyPatch) -> datetime: + """Return fixed datetime for comparison.""" + fixed_now = now() + monkeypatch.setattr( + "homeassistant.components.thermopro.button.now", + MagicMock(return_value=fixed_now), + ) + return fixed_now + + +@pytest.fixture +async def setup_thermopro( + hass: HomeAssistant, mock_thermoprodevice: ThermoProDevice +) -> None: + """Set up the Thermopro integration.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/thermopro/test_button.py b/tests/components/thermopro/test_button.py new file mode 100644 index 00000000000..e4c73af11be --- /dev/null +++ b/tests/components/thermopro/test_button.py @@ -0,0 +1,135 @@ +"""Test the ThermoPro button platform.""" + +from datetime import datetime, timedelta +import time + +import pytest +from thermopro_ble import ThermoProDevice + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import TP357_SERVICE_INFO, TP358_SERVICE_INFO + +from tests.common import async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, + patch_bluetooth_time, +) + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp357(hass: HomeAssistant) -> None: + """Test setting up creates the sensors.""" + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP357_SERVICE_INFO) + await hass.async_block_till_done() + assert not hass.states.get("button.tp358_4221_set_date_time") + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp358_discovery(hass: HomeAssistant) -> None: + """Test discovery of device with button.""" + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + assert button is not None + assert button.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp358_unavailable(hass: HomeAssistant) -> None: + """Test tp358 set date&time button goes to unavailability.""" + start_monotonic = time.monotonic() + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + assert button is not None + assert button.state == STATE_UNKNOWN + + # Fast-forward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15 + + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15), + ) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + + assert button.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp358_reavailable(hass: HomeAssistant) -> None: + """Test TP358/TP393 set date&time button goes to unavailablity and recovers.""" + start_monotonic = time.monotonic() + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + assert button is not None + assert button.state == STATE_UNKNOWN + + # Fast-forward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15 + + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15), + ) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + + assert button.state == STATE_UNAVAILABLE + + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + + button = hass.states.get("button.tp358_4221_set_date_time") + + assert button.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("setup_thermopro") +async def test_buttons_tp358_press( + hass: HomeAssistant, mock_now: datetime, mock_thermoprodevice: ThermoProDevice +) -> None: + """Test TP358/TP393 set date&time button press.""" + assert not hass.states.async_all() + assert not hass.states.get("button.tp358_4221_set_date_time") + inject_bluetooth_service_info(hass, TP358_SERVICE_INFO) + await hass.async_block_till_done() + assert hass.states.get("button.tp358_4221_set_date_time") + + await hass.services.async_call( + "button", + "press", + {ATTR_ENTITY_ID: "button.tp358_4221_set_date_time"}, + blocking=True, + ) + + mock_thermoprodevice.set_datetime.assert_awaited_once_with(mock_now, am_pm=False) + + button_state = hass.states.get("button.tp358_4221_set_date_time") + assert button_state.state != STATE_UNKNOWN From baa3b15dbc7cef6f6e3b765b284fcf58c3eaacdd Mon Sep 17 00:00:00 2001 From: Claudio Ruggeri - CR-Tech <41435902+crug80@users.noreply.github.com> Date: Sat, 22 Feb 2025 04:16:15 +0100 Subject: [PATCH 0802/1941] Fix write_registers calling after the upgrade of pymodbus to 3.8.x (#139017) --- homeassistant/components/modbus/modbus.py | 5 +++++ tests/components/modbus/conftest.py | 1 + tests/components/modbus/test_switch.py | 23 +++++++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 81cfc3127d1..006ef504590 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -384,6 +384,11 @@ class ModbusHub: {ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1} ) entry = self._pb_request[use_call] + + if use_call in {"write_registers", "write_coils"}: + if not isinstance(value, list): + value = [value] + kwargs[entry.value_attr_name] = value try: result: ModbusPDU = await entry.func(address, **kwargs) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 0a2cbf44b9e..a35cc95605d 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -42,6 +42,7 @@ class ReadResult: self.registers = register_words self.bits = register_words self.value = register_words + self.count = len(register_words) if register_words is not None else 0 def isError(self): """Set error state.""" diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 4b2c123ba75..fc994c70d49 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -12,6 +12,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CALL_TYPE_X_REGISTER_HOLDINGS, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_STATE_OFF, @@ -50,6 +51,7 @@ from tests.common import async_fire_time_changed ENTITY_ID = f"{SWITCH_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") ENTITY_ID2 = f"{ENTITY_ID}_2" ENTITY_ID3 = f"{ENTITY_ID}_3" +ENTITY_ID4 = f"{ENTITY_ID}_4" @pytest.mark.parametrize( @@ -330,6 +332,13 @@ async def test_restore_state_switch( CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {CONF_STATE_ON: [1, 3]}, }, + { + CONF_NAME: f"{TEST_ENTITY_NAME} 4", + CONF_ADDRESS: 19, + CONF_WRITE_TYPE: CALL_TYPE_X_REGISTER_HOLDINGS, + CONF_SCAN_INTERVAL: 0, + CONF_VERIFY: {CONF_STATE_ON: [1, 3]}, + }, ], }, ], @@ -381,6 +390,20 @@ async def test_switch_service_turn( await hass.async_block_till_done() assert hass.states.get(ENTITY_ID3).state == STATE_OFF + mock_modbus.read_holding_registers.return_value = ReadResult([0x03]) + assert hass.states.get(ENTITY_ID4).state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, service_data={ATTR_ENTITY_ID: ENTITY_ID4} + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID4).state == STATE_ON + mock_modbus.read_holding_registers.return_value = ReadResult([0x00]) + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, service_data={ATTR_ENTITY_ID: ENTITY_ID4} + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID4).state == STATE_OFF + mock_modbus.write_register.side_effect = ModbusException("fail write_") await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, service_data={ATTR_ENTITY_ID: ENTITY_ID2} From 3160b7baa0545b04cda68ee77ebe91b50c6ca0b0 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Fri, 21 Feb 2025 22:41:05 -0800 Subject: [PATCH 0803/1941] Swap the Gemini SDK to the newly released Unified SDK (#138246) * Swapped the old GenAI client with the newly realeased one * Fixed the Generate Content Action, Config Flow loading and code cleanup * Add a function to mask the issues with Tools which start with Hass * Fix most tests * google-genai==1.1.0 * fixes * Fixed the remaining tests * Adressed comments --------- Co-authored-by: Paulus Schoutsen Co-authored-by: tronikos --- .../__init__.py | 108 ++++---- .../config_flow.py | 45 ++-- .../const.py | 2 + .../conversation.py | 253 ++++++++++-------- .../manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../__init__.py | 30 +++ .../conftest.py | 30 +-- .../snapshots/test_conversation.ambr | 153 +---------- .../snapshots/test_init.ambr | 27 +- .../test_config_flow.py | 51 ++-- .../test_conversation.py | 206 ++++++++------ .../test_init.py | 110 ++++---- 14 files changed, 513 insertions(+), 508 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index a5c55c2099d..e9ab5cbdd3e 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -5,11 +5,10 @@ from __future__ import annotations import mimetypes from pathlib import Path -from google.ai import generativelanguage_v1beta -from google.api_core.client_options import ClientOptions -from google.api_core.exceptions import ClientError, DeadlineExceeded, GoogleAPIError -import google.generativeai as genai -import google.generativeai.types as genai_types +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 from homeassistant.config_entries import ConfigEntry @@ -29,7 +28,13 @@ from homeassistant.exceptions import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_CHAT_MODEL, CONF_PROMPT, DOMAIN, RECOMMENDED_CHAT_MODEL +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DOMAIN, + RECOMMENDED_CHAT_MODEL, + TIMEOUT_MILLIS, +) SERVICE_GENERATE_CONTENT = "generate_content" CONF_IMAGE_FILENAME = "image_filename" @@ -37,6 +42,8 @@ CONF_IMAGE_FILENAME = "image_filename" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = (Platform.CONVERSATION,) +type GoogleGenerativeAIConfigEntry = ConfigEntry[genai.Client] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Google Generative AI Conversation.""" @@ -44,42 +51,47 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def generate_content(call: ServiceCall) -> ServiceResponse: """Generate content from text and optionally images.""" prompt_parts = [call.data[CONF_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( - { - "mime_type": mime_type, - "data": await hass.async_add_executor_job( - Path(image_filename).read_bytes - ), - } - ) - model = genai.GenerativeModel(model_name=RECOMMENDED_CHAT_MODEL) + 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 try: - response = await model.generate_content_async(prompt_parts) + response = await client.aio.models.generate_content( + model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts + ) except ( - GoogleAPIError, + APIError, ValueError, - genai_types.BlockedPromptException, - genai_types.StopCandidateException, ) as err: raise HomeAssistantError(f"Error generating content: {err}") from err - if not response.parts: - raise HomeAssistantError("Error generating content") + if response.prompt_feedback: + raise HomeAssistantError( + f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}" + ) + + if not response.candidates[0].content.parts: + raise HomeAssistantError("Unknown error generating content") return {"text": response.text} @@ -100,30 +112,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> bool: """Set up Google Generative AI Conversation from a config entry.""" - genai.configure(api_key=entry.data[CONF_API_KEY]) try: - client = generativelanguage_v1beta.ModelServiceAsyncClient( - client_options=ClientOptions(api_key=entry.data[CONF_API_KEY]) + client = genai.Client(api_key=entry.data[CONF_API_KEY]) + await client.aio.models.get( + model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + config={"http_options": {"timeout": TIMEOUT_MILLIS}}, ) - await client.get_model( - name=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), timeout=5.0 - ) - except (GoogleAPIError, ValueError) as err: - if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID": - raise ConfigEntryAuthFailed(err) from err - if isinstance(err, DeadlineExceeded): + except (APIError, Timeout) as err: + if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err): + raise ConfigEntryAuthFailed(err.message) from err + if isinstance(err, Timeout): raise ConfigEntryNotReady(err) from err raise ConfigEntryError(err) from err + else: + entry.runtime_data = client 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: GoogleGenerativeAIConfigEntry +) -> bool: """Unload GoogleGenerativeAI.""" if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return False diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 83eec25ed15..00a016143f4 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -3,15 +3,13 @@ from __future__ import annotations from collections.abc import Mapping -from functools import partial import logging from types import MappingProxyType from typing import Any -from google.ai import generativelanguage_v1beta -from google.api_core.client_options import ClientOptions -from google.api_core.exceptions import ClientError, GoogleAPIError -import google.generativeai as genai +from google import genai # type: ignore[attr-defined] +from google.genai.errors import APIError, ClientError +from requests.exceptions import Timeout import voluptuous as vol from homeassistant.config_entries import ( @@ -53,6 +51,7 @@ from .const import ( RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + TIMEOUT_MILLIS, ) _LOGGER = logging.getLogger(__name__) @@ -70,15 +69,20 @@ RECOMMENDED_OPTIONS = { } -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: +async def validate_input(data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = generativelanguage_v1beta.ModelServiceAsyncClient( - client_options=ClientOptions(api_key=data[CONF_API_KEY]) + client = genai.Client(api_key=data[CONF_API_KEY]) + await client.aio.models.list( + config={ + "http_options": { + "timeout": TIMEOUT_MILLIS, + }, + "query_base": True, + } ) - await client.list_models(timeout=5.0) class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): @@ -93,9 +97,9 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: try: - await validate_input(self.hass, user_input) - except GoogleAPIError as err: - if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID": + await validate_input(user_input) + except (APIError, Timeout) as err: + if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err): errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" @@ -166,6 +170,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): self.last_rendered_recommended = config_entry.options.get( CONF_RECOMMENDED, False ) + self._genai_client = config_entry.runtime_data async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -188,7 +193,9 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], } - schema = await google_generative_ai_config_option_schema(self.hass, options) + schema = await google_generative_ai_config_option_schema( + self.hass, options, self._genai_client + ) return self.async_show_form( step_id="init", data_schema=vol.Schema(schema), @@ -198,6 +205,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): async def google_generative_ai_config_option_schema( hass: HomeAssistant, options: dict[str, Any] | MappingProxyType[str, Any], + genai_client: genai.Client, ) -> dict: """Return a schema for Google Generative AI completion options.""" hass_apis: list[SelectOptionDict] = [ @@ -236,18 +244,21 @@ async def google_generative_ai_config_option_schema( if options.get(CONF_RECOMMENDED): return schema - api_models = await hass.async_add_executor_job(partial(genai.list_models)) - + api_models_pager = await genai_client.aio.models.list(config={"query_base": True}) + api_models = [api_model async for api_model in api_models_pager] models = [ SelectOptionDict( label=api_model.display_name, value=api_model.name, ) - for api_model in sorted(api_models, key=lambda x: x.display_name) + for api_model in sorted(api_models, key=lambda x: x.display_name or "") if ( api_model.name != "models/gemini-1.0-pro" # duplicate of gemini-pro + and api_model.display_name + and api_model.name + and api_model.supported_actions and "vision" not in api_model.name - and "generateContent" in api_model.supported_generation_methods + and "generateContent" in api_model.supported_actions ) ] diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 4d83b935528..35834f6e7f9 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -22,3 +22,5 @@ CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold" RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE" + +TIMEOUT_MILLIS = 10000 diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 4e0dc92f140..c99c4c07a7d 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -6,11 +6,18 @@ import codecs from collections.abc import Callable from typing import Any, Literal, cast -from google.api_core.exceptions import GoogleAPIError -import google.generativeai as genai -from google.generativeai import protos -import google.generativeai.types as genai_types -from google.protobuf.json_format import MessageToDict +from google.genai.errors import APIError +from google.genai.types import ( + AutomaticFunctionCallingConfig, + Content, + FunctionDeclaration, + GenerateContentConfig, + HarmCategory, + Part, + SafetySetting, + Schema, + Tool, +) from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation @@ -57,21 +64,40 @@ async def async_setup_entry( SUPPORTED_SCHEMA_KEYS = { - "type", - "format", - "description", + "min_items", + "example", + "property_ordering", + "pattern", + "minimum", + "default", + "any_of", + "max_length", + "title", + "min_properties", + "min_length", + "max_items", + "maximum", "nullable", + "max_properties", + "type", + "description", "enum", + "format", "items", "properties", "required", } -def _format_schema(schema: dict[str, Any]) -> dict[str, Any]: - """Format the schema to protobuf.""" - if (subschemas := schema.get("anyOf")) or (subschemas := schema.get("allOf")): - for subschema in subschemas: # Gemini API does not support anyOf and allOf keys +def _camel_to_snake(name: str) -> str: + """Convert camel case to snake case.""" + return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") + + +def _format_schema(schema: dict[str, Any]) -> Schema: + """Format the schema to be compatible with Gemini API.""" + if subschemas := schema.get("allOf"): + for subschema in subschemas: # Gemini API does not support allOf keys if "type" in subschema: # Fallback to first subschema with 'type' field return _format_schema(subschema) return _format_schema( @@ -80,42 +106,38 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]: result = {} for key, val in schema.items(): + key = _camel_to_snake(key) if key not in SUPPORTED_SCHEMA_KEYS: continue + if key == "any_of": + val = [_format_schema(subschema) for subschema in val] if key == "type": - key = "type_" val = val.upper() - elif key == "format": - if schema.get("type") == "string" and val != "enum": - continue - if schema.get("type") not in ("number", "integer", "string"): - continue - key = "format_" - elif key == "items": + if key == "items": val = _format_schema(val) elif key == "properties": val = {k: _format_schema(v) for k, v in val.items()} result[key] = val - if result.get("enum") and result.get("type_") != "STRING": + if result.get("enum") and result.get("type") != "STRING": # enum is only allowed for STRING type. This is safe as long as the schema # contains vol.Coerce for the respective type, for example: # vol.All(vol.Coerce(int), vol.In([1, 2, 3])) - result["type_"] = "STRING" + result["type"] = "STRING" result["enum"] = [str(item) for item in result["enum"]] - if result.get("type_") == "OBJECT" and not result.get("properties"): + if result.get("type") == "OBJECT" and not result.get("properties"): # An object with undefined properties is not supported by Gemini API. # Fallback to JSON string. This will probably fail for most tools that want it, # but we don't have a better fallback strategy so far. - result["properties"] = {"json": {"type_": "STRING"}} + result["properties"] = {"json": {"type": "STRING"}} result["required"] = [] - return result + return cast(Schema, result) def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> dict[str, Any]: +) -> Tool: """Format tool specification.""" if tool.parameters.schema: @@ -125,16 +147,14 @@ def _format_tool( else: parameters = None - return protos.Tool( - { - "function_declarations": [ - { - "name": tool.name, - "description": tool.description, - "parameters": parameters, - } - ] - } + return Tool( + function_declarations=[ + FunctionDeclaration( + name=tool.name, + description=tool.description, + parameters=parameters, + ) + ] ) @@ -151,14 +171,12 @@ def _escape_decode(value: Any) -> Any: def _create_google_tool_response_content( content: list[conversation.ToolResultContent], -) -> protos.Content: +) -> Content: """Create a Google tool response content.""" - return protos.Content( + return Content( parts=[ - protos.Part( - function_response=protos.FunctionResponse( - name=tool_result.tool_name, response=tool_result.tool_result - ) + Part.from_function_response( + name=tool_result.tool_name, response=tool_result.tool_result ) for tool_result in content ] @@ -169,33 +187,36 @@ def _convert_content( content: conversation.UserContent | conversation.AssistantContent | conversation.SystemContent, -) -> genai_types.ContentDict: +) -> Content: """Convert HA content to Google content.""" if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] role = "model" if content.role == "assistant" else content.role - return {"role": role, "parts": content.content} + return Content( + role=role, + parts=[ + Part.from_text(text=content.content if content.content else ""), + ], + ) # Handle the Assistant content with tool calls. assert type(content) is conversation.AssistantContent - parts = [] + parts: list[Part] = [] if content.content: - parts.append(protos.Part(text=content.content)) + parts.append(Part.from_text(text=content.content)) if content.tool_calls: parts.extend( [ - protos.Part( - function_call=protos.FunctionCall( - name=tool_call.tool_name, - args=_escape_decode(tool_call.tool_args), - ) + Part.from_function_call( + name=tool_call.tool_name, + args=_escape_decode(tool_call.tool_args), ) for tool_call in content.tool_calls ] ) - return protos.Content({"role": "model", "parts": parts}) + return Content(role="model", parts=parts) class GoogleGenerativeAIConversationEntity( @@ -209,6 +230,7 @@ class GoogleGenerativeAIConversationEntity( def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" self.entry = entry + self._genai_client = entry.runtime_data self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -273,7 +295,7 @@ class GoogleGenerativeAIConversationEntity( except conversation.ConverseError as err: return err.as_conversation_result() - tools: list[dict[str, Any]] | None = None + tools: list[Tool | Callable[..., Any]] | None = None if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) @@ -288,13 +310,22 @@ class GoogleGenerativeAIConversationEntity( "gemini-1.0" not in model_name and "gemini-pro" not in model_name ) - prompt = chat_log.content[0].content # type: ignore[union-attr] - messages: list[genai_types.ContentDict] = [] + prompt_content = cast( + conversation.SystemContent, + chat_log.content[0], + ) + + if prompt_content.content: + prompt = prompt_content.content + else: + raise HomeAssistantError("Invalid prompt content") + + messages: list[Content] = [] # Google groups tool results, we do not. Group them before sending. tool_results: list[conversation.ToolResultContent] = [] - for chat_content in chat_log.content[1:]: + for chat_content in chat_log.content[1:-1]: if chat_content.role == "tool_result": # mypy doesn't like picking a type based on checking shared property 'role' tool_results.append(cast(conversation.ToolResultContent, chat_content)) @@ -317,85 +348,93 @@ class GoogleGenerativeAIConversationEntity( if tool_results: messages.append(_create_google_tool_response_content(tool_results)) - - model = genai.GenerativeModel( - model_name=model_name, - generation_config={ - "temperature": self.entry.options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + generateContentConfig = GenerateContentConfig( + temperature=self.entry.options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), + top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + max_output_tokens=self.entry.options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + safety_settings=[ + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=self.entry.options.get( + CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), ), - "top_p": self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "top_k": self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), - "max_output_tokens": self.entry.options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=self.entry.options.get( + CONF_HARASSMENT_BLOCK_THRESHOLD, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), ), - }, - safety_settings={ - "HARASSMENT": self.entry.options.get( - CONF_HARASSMENT_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + SafetySetting( + category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=self.entry.options.get( + CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), ), - "HATE": self.entry.options.get( - CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + SafetySetting( + category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=self.entry.options.get( + CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), ), - "SEXUAL": self.entry.options.get( - CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - "DANGEROUS": self.entry.options.get( - CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - }, + ], tools=tools or None, system_instruction=prompt if supports_system_instruction else None, + automatic_function_calling=AutomaticFunctionCallingConfig( + disable=True, maximum_remote_calls=None + ), ) if not supports_system_instruction: messages = [ - {"role": "user", "parts": prompt}, - {"role": "model", "parts": "Ok"}, + Content(role="user", parts=[Part.from_text(text=prompt)]), + Content(role="model", parts=[Part.from_text(text="Ok")]), *messages, ] - - chat = model.start_chat(history=messages) - chat_request = user_input.text + chat = self._genai_client.aio.chats.create( + model=model_name, history=messages, config=generateContentConfig + ) + chat_request: str | Content = user_input.text # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: - chat_response = await chat.send_message_async(chat_request) - except ( - GoogleAPIError, - ValueError, - genai_types.BlockedPromptException, - genai_types.StopCandidateException, - ) as err: - LOGGER.error("Error sending message: %s %s", type(err), err) + chat_response = await chat.send_message(message=chat_request) - if isinstance( - err, genai_types.StopCandidateException - ) and "finish_reason: SAFETY\n" in str(err): - error = "The message got blocked by your safety settings" - else: - error = ( - f"Sorry, I had a problem talking to Google Generative AI: {err}" + if chat_response.prompt_feedback: + raise HomeAssistantError( + f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}" ) + except ( + APIError, + ValueError, + ) as err: + LOGGER.error("Error sending message: %s %s", type(err), err) + error = f"Sorry, I had a problem talking to Google Generative AI: {err}" raise HomeAssistantError(error) from err - LOGGER.debug("Response: %s", chat_response.parts) - if not chat_response.parts: + response_parts = chat_response.candidates[0].content.parts + if not response_parts: raise HomeAssistantError( "Sorry, I had a problem getting a response from Google Generative AI." ) content = " ".join( - [part.text.strip() for part in chat_response.parts if part.text] + [part.text.strip() for part in response_parts if part.text] ) tool_calls = [] - for part in chat_response.parts: + for part in response_parts: if not part.function_call: continue - tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001 - tool_name = tool_call["name"] - tool_args = _escape_decode(tool_call["args"]) + tool_call = part.function_call + tool_name = tool_call.name + tool_args = _escape_decode(tool_call.args) tool_calls.append( llm.ToolInput(tool_name=tool_name, tool_args=tool_args) ) @@ -418,7 +457,7 @@ class GoogleGenerativeAIConversationEntity( response = intent.IntentResponse(language=user_input.language) response.async_set_speech( - " ".join([part.text.strip() for part in chat_response.parts if part.text]) + " ".join([part.text.strip() for part in response_parts if part.text]) ) return conversation.ConversationResult( response=response, conversation_id=chat_log.conversation_id diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 7b687b7da6f..cc381532c6f 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-generativeai==0.8.2"] + "requirements": ["google-genai==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4ccd6d25719..6b754d8bf59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1033,7 +1033,7 @@ google-cloud-speech==2.27.0 google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.8.2 +google-genai==1.1.0 # homeassistant.components.nest google-nest-sdm==7.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e42a970c2a0..a7b8120c991 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -883,7 +883,7 @@ google-cloud-speech==2.27.0 google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.8.2 +google-genai==1.1.0 # homeassistant.components.nest google-nest-sdm==7.1.3 diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py index 8f789d9737e..6e2d37b035b 100644 --- a/tests/components/google_generative_ai_conversation/__init__.py +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -1 +1,31 @@ """Tests for the Google Generative AI Conversation integration.""" + +from unittest.mock import Mock + +from google.genai.errors import ClientError +import requests + +CLIENT_ERROR_500 = ClientError( + 500, + Mock( + __class__=requests.Response, + json=Mock( + return_value={ + "message": "Internal Server Error", + "status": "internal-error", + } + ), + ), +) +CLIENT_ERROR_API_KEY_INVALID = ClientError( + 400, + Mock( + __class__=requests.Response, + json=Mock( + return_value={ + "message": "'reason': API_KEY_INVALID", + "status": "unauthorized", + } + ), + ), +) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 28c21a9b791..2bc81b10ce4 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -1,7 +1,6 @@ """Tests helpers.""" -from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -15,14 +14,7 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_genai() -> Generator[None]: - """Mock the genai call in async_setup_entry.""" - with patch("google.ai.generativelanguage_v1beta.ModelServiceAsyncClient.get_model"): - yield - - -@pytest.fixture -def mock_config_entry(hass: HomeAssistant, mock_genai: None) -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Mock a config entry.""" entry = MockConfigEntry( domain="google_generative_ai_conversation", @@ -31,18 +23,21 @@ def mock_config_entry(hass: HomeAssistant, mock_genai: None) -> MockConfigEntry: "api_key": "bla", }, ) + entry.runtime_data = Mock() entry.add_to_hass(hass) return entry @pytest.fixture -def mock_config_entry_with_assist( +async def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} - ) + with patch("google.genai.models.AsyncModels.get"): + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + ) + await hass.async_block_till_done() return mock_config_entry @@ -51,8 +46,11 @@ async def mock_init_component( hass: HomeAssistant, mock_config_entry: ConfigEntry ) -> None: """Initialize integration.""" - assert await async_setup_component(hass, "google_generative_ai_conversation", {}) - await hass.async_block_till_done() + with patch("google.genai.models.AsyncModels.get"): + assert await async_setup_component( + hass, "google_generative_ai_conversation", {} + ) + await hass.async_block_till_done() @pytest.fixture(autouse=True) 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 1fe02ac2536..7c9bb896bd3 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -6,106 +6,26 @@ tuple( ), dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-2.0-flash', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. - ''', - 'tools': list([ - function_declarations { - name: "test_tool" - description: "Test function" - parameters { - type_: OBJECT - properties { - key: "param3" - value { - type_: OBJECT - properties { - key: "json" - value { - type_: STRING - } - } - } - } - properties { - key: "param2" - value { - type_: NUMBER - } - } - properties { - key: "param1" - value { - type_: ARRAY - description: "Test parameters" - items { - type_: STRING - } - } - } - } - } - , - ]), - }), - ), - tuple( - '().start_chat', - 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), 'history': list([ - dict({ - 'parts': 'Please call the test function', - 'role': 'user', - }), ]), + 'model': 'models/gemini-2.0-flash', }), ), tuple( - '().start_chat().send_message_async', + '().send_message', tuple( - 'Please call the test function', ), dict({ + 'message': 'Please call the test function', }), ), tuple( - '().start_chat().send_message_async', + '().send_message', tuple( - parts { - function_response { - name: "test_tool" - response { - fields { - key: "result" - value { - string_value: "Test response" - } - } - } - } - } - , ), dict({ + 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), }), ), ]) @@ -117,75 +37,26 @@ tuple( ), dict({ - 'generation_config': dict({ - 'max_output_tokens': 150, - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, - }), - 'model_name': 'models/gemini-2.0-flash', - 'safety_settings': dict({ - 'DANGEROUS': 'BLOCK_MEDIUM_AND_ABOVE', - 'HARASSMENT': 'BLOCK_MEDIUM_AND_ABOVE', - 'HATE': 'BLOCK_MEDIUM_AND_ABOVE', - 'SEXUAL': 'BLOCK_MEDIUM_AND_ABOVE', - }), - 'system_instruction': ''' - Current time is 05:00:00. Today's date is 2024-05-24. - You are a voice assistant for Home Assistant. - Answer questions about the world truthfully. - Answer in plain text. Keep it simple and to the point. - Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. - ''', - 'tools': list([ - function_declarations { - name: "test_tool" - description: "Test function" - } - , - ]), - }), - ), - tuple( - '().start_chat', - 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=None)], 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([ - dict({ - 'parts': 'Please call the test function', - 'role': 'user', - }), ]), + 'model': 'models/gemini-2.0-flash', }), ), tuple( - '().start_chat().send_message_async', + '().send_message', tuple( - 'Please call the test function', ), dict({ + 'message': 'Please call the test function', }), ), tuple( - '().start_chat().send_message_async', + '().send_message', tuple( - parts { - function_response { - name: "test_tool" - response { - fields { - key: "result" - value { - string_value: "Test response" - } - } - } - } - } - , ), dict({ + 'message': Content(parts=[Part(video_metadata=None, thought=None, code_execution_result=None, executable_code=None, file_data=None, function_call=None, function_response=FunctionResponse(id=None, name='test_tool', response={'result': 'Test response'}), inline_data=None, text=None)], role=None), }), ), ]) 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 c9e02a6d009..e2d93611ea6 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -6,21 +6,11 @@ tuple( ), dict({ - 'model_name': 'models/gemini-2.0-flash', - }), - ), - tuple( - '().generate_content_async', - tuple( - list([ + 'contents': list([ 'Describe this image from my doorbell camera', - dict({ - 'data': b'image bytes', - 'mime_type': 'image/jpeg', - }), + b'image bytes', ]), - ), - dict({ + 'model': 'models/gemini-2.0-flash', }), ), ]) @@ -32,17 +22,10 @@ tuple( ), dict({ - 'model_name': 'models/gemini-2.0-flash', - }), - ), - tuple( - '().generate_content_async', - tuple( - list([ + 'contents': list([ 'Write an opening speech for a Home Assistant release party', ]), - ), - dict({ + 'model': 'models/gemini-2.0-flash', }), ), ]) diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index ee5291196c3..30c9d6c46e6 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -1,10 +1,9 @@ """Test the Google Generative AI Conversation config flow.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch -from google.api_core.exceptions import ClientError, DeadlineExceeded -from google.rpc.error_details_pb2 import ErrorInfo # pylint: disable=no-name-in-module import pytest +from requests.exceptions import Timeout from homeassistant import config_entries from homeassistant.components.google_generative_ai_conversation.config_flow import ( @@ -33,6 +32,8 @@ from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID + from tests.common import MockConfigEntry @@ -41,30 +42,37 @@ def mock_models(): """Mock the model list API.""" model_20_flash = Mock( display_name="Gemini 2.0 Flash", - supported_generation_methods=["generateContent"], + supported_actions=["generateContent"], ) model_20_flash.name = "models/gemini-2.0-flash" model_15_flash = Mock( display_name="Gemini 1.5 Flash", - supported_generation_methods=["generateContent"], + supported_actions=["generateContent"], ) model_15_flash.name = "models/gemini-1.5-flash-latest" model_15_pro = Mock( display_name="Gemini 1.5 Pro", - supported_generation_methods=["generateContent"], + supported_actions=["generateContent"], ) model_15_pro.name = "models/gemini-1.5-pro-latest" model_10_pro = Mock( display_name="Gemini 1.0 Pro", - supported_generation_methods=["generateContent"], + supported_actions=["generateContent"], ) model_10_pro.name = "models/gemini-pro" + + async def models_pager(): + yield model_20_flash + yield model_15_flash + yield model_15_pro + yield model_10_pro + with patch( - "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", - return_value=iter([model_20_flash, model_15_flash, model_15_pro, model_10_pro]), + "google.genai.models.AsyncModels.list", + return_value=models_pager(), ): yield @@ -86,7 +94,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient.list_models", + "google.genai.models.AsyncModels.list", ), patch( "homeassistant.components.google_generative_ai_conversation.async_setup_entry", @@ -170,7 +178,11 @@ async def test_options_switching( expected_options, ) -> None: """Test the options form.""" - hass.config_entries.async_update_entry(mock_config_entry, options=current_options) + with patch("google.genai.models.AsyncModels.get"): + hass.config_entries.async_update_entry( + mock_config_entry, options=current_options + ) + await hass.async_block_till_done() options_flow = await hass.config_entries.options.async_init( mock_config_entry.entry_id ) @@ -195,17 +207,15 @@ async def test_options_switching( ("side_effect", "error"), [ ( - ClientError("some error"), + CLIENT_ERROR_500, "cannot_connect", ), ( - DeadlineExceeded("deadline exceeded"), + Timeout("deadline exceeded"), "cannot_connect", ), ( - ClientError( - "invalid api key", error_info=ErrorInfo(reason="API_KEY_INVALID") - ), + CLIENT_ERROR_API_KEY_INVALID, "invalid_auth", ), (Exception, "unknown"), @@ -217,12 +227,7 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_client = AsyncMock() - mock_client.list_models.side_effect = side_effect - with patch( - "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient", - return_value=mock_client, - ): + with patch("google.genai.models.AsyncModels.list", side_effect=side_effect): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -259,7 +264,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with ( patch( - "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient.list_models", + "google.genai.models.AsyncModels.list", ), patch( "homeassistant.components.google_generative_ai_conversation.async_setup_entry", diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 9b255666a67..229ee0b323e 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -1,12 +1,10 @@ """Tests for the Google Generative AI Conversation integration conversation platform.""" from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, Mock, patch from freezegun import freeze_time -from google.ai.generativelanguage_v1beta.types.content import FunctionCall -from google.api_core.exceptions import GoogleAPIError -import google.generativeai.types as genai_types +from google.genai.types import FunctionCall import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -22,6 +20,8 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent, llm +from . import CLIENT_ERROR_500 + from tests.common import MockConfigEntry @@ -51,7 +51,7 @@ async def test_function_call( snapshot: SnapshotAssertion, ) -> None: """Test function calling.""" - agent_id = mock_config_entry_with_assist.entry_id + agent_id = "conversation.google_generative_ai_conversation" context = Context() mock_tool = AsyncMock() @@ -69,12 +69,12 @@ async def test_function_call( mock_get_tools.return_value = [mock_tool] - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + mock_part = Mock() mock_part.text = "" mock_part.function_call = FunctionCall( name="test_tool", @@ -92,7 +92,7 @@ async def test_function_call( return {"result": "Test response"} mock_tool.async_call.side_effect = tool_call - chat_response.parts = [mock_part] + chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] result = await conversation.async_converse( hass, "Please call the test function", @@ -104,20 +104,28 @@ async def test_function_call( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_chat.send_message_async.mock_calls[1][1][0] - mock_tool_call = type(mock_tool_call).to_dict(mock_tool_call) - assert mock_tool_call == { + mock_tool_call = mock_create.mock_calls[2][2]["message"] + assert mock_tool_call.model_dump() == { "parts": [ { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, "function_response": { + "id": None, "name": "test_tool", "response": { "result": "Test response", }, }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, }, ], - "role": "", + "role": None, } mock_tool.async_call.assert_awaited_once_with( @@ -139,7 +147,7 @@ async def test_function_call( device_id="test_device", ), ) - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot # Test conversating tracing traces = trace.async_get_traces() @@ -170,7 +178,7 @@ async def test_function_call_without_parameters( snapshot: SnapshotAssertion, ) -> None: """Test function calling without parameters.""" - agent_id = mock_config_entry_with_assist.entry_id + agent_id = "conversation.google_generative_ai_conversation" context = Context() mock_tool = AsyncMock() @@ -180,12 +188,12 @@ async def test_function_call_without_parameters( mock_get_tools.return_value = [mock_tool] - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + mock_part = Mock() mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={}) @@ -197,7 +205,7 @@ async def test_function_call_without_parameters( return {"result": "Test response"} mock_tool.async_call.side_effect = tool_call - chat_response.parts = [mock_part] + chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] result = await conversation.async_converse( hass, "Please call the test function", @@ -209,20 +217,28 @@ async def test_function_call_without_parameters( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_chat.send_message_async.mock_calls[1][1][0] - mock_tool_call = type(mock_tool_call).to_dict(mock_tool_call) - assert mock_tool_call == { + mock_tool_call = mock_create.mock_calls[2][2]["message"] + assert mock_tool_call.model_dump() == { "parts": [ { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, "function_response": { + "id": None, "name": "test_tool", "response": { "result": "Test response", }, }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, }, ], - "role": "", + "role": None, } mock_tool.async_call.assert_awaited_once_with( @@ -241,7 +257,7 @@ async def test_function_call_without_parameters( device_id="test_device", ), ) - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + assert [tuple(mock_call) for mock_call in mock_create.mock_calls] == snapshot @patch( @@ -254,7 +270,7 @@ async def test_function_exception( mock_config_entry_with_assist: MockConfigEntry, ) -> None: """Test exception in function calling.""" - agent_id = mock_config_entry_with_assist.entry_id + agent_id = "conversation.google_generative_ai_conversation" context = Context() mock_tool = AsyncMock() @@ -270,12 +286,12 @@ async def test_function_exception( mock_get_tools.return_value = [mock_tool] - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - mock_part = MagicMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + mock_part = Mock() mock_part.text = "" mock_part.function_call = FunctionCall(name="test_tool", args={"param1": 1}) @@ -287,7 +303,7 @@ async def test_function_exception( raise HomeAssistantError("Test tool exception") mock_tool.async_call.side_effect = tool_call - chat_response.parts = [mock_part] + chat_response.candidates = [Mock(content=Mock(parts=[mock_part]))] result = await conversation.async_converse( hass, "Please call the test function", @@ -299,21 +315,29 @@ async def test_function_exception( assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!" - mock_tool_call = mock_chat.send_message_async.mock_calls[1][1][0] - mock_tool_call = type(mock_tool_call).to_dict(mock_tool_call) - assert mock_tool_call == { + mock_tool_call = mock_create.mock_calls[2][2]["message"] + assert mock_tool_call.model_dump() == { "parts": [ { + "code_execution_result": None, + "executable_code": None, + "file_data": None, + "function_call": None, "function_response": { + "id": None, "name": "test_tool", "response": { "error": "HomeAssistantError", "error_text": "Test tool exception", }, }, + "inline_data": None, + "text": None, + "thought": None, + "video_metadata": None, }, ], - "role": "", + "role": None, } mock_tool.async_call.assert_awaited_once_with( hass, @@ -338,18 +362,22 @@ async def test_error_handling( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test that client errors are caught.""" - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - mock_chat.send_message_async.side_effect = GoogleAPIError("some error") + mock_create.return_value.send_message = mock_chat + mock_chat.side_effect = CLIENT_ERROR_500 result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Sorry, I had a problem talking to Google Generative AI: some error" + "Sorry, I had a problem talking to Google Generative AI: 500 internal-error. {'message': 'Internal Server Error', 'status': 'internal-error'}" ) @@ -358,20 +386,24 @@ async def test_blocked_response( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test blocked response.""" - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - mock_chat.send_message_async.side_effect = genai_types.StopCandidateException( - "finish_reason: SAFETY\n" - ) + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=Mock(block_reason_message="SAFETY")) + mock_chat.return_value = chat_response + result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "The message got blocked by your safety settings" + "The message got blocked due to content violations, reason: SAFETY" ) @@ -380,14 +412,18 @@ async def test_empty_response( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test empty response.""" - with patch("google.generativeai.GenerativeModel") as mock_model: + with patch("google.genai.chats.AsyncChats.create") as mock_create: mock_chat = AsyncMock() - mock_model.return_value.start_chat.return_value = mock_chat - chat_response = MagicMock() - mock_chat.send_message_async.return_value = chat_response - chat_response.parts = [] + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + chat_response.candidates = [Mock(content=Mock(parts=[]))] result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -402,17 +438,19 @@ async def test_converse_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test handling ChatLog raising ConverseError.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"}, - ) + with patch("google.genai.models.AsyncModels.get"): + hass.config_entries.async_update_entry( + mock_config_entry, + options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"}, + ) + await hass.async_block_till_done() result = await conversation.async_converse( hass, "hello", None, Context(), - agent_id=mock_config_entry.entry_id, + agent_id="conversation.google_generative_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -449,31 +487,39 @@ async def test_escape_decode() -> None: @pytest.mark.parametrize( - ("openapi", "protobuf"), + ("openapi", "genai_schema"), [ ( {"type": "string", "enum": ["a", "b", "c"]}, - {"type_": "STRING", "enum": ["a", "b", "c"]}, + {"type": "STRING", "enum": ["a", "b", "c"]}, ), ( {"type": "integer", "enum": [1, 2, 3]}, - {"type_": "STRING", "enum": ["1", "2", "3"]}, + {"type": "STRING", "enum": ["1", "2", "3"]}, + ), + ( + {"anyOf": [{"type": "integer"}, {"type": "number"}]}, + {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, ), - ({"anyOf": [{"type": "integer"}, {"type": "number"}]}, {"type_": "INTEGER"}), ( { - "anyOf": [ - {"anyOf": [{"type": "integer"}, {"type": "number"}]}, - {"anyOf": [{"type": "integer"}, {"type": "number"}]}, + "any_of": [ + {"any_of": [{"type": "integer"}, {"type": "number"}]}, + {"any_of": [{"type": "integer"}, {"type": "number"}]}, + ] + }, + { + "any_of": [ + {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, + {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, ] }, - {"type_": "INTEGER"}, ), - ({"type": "string", "format": "lower"}, {"type_": "STRING"}), - ({"type": "boolean", "format": "bool"}, {"type_": "BOOLEAN"}), + ({"type": "string", "format": "lower"}, {"format": "lower", "type": "STRING"}), + ({"type": "boolean", "format": "bool"}, {"format": "bool", "type": "BOOLEAN"}), ( {"type": "number", "format": "percent"}, - {"type_": "NUMBER", "format_": "percent"}, + {"type": "NUMBER", "format": "percent"}, ), ( { @@ -482,25 +528,25 @@ async def test_escape_decode() -> None: "required": [], }, { - "type_": "OBJECT", - "properties": {"var": {"type_": "STRING"}}, + "type": "OBJECT", + "properties": {"var": {"type": "STRING"}}, "required": [], }, ), ( {"type": "object", "additionalProperties": True}, { - "type_": "OBJECT", - "properties": {"json": {"type_": "STRING"}}, + "type": "OBJECT", + "properties": {"json": {"type": "STRING"}}, "required": [], }, ), ( {"type": "array", "items": {"type": "string"}}, - {"type_": "ARRAY", "items": {"type_": "STRING"}}, + {"type": "ARRAY", "items": {"type": "STRING"}}, ), ], ) -async def test_format_schema(openapi, protobuf) -> None: +async def test_format_schema(openapi, genai_schema) -> None: """Test _format_schema.""" - assert _format_schema(openapi) == protobuf + assert _format_schema(openapi) == genai_schema diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 4875323d094..f2e3ac10733 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,16 +1,17 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, Mock, patch -from google.api_core.exceptions import ClientError, DeadlineExceeded -from google.rpc.error_details_pb2 import ErrorInfo # pylint: disable=no-name-in-module import pytest +from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from . import CLIENT_ERROR_500, CLIENT_ERROR_API_KEY_INVALID + from tests.common import MockConfigEntry @@ -24,12 +25,14 @@ async def test_generate_content_service_without_images( "party for the latest version of Home Assistant!" ) - with patch("google.generativeai.GenerativeModel") as mock_model: - mock_response = MagicMock() - mock_response.text = stubbed_generated_content - mock_model.return_value.generate_content_async = AsyncMock( - return_value=mock_response - ) + with patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate: response = await hass.services.async_call( "google_generative_ai_conversation", "generate_content", @@ -41,7 +44,7 @@ async def test_generate_content_service_without_images( assert response == { "text": stubbed_generated_content, } - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot @pytest.mark.usefixtures("mock_init_component") @@ -54,19 +57,21 @@ async def test_generate_content_service_with_image( ) with ( - patch("google.generativeai.GenerativeModel") as mock_model, patch( - "homeassistant.components.google_generative_ai_conversation.Path.read_bytes", + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate, + patch( + "homeassistant.components.google_generative_ai_conversation.Image.open", return_value=b"image bytes", ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), ): - mock_response = MagicMock() - mock_response.text = stubbed_generated_content - mock_model.return_value.generate_content_async = AsyncMock( - return_value=mock_response - ) response = await hass.services.async_call( "google_generative_ai_conversation", "generate_content", @@ -81,7 +86,7 @@ async def test_generate_content_service_with_image( assert response == { "text": stubbed_generated_content, } - assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot @pytest.mark.usefixtures("mock_init_component") @@ -90,20 +95,23 @@ async def test_generate_content_service_error( mock_config_entry: MockConfigEntry, ) -> None: """Test generate content service handles errors.""" - with patch("google.generativeai.GenerativeModel") as mock_model: - mock_model.return_value.generate_content_async = AsyncMock( - side_effect=ClientError("reason") + with ( + patch( + "google.genai.models.AsyncModels.generate_content", + side_effect=CLIENT_ERROR_500, + ), + pytest.raises( + HomeAssistantError, + match="Error generating content: 500 internal-error. {'message': 'Internal Server Error', 'status': 'internal-error'}", + ), + ): + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "write a story about an epic fail"}, + blocking=True, + return_response=True, ) - with pytest.raises( - HomeAssistantError, match="Error generating content: None reason" - ): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - {"prompt": "write a story about an epic fail"}, - blocking=True, - return_response=True, - ) @pytest.mark.usefixtures("mock_init_component") @@ -113,21 +121,22 @@ async def test_generate_content_response_has_empty_parts( ) -> None: """Test generate content service handles response with empty parts.""" with ( - patch("google.generativeai.GenerativeModel") as mock_model, + patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + prompt_feedback=None, + candidates=[Mock(content=Mock(parts=[]))], + ), + ), + pytest.raises(HomeAssistantError, match="Unknown error generating content"), ): - mock_response = MagicMock() - mock_response.parts = [] - mock_model.return_value.generate_content_async = AsyncMock( - return_value=mock_response + await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "write a story about an epic fail"}, + blocking=True, + return_response=True, ) - with pytest.raises(HomeAssistantError, match="Error generating content"): - await hass.services.async_call( - "google_generative_ai_conversation", - "generate_content", - {"prompt": "write a story about an epic fail"}, - blocking=True, - return_response=True, - ) @pytest.mark.usefixtures("mock_init_component") @@ -211,19 +220,17 @@ async def test_generate_content_service_with_non_image(hass: HomeAssistant) -> N ("side_effect", "state", "reauth"), [ ( - ClientError("some error"), + CLIENT_ERROR_500, ConfigEntryState.SETUP_ERROR, False, ), ( - DeadlineExceeded("deadline exceeded"), + Timeout, ConfigEntryState.SETUP_RETRY, False, ), ( - ClientError( - "invalid api key", error_info=ErrorInfo(reason="API_KEY_INVALID") - ), + CLIENT_ERROR_API_KEY_INVALID, ConfigEntryState.SETUP_ERROR, True, ), @@ -235,10 +242,7 @@ async def test_config_entry_error( """Test different configuration entry errors.""" mock_client = AsyncMock() mock_client.get_model.side_effect = side_effect - with patch( - "google.ai.generativelanguage_v1beta.ModelServiceAsyncClient", - return_value=mock_client, - ): + with patch("google.genai.models.AsyncModels.get", side_effect=side_effect): assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state == state From 037bdb6996f4b6bcc71b460b1977773cb7b03477 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 22 Feb 2025 13:06:54 +0100 Subject: [PATCH 0804/1941] Adjust config entry state check in unifi (#138906) * Adjust config entry state check in unifi * Apply suggestions from code review Co-authored-by: Robert Svensson * Format code --------- Co-authored-by: Robert Svensson --- homeassistant/components/unifi/services.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index fc63c092d56..9d4d92839fc 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -6,7 +6,6 @@ from typing import Any from aiounifi.models.client import ClientReconnectRequest, ClientRemoveRequest import voluptuous as vol -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr @@ -67,9 +66,9 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - if mac == "": return - for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): - if config_entry.state is not ConfigEntryState.LOADED or ( - ((hub := config_entry.runtime_data) and not hub.available) + for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN): + if ( + (not (hub := config_entry.runtime_data).available) or (client := hub.api.clients.get(mac)) is None or client.is_wired ): @@ -85,10 +84,8 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> - Total time between first seen and last seen is less than 15 minutes. - Neither IP, hostname nor name is configured. """ - for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN): - if config_entry.state is not ConfigEntryState.LOADED or ( - (hub := config_entry.runtime_data) and not hub.available - ): + for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN): + if not (hub := config_entry.runtime_data).available: continue clients_to_remove = [] From 9a1f2b52cdea85666a10c42305fa375fd11e9132 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 06:07:04 -0600 Subject: [PATCH 0805/1941] Bump habluetooth to 3.24.0 (#139021) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.22.1...v3.24.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 9cdaaaa2e16..8eeb4d67109 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.22.1" + "habluetooth==3.24.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ba61ba109c0..63fbcd685c8 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.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.22.1 +habluetooth==3.24.0 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 6b754d8bf59..84a8d527ab7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.22.1 +habluetooth==3.24.0 # homeassistant.components.cloud hass-nabucasa==0.92.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7b8120c991..6c7a7a8c82f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -944,7 +944,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.22.1 +habluetooth==3.24.0 # homeassistant.components.cloud hass-nabucasa==0.92.0 From f5263203f5045595c1198f8cfcfad209dd396c51 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 22 Feb 2025 13:35:23 +0100 Subject: [PATCH 0806/1941] Fix station parser problem in Trafikverket Train (#139035) --- .../components/trafikverket_train/config_flow.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 57d74eef78a..f6a58e464a1 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -101,6 +101,9 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): _from_stations: list[StationInfoModel] _to_stations: list[StationInfoModel] + _time: str | None + _days: list + _product: str | None _data: dict[str, Any] @staticmethod @@ -243,8 +246,10 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the select station step.""" if user_input is not None: api_key: str = self._data[CONF_API_KEY] - train_from: str = user_input[CONF_FROM] - train_to: str = user_input[CONF_TO] + train_from: str = ( + user_input.get(CONF_FROM) or self._from_stations[0].signature + ) + train_to: str = user_input.get(CONF_TO) or self._to_stations[0].signature train_time: str | None = self._data.get(CONF_TIME) train_days: list = self._data[CONF_WEEKDAY] filter_product: str | None = self._data[CONF_FILTER_PRODUCT] From 4a0b1b74e3c24ef10de597e6cbd1811f323bbd5a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 22 Feb 2025 13:36:09 +0100 Subject: [PATCH 0807/1941] Implement base entity for smhi (#139042) --- homeassistant/components/smhi/entity.py | 36 ++++++++++++++++++++++++ homeassistant/components/smhi/weather.py | 22 ++++----------- tests/components/smhi/test_weather.py | 8 +++--- 3 files changed, 45 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/smhi/entity.py diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py new file mode 100644 index 00000000000..8d650d31945 --- /dev/null +++ b/homeassistant/components/smhi/entity.py @@ -0,0 +1,36 @@ +"""Support for the Swedish weather institute weather base entities.""" + +from __future__ import annotations + +import aiohttp +from pysmhi import SMHIPointForecast + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class SmhiWeatherBaseEntity(Entity): + """Representation of a base weather entity.""" + + _attr_attribution = "Swedish weather institute (SMHI)" + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + latitude: str, + longitude: str, + session: aiohttp.ClientSession, + ) -> None: + """Initialize the SMHI base weather entity.""" + 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}")}, + manufacturer="SMHI", + model="v2", + configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", + ) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index a263eeb6174..b9cac9bdf2e 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -9,7 +9,7 @@ import logging from typing import Any, Final import aiohttp -from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast +from pysmhi import SMHIForecast, SmhiForecastException from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -55,12 +55,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, sun -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 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, DOMAIN, ENTITY_ID_SENSOR_FORMAT +from .const import ATTR_SMHI_THUNDER_PROBABILITY, ENTITY_ID_SENSOR_FORMAT +from .entity import SmhiWeatherBaseEntity _LOGGER = logging.getLogger(__name__) @@ -114,18 +114,14 @@ async def async_setup_entry( async_add_entities([entity], True) -class SmhiWeather(WeatherEntity): +class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity): """Representation of a weather entity.""" - _attr_attribution = "Swedish weather institute (SMHI)" _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND _attr_native_pressure_unit = UnitOfPressure.HPA - - _attr_has_entity_name = True - _attr_name = None _attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) @@ -137,18 +133,10 @@ class SmhiWeather(WeatherEntity): session: aiohttp.ClientSession, ) -> None: """Initialize the SMHI weather entity.""" - self._attr_unique_id = f"{latitude}, {longitude}" + super().__init__(latitude, longitude, session) self._forecast_daily: list[SMHIForecast] | None = None self._forecast_hourly: list[SMHIForecast] | None = None self._fail_count = 0 - self._smhi_api = SMHIPointForecast(longitude, latitude, session=session) - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"{latitude}, {longitude}")}, - manufacturer="SMHI", - model="v2", - configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", - ) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index a39cb72d4b8..f47566f2d5c 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -110,7 +110,7 @@ async def test_properties_no_data(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", side_effect=SmhiForecastException("boom"), ): await hass.config_entries.async_setup(entry.entry_id) @@ -215,11 +215,11 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", return_value=testdata, ), patch( - "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_hourly_forecast", + "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_hourly_forecast", return_value=None, ), ): @@ -254,7 +254,7 @@ async def test_refresh_weather_forecast_retry( now = dt_util.utcnow() with patch( - "homeassistant.components.smhi.weather.SMHIPointForecast.async_get_daily_forecast", + "homeassistant.components.smhi.entity.SMHIPointForecast.async_get_daily_forecast", side_effect=error, ) as mock_get_forecast: await hass.config_entries.async_setup(entry.entry_id) From 7e5617fd5448fb7c11b857430c6fae06cf5ac0df Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 22 Feb 2025 13:36:24 +0100 Subject: [PATCH 0808/1941] Bump holidays to 0.67 (#139036) --- 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 6952d48ef32..cd5ac1ec1a9 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.66", "babel==2.15.0"] + "requirements": ["holidays==0.67", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index cbb11a06aec..beb828641a4 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.66"] + "requirements": ["holidays==0.67"] } diff --git a/requirements_all.txt b/requirements_all.txt index 84a8d527ab7..31d93bd08b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,7 +1143,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.66 +holidays==0.67 # homeassistant.components.frontend home-assistant-frontend==20250221.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c7a7a8c82f..75a1bcd502c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -972,7 +972,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.66 +holidays==0.67 # homeassistant.components.frontend home-assistant-frontend==20250221.0 From 539adaf128d179a6c17a2b55490787427f22ec2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 12:34:06 -0600 Subject: [PATCH 0809/1941] Bump async-interrupt to 1.2.2 (#139056) --- 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 63fbcd685c8..b9833719f1f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 astral==2.2 -async-interrupt==1.2.1 +async-interrupt==1.2.2 async-upnp-client==0.43.0 atomicwrites-homeassistant==1.4.1 attrs==25.1.0 diff --git a/pyproject.toml b/pyproject.toml index 4ea1e1e0481..88e7aa33a2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", "astral==2.2", - "async-interrupt==1.2.1", + "async-interrupt==1.2.2", "attrs==25.1.0", "atomicwrites-homeassistant==1.4.1", "audioop-lts==0.2.1", diff --git a/requirements.txt b/requirements.txt index b2d519e7992..5308905467b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ aiohttp-fast-zlib==0.2.2 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 astral==2.2 -async-interrupt==1.2.1 +async-interrupt==1.2.2 attrs==25.1.0 atomicwrites-homeassistant==1.4.1 audioop-lts==0.2.1 From c80663844849c514316e2f5c18d4460d489afd91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 12:34:40 -0600 Subject: [PATCH 0810/1941] Bump aiodhcpwatcher to 1.1.1 (#139058) --- 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 45aa5a29171..7b79c0a96ed 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -14,7 +14,7 @@ ], "quality_scale": "internal", "requirements": [ - "aiodhcpwatcher==1.1.0", + "aiodhcpwatcher==1.1.1", "aiodiscover==2.6.0", "cached-ipaddress==0.8.0" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b9833719f1f..05b2e73376a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodhcpwatcher==1.1.0 +aiodhcpwatcher==1.1.1 aiodiscover==2.6.0 aiodns==3.2.0 aiohasupervisor==0.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 31d93bd08b7..af9943f469b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,7 +216,7 @@ aiobotocore==2.13.1 aiocomelit==0.10.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.1.0 +aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp aiodiscover==2.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75a1bcd502c..bf4fc72ae99 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -204,7 +204,7 @@ aiobotocore==2.13.1 aiocomelit==0.10.1 # homeassistant.components.dhcp -aiodhcpwatcher==1.1.0 +aiodhcpwatcher==1.1.1 # homeassistant.components.dhcp aiodiscover==2.6.0 From f5bdd4594d210789feecdf3f7ee815109333653d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 12:35:27 -0600 Subject: [PATCH 0811/1941] Bump aiohttp-fast-zlib to 0.2.3 (#139062) --- 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 05b2e73376a..ee301fa0ef9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.6.0 aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 -aiohttp-fast-zlib==0.2.2 +aiohttp-fast-zlib==0.2.3 aiohttp==3.11.12 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 diff --git a/pyproject.toml b/pyproject.toml index 88e7aa33a2d..64775238d3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "aiohasupervisor==0.3.0", "aiohttp==3.11.12", "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.2", + "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", "astral==2.2", diff --git a/requirements.txt b/requirements.txt index 5308905467b..311164f6c69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==3.2.0 aiohasupervisor==0.3.0 aiohttp==3.11.12 aiohttp_cors==0.7.0 -aiohttp-fast-zlib==0.2.2 +aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 astral==2.2 From 883e14b409c1d3c34d148e88b0f06432dad59c58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 12:35:35 -0600 Subject: [PATCH 0812/1941] Bump fnv-hash-fast to 1.2.3 (#139059) --- 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 d7ea293b5dc..63254384666 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.2", + "fnv-hash-fast==1.2.3", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 0b8532bedea..6f555704670 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.2", + "fnv-hash-fast==1.2.3", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ee301fa0ef9..0075d626ef5 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.2 +fnv-hash-fast==1.2.3 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.24.0 diff --git a/pyproject.toml b/pyproject.toml index 64775238d3e..0a4228496e3 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.2", + "fnv-hash-fast==1.2.3", # 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 311164f6c69..2bacda6b017 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.2 +fnv-hash-fast==1.2.3 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 af9943f469b..98196dc7614 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -940,7 +940,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.2 +fnv-hash-fast==1.2.3 # homeassistant.components.foobot foobot_async==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf4fc72ae99..b05c6bdf21d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -799,7 +799,7 @@ flux-led==1.1.3 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==1.2.2 +fnv-hash-fast==1.2.3 # homeassistant.components.foobot foobot_async==1.0.0 From ee206a5a17c179438dfcfc96141832caa18ee5dd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 22 Feb 2025 20:12:28 +0100 Subject: [PATCH 0813/1941] Improve descriptions in `nuki.lock_n_go` action (#139067) --- homeassistant/components/nuki/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index beac3cb7f74..daf47bc7de1 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -58,12 +58,12 @@ }, "services": { "lock_n_go": { - "name": "Lock 'n' go", - "description": "Nuki Lock 'n' Go.", + "name": "Lock 'n' Go", + "description": "Unlocks the door, waits a few seconds then re-locks. The wait period can be customized through the app.", "fields": { "unlatch": { "name": "Unlatch", - "description": "Whether to unlatch the lock." + "description": "Whether to also unlatch the door when unlocking it." } } }, From f7e8bc458f8d32ce36eeba9fa62e09a703629564 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:19:53 +0100 Subject: [PATCH 0814/1941] Bump Stookwijzer to 1.5.7 (#139063) --- 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..e8f6081b9be 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.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 98196dc7614..607d7676769 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2802,7 +2802,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.5.7 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b05c6bdf21d..684f17c7aa4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2263,7 +2263,7 @@ statsd==3.2.1 steamodd==4.21 # homeassistant.components.stookwijzer -stookwijzer==1.5.4 +stookwijzer==1.5.7 # homeassistant.components.streamlabswater streamlabswater==1.0.1 From 4b342b7dd46b33cda74030005405730d4a1b8978 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Feb 2025 13:20:06 -0600 Subject: [PATCH 0815/1941] Bump cached-ipaddress to 0.8.1 (#139061) changelog: https://github.com/Bluetooth-Devices/cached-ipaddress/compare/v0.8.0...v0.8.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 7b79c0a96ed..382a9b94ff7 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.0", - "cached-ipaddress==0.8.0" + "cached-ipaddress==0.8.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0075d626ef5..7847599223c 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.0 +cached-ipaddress==0.8.1 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 diff --git a/requirements_all.txt b/requirements_all.txt index 607d7676769..90065832988 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -680,7 +680,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.0 +cached-ipaddress==0.8.1 # homeassistant.components.caldav caldav==1.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 684f17c7aa4..b1017a3c420 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -591,7 +591,7 @@ bthome-ble==3.12.4 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.8.0 +cached-ipaddress==0.8.1 # homeassistant.components.caldav caldav==1.3.9 From f369ded93d35994337d1ed7359e97a361cb79d02 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:20:51 +0100 Subject: [PATCH 0816/1941] Use ConfigEntry.runtime_data to store Minecraft Server runtime data (#139039) --- .../components/minecraft_server/__init__.py | 55 ++++++------------- .../minecraft_server/binary_sensor.py | 10 ++-- .../minecraft_server/coordinator.py | 30 ++++++++-- .../minecraft_server/diagnostics.py | 7 +-- .../minecraft_server/quality_scale.yaml | 2 +- .../components/minecraft_server/sensor.py | 11 ++-- 6 files changed, 56 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 55bf96a7b89..d8f60380a6c 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -9,15 +9,13 @@ import dns.rdata import dns.rdataclass import dns.rdatatype -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, CONF_TYPE, Platform +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD -from .coordinator import MinecraftServerCoordinator +from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -31,32 +29,18 @@ def load_dnspython_rdata_classes() -> None: dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: MinecraftServerConfigEntry +) -> bool: """Set up Minecraft Server from a config entry.""" # Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083) await hass.async_add_executor_job(load_dnspython_rdata_classes) - # Create API instance. - api = MinecraftServer( - hass, - entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), - entry.data[CONF_ADDRESS], - ) - - # Initialize API instance. - try: - await api.async_initialize() - except MinecraftServerAddressError as error: - raise ConfigEntryNotReady(f"Initialization failed: {error}") from error - - # Create coordinator instance. - coordinator = MinecraftServerCoordinator(hass, entry, api) + # Create coordinator instance and store it. + coordinator = MinecraftServerCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - - # Store coordinator instance. - domain_data = hass.data.setdefault(DOMAIN, {}) - domain_data[entry.entry_id] = coordinator + entry.runtime_data = coordinator # Set up platforms. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -64,21 +48,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: MinecraftServerConfigEntry +) -> bool: """Unload Minecraft Server config entry.""" - - # Unload platforms. - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - # Clean up. - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: MinecraftServerConfigEntry +) -> bool: """Migrate old config entry to a new format.""" # 1 --> 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 0817/1941] 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 0818/1941] 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 0819/1941] 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 0820/1941] 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 0821/1941] 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 0822/1941] 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 0823/1941] 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 0824/1941] 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 0825/1941] 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 0826/1941] 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 0827/1941] 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 0828/1941] 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 0829/1941] 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 0830/1941] 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 0831/1941] 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 0832/1941] 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 0833/1941] 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 0834/1941] 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 0835/1941] 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 0836/1941] 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 0837/1941] 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 0838/1941] 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 0839/1941] 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 0840/1941] 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 0841/1941] 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 0842/1941] 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 0843/1941] 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 0844/1941] 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 0845/1941] 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 0846/1941] 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 0847/1941] 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 0848/1941] 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 0849/1941] 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 0850/1941] 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 0851/1941] 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 0852/1941] 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 0853/1941] 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 0854/1941] 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 0855/1941] 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 0856/1941] 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 0857/1941] 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 0858/1941] 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 0859/1941] 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 0860/1941] 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 0861/1941] 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 0862/1941] 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 0863/1941] 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 0864/1941] 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 0865/1941] 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 0866/1941] 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 0867/1941] 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 0868/1941] 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 0869/1941] 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 0870/1941] 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 0871/1941] 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 0872/1941] 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 0873/1941] 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 0874/1941] 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 0875/1941] 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 0876/1941] 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 0877/1941] 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 0878/1941] 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 0879/1941] 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 0880/1941] 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 0881/1941] 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 0882/1941] 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 0883/1941] 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 0884/1941] 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 0885/1941] 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 0886/1941] 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 0887/1941] 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 0888/1941] 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 0889/1941] 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 0890/1941] 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 0891/1941] 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 0892/1941] 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 0893/1941] 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 0894/1941] 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 0895/1941] `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 0896/1941] 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 0897/1941] 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 0898/1941] 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 0899/1941] 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 0900/1941] 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 0901/1941] 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 0902/1941] 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 0903/1941] 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 0904/1941] 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 0905/1941] 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 0906/1941] 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 0907/1941] 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 0908/1941] 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 0909/1941] 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 0910/1941] 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 0911/1941] 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 0912/1941] 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 0913/1941] 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 0914/1941] 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 0915/1941] 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 0916/1941] 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 0917/1941] 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 0918/1941] 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 0919/1941] 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 0920/1941] 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 0921/1941] 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 0922/1941] 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 0923/1941] 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 0924/1941] 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 0925/1941] 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 0926/1941] 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 0927/1941] 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 0928/1941] 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 0929/1941] 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 0930/1941] 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 0931/1941] 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 0932/1941] 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 0933/1941] 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 0934/1941] 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 0935/1941] 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 0936/1941] 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 0937/1941] 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 0938/1941] 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 0939/1941] 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 0940/1941] 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 0941/1941] 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 0942/1941] 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 0943/1941] 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 0944/1941] 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 0945/1941] 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 0946/1941] 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 0947/1941] 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 0948/1941] 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 0949/1941] 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 0950/1941] 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 0951/1941] 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 0952/1941] 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 0953/1941] 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 0954/1941] 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 0955/1941] 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 0956/1941] 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 0957/1941] 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 0958/1941] 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 0959/1941] 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 0960/1941] 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 0961/1941] 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 0962/1941] 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 0963/1941] 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 0964/1941] 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 0965/1941] 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 0966/1941] 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 0967/1941] 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 0968/1941] 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 0969/1941] 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 0970/1941] 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 0971/1941] 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 0972/1941] 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 0973/1941] 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 0974/1941] 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 0975/1941] 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 0976/1941] 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 0977/1941] 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 0978/1941] 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 0979/1941] 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 0980/1941] 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 0981/1941] 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 0982/1941] 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 0983/1941] 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 0984/1941] 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 0985/1941] 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 0986/1941] 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 0987/1941] 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 0988/1941] 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 0989/1941] 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 0990/1941] 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 0991/1941] 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 0992/1941] 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 0993/1941] 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 0994/1941] 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 0995/1941] 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 0996/1941] 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 0997/1941] 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 0998/1941] 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 0999/1941] 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 1000/1941] 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 1001/1941] 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 1002/1941] 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 7f0db3181d13a2b80bfea7f3b3edc1604b180ed1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Feb 2025 19:54:29 +0100 Subject: [PATCH 1003/1941] Bump version to 2025.4.0 (#139381) --- .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 8745ab63470..6145e985ce3 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.3" + HA_SHORT_VERSION: "2025.4" 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 7775b618795..b9695c350a7 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 = 3 +MINOR_VERSION: Final = 4 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 a7e3917eb90..eda2a495726 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.4.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9dbce6d904e8db6c19d7b440f7cbdbe9ec1ab287 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 1004/1941] 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 6d7dad41d9ac70b1991f6e8360d93b5a817deb9a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2025 20:31:45 +0100 Subject: [PATCH 1005/1941] Bump hatasmota to 0.10.0 (#139382) --- homeassistant/components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 783483c6ffd..2e0d8af2338 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.9.2"] + "requirements": ["HATasmota==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcda559d7d3..0fc22d7564b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==4.9.2 # homeassistant.components.tasmota -HATasmota==0.9.2 +HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==1.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ed82bd81b0..cb8d26677e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ DoorBirdPy==3.0.8 HAP-python==4.9.2 # homeassistant.components.tasmota -HATasmota==0.9.2 +HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==1.8.1 From 42f55bf271ab872754a112d842e7edb54b05de78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 26 Feb 2025 21:02:00 +0100 Subject: [PATCH 1006/1941] Small improvements to Home Connect strings and icons (#139386) * Small improvements to Home Connect strings and icons * Fix test --- .../components/home_connect/icons.json | 17 +++++++++++++++++ .../components/home_connect/strings.json | 10 +++++----- tests/components/home_connect/test_entity.py | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 651c00328b6..f781db3ab24 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -49,6 +49,23 @@ "default": "mdi:map-marker-remove-variant" } }, + "button": { + "open_door": { + "default": "mdi:door-open" + }, + "partly_open_door": { + "default": "mdi:door-open" + }, + "pause_program": { + "default": "mdi:pause" + }, + "resume_program": { + "default": "mdi:play" + }, + "stop_program": { + "default": "mdi:stop" + } + }, "sensor": { "operation_state": { "default": "mdi:state-machine", diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 92b59919583..7b06128dbe6 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -354,7 +354,7 @@ "options": { "consumer_products_coffee_maker_enum_type_flow_rate_normal": "Normal", "consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense", - "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus" + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense +" } }, "coffee_milk_ratio": { @@ -410,7 +410,7 @@ "laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry", "laundry_care_dryer_enum_type_drying_target_gentle_dry": "Gentle dry", "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "Cupboard dry", - "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry plus", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry +", "laundry_care_dryer_enum_type_drying_target_extra_dry": "Extra dry" } }, @@ -592,7 +592,7 @@ "description": "Defines if the program sequence is optimized with a special drying cycle to ensure more shine on glasses and plastic items." }, "dishcare_dishwasher_option_vario_speed_plus": { - "name": "Vario speed plus", + "name": "Vario speed +", "description": "Defines if the program run time is reduced by up to 66% with the usual optimum cleaning and drying." }, "dishcare_dishwasher_option_silence_on_demand": { @@ -608,7 +608,7 @@ "description": "Defines if improved drying for glasses and plasticware is enabled." }, "dishcare_dishwasher_option_hygiene_plus": { - "name": "Hygiene plus", + "name": "Hygiene +", "description": "Defines if the cleaning is done with increased temperature. This ensures maximum hygienic cleanliness for regular use." }, "dishcare_dishwasher_option_eco_dry": { @@ -1462,7 +1462,7 @@ "inactive": "Inactive", "ready": "Ready", "delayedstart": "Delayed start", - "run": "Run", + "run": "Running", "pause": "[%key:common::state::paused%]", "actionrequired": "Action required", "finished": "Finished", diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index f173cda0b0c..2422cbe547c 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -85,7 +85,7 @@ def platforms() -> list[str]: [False, True, True], ( OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS, - "switch.dishwasher_hygiene_plus", + "switch.dishwasher_hygiene", ), (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), ) From f3fb7cd8e83de7b7910dddc647f13d73c14cb481 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 26 Feb 2025 14:14:03 -0600 Subject: [PATCH 1007/1941] 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 0fc22d7564b..acac164af8e 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 cb8d26677e2..e37faf9f609 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 036eef2b6bc3941d05a53a050089f2e558df9e51 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 1008/1941] 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 acac164af8e..70b8bf20e41 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 e37faf9f609..f86e597f50c 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 b505722f3807560cf41939ca2dd37a7fda29997e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 10:00:50 +0100 Subject: [PATCH 1009/1941] 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 70b8bf20e41..d1186a9d1a7 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 f86e597f50c..2ee967f69ac 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 2150a668b0bc0bc0a22e3d7c353f06b70a822424 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 10:17:57 +0100 Subject: [PATCH 1010/1941] Add reauthentication to azure_storage (#139411) * Add reauthentication to azure_storage * update docstring --- .../components/azure_storage/__init__.py | 8 ++- .../components/azure_storage/config_flow.py | 70 ++++++++++++++++--- .../azure_storage/quality_scale.yaml | 2 +- .../components/azure_storage/strings.json | 13 +++- .../azure_storage/test_config_flow.py | 61 ++++++++++++++++ 5 files changed, 140 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/azure_storage/__init__.py b/homeassistant/components/azure_storage/__init__.py index 873a9ab90ca..f22e7b70c12 100644 --- a/homeassistant/components/azure_storage/__init__.py +++ b/homeassistant/components/azure_storage/__init__.py @@ -13,7 +13,11 @@ 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.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( @@ -52,7 +56,7 @@ async def async_setup_entry( translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, ) from err except ClientAuthenticationError as err: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="invalid_auth", translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]}, diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py index e5b1214fa5b..c98576af5d1 100644 --- a/homeassistant/components/azure_storage/config_flow.py +++ b/homeassistant/components/azure_storage/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Azure Storage integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -26,6 +27,26 @@ _LOGGER = logging.getLogger(__name__) class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for azure storage.""" + def get_account_url(self, account_name: str) -> str: + """Get the account URL.""" + return f"https://{account_name}.blob.core.windows.net/" + + async def validate_config( + self, container_client: ContainerClient + ) -> dict[str, str]: + """Validate the configuration.""" + errors: dict[str, str] = {} + 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" + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -38,20 +59,13 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): {CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]} ) container_client = ContainerClient( - account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/", + account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]), 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" + errors = await self.validate_config(container_client) + if not errors: return self.async_create_entry( title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}", @@ -70,3 +84,39 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + errors: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + container_client = ContainerClient( + account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]), + container_name=reauth_entry.data[CONF_CONTAINER_NAME], + credential=user_input[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + ) + errors = await self.validate_config(container_client) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data={**reauth_entry.data, **user_input}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_STORAGE_ACCOUNT_KEY): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml index 6b6f90de494..5b147dfe0e4 100644 --- a/homeassistant/components/azure_storage/quality_scale.yaml +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -57,7 +57,7 @@ rules: status: exempt comment: | This integration does not have platforms. - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 4bd4cb0dfba..5d39b54b8db 100644 --- a/homeassistant/components/azure_storage/strings.json +++ b/homeassistant/components/azure_storage/strings.json @@ -19,10 +19,21 @@ }, "description": "Set up an Azure (Blob) storage account to be used for backups.", "title": "Add Azure storage account" + }, + "reauth_confirm": { + "data": { + "storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]" + }, + "data_description": { + "storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]" + }, + "description": "Provide a new storage account key.", + "title": "Reauthenticate Azure storage account" } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "issues": { diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py index ed8bbed0718..d5c0726e94a 100644 --- a/tests/components/azure_storage/test_config_flow.py +++ b/tests/components/azure_storage/test_config_flow.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration from .const import USER_INPUT from tests.common import MockConfigEntry @@ -111,3 +112,63 @@ async def test_abort_if_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works.""" + + await setup_integration(hass, mock_config_entry) + + 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"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_STORAGE_ACCOUNT_KEY: "new_key", + } + + +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reauth flow works with an errors.""" + + await setup_integration(hass, mock_config_entry) + + mock_client.exists.side_effect = Exception() + + 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"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + # fix the error and finish the flow successfully + mock_client.exists.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_STORAGE_ACCOUNT_KEY: "new_key"} + ) + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_STORAGE_ACCOUNT_KEY: "new_key", + } From 63daed0ed6c8765bfc2391b9b26dd17b02340c5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:43:13 +0100 Subject: [PATCH 1011/1941] Bump codecov/codecov-action from 5.3.1 to 5.4.0 (#139408) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6145e985ce3..97986f26ee3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1276,7 +1276,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.3.1 + uses: codecov/codecov-action@v5.4.0 with: fail_ci_if_error: true flags: full-suite @@ -1415,7 +1415,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.3.1 + uses: codecov/codecov-action@v5.4.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From b1a70c86c3fda7dd042f4df32b689fd8db4d5945 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:44:13 +0100 Subject: [PATCH 1012/1941] Bump docker/build-push-action from 6.14.0 to 6.15.0 (#139407) 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 0ad4c510a55..df5d3eee6ae 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -509,7 +509,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile @@ -522,7 +522,7 @@ jobs: - name: Push Docker image if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' id: push - uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0 + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile From 8c98cede60fd1ab6e2ceb83f8d9f4ebfd2b639a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:44:50 +0100 Subject: [PATCH 1013/1941] Bump actions/attest-build-provenance from 2.2.0 to 2.2.1 (#139406) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index df5d3eee6ae..ed5005584bd 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0 + uses: actions/attest-build-provenance@f9eaf234fc1c2e333c1eca18177db0f44fa6ba52 # v2.2.1 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From df59adf5d1bf37f752e54709f78016abc20f0a57 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 11:06:03 +0100 Subject: [PATCH 1014/1941] Add reconfiguration to azure_storage (#139414) * Add reauthentication to azure_storage * Add reconfigure to azure_storage * iqs * update string * ruff --- .../components/azure_storage/config_flow.py | 38 +++++++++++++++++++ .../azure_storage/quality_scale.yaml | 2 +- .../components/azure_storage/strings.json | 15 +++++++- .../azure_storage/test_config_flow.py | 24 ++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py index c98576af5d1..2862d290f95 100644 --- a/homeassistant/components/azure_storage/config_flow.py +++ b/homeassistant/components/azure_storage/config_flow.py @@ -120,3 +120,41 @@ class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + container_client = ContainerClient( + account_url=self.get_account_url( + reconfigure_entry.data[CONF_ACCOUNT_NAME] + ), + container_name=user_input[CONF_CONTAINER_NAME], + credential=user_input[CONF_STORAGE_ACCOUNT_KEY], + transport=AioHttpTransport(session=async_get_clientsession(self.hass)), + ) + errors = await self.validate_config(container_client) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data={**reconfigure_entry.data, **user_input}, + ) + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required( + CONF_CONTAINER_NAME, + default=reconfigure_entry.data[CONF_CONTAINER_NAME], + ): str, + vol.Required( + CONF_STORAGE_ACCOUNT_KEY, + default=reconfigure_entry.data[CONF_STORAGE_ACCOUNT_KEY], + ): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml index 5b147dfe0e4..6199ba514a3 100644 --- a/homeassistant/components/azure_storage/quality_scale.yaml +++ b/homeassistant/components/azure_storage/quality_scale.yaml @@ -121,7 +121,7 @@ rules: status: exempt comment: | This integration does not have entities. - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: done stale-devices: status: exempt diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 5d39b54b8db..e9053f113cc 100644 --- a/homeassistant/components/azure_storage/strings.json +++ b/homeassistant/components/azure_storage/strings.json @@ -29,11 +29,24 @@ }, "description": "Provide a new storage account key.", "title": "Reauthenticate Azure storage account" + }, + "reconfigure": { + "data": { + "container_name": "[%key:component::azure_storage::config::step::user::data::container_name%]", + "storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]" + }, + "data_description": { + "container_name": "[%key:component::azure_storage::config::step::user::data_description::container_name%]", + "storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]" + }, + "description": "Change the settings of the Azure storage integration.", + "title": "Reconfigure Azure storage account" } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "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%]" } }, "issues": { diff --git a/tests/components/azure_storage/test_config_flow.py b/tests/components/azure_storage/test_config_flow.py index d5c0726e94a..67dc44f9f2c 100644 --- a/tests/components/azure_storage/test_config_flow.py +++ b/tests/components/azure_storage/test_config_flow.py @@ -172,3 +172,27 @@ async def test_reauth_flow_errors( **USER_INPUT, CONF_STORAGE_ACCOUNT_KEY: "new_key", } + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the reconfigure flow works.""" + + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_CONTAINER_NAME: "new_container"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + **USER_INPUT, + CONF_CONTAINER_NAME: "new_container", + } From cc18ec2de8674eb4be213b6ae7dbf4d0681a3622 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 1015/1941] 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 2422cbe547c..bad02888dbf 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 7b14b6af0ed5b6eb920373ec923836e8328f7154 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 27 Feb 2025 20:03:44 +0900 Subject: [PATCH 1016/1941] Add water heater entity to LG ThinQ (#138257) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/__init__.py | 1 + .../components/lg_thinq/water_heater.py | 201 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 homeassistant/components/lg_thinq/water_heater.py diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index 72d81af4ff0..f83cbadf925 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -47,6 +47,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, Platform.VACUUM, + Platform.WATER_HEATER, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lg_thinq/water_heater.py b/homeassistant/components/lg_thinq/water_heater.py new file mode 100644 index 00000000000..5a5c8d024b6 --- /dev/null +++ b/homeassistant/components/lg_thinq/water_heater.py @@ -0,0 +1,201 @@ +"""Support for waterheater entities.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import DeviceType +from thinqconnect.integration import ExtendedProperty + +from homeassistant.components.water_heater import ( + ATTR_OPERATION_MODE, + STATE_ECO, + STATE_HEAT_PUMP, + STATE_OFF, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityDescription, + WaterHeaterEntityFeature, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ThinqConfigEntry +from .coordinator import DeviceDataUpdateCoordinator +from .entity import ThinQEntity + +DEVICE_TYPE_WH_MAP: dict[DeviceType, WaterHeaterEntityDescription] = { + DeviceType.WATER_HEATER: WaterHeaterEntityDescription( + key=ExtendedProperty.WATER_HEATER, + name=None, + ), + DeviceType.SYSTEM_BOILER: WaterHeaterEntityDescription( + key=ExtendedProperty.WATER_BOILER, + name=None, + ), +} + +# Mapping between device and HA operation modes +DEVICE_OP_MODE_TO_HA = { + "auto": STATE_ECO, + "heat_pump": STATE_HEAT_PUMP, + "turbo": STATE_PERFORMANCE, + "vacation": STATE_OFF, +} +HA_STATE_TO_DEVICE_OP_MODE = {v: k for k, v in DEVICE_OP_MODE_TO_HA.items()} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThinqConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up an entry for water_heater platform.""" + entities: list[ThinQWaterHeaterEntity] = [] + for coordinator in entry.runtime_data.coordinators.values(): + if ( + description := DEVICE_TYPE_WH_MAP.get(coordinator.api.device.device_type) + ) is not None: + if coordinator.api.device.device_type == DeviceType.WATER_HEATER: + entities.append( + ThinQWaterHeaterEntity( + coordinator, description, ExtendedProperty.WATER_HEATER + ) + ) + elif coordinator.api.device.device_type == DeviceType.SYSTEM_BOILER: + entities.append( + ThinQWaterBoilerEntity( + coordinator, description, ExtendedProperty.WATER_BOILER + ) + ) + if entities: + async_add_entities(entities) + + +class ThinQWaterHeaterEntity(ThinQEntity, WaterHeaterEntity): + """Represent a ThinQ water heater entity.""" + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: WaterHeaterEntityDescription, + property_id: str, + ) -> None: + """Initialize a water_heater entity.""" + super().__init__(coordinator, entity_description, property_id) + self._attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + if modes := self.data.job_modes: + self._attr_operation_list = [ + DEVICE_OP_MODE_TO_HA.get(mode, mode) for mode in modes + ] + else: + self._attr_operation_list = [STATE_HEAT_PUMP] + + def _update_status(self) -> None: + """Update status itself.""" + super()._update_status() + self._attr_current_temperature = self.data.current_temp + self._attr_target_temperature = self.data.target_temp + + 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 + if self.data.step is not None: + self._attr_target_temperature_step = self.data.step + + self._attr_temperature_unit = ( + self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS + ) + if self.data.is_on: + self._attr_current_operation = ( + DEVICE_OP_MODE_TO_HA.get(job_mode, job_mode) + if (job_mode := self.data.job_mode) is not None + else STATE_HEAT_PUMP + ) + else: + self._attr_current_operation = STATE_OFF + + _LOGGER.debug( + "[%s:%s] update status: c:%s, t:%s, op_mode:%s, op_list:%s, is_on:%s", + self.coordinator.device_name, + self.property_id, + self.current_temperature, + self.target_temperature, + self.current_operation, + self.operation_list, + self.data.is_on, + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + _LOGGER.debug( + "[%s:%s] async_set_temperature: %s", + self.coordinator.device_name, + self.property_id, + kwargs, + ) + if (operation_mode := kwargs.get(ATTR_OPERATION_MODE)) is not None: + await self.async_set_operation_mode(str(operation_mode)) + if operation_mode == STATE_OFF: + return + + if ( + temperature := kwargs.get(ATTR_TEMPERATURE) + ) is not None and temperature != self.target_temperature: + await self.async_call_api( + self.coordinator.api.async_set_target_temperature( + self.property_id, temperature + ) + ) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new operation mode.""" + mode = HA_STATE_TO_DEVICE_OP_MODE.get(operation_mode, operation_mode) + _LOGGER.debug( + "[%s:%s] async_set_operation_mode: %s", + self.coordinator.device_name, + self.property_id, + mode, + ) + await self.async_call_api( + self.coordinator.api.async_set_job_mode(self.property_id, mode) + ) + + +class ThinQWaterBoilerEntity(ThinQWaterHeaterEntity): + """Represent a ThinQ water boiler entity.""" + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: WaterHeaterEntityDescription, + property_id: str, + ) -> None: + """Initialize a water_heater entity.""" + super().__init__(coordinator, entity_description, property_id) + self._attr_supported_features |= WaterHeaterEntityFeature.ON_OFF + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + _LOGGER.debug( + "[%s:%s] async_turn_on", self.coordinator.device_name, self.property_id + ) + await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + _LOGGER.debug( + "[%s:%s] async_turn_off", self.coordinator.device_name, self.property_id + ) + await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id)) From 5b1783e85980a4b4e11ee4285a75a0f3242424b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 11:41:27 +0000 Subject: [PATCH 1017/1941] 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 d1186a9d1a7..0fddd6a3f65 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 2ee967f69ac..ca7aa099d97 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 735b843f5e55fd83cf37d5961bcdf4a9511e1466 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 12:22:43 +0000 Subject: [PATCH 1018/1941] 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 0fddd6a3f65..ce90fe2120e 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 ca7aa099d97..e198c65fb27 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 7ae13a4d7245742d383d4e99f73b95c84feaef4b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 13:25:55 +0100 Subject: [PATCH 1019/1941] 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 ce90fe2120e..a4bc8becc83 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 e198c65fb27..5612b5547b2 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 59eb323f8d9314d128b0df9984856888695a6988 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 27 Feb 2025 14:29:57 +0100 Subject: [PATCH 1020/1941] 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 a4bc8becc83..78848c01ed3 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 5612b5547b2..aa7f720b8f1 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 f111a2c34a8d4e0be6501334bc11f9d873fef5e5 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 27 Feb 2025 14:30:29 +0100 Subject: [PATCH 1021/1941] 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 0da6b28808c5fce034fb257ad68042ea601f7c71 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 28 Feb 2025 03:02:14 +1300 Subject: [PATCH 1022/1941] Add lawn mower entity id format (#139402) * add missing entity id format * use ENTITY_ID_FORMAT in mqtt lawn mower --- homeassistant/components/lawn_mower/__init__.py | 1 + homeassistant/components/mqtt/lawn_mower.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index 0680bfc9d71..f8c3e0cd67d 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -28,6 +28,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) DATA_COMPONENT: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN) +ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 7727efcf04d..1917c56f209 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import lawn_mower from homeassistant.components.lawn_mower import ( + ENTITY_ID_FORMAT, LawnMowerActivity, LawnMowerEntity, LawnMowerEntityFeature, @@ -50,7 +51,6 @@ CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic" CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template" DEFAULT_NAME = "MQTT Lawn Mower" -ENTITY_ID_FORMAT = lawn_mower.DOMAIN + ".{}" MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset() From f677b910a6582704f5f8f481abb49bd04c9a353b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 15:23:25 +0100 Subject: [PATCH 1023/1941] 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 744a7a0e826e67f820d086218e900ed520ea3215 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Feb 2025 14:51:40 +0000 Subject: [PATCH 1024/1941] 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 df594748cffe38a9fad44326c056c1019dfe6938 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 16:00:24 +0100 Subject: [PATCH 1025/1941] Bump ruff to 0.9.8 (#139434) --- .pre-commit-config.yaml | 2 +- homeassistant/components/zone/__init__.py | 4 ++-- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b701b21b9e..37114684c9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.7 + rev: v0.9.8 hooks: - id: ruff args: diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 1c43a79e10e..813425c95f2 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -363,7 +363,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from storage.""" zone = cls(config) zone.editable = True - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @classmethod @@ -371,7 +371,7 @@ class Zone(collection.CollectionEntity): """Return entity instance initialized from yaml.""" zone = cls(config) zone.editable = False - zone._generate_attrs() # noqa: SLF001 + zone._generate_attrs() return zone @property diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8c9308e739b..c133c4b544a 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.9.7 +ruff==0.9.8 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 1f177643bd5..c09d547ba79 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -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 \ + stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.8 \ 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" From b02eaed6b0f4cbfc4ae341ee2946cb773628b70d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Feb 2025 16:42:08 +0100 Subject: [PATCH 1026/1941] 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 78848c01ed3..920bb3ac81c 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 aa7f720b8f1..2ddd495c900 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 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 1027/1941] 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 1028/1941] 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 1029/1941] 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 1030/1941] 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 1031/1941] 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 1032/1941] 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 1033/1941] 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 1034/1941] 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 1035/1941] 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 1036/1941] 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 1037/1941] 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 1038/1941] 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 1039/1941] 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 1040/1941] 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 a339fbaa8291bde5c9aeb6345684b28b50678516 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 16:56:30 +0000 Subject: [PATCH 1041/1941] Bump aioesphomeapi to 29.3.0 (#139441) --- 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 d07754d68a0..fea2aa03c7a 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.2.0", + "aioesphomeapi==29.3.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.8.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 920bb3ac81c..e7b05e7c455 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.2.0 +aioesphomeapi==29.3.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ddd495c900..239b8ac90ed 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.2.0 +aioesphomeapi==29.3.0 # homeassistant.components.flo aioflo==2021.11.0 From 9502dbee56760f42e7129d7166673d40de1e2201 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 27 Feb 2025 18:39:01 +0100 Subject: [PATCH 1042/1941] Add more diagnostic info to Reolink (#139436) * Add diagnostic info * Bump reolink-aio to 0.12.1 * Add tests --- .../components/reolink/diagnostics.py | 10 +++++++ tests/components/reolink/conftest.py | 6 ++++ .../reolink/snapshots/test_diagnostics.ambr | 29 +++++++++++++++++++ tests/components/reolink/test_diagnostics.py | 2 ++ 4 files changed, 47 insertions(+) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 693f2ba59a4..1d0e5d919e7 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -25,6 +25,14 @@ async def async_get_config_entry_diagnostics( IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch) IPC_cam[ch]["encoding main"] = await api.get_encoding(ch) + chimes: dict[int, dict[str, Any]] = {} + for chime in api.chime_list: + chimes[chime.dev_id] = {} + chimes[chime.dev_id]["channel"] = chime.channel + chimes[chime.dev_id]["name"] = chime.name + chimes[chime.dev_id]["online"] = chime.online + chimes[chime.dev_id]["event_types"] = chime.chime_event_types + return { "model": api.model, "hardware version": api.hardware_version, @@ -41,9 +49,11 @@ async def async_get_config_entry_diagnostics( "channels": api.channels, "stream channels": api.stream_channels, "IPC cams": IPC_cam, + "Chimes": chimes, "capabilities": api.capabilities, "cmd list": host.update_cmd, "firmware ch list": host.firmware_ch_list, "api versions": api.checked_api_versions, "abilities": api.abilities, + "BC_abilities": api.baichuan.abilities, } diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2862aa55b4d..5af55b48dda 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -123,6 +123,8 @@ def reolink_connect_class() -> Generator[MagicMock]: "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" ) + reolink_connect.chime_list = [] + # enums host_mock.whiteled_mode.return_value = 1 host_mock.whiteled_mode_list.return_value = ["off", "auto"] @@ -137,6 +139,10 @@ 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") + host_mock.baichuan.abilities = { + 0: {"chnID": 0, "aitype": 34615}, + "Host": {"pushAlarm": 7}, + } yield host_mock_class diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 71c5397fbd1..f8d5318e9bd 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -1,6 +1,27 @@ # serializer version: 1 # name: test_entry_diagnostics dict({ + 'BC_abilities': dict({ + '0': dict({ + 'aitype': 34615, + 'chnID': 0, + }), + 'Host': dict({ + 'pushAlarm': 7, + }), + }), + 'Chimes': dict({ + '12345678': dict({ + 'channel': 0, + 'event_types': list([ + 'md', + 'people', + 'visitor', + ]), + 'name': 'Test chime', + 'online': True, + }), + }), 'HTTP(S) port': 1234, 'HTTPS': True, 'IPC cams': dict({ @@ -41,6 +62,10 @@ 0, ]), 'cmd list': dict({ + 'DingDongOpt': dict({ + '0': 2, + 'null': 2, + }), 'GetAiAlarm': dict({ '0': 5, 'null': 5, @@ -81,6 +106,10 @@ '0': 2, 'null': 4, }), + 'GetDingDongCfg': dict({ + '0': 3, + 'null': 3, + }), 'GetEmail': dict({ '0': 1, 'null': 2, diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index 57b474c13ad..d45163d3cf0 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from reolink_aio.api import Chime from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -15,6 +16,7 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, reolink_connect: MagicMock, + test_chime: Chime, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: From ffac52255423fd572246b6906d72aaa872f64f1a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 18:39:18 +0100 Subject: [PATCH 1043/1941] 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 df006aeaded7e6ba9eba95966a23c237c8d1ce51 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 1044/1941] 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 e7b05e7c455..8cd0e8ea131 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 239b8ac90ed..f8824b27cb2 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 8cc7e7b76fe1611573edb10dcc3fdd63c8fd5ba9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 27 Feb 2025 20:07:12 +0100 Subject: [PATCH 1045/1941] Full test coverage for Vodafone Station init (#139451) Full test coverage for Vodafone Station init --- .../components/vodafone_station/test_init.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/components/vodafone_station/test_init.py diff --git a/tests/components/vodafone_station/test_init.py b/tests/components/vodafone_station/test_init.py new file mode 100644 index 00000000000..12b3c3dce8f --- /dev/null +++ b/tests/components/vodafone_station/test_init.py @@ -0,0 +1,33 @@ +"""Tests for Vodafone Station init.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.device_tracker import CONF_CONSIDER_HOME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_reload_config_entry_with_options( + hass: HomeAssistant, + mock_vodafone_station_router: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the the config entry is reloaded with options.""" + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CONSIDER_HOME: 37, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_CONSIDER_HOME: 37, + } From 4c00c56afde0da4cdbae0be6b60d843b50890e5c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 20:30:18 +0100 Subject: [PATCH 1046/1941] 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 8cd0e8ea131..b4235c7de0a 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 f8824b27cb2..624052bc2e9 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 938855bea3eed1ebc0a099f12be42b4233bd10e8 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 27 Feb 2025 20:42:04 +0100 Subject: [PATCH 1047/1941] 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 ef7058f70311642e6a117fd4b29fb69293fac858 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 27 Feb 2025 21:47:20 +0100 Subject: [PATCH 1048/1941] Improve descriptions of `lyric.set_hold_time` action and field (#139385) * Fix misleading descriptions on lyric.set_hold_time action While on Honeywell Lyric thermostats the user can set a "Hold Until" time of day, the set_hold_time action does define a time period instead (Example: 01:00:00) Therefore both descriptions are incorrectly using "until" for explaining the purpose of the action itself and the `time_period` field. This commit re-words both and adds some additional context that helps users (and translators) better understand this action and its purpose. In addition the action name is changed to proper sentence-casing. * Replace "time" with "duration" for additional clarity --- homeassistant/components/lyric/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 83c65359643..bc48a791e70 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -53,12 +53,12 @@ }, "services": { "set_hold_time": { - "name": "Set Hold Time", - "description": "Sets the time to hold until.", + "name": "Set hold time", + "description": "Sets the time period to keep the temperature and override the schedule.", "fields": { "time_period": { - "name": "Time Period", - "description": "Time to hold until." + "name": "Time period", + "description": "Duration for which to override the schedule." } } } From e11ead410bbd9179ded05ba5e07b6de9919ec0ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 20:50:23 +0000 Subject: [PATCH 1049/1941] Add coverage to ensure we do not load base platforms before recorder (#139464) --- tests/test_bootstrap.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 0d7c8614c6f..e89d038f8ce 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1528,3 +1528,46 @@ def test_should_rollover_is_always_false() -> None: ).shouldRollover(Mock()) is False ) + + +async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> None: + """Verify stage 0 not load base platforms before recorder. + + If a stage 0 integration has a base platform in its dependencies and + it loads before the recorder, it may load integrations that expect + the recorder to be loaded. We need to ensure that no stage 0 integration + has a base platform in its dependencies that loads before the recorder. + """ + integrations_before_recorder: set[str] = set() + for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: + integrations_before_recorder |= integrations + if "recorder" in integrations: + break + + integrations_or_execs = await loader.async_get_integrations( + hass, integrations_before_recorder + ) + integrations: list[Integration] = [] + resolve_deps_tasks: list[asyncio.Task[bool]] = [] + for integration in integrations_or_execs.values(): + assert not isinstance(integrations_or_execs, Exception) + integrations.append(integration) + resolve_deps_tasks.append(integration.resolve_dependencies()) + + await asyncio.gather(*resolve_deps_tasks) + base_platform_py_files = {f"{base_platform}.py" for base_platform in BASE_PLATFORMS} + for integration in integrations: + domain_with_base_platforms_deps = BASE_PLATFORMS.intersection( + integration.all_dependencies + ) + assert not domain_with_base_platforms_deps, ( + f"{integration.domain} has base platforms in dependencies: " + f"{domain_with_base_platforms_deps}" + ) + integration_top_level_files = base_platform_py_files.intersection( + integration._top_level_files + ) + assert not integration_top_level_files, ( + f"{integration.domain} has base platform files in top level files: " + f"{integration_top_level_files}" + ) From 0afdd9556f41a33d845ad19ad57a0e329fffe94a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2025 21:45:13 +0000 Subject: [PATCH 1050/1941] Bump aioesphomeapi to 29.3.1 (#139465) --- homeassistant/components/esphome/diagnostics.py | 4 +--- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 5 ++++- tests/components/esphome/test_diagnostics.py | 1 + 6 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 58c9a8fe666..c68bd560791 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -13,9 +13,7 @@ from . import CONF_NOISE_PSK from .dashboard import async_get_dashboard from .entry_data import ESPHomeConfigEntry -CONF_MAC_ADDRESS = "mac_address" - -REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, CONF_MAC_ADDRESS} +REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, "mac_address", "bluetooth_mac_address"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index fea2aa03c7a..b4360077604 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.3.0", + "aioesphomeapi==29.3.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.8.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index b4235c7de0a..a321d9467b6 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.3.0 +aioesphomeapi==29.3.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 624052bc2e9..38feed9656c 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.3.0 +aioesphomeapi==29.3.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index dc6195bfe1f..94f621b8646 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -581,7 +581,10 @@ async def mock_bluetooth_entry( return await _mock_generic_device_entry( hass, mock_client, - {"bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags}, + { + "bluetooth_mac_address": "AA:BB:CC:DD:EE:FC", + "bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags, + }, ([], []), [], ) diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 2b2629324d2..a4b858ed7de 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -89,6 +89,7 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "bluetooth_mac_address": "**REDACTED**", "bluetooth_proxy_feature_flags": 63, "compilation_time": "", "esphome_version": "1.0.0", From ef13b35c359f2a6362d14c4b0ce1a25f5f17923d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 27 Feb 2025 22:50:34 +0100 Subject: [PATCH 1051/1941] 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 6fa93edf2751b4f4f28c2267dc3479681c6f9228 Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 27 Feb 2025 23:27:18 +0100 Subject: [PATCH 1052/1941] Bump pyfibaro to 0.8.2 (#139471) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index d2a1186b05b..cd4d1de838c 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.8.0"] + "requirements": ["pyfibaro==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a321d9467b6..1c1b33fca80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1957,7 +1957,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38feed9656c..9bd33de07c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.8.0 +pyfibaro==0.8.2 # homeassistant.components.fido pyfido==2.1.2 From 4e8186491cf655c8e61c6cf0e955e89d89ce916a Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 27 Feb 2025 19:10:42 -0800 Subject: [PATCH 1053/1941] 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 6953c20a657543c36ceb6bf9778b5e68f92515f3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 09:15:13 +0100 Subject: [PATCH 1054/1941] 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 05df57295193d3f01b40cbfe7fbc80498571bf68 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 10:30:31 +0100 Subject: [PATCH 1055/1941] 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 1c1b33fca80..00509109413 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 9bd33de07c7..609639b0735 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 9d10e0e054b2e8ebcf88304c24fd5166aed0c5c0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 28 Feb 2025 11:18:16 +0100 Subject: [PATCH 1056/1941] 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 1be98366635c32360736900d607c90194ccbe37c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 11:44:16 +0100 Subject: [PATCH 1057/1941] 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 5cf56ec11370c702a25edb03bf3685bef2c6f812 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 11:44:58 +0100 Subject: [PATCH 1058/1941] Adjust recorder backup platform tests (#139492) --- tests/components/recorder/test_backup.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/components/recorder/test_backup.py b/tests/components/recorder/test_backup.py index bed9e88fcbf..a4362b1fa4c 100644 --- a/tests/components/recorder/test_backup.py +++ b/tests/components/recorder/test_backup.py @@ -17,7 +17,7 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) -> "homeassistant.components.recorder.core.Recorder.lock_database" ) as lock_mock: await async_pre_backup(hass) - assert lock_mock.called + assert lock_mock.called RAISES_HASS_NOT_RUNNING = pytest.raises( @@ -75,13 +75,17 @@ async def test_async_pre_backup_with_migration( ) -> None: """Test pre backup with migration.""" with ( + patch( + "homeassistant.components.recorder.core.Recorder.lock_database" + ) as lock_mock, patch( "homeassistant.components.recorder.backup.async_migration_in_progress", return_value=True, ), - pytest.raises(HomeAssistantError), + pytest.raises(HomeAssistantError, match="Database migration in progress"), ): await async_pre_backup(hass) + assert not lock_mock.called async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) -> None: @@ -90,7 +94,7 @@ async def test_async_post_backup(recorder_mock: Recorder, hass: HomeAssistant) - "homeassistant.components.recorder.core.Recorder.unlock_database" ) as unlock_mock: await async_post_backup(hass) - assert unlock_mock.called + assert unlock_mock.called async def test_async_post_backup_failure( @@ -102,7 +106,9 @@ async def test_async_post_backup_failure( "homeassistant.components.recorder.core.Recorder.unlock_database", return_value=False, ) as unlock_mock, - pytest.raises(HomeAssistantError), + pytest.raises( + HomeAssistantError, match="Could not release database write lock" + ), ): await async_post_backup(hass) assert unlock_mock.called From 12cb349160c5f47f6776e647e275f8b9e5444f31 Mon Sep 17 00:00:00 2001 From: pglab-electronics <89299919+pglab-electronics@users.noreply.github.com> Date: Fri, 28 Feb 2025 12:07:01 +0100 Subject: [PATCH 1059/1941] Add Sensor to PG LAB Integration (#138802) --- .../components/pglab/device_sensor.py | 56 +++++++++ homeassistant/components/pglab/discovery.py | 28 ++++- homeassistant/components/pglab/entity.py | 18 ++- homeassistant/components/pglab/sensor.py | 119 ++++++++++++++++++ homeassistant/components/pglab/strings.json | 11 ++ .../pglab/snapshots/test_sensor.ambr | 95 ++++++++++++++ tests/components/pglab/test_sensor.py | 71 +++++++++++ 7 files changed, 391 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/pglab/device_sensor.py create mode 100644 homeassistant/components/pglab/sensor.py create mode 100644 tests/components/pglab/snapshots/test_sensor.ambr create mode 100644 tests/components/pglab/test_sensor.py diff --git a/homeassistant/components/pglab/device_sensor.py b/homeassistant/components/pglab/device_sensor.py new file mode 100644 index 00000000000..d202d11d6e7 --- /dev/null +++ b/homeassistant/components/pglab/device_sensor.py @@ -0,0 +1,56 @@ +"""Device Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pypglab.device import Device as PyPGLabDevice +from pypglab.sensor import Sensor as PyPGLabSensors + +from homeassistant.core import callback + +if TYPE_CHECKING: + from .entity import PGLabEntity + + +class PGLabDeviceSensor: + """Keeps PGLab device sensor update.""" + + def __init__(self, pglab_device: PyPGLabDevice) -> None: + """Initialize the device sensor.""" + + # get a reference of PG Lab device internal sensors state + self._sensors: PyPGLabSensors = pglab_device.sensors + + self._ha_sensors: list[PGLabEntity] = [] # list of HA entity sensors + + async def subscribe_topics(self): + """Subscribe to the device sensors topics.""" + self._sensors.set_on_state_callback(self.state_updated) + await self._sensors.subscribe_topics() + + def add_ha_sensor(self, entity: PGLabEntity) -> None: + """Add a new HA sensor to the list.""" + self._ha_sensors.append(entity) + + def remove_ha_sensor(self, entity: PGLabEntity) -> None: + """Remove a HA sensor from the list.""" + self._ha_sensors.remove(entity) + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # notify all HA sensors that PG LAB device sensor fields have been updated + for s in self._ha_sensors: + s.state_updated(payload) + + @property + def state(self) -> dict: + """Return the device sensors state.""" + return self._sensors.state + + @property + def sensors(self) -> PyPGLabSensors: + """Return the pypglab device sensors.""" + return self._sensors diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index af6bedc9bf4..fec6f5ce40d 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -28,17 +28,20 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import Entity from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER +from .device_sensor import PGLabDeviceSensor if TYPE_CHECKING: from . import PGLABConfigEntry # Supported platforms. PLATFORMS = [ + Platform.SENSOR, Platform.SWITCH, ] # Used to create a new component entity. CREATE_NEW_ENTITY = { + Platform.SENSOR: "pglab_create_new_entity_sensor", Platform.SWITCH: "pglab_create_new_entity_switch", } @@ -74,6 +77,7 @@ class DiscoverDeviceInfo: # When the hash string changes the devices entities must be rebuilt. self._hash = pglab_device.hash self._entities: list[tuple[str, str]] = [] + self._sensors = PGLabDeviceSensor(pglab_device) def add_entity(self, entity: Entity) -> None: """Add an entity.""" @@ -93,6 +97,20 @@ class DiscoverDeviceInfo: """Return array of entities available.""" return self._entities + @property + def sensors(self) -> PGLabDeviceSensor: + """Return the PGLab device sensor.""" + return self._sensors + + +async def createDiscoverDeviceInfo(pglab_device: PyPGLabDevice) -> DiscoverDeviceInfo: + """Create a new DiscoverDeviceInfo instance.""" + discovery_info = DiscoverDeviceInfo(pglab_device) + + # Subscribe to sensor state changes. + await discovery_info.sensors.subscribe_topics() + return discovery_info + @dataclass class PGLabDiscovery: @@ -223,7 +241,7 @@ class PGLabDiscovery: self.__clean_discovered_device(hass, pglab_device.id) # Add a new device. - discovery_info = DiscoverDeviceInfo(pglab_device) + discovery_info = await createDiscoverDeviceInfo(pglab_device) self._discovered[pglab_device.id] = discovery_info # Create all new relay entities. @@ -233,6 +251,14 @@ class PGLabDiscovery: hass, CREATE_NEW_ENTITY[Platform.SWITCH], pglab_device, r ) + # Create all new sensor entities. + async_dispatcher_send( + hass, + CREATE_NEW_ENTITY[Platform.SENSOR], + pglab_device, + discovery_info.sensors, + ) + topics = { "discovery_topic": { "topic": f"{self._discovery_topic}/#", diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 1b8975a3bbe..175b4c1eb0f 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -43,12 +43,20 @@ class PGLabEntity(Entity): connections={(CONNECTION_NETWORK_MAC, device.mac)}, ) - async def async_added_to_hass(self) -> None: - """Update the device discovery info.""" - + async def subscribe_to_update(self): + """Subscribe to the entity updates.""" self._entity.set_on_state_callback(self.state_updated) await self._entity.subscribe_topics() + async def unsubscribe_to_update(self): + """Unsubscribe to the entity updates.""" + await self._entity.unsubscribe_topics() + self._entity.set_on_state_callback(None) + + async def async_added_to_hass(self) -> None: + """Update the device discovery info.""" + + await self.subscribe_to_update() await super().async_added_to_hass() # Inform PGLab discovery instance that a new entity is available. @@ -60,9 +68,7 @@ class PGLabEntity(Entity): """Unsubscribe when removed.""" await super().async_will_remove_from_hass() - - await self._entity.unsubscribe_topics() - self._entity.set_on_state_callback(None) + await self.unsubscribe_to_update() @callback def state_updated(self, payload: str) -> None: diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py new file mode 100644 index 00000000000..f868e7ae101 --- /dev/null +++ b/homeassistant/components/pglab/sensor.py @@ -0,0 +1,119 @@ +"""Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from datetime import timedelta + +from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE +from pypglab.device import Device as PyPGLabDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import Platform, UnitOfElectricPotential, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow + +from . import PGLABConfigEntry +from .device_sensor import PGLabDeviceSensor +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + +SENSOR_INFO: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_VOLTAGE, + translation_key="mpu_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_REBOOT_TIME, + translation_key="runtime", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PGLABConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor for device.""" + + @callback + def async_discover( + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + ) -> None: + """Discover and add a PG LAB Sensor.""" + pglab_discovery = config_entry.runtime_data + for description in SENSOR_INFO: + pglab_sensor = PGLabSensor( + pglab_discovery, pglab_device, pglab_device_sensor, description + ) + async_add_entities([pglab_sensor]) + + # Register the callback to create the sensor entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.SENSOR, async_discover) + + +class PGLabSensor(PGLabEntity, SensorEntity): + """A PGLab sensor.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + description: SensorEntityDescription, + ) -> None: + """Initialize the Sensor class.""" + + super().__init__( + discovery=pglab_discovery, + device=pglab_device, + entity=pglab_device_sensor.sensors, + ) + + self._type = description.key + self._pglab_device_sensor = pglab_device_sensor + self._attr_unique_id = f"{pglab_device.id}_{description.key}" + self.entity_description = description + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # get the sensor value from pglab multi fields sensor + value = self._pglab_device_sensor.state[self._type] + + if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + self._attr_native_value = utcnow() - timedelta(seconds=value) + else: + self._attr_native_value = value + + super().state_updated(payload) + + async def subscribe_to_update(self): + """Register the HA sensor to be notify when the sensor status is changed.""" + self._pglab_device_sensor.add_ha_sensor(self) + + async def unsubscribe_to_update(self): + """Unregister the HA sensor from sensor tatus updates.""" + self._pglab_device_sensor.remove_ha_sensor(self) diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json index 8f9021cdcca..4fad408ad98 100644 --- a/homeassistant/components/pglab/strings.json +++ b/homeassistant/components/pglab/strings.json @@ -19,6 +19,17 @@ "relay": { "name": "Relay {relay_id}" } + }, + "sensor": { + "temperature": { + "name": "Temperature" + }, + "runtime": { + "name": "Run time" + }, + "mpu_voltage": { + "name": "MPU voltage" + } } } } diff --git a/tests/components/pglab/snapshots/test_sensor.ambr b/tests/components/pglab/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f25f459bb70 --- /dev/null +++ b/tests/components/pglab/snapshots/test_sensor.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_sensors[mpu_voltage][initial_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[mpu_voltage][updated_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.31', + }) +# --- +# name: test_sensors[run_time][initial_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[run_time][updated_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-26T01:04:54+00:00', + }) +# --- +# name: test_sensors[temperature][initial_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[temperature][updated_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.4', + }) +# --- diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py new file mode 100644 index 00000000000..ff20d1452a4 --- /dev/null +++ b/tests/components/pglab/test_sensor.py @@ -0,0 +1,71 @@ +"""The tests for the PG LAB Electronics sensor.""" + +import json + +from freezegun import freeze_time +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def send_discovery_message(hass: HomeAssistant) -> None: + """Send mqtt discovery message.""" + + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "00000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + +@freeze_time("2024-02-26 01:21:34") +@pytest.mark.parametrize( + "sensor_suffix", + [ + "temperature", + "mpu_voltage", + "run_time", + ], +) +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mqtt_mock: MqttMockHAClient, + setup_pglab, + sensor_suffix: str, +) -> None: + """Check if sensors are properly created and updated.""" + + # send the discovery message to make E-BOARD device discoverable + await send_discovery_message(hass) + + # check initial sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"initial_sensor_{sensor_suffix}") + + # update sensors value via mqtt + update_payload = {"temp": 33.4, "volt": 3.31, "rtime": 1000} + async_fire_mqtt_message(hass, "pglab/test/sensor/value", json.dumps(update_payload)) + await hass.async_block_till_done() + + # check updated sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"updated_sensor_{sensor_suffix}") From a296c5e9ad301cc56fad055078073bc5bb3386b5 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 28 Feb 2025 06:44:01 -0500 Subject: [PATCH 1060/1941] Add floor_entities function and filter (#136509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/helpers/template.py | 12 ++++++ tests/helpers/test_template.py | 69 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 7866250d658..7dc3097cdb3 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1525,6 +1525,15 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: return [entry.id for entry in entries if entry.id] +def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: + """Return entity_ids for a given floor ID or name.""" + return [ + entity_id + for area_id in floor_areas(hass, floor_id_or_name) + for entity_id in area_entities(hass, area_id) + ] + + def areas(hass: HomeAssistant) -> Iterable[str | None]: """Return all areas.""" return list(area_registry.async_get(hass).areas) @@ -3048,6 +3057,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["floor_areas"] = hassfunction(floor_areas) self.filters["floor_areas"] = self.globals["floor_areas"] + self.globals["floor_entities"] = hassfunction(floor_entities) + self.filters["floor_entities"] = self.globals["floor_entities"] + self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = self.globals["integration_entities"] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b3a30806cbd..016aedb2f99 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -5881,6 +5881,75 @@ async def test_floor_areas( assert info.rate_limit is None +async def test_floor_entities( + hass: HomeAssistant, + floor_registry: fr.FloorRegistry, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test floor_entities function.""" + + # Test non existing floor ID + info = render_to_info(hass, "{{ floor_entities('skyring') }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 'skyring' | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ floor_entities(42) }}") + assert_result_info(info, []) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | floor_entities }}") + assert_result_info(info, []) + assert info.rate_limit is None + + floor = floor_registry.async_create("First floor") + area1 = area_registry.async_create("Living room") + area2 = area_registry.async_create("Dining room") + area_registry.async_update(area1.id, floor_id=floor.floor_id) + area_registry.async_update(area2.id, floor_id=floor.floor_id) + + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "living_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area1.id) + entity_entry = entity_registry.async_get_or_create( + "light", + "hue", + "dining_room", + config_entry=config_entry, + ) + entity_registry.async_update_entity(entity_entry.entity_id, area_id=area2.id) + + # Get entities by floor ID + expected = ["light.hue_living_room", "light.hue_dining_room"] + info = render_to_info(hass, f"{{{{ floor_entities('{floor.floor_id}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.floor_id}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + # Get entities by floor name + info = render_to_info(hass, f"{{{{ floor_entities('{floor.name}') }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{floor.name}' | floor_entities }}}}") + assert_result_info(info, expected) + assert info.rate_limit is None + + async def test_labels( hass: HomeAssistant, label_registry: lr.LabelRegistry, From 9a62b0f2457e6e0b95d52f3c83083a666e563322 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 13:05:30 +0100 Subject: [PATCH 1061/1941] Enable ASYNC ruff rules (#139507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- pyproject.toml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eda2a495726..5ee20b96bfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -705,12 +705,7 @@ required-version = ">=0.9.1" [tool.ruff.lint] select = [ "A001", # Variable {name} is shadowing a Python builtin - "ASYNC210", # Async functions should not call blocking HTTP methods - "ASYNC220", # Async functions should not create subprocesses with blocking methods - "ASYNC221", # Async functions should not run processes with blocking methods - "ASYNC222", # Async functions should not wait on processes with blocking methods - "ASYNC230", # Async functions should not open files with blocking methods like open - "ASYNC251", # Async functions should not call time.sleep + "ASYNC", # flake8-async "B002", # Python does not support the unary prefix increment "B005", # Using .strip() with multi-character strings is misleading "B007", # Loop control variable {name} not used within loop body @@ -810,6 +805,8 @@ select = [ ] ignore = [ + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line From 62dc0ac485b8b3b98794e767b45d754a5382000a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 13:38:56 +0100 Subject: [PATCH 1062/1941] Bump actions/cache from 4.2.1 to 4.2.2 (#139490) Bumps [actions/cache](https://github.com/actions/cache) from 4.2.1 to 4.2.2. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4.2.1...v4.2.2) --- updated-dependencies: - dependency-name: actions/cache 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/ci.yaml | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 97986f26ee3..829888f3fe2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -240,7 +240,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: venv key: >- @@ -256,7 +256,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -286,7 +286,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -295,7 +295,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -326,7 +326,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -335,7 +335,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -366,7 +366,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -375,7 +375,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -482,7 +482,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: venv key: >- @@ -490,7 +490,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -578,7 +578,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -611,7 +611,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -649,7 +649,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -692,7 +692,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -739,7 +739,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -791,7 +791,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -799,7 +799,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.1 + uses: actions/cache@v4.2.2 with: path: .mypy_cache key: >- @@ -865,7 +865,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -929,7 +929,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -1051,7 +1051,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -1181,7 +1181,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true @@ -1328,7 +1328,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.1 + uses: actions/cache/restore@v4.2.2 with: path: venv fail-on-cache-miss: true From 0310418efcab37affbc927a0dc509bdd7a6bf792 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 13:54:31 +0100 Subject: [PATCH 1063/1941] Bump dawidd6/action-download-artifact from 8 to 9 (#139488) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 8 to 9. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/v8...v9) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] 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 ed5005584bd..e730f03e1b4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -94,7 +94,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v8 + uses: dawidd6/action-download-artifact@v9 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,7 +105,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v8 + uses: dawidd6/action-download-artifact@v9 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package From d6f9040bafecbe994c57ddf65dcb9665fc6c27c7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 28 Feb 2025 14:14:56 +0100 Subject: [PATCH 1064/1941] 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 b79c6e772af85618ba0c19836c099b68ee48510a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 14:17:02 +0100 Subject: [PATCH 1065/1941] 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 d157919da2111f8a0fa5efe12a0220028cbe4acd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:19:18 +0100 Subject: [PATCH 1066/1941] Bump actions/attest-build-provenance from 2.2.1 to 2.2.2 (#139489) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.2.1 to 2.2.2. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/f9eaf234fc1c2e333c1eca18177db0f44fa6ba52...bd77c077858b8d561b7a36cbe48ef4cc642ca39d) --- updated-dependencies: - dependency-name: actions/attest-build-provenance 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index e730f03e1b4..f3bdd0084af 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@f9eaf234fc1c2e333c1eca18177db0f44fa6ba52 # v2.2.1 + uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From 030a1460de46fd60f014a36723c7773c2c6066fd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 28 Feb 2025 14:20:39 +0100 Subject: [PATCH 1067/1941] Log a warning when replacing existing config entry with same unique id (#130567) * Log a warning when replacing existing config entry with same unique id * Exclude mobile_app * Ignore custom integrations * Apply suggestions from code review * Apply suggestions from code review * Update config_entries.py * Fix handler * Adjust and add tests * Apply suggestions from code review * Apply suggestions from code review * Update comment * Update config_entries.py * Apply suggestions from code review --- homeassistant/config_entries.py | 17 ++++++++++ tests/test_config_entries.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2639c429e71..98d9e3c760c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1628,6 +1628,23 @@ class ConfigEntriesFlowManager( result["handler"], flow.unique_id ) + if existing_entry is not None and flow.handler != "mobile_app": + # This causes the old entry to be removed and replaced, when the flow + # should instead be aborted. + # In case of manual flows, integrations should implement options, reauth, + # reconfigure to allow the user to change settings. + # In case of non user visible flows, the integration should optionally + # update the existing entry before aborting. + # see https://developers.home-assistant.io/blog/2025/01/16/config-flow-unique-id/ + report_usage( + "creates a config entry when another entry with the same unique ID " + "exists", + core_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + integration_domain=flow.handler, + ) + # Unload the entry before setting up the new one. if existing_entry is not None and existing_entry.state.recoverable: await self.config_entries.async_unload(existing_entry.entry_id) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 7066417bfee..66aa29d95d1 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8899,3 +8899,63 @@ async def test_add_description_placeholder_automatically_not_overwrites( result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], None) assert result["type"] == FlowResultType.FORM assert result["description_placeholders"] == {"name": "Custom title"} + + +@pytest.mark.parametrize( + ("domain", "expected_log"), + [ + ("some_integration", True), + ("mobile_app", False), + ], +) +async def test_create_entry_existing_unique_id( + hass: HomeAssistant, + domain: str, + expected_log: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test to highlight unexpected behavior on create_entry.""" + entry = MockConfigEntry( + title="From config flow", + domain=domain, + entry_id="01J915Q6T9F6G5V0QJX6HBC94T", + data={"host": "any", "port": 123}, + unique_id="mock-unique-id", + ) + entry.add_to_hass(hass) + + assert len(hass.config_entries.async_entries(domain)) == 1 + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule(domain, async_setup_entry=mock_setup_entry)) + mock_platform(hass, f"{domain}.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + await self.async_set_unique_id("mock-unique-id") + return self.async_create_entry(title="mock-title", data={}) + + with ( + mock_config_flow(domain, TestFlow), + patch.object(frame, "_REPORTED_INTEGRATIONS", set()), + ): + result = await hass.config_entries.flow.async_init( + domain, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert len(hass.config_entries.async_entries(domain)) == 1 + + log_text = ( + f"Detected that integration '{domain}' creates a config entry " + "when another entry with the same unique ID exists. Please " + "create a bug report at https:" + ) + assert (log_text in caplog.text) == expected_log From 228a4eb39129ba39efaa328bf6f4a77560f78baf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2025 14:25:35 +0100 Subject: [PATCH 1068/1941] 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 ac15d9b3d400fe7b581f52d9e642763e4c70cb0b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 28 Feb 2025 23:26:39 +1000 Subject: [PATCH 1069/1941] 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 c2a773641778088fe97cb52ce2f072b8fd90eff4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 28 Feb 2025 14:30:47 +0100 Subject: [PATCH 1070/1941] 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 40d2d6df2cbeb0561f9f55104a6d361bb211053b Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 28 Feb 2025 06:32:52 -0700 Subject: [PATCH 1071/1941] 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 00509109413..9f70f98ecf0 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 609639b0735..5bf3fde31e1 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 3cd7f502165ec10db0fd2acb01d3fc0f555210e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 15:47:51 +0100 Subject: [PATCH 1072/1941] 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 9f70f98ecf0..18d94649d0b 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 5bf3fde31e1..98af884569b 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 1b27365c58c3e6607ace6ba5120239f4864752fe Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 28 Feb 2025 16:00:31 +0100 Subject: [PATCH 1073/1941] 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 18d94649d0b..c8e7bbc806a 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 98af884569b..e8e7c4a34f4 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 0f0866cd5281df5d62b3d14fc55241093e0f128a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 28 Feb 2025 16:03:47 +0100 Subject: [PATCH 1074/1941] Improve description of `mode` field in `geniushub.set_zone_mode` action (#139513) Improve description of `mode` field in 'geniushub.set_zone_mode' action As the three choices for the `mode` field show up as radio buttons in the UI the description does not need to repeat them. This improves translations by avoiding any over-translation of these values. --- homeassistant/components/geniushub/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json index 42d53c7fa00..79eee2c9a1b 100644 --- a/homeassistant/components/geniushub/strings.json +++ b/homeassistant/components/geniushub/strings.json @@ -45,7 +45,7 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "One of: off, timer or footprint." + "description": "The zone's operating mode." } } }, From 5fa5d08b18f136ed0a2b57a2a3d95826c771233d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 28 Feb 2025 16:16:23 +0100 Subject: [PATCH 1075/1941] Bump wheels to 2025.02.0 (#139525) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4b1628c57bb..c651ccbe715 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -159,7 +159,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2024.11.0 + uses: home-assistant/wheels@2025.02.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2024.11.0 + uses: home-assistant/wheels@2025.02.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 0681652aec080208e0a76f22a9bb2e766332d680 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 28 Feb 2025 16:18:57 +0100 Subject: [PATCH 1076/1941] 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 fca19a3ec139233788670e733684e8612156a91c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 28 Feb 2025 09:25:38 -0600 Subject: [PATCH 1077/1941] 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 271d225e5124745e6aaf94355d20ee34507ab1bc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 28 Feb 2025 17:05:36 +0100 Subject: [PATCH 1078/1941] 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 c8e7bbc806a..69024d3dfbf 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 e8e7c4a34f4..9b1edabb9b6 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 c5e5fe555d929655a3852fae5b52ed6ac024dced Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 28 Feb 2025 06:32:52 -0700 Subject: [PATCH 1079/1941] 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 1080/1941] 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 1081/1941] 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 1082/1941] 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 1083/1941] 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 1084/1941] 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 1085/1941] 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 1086/1941] 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 1087/1941] 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 1088/1941] 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 1089/1941] 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 1090/1941] 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 1091/1941] 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 1092/1941] 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 1093/1941] 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 1094/1941] 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 1095/1941] 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 1096/1941] 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 1097/1941] 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 1098/1941] 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 1099/1941] 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 1100/1941] 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 2e077cbf12586aef2e75433bb75793e44b82a07a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Fri, 28 Feb 2025 19:32:07 +0200 Subject: [PATCH 1101/1941] Bump pyoverkiz to 1.16.1 (#139532) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index c25accd87f3..14f69291be4 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.0"], + "requirements": ["pyoverkiz==1.16.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 69024d3dfbf..dbaa1bd3b1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.0 +pyoverkiz==1.16.1 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b1edabb9b6..693e9002389 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.0 +pyoverkiz==1.16.1 # homeassistant.components.onewire pyownet==0.10.0.post1 From e9bb4625d8d0fde99d91e9a8bbb7edc6cd6f5383 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 28 Feb 2025 10:33:58 -0700 Subject: [PATCH 1102/1941] Set device class for wind direction weatherflow entities (#139397) * Set wind_direction device class in weatherflow * Remove measurement state_class from wind direction entities --- homeassistant/components/weatherflow/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index 683413236c1..8eee472fe5c 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -267,16 +267,16 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( WeatherFlowSensorEntityDescription( key="wind_direction", translation_key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), WeatherFlowSensorEntityDescription( key="wind_direction_average", translation_key="wind_direction_average", + device_class=SensorDeviceClass.WIND_DIRECTION, native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), ) From 49c27ae7bc72ce14069eba1ce0e83f5b07669a7f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 28 Feb 2025 12:02:30 -0600 Subject: [PATCH 1103/1941] Check area temperature sensors in get temperature intent (#139221) * Check area temperature sensors in get temperature intent * Fix candidate check * Add new code back in * Remove cruft from climate --- homeassistant/components/intent/__init__.py | 73 ++++++++- homeassistant/helpers/intent.py | 22 ++- tests/components/intent/test_temperature.py | 173 ++++++++++++++++++-- 3 files changed, 247 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 2f9587e2173..922fa376903 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -2,13 +2,14 @@ from __future__ import annotations +from collections.abc import Collection import logging from typing import Any, Protocol from aiohttp import web import voluptuous as vol -from homeassistant.components import http +from homeassistant.components import http, sensor from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.cover import ( ATTR_POSITION, @@ -40,7 +41,12 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State -from homeassistant.helpers import config_validation as cv, integration_platform, intent +from homeassistant.helpers import ( + area_registry as ar, + config_validation as cv, + integration_platform, + intent, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -454,6 +460,9 @@ class GetTemperatureIntent(intent.IntentHandler): slot_schema = { vol.Optional("area"): intent.non_empty_string, vol.Optional("name"): intent.non_empty_string, + vol.Optional("floor"): intent.non_empty_string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, } platforms = {CLIMATE_DOMAIN} @@ -470,13 +479,71 @@ class GetTemperatureIntent(intent.IntentHandler): if "area" in slots: area = slots["area"]["value"] + floor_name: str | None = None + if "floor" in slots: + floor_name = slots["floor"]["value"] + + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + + if (not name) and (area or match_preferences.area_id): + # Look for temperature sensors assigned to an area + area_registry = ar.async_get(hass) + area_temperature_ids: dict[str, str] = {} + + # Keep candidates that are registered as area temperature sensors + def area_candidate_filter( + candidate: intent.MatchTargetsCandidate, + possible_area_ids: Collection[str], + ) -> bool: + for area_id in possible_area_ids: + temperature_id = area_temperature_ids.get(area_id) + if (temperature_id is None) and ( + area_entry := area_registry.async_get_area(area_id) + ): + temperature_id = area_entry.temperature_entity_id or "" + area_temperature_ids[area_id] = temperature_id + + if candidate.state.entity_id == temperature_id: + return True + + return False + + match_constraints = intent.MatchTargetsConstraints( + area_name=area, + floor_name=floor_name, + domains=[sensor.DOMAIN], + device_classes=[sensor.SensorDeviceClass.TEMPERATURE], + assistant=intent_obj.assistant, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, + match_constraints, + match_preferences, + area_candidate_filter=area_candidate_filter, + ) + if match_result.is_match: + # Found temperature sensor + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.QUERY_ANSWER + response.async_set_states(matched_states=match_result.states) + return response + + # Look for climate devices match_constraints = intent.MatchTargetsConstraints( name=name, area_name=area, + floor_name=floor_name, domains=[CLIMATE_DOMAIN], assistant=intent_obj.assistant, + single_target=True, + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences ) - match_result = intent.async_match_targets(hass, match_constraints) if not match_result.is_match: raise intent.MatchFailedError( result=match_result, constraints=match_constraints diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index cecb84d0373..0bb96615d3f 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -507,12 +507,22 @@ def _add_areas( candidate.area = areas.async_get_area(candidate.device.area_id) +def _default_area_candidate_filter( + candidate: MatchTargetsCandidate, possible_area_ids: Collection[str] +) -> bool: + """Keep candidates in the possible areas.""" + return (candidate.area is not None) and (candidate.area.id in possible_area_ids) + + @callback def async_match_targets( # noqa: C901 hass: HomeAssistant, constraints: MatchTargetsConstraints, preferences: MatchTargetsPreferences | None = None, states: list[State] | None = None, + area_candidate_filter: Callable[ + [MatchTargetsCandidate, Collection[str]], bool + ] = _default_area_candidate_filter, ) -> MatchTargetsResult: """Match entities based on constraints in order to handle an intent.""" preferences = preferences or MatchTargetsPreferences() @@ -623,9 +633,7 @@ def async_match_targets( # noqa: C901 } candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -649,9 +657,7 @@ def async_match_targets( # noqa: C901 # May be constrained by floors above possible_area_ids.intersection_update(matching_area_ids) candidates = [ - c - for c in candidates - if (c.area is not None) and (c.area.id in possible_area_ids) + c for c in candidates if area_candidate_filter(c, possible_area_ids) ] if not candidates: return MatchTargetsResult( @@ -701,7 +707,7 @@ def async_match_targets( # noqa: C901 group_candidates = [ c for c in group_candidates - if (c.area is not None) and (c.area.id == preferences.area_id) + if area_candidate_filter(c, {preferences.area_id}) ] if len(group_candidates) < 2: # Disambiguated by area @@ -747,7 +753,7 @@ def async_match_targets( # noqa: C901 if preferences.area_id: # Filter by area filtered_candidates = [ - c for c in candidates if c.area and (c.area.id == preferences.area_id) + c for c in candidates if area_candidate_filter(c, {preferences.area_id}) ] if (len(filtered_candidates) > 1) and preferences.floor_id: diff --git a/tests/components/intent/test_temperature.py b/tests/components/intent/test_temperature.py index 0279fa44b28..622e55fe24a 100644 --- a/tests/components/intent/test_temperature.py +++ b/tests/components/intent/test_temperature.py @@ -14,10 +14,16 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.const import ATTR_DEVICE_CLASS, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry as ar, entity_registry as er, intent +from homeassistant.helpers import ( + area_registry as ar, + entity_registry as er, + floor_registry as fr, + intent, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component @@ -131,6 +137,7 @@ async def test_get_temperature( hass: HomeAssistant, area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, + floor_registry: fr.FloorRegistry, ) -> None: """Test HassClimateGetTemperature intent.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -157,29 +164,133 @@ async def test_get_temperature( # Add climate entities to different areas: # climate_1 => living room # climate_2 => bedroom - # nothing in office + # nothing in bathroom + # nothing in office yet + # nothing in attic yet 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") + attic_area = area_registry.async_create(name="Attic") + bathroom_area = area_registry.async_create(name="Bathroom") 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) + # Put areas on different floors: + # first floor => living room and office + # 2nd floor => bedroom + # 3rd floor => attic + floor_registry = fr.async_get(hass) + first_floor = floor_registry.async_create("First floor") + living_room_area = area_registry.async_update( + living_room_area.id, floor_id=first_floor.floor_id + ) + office_area = area_registry.async_update( + office_area.id, floor_id=first_floor.floor_id + ) + + second_floor = floor_registry.async_create("Second floor") + bedroom_area = area_registry.async_update( + bedroom_area.id, floor_id=second_floor.floor_id + ) + bathroom_area = area_registry.async_update( + bathroom_area.id, floor_id=second_floor.floor_id + ) + + third_floor = floor_registry.async_create("Third floor") + attic_area = area_registry.async_update( + attic_area.id, floor_id=third_floor.floor_id + ) + + # Add temperature sensors to each area that should *not* be selected + for area in (living_room_area, office_area, bedroom_area, attic_area): + wrong_temperature_entry = entity_registry.async_get_or_create( + "sensor", "test", f"wrong_temperature_{area.id}" + ) + hass.states.async_set( + wrong_temperature_entry.entity_id, + "10.0", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + entity_registry.async_update_entity( + wrong_temperature_entry.entity_id, area_id=area.id + ) + + # Create temperature sensor and assign them to the office/attic + office_temperature_id = "sensor.office_temperature" + attic_temperature_id = "sensor.attic_temperature" + hass.states.async_set( + office_temperature_id, + "15.5", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + office_area = area_registry.async_update( + office_area.id, temperature_entity_id=office_temperature_id + ) + + hass.states.async_set( + attic_temperature_id, + "18.1", + { + ATTR_TEMPERATURE: "Temperature", + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + attic_area = area_registry.async_update( + attic_area.id, temperature_entity_id=attic_temperature_id + ) + + # Multiple climate entities match (error) + with pytest.raises(intent.MatchFailedError) as error: + await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {}, + 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.MULTIPLE_TARGETS + ) + + # Select by area (office_temperature) response = await intent.async_handle( hass, "test", intent.INTENT_GET_TEMPERATURE, - {}, + {"area": {"value": office_area.name}}, 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 + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == office_temperature_id state = response.matched_states[0] - assert state.attributes["current_temperature"] == 10.0 + assert state.state == "15.5" + + # Select by preferred area (attic_temperature) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": attic_area.id}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == attic_temperature_id + state = response.matched_states[0] + assert state.state == "18.1" # Select by area (climate_2) response = await intent.async_handle( @@ -215,7 +326,7 @@ async def test_get_temperature( hass, "test", intent.INTENT_GET_TEMPERATURE, - {"area": {"value": office_area.name}}, + {"area": {"value": bathroom_area.name}}, assistant=conversation.DOMAIN, ) @@ -224,7 +335,7 @@ async def test_get_temperature( 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.area_name == bathroom_area.name assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) assert constraints.device_classes is None @@ -262,6 +373,48 @@ async def test_get_temperature( assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN}) assert constraints.device_classes is None + # Select by floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"floor": {"value": first_floor.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_1.entity_id + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + + # Select by preferred area (climate_2) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_area_id": {"value": bedroom_area.id}}, + 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 preferred floor (climate_1) + response = await intent.async_handle( + hass, + "test", + intent.INTENT_GET_TEMPERATURE, + {"preferred_floor_id": {"value": first_floor.floor_id}}, + 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 + state = response.matched_states[0] + assert state.attributes["current_temperature"] == 10.0 + async def test_get_temperature_no_entities( hass: HomeAssistant, From 70bb56e0fc07822b5f48ee80f5fa5b7f8cec56b8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 18:36:12 +0000 Subject: [PATCH 1104/1941] Text-to-Speech refactor (#139482) * Refactor TTS * More cleanup * Cleanup * Consolidate more * Inline another function * Inline another function * Improve cleanup --- homeassistant/components/tts/__init__.py | 586 ++++++++++-------- homeassistant/components/tts/media_source.py | 13 +- tests/components/elevenlabs/test_tts.py | 2 +- tests/components/google_translate/test_tts.py | 2 +- tests/components/marytts/test_tts.py | 2 +- tests/components/microsoft/test_tts.py | 2 +- tests/components/tts/test_init.py | 2 +- tests/components/tts/test_media_source.py | 6 +- tests/components/voicerss/test_tts.py | 6 +- tests/components/yandextts/test_tts.py | 4 +- 10 files changed, 357 insertions(+), 268 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 6c7e521f3ef..199d644738b 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime from functools import partial import hashlib @@ -16,6 +17,7 @@ import re import secrets import subprocess import tempfile +from time import monotonic from typing import Any, Final, TypedDict, final from aiohttp import web @@ -37,11 +39,20 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT, STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + HassJob, + HassJobType, + HomeAssistant, + ServiceCall, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent @@ -129,9 +140,10 @@ SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) class TTSCache(TypedDict): """Cached TTS file.""" - filename: str + extension: str voice: bytes pending: asyncio.Task | None + last_used: float @callback @@ -192,9 +204,11 @@ async def async_get_media_source_audio( media_source_id: str, ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" - return await hass.data[DATA_TTS_MANAGER].async_get_tts_audio( - **media_source_id_to_kwargs(media_source_id), + manager = hass.data[DATA_TTS_MANAGER] + cache_key = manager.async_cache_message_in_memory( + **media_source_id_to_kwargs(media_source_id) ) + return await manager.async_get_tts_audio(cache_key) @callback @@ -306,11 +320,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Legacy config options conf = config[DOMAIN][0] if config.get(DOMAIN) else {} - use_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) + use_file_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE) cache_dir: str = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) - time_memory: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) + memory_cache_maxage: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - tts = SpeechManager(hass, use_cache, cache_dir, time_memory) + tts = SpeechManager(hass, use_file_cache, cache_dir, memory_cache_maxage) try: await tts.async_init_cache() @@ -383,6 +397,40 @@ CACHED_PROPERTIES_WITH_ATTR_ = { } +@dataclass +class ResultStream: + """Class that will stream the result when available.""" + + # Streaming/conversion properties + url: str + extension: str + content_type: str + + # TTS properties + engine: str + use_file_cache: bool + language: str + options: dict + + _manager: SpeechManager + + @cached_property + def _result_cache_key(self) -> asyncio.Future[str]: + """Get the future that returns the cache key.""" + return asyncio.Future() + + @callback + def async_set_message_cache_key(self, cache_key: str) -> None: + """Set cache key for message to be streamed.""" + self._result_cache_key.set_result(cache_key) + + async def async_get_result(self) -> bytes: + """Get the stream of this result.""" + cache_key = await self._result_cache_key + _extension, data = await self._manager.async_get_tts_audio(cache_key) + return data + + class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Represent a single TTS engine.""" @@ -521,29 +569,82 @@ def _hash_options(options: dict) -> str: return opts_hash.hexdigest() +class MemcacheCleanup: + """Helper to clean up the stale sessions.""" + + unsub: CALLBACK_TYPE | None = None + + def __init__( + self, hass: HomeAssistant, maxage: float, memcache: dict[str, TTSCache] + ) -> None: + """Initialize the cleanup.""" + self.hass = hass + self.maxage = maxage + self.memcache = memcache + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop) + self.cleanup_job = HassJob( + self._cleanup, "chat_session_cleanup", job_type=HassJobType.Callback + ) + + @callback + def schedule(self) -> None: + """Schedule the cleanup.""" + if self.unsub: + return + self.unsub = async_call_later( + self.hass, + self.maxage + 1, + self.cleanup_job, + ) + + @callback + def _on_hass_stop(self, event: Event) -> None: + """Cancel the cleanup on shutdown.""" + if self.unsub: + self.unsub() + self.unsub = None + + @callback + def _cleanup(self, _now: datetime) -> None: + """Clean up and schedule follow-up if necessary.""" + self.unsub = None + memcache = self.memcache + maxage = self.maxage + now = monotonic() + + for cache_key, info in list(memcache.items()): + if info["last_used"] + maxage < now: + _LOGGER.debug("Cleaning up %s", cache_key) + del memcache[cache_key] + + # Still items left, check again in timeout time. + if memcache: + self.schedule() + + class SpeechManager: """Representation of a speech store.""" def __init__( self, hass: HomeAssistant, - use_cache: bool, + use_file_cache: bool, cache_dir: str, - time_memory: int, + memory_cache_maxage: int, ) -> None: """Initialize a speech store.""" self.hass = hass self.providers: dict[str, Provider] = {} - self.use_cache = use_cache + self.use_file_cache = use_file_cache self.cache_dir = cache_dir - self.time_memory = time_memory + self.memory_cache_maxage = memory_cache_maxage self.file_cache: dict[str, str] = {} self.mem_cache: dict[str, TTSCache] = {} - - # filename <-> token - self.filename_to_token: dict[str, str] = {} - self.token_to_filename: dict[str, str] = {} + self.token_to_stream: dict[str, ResultStream] = {} + self.memcache_cleanup = MemcacheCleanup( + hass, memory_cache_maxage, self.mem_cache + ) def _init_cache(self) -> dict[str, str]: """Init cache folder and fetch files.""" @@ -563,18 +664,21 @@ class SpeechManager: async def async_clear_cache(self) -> None: """Read file cache and delete files.""" - self.mem_cache = {} + self.mem_cache.clear() - def remove_files() -> None: + def remove_files(files: list[str]) -> None: """Remove files from filesystem.""" - for filename in self.file_cache.values(): + for filename in files: try: os.remove(os.path.join(self.cache_dir, filename)) except OSError as err: _LOGGER.warning("Can't remove cache file '%s': %s", filename, err) - await self.hass.async_add_executor_job(remove_files) - self.file_cache = {} + task = self.hass.async_add_executor_job( + remove_files, list(self.file_cache.values()) + ) + self.file_cache.clear() + await task @callback def async_register_legacy_engine( @@ -629,107 +733,153 @@ class SpeechManager: return language, merged_options - async def async_get_url_path( + @callback + def async_create_result_stream( self, engine: str, - message: str, - cache: bool | None = None, + message: str | None = None, + use_file_cache: bool | None = None, language: str | None = None, options: dict | None = None, - ) -> str: - """Get URL for play message. - - This method is a coroutine. - """ + ) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache + if use_file_cache is None: + use_file_cache = self.use_file_cache - # Is speech already in memory - if cache_key in self.mem_cache: - filename = self.mem_cache[cache_key]["filename"] - # Is file store in file cache - elif use_cache and cache_key in self.file_cache: - filename = self.file_cache[cache_key] - self.hass.async_create_task(self._async_file_to_mem(cache_key)) - # Load speech from engine into memory - else: - filename = await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + token = f"{secrets.token_urlsafe(16)}.{extension}" + content, _ = mimetypes.guess_type(token) + result_stream = ResultStream( + url=f"/api/tts_proxy/{token}", + extension=extension, + content_type=content or "audio/mpeg", + use_file_cache=use_file_cache, + engine=engine, + language=language, + options=options, + _manager=self, + ) + self.token_to_stream[token] = result_stream - # Use a randomly generated token instead of exposing the filename - token = self.filename_to_token.get(filename) - if not token: - # Keep extension (.mp3, etc.) - token = secrets.token_urlsafe(16) + os.path.splitext(filename)[1] + if message is None: + return result_stream - # Map token <-> filename - self.filename_to_token[filename] = token - self.token_to_filename[token] = filename + cache_key = self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) + result_stream.async_set_message_cache_key(cache_key) - return f"/api/tts_proxy/{token}" - - async def async_get_tts_audio( - self, - engine: str, - message: str, - cache: bool | None = None, - language: str | None = None, - options: dict | None = None, - ) -> tuple[str, bytes]: - """Fetch TTS audio.""" - if (engine_instance := get_engine_instance(self.hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - - language, options = self.process_options(engine_instance, language, options) - cache_key = self._generate_cache_key(message, language, options, engine) - use_cache = cache if cache is not None else self.use_cache - - # If we have the file, load it into memory if necessary - if cache_key not in self.mem_cache: - if use_cache and cache_key in self.file_cache: - await self._async_file_to_mem(cache_key) - else: - await self._async_get_tts_audio( - engine_instance, cache_key, message, use_cache, language, options - ) - - extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:] - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - return extension, cached["voice"] + return result_stream @callback - def _generate_cache_key( + def async_cache_message_in_memory( self, - message: str, - language: str, - options: dict | None, engine: str, + message: str, + use_file_cache: bool | None = None, + language: str | None = None, + options: dict | None = None, ) -> str: - """Generate a cache key for a message.""" + """Make sure a message is cached in memory and returns cache key.""" + if (engine_instance := get_engine_instance(self.hass, engine)) is None: + raise HomeAssistantError(f"Provider {engine} not found") + + language, options = self.process_options(engine_instance, language, options) + if use_file_cache is None: + use_file_cache = self.use_file_cache + + return self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) + + @callback + def _async_ensure_cached_in_memory( + self, + engine: str, + engine_instance: TextToSpeechEntity | Provider, + message: str, + use_file_cache: bool, + language: str, + options: dict, + ) -> str: + """Ensure a message is cached. + + Requires options, language to be processed. + """ options_key = _hash_options(options) if options else "-" msg_hash = hashlib.sha1(bytes(message, "utf-8")).hexdigest() - return KEY_PATTERN.format( + cache_key = KEY_PATTERN.format( msg_hash, language.replace("_", "-"), options_key, engine ).lower() - async def _async_get_tts_audio( + # Is speech already in memory + if cache_key in self.mem_cache: + return cache_key + + if use_file_cache and cache_key in self.file_cache: + coro = self._async_load_file_to_mem(cache_key) + else: + coro = self._async_generate_tts_audio( + engine_instance, cache_key, message, use_file_cache, language, options + ) + + task = self.hass.async_create_task(coro, eager_start=False) + + def handle_error(future: asyncio.Future) -> None: + """Handle error.""" + if not (err := future.exception()): + return + # Truncate message so we don't flood the logs. Cutting off at 32 chars + # but since we add 3 dots to truncated message, we cut off at 35. + trunc_msg = message if len(message) < 35 else f"{message[0:32]}…" + _LOGGER.error("Error generating audio for %s: %s", trunc_msg, err) + self.mem_cache.pop(cache_key, None) + + task.add_done_callback(handle_error) + + self.mem_cache[cache_key] = { + "extension": "", + "voice": b"", + "pending": task, + "last_used": monotonic(), + } + return cache_key + + async def async_get_tts_audio(self, cache_key: str) -> tuple[str, bytes]: + """Fetch TTS audio.""" + cached = self.mem_cache.get(cache_key) + if cached is None: + raise HomeAssistantError("Audio not cached") + if pending := cached.get("pending"): + await pending + cached = self.mem_cache[cache_key] + cached["last_used"] = monotonic() + return cached["extension"], cached["voice"] + + async def _async_generate_tts_audio( self, engine_instance: TextToSpeechEntity | Provider, cache_key: str, message: str, - cache: bool, + cache_to_disk: bool, language: str, options: dict[str, Any], - ) -> str: - """Receive TTS, store for view in cache and return filename. + ) -> None: + """Start loading of the TTS audio. This method is a coroutine. """ @@ -773,96 +923,66 @@ class SpeechManager: if sample_bytes is not None: sample_bytes = int(sample_bytes) - async def get_tts_data() -> str: - """Handle data available.""" - if engine_instance.name is None or engine_instance.name is UNDEFINED: - raise HomeAssistantError("TTS engine name is not set.") + if engine_instance.name is None or engine_instance.name is UNDEFINED: + raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider): - extension, data = await engine_instance.async_get_tts_audio( - message, language, options - ) - else: - extension, data = await engine_instance.internal_async_get_tts_audio( - message, language, options - ) - - if data is None or extension is None: - raise HomeAssistantError( - f"No TTS from {engine_instance.name} for '{message}'" - ) - - # Only convert if we have a preferred format different than the - # expected format from the TTS system, or if a specific sample - # rate/format/channel count is requested. - needs_conversion = ( - (final_extension != extension) - or (sample_rate is not None) - or (sample_channels is not None) - or (sample_bytes is not None) + if isinstance(engine_instance, Provider): + extension, data = await engine_instance.async_get_tts_audio( + message, language, options + ) + else: + extension, data = await engine_instance.internal_async_get_tts_audio( + message, language, options ) - if needs_conversion: - data = await async_convert_audio( - self.hass, - extension, - data, - to_extension=final_extension, - to_sample_rate=sample_rate, - to_sample_channels=sample_channels, - to_sample_bytes=sample_bytes, - ) + if data is None or extension is None: + raise HomeAssistantError( + f"No TTS from {engine_instance.name} for '{message}'" + ) - # Create file infos - filename = f"{cache_key}.{final_extension}".lower() + # Only convert if we have a preferred format different than the + # expected format from the TTS system, or if a specific sample + # rate/format/channel count is requested. + needs_conversion = ( + (final_extension != extension) + or (sample_rate is not None) + or (sample_channels is not None) + or (sample_bytes is not None) + ) - # Validate filename - if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( - filename - ): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine_instance.name} is invalid!" - ) - - # Save to memory - if final_extension == "mp3": - data = self.write_tags( - filename, data, engine_instance.name, message, language, options - ) - - self._async_store_to_memcache(cache_key, filename, data) - - if cache: - self.hass.async_create_task( - self._async_save_tts_audio(cache_key, filename, data) - ) - - return filename - - audio_task = self.hass.async_create_task(get_tts_data(), eager_start=False) - - def handle_error(_future: asyncio.Future) -> None: - """Handle error.""" - if audio_task.exception(): - self.mem_cache.pop(cache_key, None) - - audio_task.add_done_callback(handle_error) + if needs_conversion: + data = await async_convert_audio( + self.hass, + extension, + data, + to_extension=final_extension, + to_sample_rate=sample_rate, + to_sample_channels=sample_channels, + to_sample_bytes=sample_bytes, + ) + # Create file infos filename = f"{cache_key}.{final_extension}".lower() - self.mem_cache[cache_key] = { - "filename": filename, - "voice": b"", - "pending": audio_task, - } - return filename - async def _async_save_tts_audio( - self, cache_key: str, filename: str, data: bytes - ) -> None: - """Store voice data to file and file_cache. + # Validate filename + if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( + filename + ): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine_instance.name} is invalid!" + ) + + # Save to memory + if final_extension == "mp3": + data = self.write_tags( + filename, data, engine_instance.name, message, language, options + ) + + self._async_store_to_memcache(cache_key, final_extension, data) + + if not cache_to_disk: + return - This method is a coroutine. - """ voice_file = os.path.join(self.cache_dir, filename) def save_speech() -> None: @@ -870,13 +990,19 @@ class SpeechManager: with open(voice_file, "wb") as speech: speech.write(data) - try: - await self.hass.async_add_executor_job(save_speech) - self.file_cache[cache_key] = filename - except OSError as err: - _LOGGER.error("Can't write %s: %s", filename, err) + # Don't await, we're going to do this in the background + task = self.hass.async_add_executor_job(save_speech) - async def _async_file_to_mem(self, cache_key: str) -> None: + def write_done(future: asyncio.Future) -> None: + """Write is done task.""" + if err := future.exception(): + _LOGGER.error("Can't write %s: %s", filename, err) + else: + self.file_cache[cache_key] = filename + + task.add_done_callback(write_done) + + async def _async_load_file_to_mem(self, cache_key: str) -> None: """Load voice from file cache into memory. This method is a coroutine. @@ -897,64 +1023,22 @@ class SpeechManager: del self.file_cache[cache_key] raise HomeAssistantError(f"Can't read {voice_file}") from err - self._async_store_to_memcache(cache_key, filename, data) + extension = os.path.splitext(filename)[1][1:] + + self._async_store_to_memcache(cache_key, extension, data) @callback def _async_store_to_memcache( - self, cache_key: str, filename: str, data: bytes + self, cache_key: str, extension: str, data: bytes ) -> None: """Store data to memcache and set timer to remove it.""" self.mem_cache[cache_key] = { - "filename": filename, + "extension": extension, "voice": data, "pending": None, + "last_used": monotonic(), } - - @callback - def async_remove_from_mem(_: datetime) -> None: - """Cleanup memcache.""" - self.mem_cache.pop(cache_key, None) - - async_call_later( - self.hass, - self.time_memory, - HassJob( - async_remove_from_mem, - name="tts remove_from_mem", - cancel_on_shutdown=True, - ), - ) - - async def async_read_tts(self, token: str) -> tuple[str | None, bytes]: - """Read a voice file and return binary. - - This method is a coroutine. - """ - filename = self.token_to_filename.get(token) - if not filename: - raise HomeAssistantError(f"{token} was not recognized!") - - if not (record := _RE_VOICE_FILE.match(filename.lower())) and not ( - record := _RE_LEGACY_VOICE_FILE.match(filename.lower()) - ): - raise HomeAssistantError("Wrong tts file format!") - - cache_key = KEY_PATTERN.format( - record.group(1), record.group(2), record.group(3), record.group(4) - ) - - if cache_key not in self.mem_cache: - if cache_key not in self.file_cache: - raise HomeAssistantError(f"{cache_key} not in cache!") - await self._async_file_to_mem(cache_key) - - cached = self.mem_cache[cache_key] - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - - content, _ = mimetypes.guess_type(filename) - return content, cached["voice"] + self.memcache_cleanup.schedule() @staticmethod def write_tags( @@ -1042,9 +1126,9 @@ class TextToSpeechUrlView(HomeAssistantView): url = "/api/tts_get_url" name = "api:tts:geturl" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager async def post(self, request: web.Request) -> web.Response: """Generate speech and provide url.""" @@ -1061,45 +1145,53 @@ class TextToSpeechUrlView(HomeAssistantView): engine = data.get("engine_id") or data[ATTR_PLATFORM] message = data[ATTR_MESSAGE] - cache = data.get(ATTR_CACHE) + use_file_cache = data.get(ATTR_CACHE) language = data.get(ATTR_LANGUAGE) options = data.get(ATTR_OPTIONS) try: - path = await self.tts.async_get_url_path( - engine, message, cache=cache, language=language, options=options + stream = self.manager.async_create_result_stream( + engine, + message, + use_file_cache=use_file_cache, + language=language, + options=options, ) except HomeAssistantError as err: _LOGGER.error("Error on init tts: %s", err) return self.json({"error": err}, HTTPStatus.BAD_REQUEST) - base = get_url(self.tts.hass) - url = base + path + base = get_url(self.manager.hass) + url = base + stream.url - return self.json({"url": url, "path": path}) + return self.json({"url": url, "path": stream.url}) class TextToSpeechView(HomeAssistantView): """TTS view to serve a speech audio.""" requires_auth = False - url = "/api/tts_proxy/{filename}" + url = "/api/tts_proxy/{token}" name = "api:tts_speech" - def __init__(self, tts: SpeechManager) -> None: + def __init__(self, manager: SpeechManager) -> None: """Initialize a tts view.""" - self.tts = tts + self.manager = manager - async def get(self, request: web.Request, filename: str) -> web.Response: + async def get(self, request: web.Request, token: str) -> web.Response: """Start a get request.""" - try: - # filename is actually token, but we keep its name for compatibility - content, data = await self.tts.async_read_tts(filename) - except HomeAssistantError as err: - _LOGGER.error("Error on load tts: %s", err) + stream = self.manager.token_to_stream.get(token) + + if stream is None: return web.Response(status=HTTPStatus.NOT_FOUND) - return web.Response(body=data, content_type=content) + try: + data = await stream.async_get_result() + except HomeAssistantError as err: + _LOGGER.error("Error on get tts: %s", err) + return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return web.Response(body=data, content_type=stream.content_type) @websocket_api.websocket_command( diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 4f1fa59f001..aa2cd6e7555 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import mimetypes from typing import TypedDict from yarl import URL @@ -73,7 +72,7 @@ class MediaSourceOptions(TypedDict): message: str language: str | None options: dict | None - cache: bool | None + use_file_cache: bool | None @callback @@ -98,10 +97,10 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: "message": parsed.query["message"], "language": parsed.query.get("language"), "options": options, - "cache": None, + "use_file_cache": None, } if "cache" in parsed.query: - kwargs["cache"] = parsed.query["cache"] == "true" + kwargs["use_file_cache"] = parsed.query["cache"] == "true" return kwargs @@ -119,7 +118,7 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" try: - url = await self.hass.data[DATA_TTS_MANAGER].async_get_url_path( + stream = self.hass.data[DATA_TTS_MANAGER].async_create_result_stream( **media_source_id_to_kwargs(item.identifier) ) except Unresolvable: @@ -127,9 +126,7 @@ class TTSMediaSource(MediaSource): except HomeAssistantError as err: raise Unresolvable(str(err)) from err - mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg" - - return PlayMedia(url, mime_type) + return PlayMedia(stream.url, stream.content_type) async def async_browse_media( self, diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index c4234cb38ae..a63672cc85d 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -350,7 +350,7 @@ async def test_tts_service_speak_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) tts_entity._client.generate.assert_called_once_with( diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 5b691da4bdc..54ad47405a1 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -475,6 +475,6 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_gtts.mock_calls) == 2 diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 0ad27cde29b..25231c15a32 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -155,7 +155,7 @@ async def test_service_say_http_error( await retrieve_media( hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) mock_speak.assert_called_once() diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py index e10ec589113..38f1318a683 100644 --- a/tests/components/microsoft/test_tts.py +++ b/tests/components/microsoft/test_tts.py @@ -366,7 +366,7 @@ async def test_service_say_error( await retrieve_media( hass, hass_client, service_calls[1].data[ATTR_MEDIA_CONTENT_ID] ) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(mock_tts.mock_calls) == 2 diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 4d0767cddf3..86ca2de5791 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1197,7 +1197,7 @@ async def test_service_get_tts_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index d90923b02ab..9e50cc6b512 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -268,7 +268,7 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": 5}, - "cache": True, + "use_file_cache": True, } kwargs = { @@ -284,7 +284,7 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": [5, 6]}, - "cache": True, + "use_file_cache": True, } kwargs = { @@ -300,5 +300,5 @@ async def test_generate_media_source_id_and_media_source_id_to_kwargs( "message": "hello", "language": "en_US", "options": {"age": {"k1": [5, 6], "k2": "v2"}}, - "cache": True, + "use_file_cache": True, } diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 776c0ac153a..e6a30d7fac2 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -200,7 +200,7 @@ async def test_service_say_error( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -234,7 +234,7 @@ async def test_service_say_timeout( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA @@ -273,7 +273,7 @@ async def test_service_say_error_msg( assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == FORM_DATA diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index 77878c2be51..098fc025bf3 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -223,7 +223,7 @@ async def test_service_say_timeout( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) assert len(aioclient_mock.mock_calls) == 1 @@ -269,7 +269,7 @@ async def test_service_say_http_error( assert len(calls) == 1 assert ( await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.NOT_FOUND + == HTTPStatus.INTERNAL_SERVER_ERROR ) From bf27ccce17bbaf7bed0378165dec0ef89ad866c2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 28 Feb 2025 19:58:26 +0100 Subject: [PATCH 1105/1941] Clarify description of `icloud.update` action (#139535) Currently the description of the `icloud.update` action can be easily misunderstood as just updating the device list or forcing a software update on all devices. This commit changes the description to make clear that it asks for a state update of all devices. --- homeassistant/components/icloud/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index adc96043d66..fc78e8c2ba6 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -46,7 +46,7 @@ "services": { "update": { "name": "Update", - "description": "Updates iCloud devices.", + "description": "Asks for a state update of all devices linked to an iCloud account.", "fields": { "account": { "name": "Account", From 086c91485ff527cfead19b9c0792e9d3503a22c7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:03:24 +0100 Subject: [PATCH 1106/1941] 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 90fc6ffdbfec3038b0d022a4692c4a0f9cc0de8c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 19:15:31 +0000 Subject: [PATCH 1107/1941] Add support for continue conversation in Assist Pipeline (#139480) * Add support for continue conversation in Assist Pipeline * Also forward to ESPHome * Update snapshot * And mobile app --- .../components/assist_pipeline/__init__.py | 2 +- .../components/assist_pipeline/pipeline.py | 99 +++++++++-- .../assist_pipeline/websocket_api.py | 2 +- .../components/conversation/models.py | 2 + .../components/esphome/assist_satellite.py | 5 +- .../snapshots/test_conversation.ambr | 1 + tests/components/assist_pipeline/conftest.py | 15 +- .../assist_pipeline/snapshots/test_init.ambr | 21 ++- .../snapshots/test_websocket.ambr | 7 + tests/components/assist_pipeline/test_init.py | 168 ++++++++++++++++-- tests/components/conversation/__init__.py | 3 +- .../conversation/snapshots/test_chat_log.ambr | 2 + .../snapshots/test_default_agent.ambr | 19 ++ .../conversation/snapshots/test_http.ambr | 12 ++ .../conversation/snapshots/test_init.ambr | 13 ++ .../esphome/test_assist_satellite.py | 29 ++- tests/components/mobile_app/test_webhook.py | 1 + .../ollama/snapshots/test_conversation.ambr | 1 + tests/syrupy.py | 6 +- 19 files changed, 362 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 9a32821e3a0..59bd987d90e 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -117,7 +117,7 @@ async def async_pipeline_from_audio_stream( """ with chat_session.async_get_chat_session(hass, conversation_id) as session: pipeline_input = PipelineInput( - conversation_id=session.conversation_id, + session=session, device_id=device_id, stt_metadata=stt_metadata, stt_stream=stt_stream, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 75811a0ec36..038874d1966 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -96,6 +96,9 @@ ENGINE_LANGUAGE_PAIRS = ( ) KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN) +KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey( + "pipeline_conversation_data" +) def validate_language(data: dict[str, Any]) -> Any: @@ -590,6 +593,12 @@ class PipelineRun: _device_id: str | None = None """Optional device id set during run start.""" + _conversation_data: PipelineConversationData | None = None + """Data tied to the conversation ID.""" + + _intent_agent_only = False + """If request should only be handled by agent, ignoring sentence triggers and local processing.""" + def __post_init__(self) -> None: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language @@ -1007,19 +1016,36 @@ class PipelineRun: yield chunk.audio - async def prepare_recognize_intent(self) -> None: + async def prepare_recognize_intent(self, session: chat_session.ChatSession) -> None: """Prepare recognizing an intent.""" - agent_info = conversation.async_get_agent_info( - self.hass, - self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + self._conversation_data = async_get_pipeline_conversation_data( + self.hass, session ) - if agent_info is None: - engine = self.pipeline.conversation_engine or "default" - raise IntentRecognitionError( - code="intent-not-supported", - message=f"Intent recognition engine {engine} is not found", + if self._conversation_data.continue_conversation_agent is not None: + agent_info = conversation.async_get_agent_info( + self.hass, self._conversation_data.continue_conversation_agent ) + self._conversation_data.continue_conversation_agent = None + if agent_info is None: + raise IntentRecognitionError( + code="intent-agent-not-found", + message=f"Intent recognition engine {self._conversation_data.continue_conversation_agent} asked for follow-up but is no longer found", + ) + self._intent_agent_only = True + + else: + agent_info = conversation.async_get_agent_info( + self.hass, + self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT, + ) + + if agent_info is None: + engine = self.pipeline.conversation_engine or "default" + raise IntentRecognitionError( + code="intent-not-supported", + message=f"Intent recognition engine {engine} is not found", + ) self.intent_agent = agent_info.id @@ -1031,7 +1057,7 @@ class PipelineRun: conversation_extra_system_prompt: str | None, ) -> str: """Run intent recognition portion of pipeline. Returns text to speak.""" - if self.intent_agent is None: + if self.intent_agent is None or self._conversation_data is None: raise RuntimeError("Recognize intent was not prepared") if self.pipeline.conversation_language == MATCH_ALL: @@ -1078,7 +1104,7 @@ class PipelineRun: agent_id = self.intent_agent processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT intent_response: intent.IntentResponse | None = None - if not processed_locally: + if not processed_locally and not self._intent_agent_only: # Sentence triggers override conversation agent if ( trigger_response_text @@ -1195,6 +1221,9 @@ class PipelineRun: ) ) + if conversation_result.continue_conversation: + self._conversation_data.continue_conversation_agent = agent_id + return speech async def prepare_text_to_speech(self) -> None: @@ -1458,8 +1487,8 @@ class PipelineInput: run: PipelineRun - conversation_id: str - """Identifier for the conversation.""" + session: chat_session.ChatSession + """Session for the conversation.""" stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -1484,7 +1513,9 @@ class PipelineInput: async def execute(self) -> None: """Run pipeline.""" - self.run.start(conversation_id=self.conversation_id, device_id=self.device_id) + self.run.start( + conversation_id=self.session.conversation_id, device_id=self.device_id + ) current_stage: PipelineStage | None = self.run.start_stage stt_audio_buffer: list[EnhancedAudioChunk] = [] stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None @@ -1568,7 +1599,7 @@ class PipelineInput: assert intent_input is not None tts_input = await self.run.recognize_intent( intent_input, - self.conversation_id, + self.session.conversation_id, self.device_id, self.conversation_extra_system_prompt, ) @@ -1652,7 +1683,7 @@ class PipelineInput: <= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT) <= end_stage_index ): - prepare_tasks.append(self.run.prepare_recognize_intent()) + prepare_tasks.append(self.run.prepare_recognize_intent(self.session)) if ( start_stage_index @@ -1931,7 +1962,7 @@ class PipelineRunDebug: class PipelineStore(Store[SerializedPipelineStorageCollection]): - """Store entity registry data.""" + """Store pipeline data.""" async def _async_migrate_func( self, @@ -2013,3 +2044,37 @@ async def async_run_migrations(hass: HomeAssistant) -> None: for pipeline, attr_updates in updates: await async_update_pipeline(hass, pipeline, **attr_updates) + + +@dataclass +class PipelineConversationData: + """Hold data for the duration of a conversation.""" + + continue_conversation_agent: str | None = None + """The agent that requested the conversation to be continued.""" + + +@callback +def async_get_pipeline_conversation_data( + hass: HomeAssistant, session: chat_session.ChatSession +) -> PipelineConversationData: + """Get the pipeline data for a specific conversation.""" + all_conversation_data = hass.data.get(KEY_PIPELINE_CONVERSATION_DATA) + if all_conversation_data is None: + all_conversation_data = {} + hass.data[KEY_PIPELINE_CONVERSATION_DATA] = all_conversation_data + + data = all_conversation_data.get(session.conversation_id) + + if data is not None: + return data + + @callback + def do_cleanup() -> None: + """Handle cleanup.""" + all_conversation_data.pop(session.conversation_id) + + session.async_on_cleanup(do_cleanup) + + data = all_conversation_data[session.conversation_id] = PipelineConversationData() + return data diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index d2d54a1b7c3..937b3a0ea45 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -239,7 +239,7 @@ async def websocket_run( with chat_session.async_get_chat_session( hass, msg.get("conversation_id") ) as session: - input_args["conversation_id"] = session.conversation_id + input_args["session"] = session pipeline_input = PipelineInput(**input_args) try: diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 08a68fa0164..7bdd13afc01 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -62,12 +62,14 @@ class ConversationResult: response: intent.IntentResponse conversation_id: str | None = None + continue_conversation: bool = False def as_dict(self) -> dict[str, Any]: """Return result as a dict.""" return { "response": self.response.as_dict(), "conversation_id": self.conversation_id, + "continue_conversation": self.continue_conversation, } diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 016b1c3494d..0af74621153 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -284,7 +284,10 @@ class EsphomeAssistSatellite( elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: assert event.data is not None data_to_send = { - "conversation_id": event.data["intent_output"]["conversation_id"] or "", + "conversation_id": event.data["intent_output"]["conversation_id"], + "continue_conversation": event.data["intent_output"][ + "continue_conversation" + ], } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 93f3b03d9af..de414019317 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ + 'continue_conversation': False, 'conversation_id': '1234', 'response': IntentResponse( card=dict({ diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 02ec7c04607..a0549f27f05 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import AsyncIterable, Generator from pathlib import Path from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest @@ -24,7 +24,7 @@ from homeassistant.components.assist_pipeline.pipeline import ( from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import chat_session, device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.setup import async_setup_component @@ -379,3 +379,14 @@ def pipeline_storage(pipeline_data) -> PipelineStorageCollection: def make_10ms_chunk(header: bytes) -> bytes: """Return 10ms of zeros with the given header.""" return header + bytes(BYTES_PER_CHUNK - len(header)) + + +@pytest.fixture +def mock_chat_session(hass: HomeAssistant) -> Generator[chat_session.ChatSession]: + """Mock the ulid of chat sessions.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + patch("homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid"), + chat_session.async_get_chat_session(hass) as session, + ): + yield session diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 11e6bc2339a..f5e5f813db6 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -45,6 +45,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -137,6 +138,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -229,6 +231,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -345,6 +348,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -432,7 +436,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -440,7 +444,7 @@ }), dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -452,6 +456,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -484,7 +489,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -492,7 +497,7 @@ }), dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -504,6 +509,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -536,7 +542,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -544,7 +550,7 @@ }), dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -556,6 +562,7 @@ dict({ 'data': dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -588,7 +595,7 @@ list([ dict({ 'data': dict({ - 'conversation_id': 'mock-conversation-id', + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index f677fa6d8cf..509f2072509 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -43,6 +43,7 @@ # name: test_audio_pipeline.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -127,6 +128,7 @@ # name: test_audio_pipeline_debug.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -223,6 +225,7 @@ # name: test_audio_pipeline_with_enhancements.4 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -329,6 +332,7 @@ # name: test_audio_pipeline_with_wake_word_no_timeout.6 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -596,6 +600,7 @@ # name: test_pipeline_empty_tts_output.2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -715,6 +720,7 @@ # name: test_text_only_pipeline[extra_msg0].2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -762,6 +768,7 @@ # name: test_text_only_pipeline[extra_msg1].2 dict({ 'intent_output': dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 1651950c173..e983e4a96e3 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -27,7 +27,7 @@ from homeassistant.components.assist_pipeline.const import ( ) from homeassistant.const import MATCH_ALL from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import chat_session, intent from homeassistant.setup import async_setup_component from .conftest import ( @@ -675,6 +675,7 @@ async def test_wake_word_detection_aborted( mock_wake_word_provider_entity: MockWakeWordEntity, init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test creating a pipeline from an audio stream with wake word.""" @@ -693,7 +694,7 @@ async def test_wake_word_detection_aborted( pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) pipeline_input = assist_pipeline.pipeline.PipelineInput( - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, stt_metadata=stt.SpeechMetadata( language="", @@ -766,6 +767,7 @@ async def test_tts_audio_output( mock_tts_provider: MockTTSProvider, init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test using tts_audio_output with wav sets options correctly.""" @@ -780,7 +782,7 @@ async def test_tts_audio_output( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -823,6 +825,7 @@ async def test_tts_wav_preferred_format( hass_client: ClientSessionGenerator, mock_tts_provider: MockTTSProvider, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that preferred format options are given to the TTS system if supported.""" @@ -837,7 +840,7 @@ async def test_tts_wav_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -891,6 +894,7 @@ async def test_tts_dict_preferred_format( hass_client: ClientSessionGenerator, mock_tts_provider: MockTTSProvider, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that preferred format options are given to the TTS system if supported.""" @@ -905,7 +909,7 @@ async def test_tts_dict_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id="mock-conversation-id", + session=mock_chat_session, device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -962,6 +966,7 @@ async def test_tts_dict_preferred_format( async def test_sentence_trigger_overrides_conversation_agent( hass: HomeAssistant, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that sentence triggers are checked before a non-default conversation agent.""" @@ -991,7 +996,7 @@ async def test_sentence_trigger_overrides_conversation_agent( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test trigger sentence", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1039,6 +1044,7 @@ async def test_sentence_trigger_overrides_conversation_agent( async def test_prefer_local_intents( hass: HomeAssistant, init_components, + mock_chat_session: chat_session.ChatSession, pipeline_data: assist_pipeline.pipeline.PipelineData, ) -> None: """Test that the default agent is checked first when local intents are preferred.""" @@ -1069,7 +1075,7 @@ async def test_prefer_local_intents( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="I'd like to order a stout please", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1113,10 +1119,150 @@ async def test_prefer_local_intents( ) +async def test_intent_continue_conversation( + hass: HomeAssistant, + init_components, + mock_chat_session: chat_session.ChatSession, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that a conversation agent flagging continue conversation gets response.""" + events: list[assist_pipeline.PipelineEvent] = [] + + # Fake a test agent and prefer local intents + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine="test-agent" + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Set a timer", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ): + await pipeline_input.validate() + + response = intent.IntentResponse("en") + response.async_set_speech("For how long?") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + continue_conversation=True, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[1]["intent_output"]["continue_conversation"] is True + + # Change conversation agent to default one and register sentence trigger that should not be called + await assist_pipeline.pipeline.async_update_pipeline( + hass, pipeline, conversation_engine=None + ) + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": ["Hello"], + }, + "action": { + "set_conversation_response": "test trigger response", + }, + } + }, + ) + + # Because we did continue conversation, it should respond to the test agent again. + events.clear() + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + intent_input="Hello", + session=mock_chat_session, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.INTENT, + end_stage=assist_pipeline.PipelineStage.INTENT, + event_callback=events.append, + ), + ) + + # Ensure prepare succeeds + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_get_agent_info", + return_value=conversation.AgentInfo(id="test-agent", name="Test Agent"), + ) as mock_prepare: + await pipeline_input.validate() + + # It requested test agent even if that was not default agent. + assert mock_prepare.mock_calls[0][1][1] == "test-agent" + + response = intent.IntentResponse("en") + response.async_set_speech("Timer set for 20 minutes") + + with patch( + "homeassistant.components.assist_pipeline.pipeline.conversation.async_converse", + return_value=conversation.ConversationResult( + response=response, + conversation_id=mock_chat_session.conversation_id, + ), + ) as mock_async_converse: + await pipeline_input.execute() + + mock_async_converse.assert_called() + + # Snapshot will show it was still handled by the test agent and not default agent + results = [ + event.data + for event in events + if event.type + in ( + assist_pipeline.PipelineEventType.INTENT_START, + assist_pipeline.PipelineEventType.INTENT_END, + ) + ] + assert results[0]["engine"] == "test-agent" + assert results[1]["intent_output"]["continue_conversation"] is False + + async def test_stt_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test that the STT language is used first when the conversation language is '*' (all languages).""" @@ -1147,7 +1293,7 @@ async def test_stt_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1192,6 +1338,7 @@ async def test_tts_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test that the TTS language is used after STT when the conversation language is '*' (all languages).""" @@ -1222,7 +1369,7 @@ async def test_tts_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1267,6 +1414,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components, + mock_chat_session: chat_session.ChatSession, snapshot: SnapshotAssertion, ) -> None: """Test that the pipeline language is used last when the conversation language is '*' (all languages).""" @@ -1297,7 +1445,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", - conversation_id="mock-conversation-id", + session=mock_chat_session, run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 314188dbd82..eeab8b6b9af 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import AsyncGenerator from dataclasses import dataclass, field from typing import Literal from unittest.mock import patch @@ -49,7 +50,7 @@ class MockAgent(conversation.AbstractConversationAgent): @pytest.fixture -async def mock_chat_log(hass: HomeAssistant) -> MockChatLog: +async def mock_chat_log(hass: HomeAssistant) -> AsyncGenerator[MockChatLog]: """Return mock chat logs.""" # pylint: disable-next=contextmanager-generator-missing-cleanup with ( diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index 1ddbf68bb84..ff8ebf724cd 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -151,6 +151,7 @@ # --- # name: test_template_error dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -171,6 +172,7 @@ # --- # name: test_unknown_llm_api dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_default_agent.ambr b/tests/components/conversation/snapshots/test_default_agent.ambr index c2b16ea2912..02e4ef1befe 100644 --- a/tests/components/conversation/snapshots/test_default_agent.ambr +++ b/tests/components/conversation/snapshots/test_default_agent.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_custom_sentences dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -26,6 +27,7 @@ # --- # name: test_custom_sentences.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -51,6 +53,7 @@ # --- # name: test_custom_sentences_config dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -76,6 +79,7 @@ # --- # name: test_intent_alias_added_removed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -106,6 +110,7 @@ # --- # name: test_intent_alias_added_removed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -136,6 +141,7 @@ # --- # name: test_intent_alias_added_removed.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -156,6 +162,7 @@ # --- # name: test_intent_conversion_not_expose_new dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -176,6 +183,7 @@ # --- # name: test_intent_conversion_not_expose_new.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -206,6 +214,7 @@ # --- # name: test_intent_entity_added_removed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -236,6 +245,7 @@ # --- # name: test_intent_entity_added_removed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -266,6 +276,7 @@ # --- # name: test_intent_entity_added_removed.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -296,6 +307,7 @@ # --- # name: test_intent_entity_added_removed.3 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -316,6 +328,7 @@ # --- # name: test_intent_entity_exposed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -346,6 +359,7 @@ # --- # name: test_intent_entity_fail_if_unexposed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -366,6 +380,7 @@ # --- # name: test_intent_entity_remove_custom_name dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -386,6 +401,7 @@ # --- # name: test_intent_entity_remove_custom_name.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -416,6 +432,7 @@ # --- # name: test_intent_entity_remove_custom_name.2 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -436,6 +453,7 @@ # --- # name: test_intent_entity_renamed dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -466,6 +484,7 @@ # --- # name: test_intent_entity_renamed.1 dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index c6ac6c2df9c..849a5b17102 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -202,6 +202,7 @@ # --- # name: test_http_api_handle_failure dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -222,6 +223,7 @@ # --- # name: test_http_api_no_match dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -242,6 +244,7 @@ # --- # name: test_http_api_unexpected_failure dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -262,6 +265,7 @@ # --- # name: test_http_processing_intent[None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -292,6 +296,7 @@ # --- # name: test_http_processing_intent[conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -322,6 +327,7 @@ # --- # name: test_http_processing_intent[homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -352,6 +358,7 @@ # --- # name: test_ws_api[payload0] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -372,6 +379,7 @@ # --- # name: test_ws_api[payload1] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -392,6 +400,7 @@ # --- # name: test_ws_api[payload2] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -412,6 +421,7 @@ # --- # name: test_ws_api[payload3] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -432,6 +442,7 @@ # --- # name: test_ws_api[payload4] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -452,6 +463,7 @@ # --- # name: test_ws_api[payload5] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 911c7043a6d..3d843d4e32a 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_custom_agent dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -44,6 +45,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -74,6 +76,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -104,6 +107,7 @@ # --- # name: test_turn_on_intent[None-turn kitchen on-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -134,6 +138,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -164,6 +169,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -194,6 +200,7 @@ # --- # name: test_turn_on_intent[None-turn on kitchen-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -224,6 +231,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -254,6 +262,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -284,6 +293,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -314,6 +324,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -344,6 +355,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-conversation.home_assistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ @@ -374,6 +386,7 @@ # --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] dict({ + 'continue_conversation': False, 'conversation_id': , 'response': dict({ 'card': dict({ diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 30535236970..56914a0b829 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -25,7 +25,7 @@ from aioesphomeapi import ( ) import pytest -from homeassistant.components import assist_satellite, tts +from homeassistant.components import assist_satellite, conversation, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, @@ -285,12 +285,21 @@ async def test_pipeline_api_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + continue_conversation=True, + ).as_dict() + }, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, - {"conversation_id": conversation_id}, + { + "conversation_id": conversation_id, + "continue_conversation": True, + }, ) # TTS @@ -484,7 +493,12 @@ async def test_pipeline_udp_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) @@ -690,7 +704,12 @@ async def test_pipeline_media_player( event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, - data={"intent_output": {"conversation_id": conversation_id}}, + data={ + "intent_output": conversation.ConversationResult( + response=intent_helper.IntentResponse("en"), + conversation_id=conversation_id, + ).as_dict() + }, ) ) diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index dda5f369ad5..b071caebd16 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1081,6 +1081,7 @@ async def test_webhook_handle_conversation_process( }, }, "conversation_id": None, + "continue_conversation": False, } diff --git a/tests/components/ollama/snapshots/test_conversation.ambr b/tests/components/ollama/snapshots/test_conversation.ambr index 93f3b03d9af..de414019317 100644 --- a/tests/components/ollama/snapshots/test_conversation.ambr +++ b/tests/components/ollama/snapshots/test_conversation.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_unknown_hass_api dict({ + 'continue_conversation': False, 'conversation_id': '1234', 'response': IntentResponse( card=dict({ diff --git a/tests/syrupy.py b/tests/syrupy.py index 3c8e398f0f8..e028d5839cb 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -109,7 +109,11 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_issue_registry_entry(data) elif isinstance(data, dict) and "flow_id" in data and "handler" in data: serializable_data = cls._serializable_flow_result(data) - elif isinstance(data, dict) and set(data) == {"conversation_id", "response"}: + elif isinstance(data, dict) and set(data) == { + "conversation_id", + "response", + "continue_conversation", + }: serializable_data = cls._serializable_conversation_result(data) elif isinstance(data, vol.Schema): serializable_data = voluptuous_serialize.convert(data) From 39bc37d22568cfc3add4c205cb030bd2a3dbd083 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:33:25 +0100 Subject: [PATCH 1108/1941] 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 455363871f99e041e649c09b313a001134cc9620 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 20:39:49 +0100 Subject: [PATCH 1109/1941] 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 1a80934593907875194ad2b0cf291bd890c6330e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 19:40:13 +0000 Subject: [PATCH 1110/1941] Move TTS entity to own file (#139538) * Move entity to own file * Move entity tests --- homeassistant/components/tts/__init__.py | 161 +---------------------- homeassistant/components/tts/entity.py | 159 ++++++++++++++++++++++ tests/components/tts/test_entity.py | 144 ++++++++++++++++++++ tests/components/tts/test_init.py | 138 +------------------ 4 files changed, 310 insertions(+), 292 deletions(-) create mode 100644 homeassistant/components/tts/entity.py create mode 100644 tests/components/tts/test_entity.py diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 199d644738b..5b2da44eae2 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -3,10 +3,8 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime -from functools import partial import hashlib from http import HTTPStatus import io @@ -18,7 +16,7 @@ import secrets import subprocess import tempfile from time import monotonic -from typing import Any, Final, TypedDict, final +from typing import Any, Final, TypedDict from aiohttp import web import mutagen @@ -28,22 +26,8 @@ import voluptuous as vol from homeassistant.components import ffmpeg, websocket_api from homeassistant.components.http import HomeAssistantView -from homeassistant.components.media_player import ( - ATTR_MEDIA_ANNOUNCE, - ATTR_MEDIA_CONTENT_ID, - ATTR_MEDIA_CONTENT_TYPE, - DOMAIN as DOMAIN_MP, - SERVICE_PLAY_MEDIA, - MediaType, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - EVENT_HOMEASSISTANT_STOP, - PLATFORM_FORMAT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -58,9 +42,8 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.util import dt as dt_util, language as language_util +from homeassistant.util import language as language_util from .const import ( ATTR_CACHE, @@ -78,6 +61,7 @@ from .const import ( DOMAIN, TtsAudioType, ) +from .entity import TextToSpeechEntity from .helper import get_engine_instance from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy from .media_source import generate_media_source_id, media_source_id_to_kwargs @@ -95,6 +79,7 @@ __all__ = [ "PLATFORM_SCHEMA_BASE", "Provider", "SampleFormat", + "TextToSpeechEntity", "TtsAudioType", "Voice", "async_default_engine", @@ -389,14 +374,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -CACHED_PROPERTIES_WITH_ATTR_ = { - "default_language", - "default_options", - "supported_languages", - "supported_options", -} - - @dataclass class ResultStream: """Class that will stream the result when available.""" @@ -431,134 +408,6 @@ class ResultStream: return data -class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): - """Represent a single TTS engine.""" - - _attr_should_poll = False - __last_tts_loaded: str | None = None - - _attr_default_language: str - _attr_default_options: Mapping[str, Any] | None = None - _attr_supported_languages: list[str] - _attr_supported_options: list[str] | None = None - - @property - @final - def state(self) -> str | None: - """Return the state of the entity.""" - if self.__last_tts_loaded is None: - return None - return self.__last_tts_loaded - - @cached_property - def supported_languages(self) -> list[str]: - """Return a list of supported languages.""" - return self._attr_supported_languages - - @cached_property - def default_language(self) -> str: - """Return the default language.""" - return self._attr_default_language - - @cached_property - def supported_options(self) -> list[str] | None: - """Return a list of supported options like voice, emotions.""" - return self._attr_supported_options - - @cached_property - def default_options(self) -> Mapping[str, Any] | None: - """Return a mapping with the default options.""" - return self._attr_default_options - - @callback - def async_get_supported_voices(self, language: str) -> list[Voice] | None: - """Return a list of supported voices for a language.""" - return None - - async def async_internal_added_to_hass(self) -> None: - """Call when the entity is added to hass.""" - await super().async_internal_added_to_hass() - try: - _ = self.default_language - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - ) from err - try: - _ = self.supported_languages - except AttributeError as err: - raise AttributeError( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - ) from err - state = await self.async_get_last_state() - if ( - state is not None - and state.state is not None - and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) - ): - self.__last_tts_loaded = state.state - - async def async_speak( - self, - media_player_entity_id: list[str], - message: str, - cache: bool, - language: str | None = None, - options: dict | None = None, - ) -> None: - """Speak via a Media Player.""" - await self.hass.services.async_call( - DOMAIN_MP, - SERVICE_PLAY_MEDIA, - { - ATTR_ENTITY_ID: media_player_entity_id, - ATTR_MEDIA_CONTENT_ID: generate_media_source_id( - self.hass, - message=message, - engine=self.entity_id, - language=language, - options=options, - cache=cache, - ), - ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, - ATTR_MEDIA_ANNOUNCE: True, - }, - blocking=True, - context=self._context, - ) - - @final - async def internal_async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Process an audio stream to TTS service. - - Only streaming content is allowed! - """ - self.__last_tts_loaded = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self.async_get_tts_audio( - message=message, language=language, options=options - ) - - def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine.""" - raise NotImplementedError - - async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: - """Load tts audio file from the engine. - - Return a tuple of file extension and data as bytes. - """ - return await self.hass.async_add_executor_job( - partial(self.get_tts_audio, message, language, options=options) - ) - - def _hash_options(options: dict) -> str: """Hashes an options dictionary.""" opts_hash = hashlib.blake2s(digest_size=5) diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py new file mode 100644 index 00000000000..ef65886452d --- /dev/null +++ b/homeassistant/components/tts/entity.py @@ -0,0 +1,159 @@ +"""Entity for Text-to-Speech.""" + +from collections.abc import Mapping +from functools import partial +from typing import Any, final + +from propcache.api import cached_property + +from homeassistant.components.media_player import ( + ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, + MediaType, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import callback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +from .const import TtsAudioType +from .media_source import generate_media_source_id +from .models import Voice + +CACHED_PROPERTIES_WITH_ATTR_ = { + "default_language", + "default_options", + "supported_languages", + "supported_options", +} + + +class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): + """Represent a single TTS engine.""" + + _attr_should_poll = False + __last_tts_loaded: str | None = None + + _attr_default_language: str + _attr_default_options: Mapping[str, Any] | None = None + _attr_supported_languages: list[str] + _attr_supported_options: list[str] | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + if self.__last_tts_loaded is None: + return None + return self.__last_tts_loaded + + @cached_property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return self._attr_supported_languages + + @cached_property + def default_language(self) -> str: + """Return the default language.""" + return self._attr_default_language + + @cached_property + def supported_options(self) -> list[str] | None: + """Return a list of supported options like voice, emotions.""" + return self._attr_supported_options + + @cached_property + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" + return self._attr_default_options + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return None + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_internal_added_to_hass() + try: + _ = self.default_language + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + ) from err + try: + _ = self.supported_languages + except AttributeError as err: + raise AttributeError( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + ) from err + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_tts_loaded = state.state + + async def async_speak( + self, + media_player_entity_id: list[str], + message: str, + cache: bool, + language: str | None = None, + options: dict | None = None, + ) -> None: + """Speak via a Media Player.""" + await self.hass.services.async_call( + DOMAIN_MP, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player_entity_id, + ATTR_MEDIA_CONTENT_ID: generate_media_source_id( + self.hass, + message=message, + engine=self.entity_id, + language=language, + options=options, + cache=cache, + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + context=self._context, + ) + + @final + async def internal_async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Process an audio stream to TTS service. + + Only streaming content is allowed! + """ + self.__last_tts_loaded = dt_util.utcnow().isoformat() + self.async_write_ha_state() + return await self.async_get_tts_audio( + message=message, language=language, options=options + ) + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + raise NotImplementedError + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine. + + Return a tuple of file extension and data as bytes. + """ + return await self.hass.async_add_executor_job( + partial(self.get_tts_audio, message, language, options=options) + ) diff --git a/tests/components/tts/test_entity.py b/tests/components/tts/test_entity.py new file mode 100644 index 00000000000..d82ec6a5d2b --- /dev/null +++ b/tests/components/tts/test_entity.py @@ -0,0 +1,144 @@ +"""Tests for the TTS entity.""" + +import pytest + +from homeassistant.components import tts +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, State + +from .common import ( + DEFAULT_LANG, + SUPPORT_LANGUAGES, + TEST_DOMAIN, + MockTTSEntity, + mock_config_entry_setup, +) + +from tests.common import mock_restore_cache + + +class DefaultEntity(tts.TextToSpeechEntity): + """Test entity.""" + + _attr_supported_languages = SUPPORT_LANGUAGES + _attr_default_language = DEFAULT_LANG + + +async def test_default_entity_attributes() -> None: + """Test default entity attributes.""" + entity = DefaultEntity() + + assert entity.hass is None + assert entity.default_language == DEFAULT_LANG + assert entity.supported_languages == SUPPORT_LANGUAGES + assert entity.supported_options is None + assert entity.default_options is None + assert entity.async_get_supported_voices("test") is None + + +async def test_restore_state( + hass: HomeAssistant, + mock_tts_entity: MockTTSEntity, +) -> None: + """Test we restore state in the integration.""" + entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" + timestamp = "2023-01-01T23:59:59+00:00" + mock_restore_cache(hass, (State(entity_id, timestamp),)) + + config_entry = await mock_config_entry_setup(hass, mock_tts_entity) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + state = hass.states.get(entity_id) + assert state + assert state.state == timestamp + + +async def test_tts_entity_subclass_properties( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" + + class TestClass1(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass1()) + + class TestClass2(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass2()) + + assert all(record.exc_info is None for record in caplog.records) + + caplog.clear() + + class TestClass3(tts.TextToSpeechEntity): + _attr_default_language = DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass3()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass4(tts.TextToSpeechEntity): + _attr_supported_languages = SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass4()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass5(tts.TextToSpeechEntity): + @property + def default_language(self) -> str: + return DEFAULT_LANG + + await mock_config_entry_setup(hass, TestClass5()) + + assert ( + "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) + caplog.clear() + + class TestClass6(tts.TextToSpeechEntity): + @property + def supported_languages(self) -> list[str]: + return SUPPORT_LANGUAGES + + await mock_config_entry_setup(hass, TestClass6()) + + assert ( + "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" + in [ + str(record.exc_info[1]) + for record in caplog.records + if record.exc_info is not None + ] + ) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 86ca2de5791..8dece920907 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -20,14 +20,13 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, - SUPPORT_LANGUAGES, TEST_DOMAIN, MockTTS, MockTTSEntity, @@ -38,37 +37,12 @@ from .common import ( retrieve_media, ) -from tests.common import ( - MockModule, - async_mock_service, - mock_integration, - mock_platform, - mock_restore_cache, -) +from tests.common import MockModule, async_mock_service, mock_integration, mock_platform from tests.typing import ClientSessionGenerator, WebSocketGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags -class DefaultEntity(tts.TextToSpeechEntity): - """Test entity.""" - - _attr_supported_languages = SUPPORT_LANGUAGES - _attr_default_language = DEFAULT_LANG - - -async def test_default_entity_attributes() -> None: - """Test default entity attributes.""" - entity = DefaultEntity() - - assert entity.hass is None - assert entity.default_language == DEFAULT_LANG - assert entity.supported_languages == SUPPORT_LANGUAGES - assert entity.supported_options is None - assert entity.default_options is None - assert entity.async_get_supported_voices("test") is None - - async def test_config_entry_unload( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -120,24 +94,6 @@ async def test_config_entry_unload( assert state is None -async def test_restore_state( - hass: HomeAssistant, - mock_tts_entity: MockTTSEntity, -) -> None: - """Test we restore state in the integration.""" - entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" - timestamp = "2023-01-01T23:59:59+00:00" - mock_restore_cache(hass, (State(entity_id, timestamp),)) - - config_entry = await mock_config_entry_setup(hass, mock_tts_entity) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - state = hass.states.get(entity_id) - assert state - assert state.state == timestamp - - @pytest.mark.parametrize( "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True ) @@ -1840,96 +1796,6 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") -async def test_ttsentity_subclass_properties( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test for errors when subclasses of the TextToSpeechEntity are missing required properties.""" - - class TestClass1(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass1()) - - class TestClass2(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass2()) - - assert all(record.exc_info is None for record in caplog.records) - - caplog.clear() - - class TestClass3(tts.TextToSpeechEntity): - _attr_default_language = DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass3()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass4(tts.TextToSpeechEntity): - _attr_supported_languages = SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass4()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass5(tts.TextToSpeechEntity): - @property - def default_language(self) -> str: - return DEFAULT_LANG - - await mock_config_entry_setup(hass, TestClass5()) - - assert ( - "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - caplog.clear() - - class TestClass6(tts.TextToSpeechEntity): - @property - def supported_languages(self) -> list[str]: - return SUPPORT_LANGUAGES - - await mock_config_entry_setup(hass, TestClass6()) - - assert ( - "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property" - in [ - str(record.exc_info[1]) - for record in caplog.records - if record.exc_info is not None - ] - ) - - async def test_default_engine_prefer_entity( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, From 437e54511620de7dc5b44a69d7f8682f8e6ae769 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 28 Feb 2025 20:45:47 +0100 Subject: [PATCH 1111/1941] Rework Comelit tests (#139475) * Rework Comelit tests * allign * restore coverage --- tests/components/comelit/__init__.py | 12 + tests/components/comelit/conftest.py | 104 +++++++ tests/components/comelit/const.py | 38 +-- .../comelit/snapshots/test_diagnostics.ambr | 3 +- tests/components/comelit/test_config_flow.py | 264 +++++++++++------- tests/components/comelit/test_diagnostics.py | 51 +--- 6 files changed, 304 insertions(+), 168 deletions(-) create mode 100644 tests/components/comelit/conftest.py diff --git a/tests/components/comelit/__init__.py b/tests/components/comelit/__init__.py index 916a684de4b..6475f500f01 100644 --- a/tests/components/comelit/__init__.py +++ b/tests/components/comelit/__init__.py @@ -1 +1,13 @@ """Tests for the Comelit SimpleHome integration.""" + +from homeassistant.core import HomeAssistant + +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() diff --git a/tests/components/comelit/conftest.py b/tests/components/comelit/conftest.py new file mode 100644 index 00000000000..d2d450ccb8d --- /dev/null +++ b/tests/components/comelit/conftest.py @@ -0,0 +1,104 @@ +"""Configure tests for Comelit SimpleHome.""" + +import pytest + +from homeassistant.components.comelit.const import ( + BRIDGE, + DOMAIN as COMELIT_DOMAIN, + VEDO, +) +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE + +from .const import ( + BRIDGE_DEVICE_QUERY, + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + VEDO_DEVICE_QUERY, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) + +from tests.common import AsyncMock, Generator, MockConfigEntry, patch + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.comelit.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_serial_bridge() -> Generator[AsyncMock]: + """Mock a Comelit serial bridge.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComeliteSerialBridgeApi", + autospec=True, + ) as mock_comelit_serial_bridge, + patch( + "homeassistant.components.comelit.config_flow.ComeliteSerialBridgeApi", + new=mock_comelit_serial_bridge, + ), + ): + bridge = mock_comelit_serial_bridge.return_value + bridge.get_all_devices.return_value = BRIDGE_DEVICE_QUERY + bridge.host = BRIDGE_HOST + bridge.port = BRIDGE_PORT + bridge.pin = BRIDGE_PIN + yield bridge + + +@pytest.fixture +def mock_serial_bridge_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit bridge.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + }, + ) + + +@pytest.fixture +def mock_vedo() -> Generator[AsyncMock]: + """Mock a Comelit vedo.""" + with ( + patch( + "homeassistant.components.comelit.coordinator.ComelitVedoApi", + autospec=True, + ) as mock_comelit_vedo, + patch( + "homeassistant.components.comelit.config_flow.ComelitVedoApi", + new=mock_comelit_vedo, + ), + ): + vedo = mock_comelit_vedo.return_value + vedo.get_all_areas_and_zones.return_value = VEDO_DEVICE_QUERY + vedo.host = VEDO_HOST + vedo.port = VEDO_PORT + vedo.pin = VEDO_PIN + vedo.type = VEDO + yield vedo + + +@pytest.fixture +def mock_vedo_config_entry() -> Generator[MockConfigEntry]: + """Mock a Comelit config entry for Comelit vedo.""" + return MockConfigEntry( + domain=COMELIT_DOMAIN, + data={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 92fdfebfa1d..3151b83d175 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,7 +1,10 @@ """Common stuff for Comelit SimpleHome tests.""" -from aiocomelit import ComelitVedoAreaObject, ComelitVedoZoneObject -from aiocomelit.api import ComelitSerialBridgeObject +from aiocomelit import ( + ComelitSerialBridgeObject, + ComelitVedoAreaObject, + ComelitVedoZoneObject, +) from aiocomelit.const import ( CLIMATE, COVER, @@ -9,37 +12,20 @@ from aiocomelit.const import ( LIGHT, OTHER, SCENARIO, - VEDO, WATT, AlarmAreaState, AlarmZoneState, ) -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE +BRIDGE_HOST = "fake_bridge_host" +BRIDGE_PORT = 80 +BRIDGE_PIN = 1234 -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_PORT: 80, - CONF_PIN: 1234, - }, - { - CONF_HOST: "fake_vedo_host", - CONF_PORT: 8080, - CONF_PIN: 1234, - CONF_TYPE: VEDO, - }, - ] - } -} +VEDO_HOST = "fake_vedo_host" +VEDO_PORT = 8080 +VEDO_PIN = 5678 -MOCK_USER_BRIDGE_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] -MOCK_USER_VEDO_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][1] - -FAKE_PIN = 5678 +FAKE_PIN = 0000 BRIDGE_DEVICE_QUERY = { CLIMATE: {}, diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index 877f48a4611..b9891eb3209 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -57,9 +57,10 @@ }), 'entry': dict({ 'data': dict({ - 'host': 'fake_host', + 'host': 'fake_bridge_host', 'pin': '**REDACTED**', 'port': 80, + 'type': 'Serial bridge', }), 'disabled_by': None, 'discovery_keys': dict({ diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index eeaea0e41e9..dd1d1fb3836 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -1,59 +1,93 @@ """Tests for Comelit SimpleHome config flow.""" -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock from aiocomelit import CannotAuthenticate, CannotConnect +from aiocomelit.const import BRIDGE, VEDO import pytest from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import FAKE_PIN, MOCK_USER_BRIDGE_DATA, MOCK_USER_VEDO_DATA +from .const import ( + BRIDGE_HOST, + BRIDGE_PIN, + BRIDGE_PORT, + FAKE_PIN, + VEDO_HOST, + VEDO_PIN, + VEDO_PORT, +) from tests.common import MockConfigEntry -@pytest.mark.parametrize( - ("class_api", "user_input"), - [ - ("ComeliteSerialBridgeApi", MOCK_USER_BRIDGE_DATA), - ("ComelitVedoApi", MOCK_USER_VEDO_DATA), - ], -) -async def test_full_flow( - hass: HomeAssistant, class_api: str, user_input: dict[str, Any] +async def test_flow_serial_bridge( + hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, ) -> None: """Test starting a flow by user.""" - with ( - patch( - f"aiocomelit.api.{class_api}.login", - ), - patch( - f"aiocomelit.api.{class_api}.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry") as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=user_input - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == user_input[CONF_HOST] - assert result["data"][CONF_PORT] == user_input[CONF_PORT] - assert result["data"][CONF_PIN] == user_input[CONF_PIN] - assert not result["result"].unique_id - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - assert mock_setup_entry.called + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BRIDGE_PIN, + CONF_TYPE: BRIDGE, + } + assert not result["result"].unique_id + await hass.async_block_till_done() + + +async def test_flow_vedo( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: + """Test starting a flow by user.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } + assert not result["result"].unique_id + await hass.async_block_till_done() @pytest.mark.parametrize( @@ -64,7 +98,13 @@ async def test_full_flow( (ConnectionResetError, "unknown"), ], ) -async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: +async def test_exception_connection( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + side_effect, + error, +) -> None: """Test starting a flow by user with a connection error.""" result = await hass.config_entries.flow.async_init( @@ -73,59 +113,65 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "user" - with ( - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - side_effect=side_effect, - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch( - "homeassistant.components.comelit.async_setup_entry", - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_BRIDGE_DATA - ) + mock_vedo.login.side_effect = side_effect - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] is not None - assert result["errors"]["base"] == error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == VEDO_HOST + assert result["data"] == { + CONF_HOST: VEDO_HOST, + CONF_PORT: VEDO_PORT, + CONF_PIN: VEDO_PIN, + CONF_TYPE: VEDO, + } -async def test_reauth_successful(hass: HomeAssistant) -> None: +async def test_reauth_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, +) -> None: """Test starting a reauthentication flow.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", - ), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - patch("requests.get") as mock_request_get, - ): - mock_request_get.return_value.status_code = 200 + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" @pytest.mark.parametrize( @@ -136,30 +182,40 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: (ConnectionResetError, "unknown"), ], ) -async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: """Test starting a reauthentication flow but no connection found.""" - - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_vedo_config_entry.add_to_hass(hass) + result = await mock_vedo_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), - patch("homeassistant.components.comelit.async_setup_entry"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_PIN: FAKE_PIN, - }, - ) + mock_vedo.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: FAKE_PIN, + }, + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - assert result["errors"] is not None - assert result["errors"]["base"] == error + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_vedo.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: VEDO_PIN, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_vedo_config_entry.data[CONF_PIN] == VEDO_PIN diff --git a/tests/components/comelit/test_diagnostics.py b/tests/components/comelit/test_diagnostics.py index 39d75af1152..cabcd0f4cac 100644 --- a/tests/components/comelit/test_diagnostics.py +++ b/tests/components/comelit/test_diagnostics.py @@ -2,21 +2,14 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock from syrupy import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.comelit.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import ( - BRIDGE_DEVICE_QUERY, - MOCK_USER_BRIDGE_DATA, - MOCK_USER_VEDO_DATA, - VEDO_DEVICE_QUERY, -) +from . import setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -25,25 +18,17 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics_bridge( hass: HomeAssistant, + mock_serial_bridge: AsyncMock, + mock_serial_bridge_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Bridge config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_serial_bridge_config_entry) - with ( - patch("aiocomelit.api.ComeliteSerialBridgeApi.login"), - patch( - "aiocomelit.api.ComeliteSerialBridgeApi.get_all_devices", - return_value=BRIDGE_DEVICE_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_serial_bridge_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", @@ -54,25 +39,17 @@ async def test_entry_diagnostics_bridge( async def test_entry_diagnostics_vedo( hass: HomeAssistant, + mock_vedo: AsyncMock, + mock_vedo_config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: """Test Vedo System config entry diagnostics.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VEDO_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_vedo_config_entry) - with ( - patch("aiocomelit.api.ComelitVedoApi.login"), - patch( - "aiocomelit.api.ComelitVedoApi.get_all_areas_and_zones", - return_value=VEDO_DEVICE_QUERY, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state == ConfigEntryState.LOADED - assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot( + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_vedo_config_entry + ) == snapshot( exclude=props( "entry_id", "created_at", From 6ce48eab45564934a6648b23ca8e8b4348600b5c Mon Sep 17 00:00:00 2001 From: rappenze Date: Fri, 28 Feb 2025 20:47:03 +0100 Subject: [PATCH 1112/1941] Use new pyfibaro library features (#139476) --- homeassistant/components/fibaro/__init__.py | 113 +++++++----------- .../components/fibaro/config_flow.py | 17 +-- tests/components/fibaro/conftest.py | 7 +- tests/components/fibaro/test_config_flow.py | 58 +++------ 4 files changed, 76 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 8ede0169482..9a521e27486 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -7,21 +7,20 @@ from collections.abc import Callable, Mapping import logging from typing import Any -from pyfibaro.fibaro_client import FibaroClient +from pyfibaro.fibaro_client import ( + FibaroAuthenticationFailed, + FibaroClient, + FibaroConnectFailed, +) from pyfibaro.fibaro_device import DeviceModel -from pyfibaro.fibaro_room import RoomModel +from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver -from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.util import slugify @@ -74,63 +73,31 @@ FIBARO_TYPEMAP = { class FibaroController: """Initiate Fibaro Controller Class.""" - def __init__(self, config: Mapping[str, Any]) -> None: + def __init__( + self, fibaro_client: FibaroClient, info: InfoModel, import_plugins: bool + ) -> None: """Initialize the Fibaro controller.""" - - # The FibaroClient uses the correct API version automatically - self._client = FibaroClient(config[CONF_URL]) - self._client.set_authentication(config[CONF_USERNAME], config[CONF_PASSWORD]) + self._client = fibaro_client + self._fibaro_info = info # Whether to import devices from plugins - self._import_plugins = config[CONF_IMPORT_PLUGINS] - self._room_map: dict[int, RoomModel] # Mapping roomId to room object + self._import_plugins = import_plugins + # Mapping roomId to room object + self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list ) # List of devices by entity platform # All scenes - self._scenes: list[SceneModel] = [] + self._scenes = self._client.read_scenes() self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId # Event callbacks by device id self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} - self.hub_serial: str # Unique serial number of the hub - self.hub_name: str # The friendly name of the hub - self.hub_model: str - self.hub_software_version: str - self.hub_api_url: str = config[CONF_URL] + # Unique serial number of the hub + self.hub_serial = info.serial_number # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} - - def connect(self) -> None: - """Start the communication with the Fibaro controller.""" - - # Return value doesn't need to be checked, - # it is only relevant when connecting without credentials - self._client.connect() - info = self._client.read_info() - self.hub_serial = info.serial_number - self.hub_name = info.hc_name - self.hub_model = info.platform - self.hub_software_version = info.current_version - - self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} self._read_devices() - self._scenes = self._client.read_scenes() - - def connect_with_error_handling(self) -> None: - """Translate connect errors to easily differentiate auth and connect failures. - - When there is a better error handling in the used library this can be improved. - """ - try: - self.connect() - except HTTPError as http_ex: - if http_ex.response.status_code == 403: - raise FibaroAuthFailed from http_ex - - raise FibaroConnectFailed from http_ex - except Exception as ex: - raise FibaroConnectFailed from ex def enable_state_handler(self) -> None: """Start StateHandler thread for monitoring updates.""" @@ -310,6 +277,14 @@ class FibaroController: """Return list of scenes.""" return self._scenes + def read_fibaro_info(self) -> InfoModel: + """Return the general info about the hub.""" + return self._fibaro_info + + def get_frontend_url(self) -> str: + """Return the url to the Fibaro hub web UI.""" + return self._client.frontend_url() + def _read_devices(self) -> None: """Read and process the device list.""" devices = self._client.read_devices() @@ -375,11 +350,17 @@ class FibaroController: pass +def connect_fibaro_client(data: Mapping[str, Any]) -> tuple[InfoModel, FibaroClient]: + """Connect to the fibaro hub and read some basic data.""" + client = FibaroClient(data[CONF_URL]) + info = client.connect_with_credentials(data[CONF_USERNAME], data[CONF_PASSWORD]) + return (info, client) + + def init_controller(data: Mapping[str, Any]) -> FibaroController: - """Validate the user input allows us to connect to fibaro.""" - controller = FibaroController(data) - controller.connect_with_error_handling() - return controller + """Connect to the fibaro hub and init the controller.""" + info, client = connect_fibaro_client(data) + return FibaroController(client, info, data[CONF_IMPORT_PLUGINS]) async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool: @@ -393,22 +374,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo raise ConfigEntryNotReady( f"Could not connect to controller at {entry.data[CONF_URL]}" ) from connect_ex - except FibaroAuthFailed as auth_ex: + except FibaroAuthenticationFailed as auth_ex: raise ConfigEntryAuthFailed from auth_ex entry.runtime_data = controller # register the hub device info separately as the hub has sometimes no entities + fibaro_info = controller.read_fibaro_info() device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, controller.hub_serial)}, serial_number=controller.hub_serial, - manufacturer="Fibaro", - name=controller.hub_name, - model=controller.hub_model, - sw_version=controller.hub_software_version, - configuration_url=controller.hub_api_url.removesuffix("/api/"), + manufacturer=fibaro_info.manufacturer_name, + name=fibaro_info.hc_name, + model=fibaro_info.model_name, + sw_version=fibaro_info.current_version, + configuration_url=controller.get_frontend_url(), + connections={(dr.CONNECTION_NETWORK_MAC, fibaro_info.mac_address)}, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -443,11 +426,3 @@ async def async_remove_config_entry_device( return False return True - - -class FibaroConnectFailed(HomeAssistantError): - """Error to indicate we cannot connect to fibaro home center.""" - - -class FibaroAuthFailed(HomeAssistantError): - """Error to indicate that authentication failed on fibaro home center.""" diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py index 0ffd9aaa48f..d941ceab37f 100644 --- a/homeassistant/components/fibaro/config_flow.py +++ b/homeassistant/components/fibaro/config_flow.py @@ -6,6 +6,7 @@ from collections.abc import Mapping import logging from typing import Any +from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed from slugify import slugify import voluptuous as vol @@ -13,7 +14,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import FibaroAuthFailed, FibaroConnectFailed, init_controller +from . import connect_fibaro_client from .const import CONF_IMPORT_PLUGINS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,16 +34,16 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - controller = await hass.async_add_executor_job(init_controller, data) + info, _ = await hass.async_add_executor_job(connect_fibaro_client, data) _LOGGER.debug( "Successfully connected to fibaro home center %s with name %s", - controller.hub_serial, - controller.hub_name, + info.serial_number, + info.hc_name, ) return { - "serial_number": slugify(controller.hub_serial), - "name": controller.hub_name, + "serial_number": slugify(info.serial_number), + "name": info.hc_name, } @@ -75,7 +76,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN): info = await _validate_input(self.hass, user_input) except FibaroConnectFailed: errors["base"] = "cannot_connect" - except FibaroAuthFailed: + except FibaroAuthenticationFailed: errors["base"] = "invalid_auth" else: await self.async_set_unique_id(info["serial_number"]) @@ -106,7 +107,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN): await _validate_input(self.hass, new_data) except FibaroConnectFailed: errors["base"] = "cannot_connect" - except FibaroAuthFailed: + except FibaroAuthenticationFailed: errors["base"] = "invalid_auth" else: return self.async_update_reload_and_abort( diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 583c44a41e6..17357e34198 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -209,19 +209,22 @@ def mock_fibaro_client() -> Generator[Mock]: info_mock.hc_name = TEST_NAME info_mock.current_version = TEST_VERSION info_mock.platform = TEST_MODEL + info_mock.manufacturer_name = "Fibaro" + info_mock.model_name = "Home Center 2" + info_mock.mac_address = "00:22:4d:b7:13:24" with patch( "homeassistant.components.fibaro.FibaroClient", autospec=True ) as fibaro_client_mock: client = fibaro_client_mock.return_value - client.set_authentication.return_value = None - client.connect.return_value = True + client.connect_with_credentials.return_value = info_mock client.read_info.return_value = info_mock client.read_rooms.return_value = [] client.read_scenes.return_value = [] client.read_devices.return_value = [] client.register_update_handler.return_value = None client.unregister_update_handler.return_value = None + client.frontend_url.return_value = TEST_URL.removesuffix("/api/") yield client diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index 508bb81973d..aee7c2eb903 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -2,8 +2,8 @@ from unittest.mock import Mock +from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed import pytest -from requests.exceptions import HTTPError from homeassistant import config_entries from homeassistant.components.fibaro import DOMAIN @@ -23,8 +23,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_fibaro_client") async def _recovery_after_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -48,8 +50,10 @@ async def _recovery_after_failure_works( async def _recovery_after_reauth_failure_works( hass: HomeAssistant, mock_fibaro_client: Mock, result: FlowResult ) -> None: - mock_fibaro_client.connect.side_effect = None - mock_fibaro_client.connect.return_value = True + mock_fibaro_client.connect_with_credentials.side_effect = None + mock_fibaro_client.connect_with_credentials.return_value = ( + mock_fibaro_client.read_info() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -101,7 +105,9 @@ async def test_config_flow_user_initiated_auth_failure( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -119,7 +125,7 @@ async def test_config_flow_user_initiated_auth_failure( await _recovery_after_failure_works(hass, mock_fibaro_client, result) -async def test_config_flow_user_initiated_unknown_failure_1( +async def test_config_flow_user_initiated_connect_failure( hass: HomeAssistant, mock_fibaro_client: Mock ) -> None: """Unknown failure in flow manually initialized by the user.""" @@ -131,37 +137,7 @@ async def test_config_flow_user_initiated_unknown_failure_1( assert result["step_id"] == "user" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=500)) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - await _recovery_after_failure_works(hass, mock_fibaro_client, result) - - -async def test_config_flow_user_initiated_unknown_failure_2( - hass: HomeAssistant, mock_fibaro_client: Mock -) -> None: - """Unknown failure in flow manually initialized by the user.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -208,7 +184,7 @@ async def test_reauth_connect_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = Exception() + mock_fibaro_client.connect_with_credentials.side_effect = FibaroConnectFailed() result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -233,7 +209,9 @@ async def test_reauth_auth_failure( assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - mock_fibaro_client.connect.side_effect = HTTPError(response=Mock(status_code=403)) + mock_fibaro_client.connect_with_credentials.side_effect = ( + FibaroAuthenticationFailed() + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], From 5a6ffe19013cac6ce373ea54c12b80b983bd2ae9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 19:49:31 +0000 Subject: [PATCH 1113/1941] Update Bluetooth remote config entries if the MAC is corrected (#139457) * fix ble mac * fixes * fixes * fixes * restore deleted test --- .../components/bluetooth/__init__.py | 19 +++++-- .../components/bluetooth/config_flow.py | 18 +++++-- .../components/bluetooth/test_config_flow.py | 37 ++++++++++++++ tests/components/bluetooth/test_init.py | 49 +++++++++++++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c46ef22803e..7abc929fde5 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -311,11 +311,24 @@ async def async_update_device( update the device with the new location so they can figure out where the adapter is. """ + address = details[ADAPTER_ADDRESS] + connections = {(dr.CONNECTION_BLUETOOTH, address)} device_registry = dr.async_get(hass) + # We only have one device for the config entry + # so if the address has been corrected, make + # sure the device entry reflects the correct + # address + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + for conn_type, conn_value in device.connections: + if conn_type == dr.CONNECTION_BLUETOOTH and conn_value != address: + device_registry.async_update_device( + device.id, new_connections=connections + ) + break device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]), - connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])}, + name=adapter_human_name(adapter, address), + connections=connections, manufacturer=details[ADAPTER_MANUFACTURER], model=adapter_model(details), sw_version=details.get(ADAPTER_SW_VERSION), @@ -342,9 +355,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) ) + return True address = entry.unique_id assert address is not None - assert source_entry is not None source_domain = entry.data[CONF_SOURCE_DOMAIN] if mac_manufacturer := await get_manufacturer_from_mac(address): manufacturer = f"{mac_manufacturer} ({source_domain})" diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index e76277306f5..328707bd722 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -186,16 +186,28 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by an external scanner.""" source = user_input[CONF_SOURCE] await self.async_set_unique_id(source) + source_config_entry_id = user_input[CONF_SOURCE_CONFIG_ENTRY_ID] data = { CONF_SOURCE: source, CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], - CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], + CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID], } self._abort_if_unique_id_configured(updates=data) - manager = get_manager() - scanner = manager.async_scanner_by_source(source) + for entry in self._async_current_entries(include_ignore=False): + # If the mac address needs to be corrected, migrate + # the config entry to the new mac address + if ( + entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID) == source_config_entry_id + and entry.unique_id != source + ): + self.hass.config_entries.async_update_entry( + entry, unique_id=source, data={**entry.data, **data} + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="already_configured") + scanner = get_manager().async_scanner_by_source(source) assert scanner is not None return self.async_create_entry(title=scanner.name, data=data) diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index f0136396c22..45d177de132 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -608,3 +608,40 @@ async def test_async_step_integration_discovery_remote_adapter( await hass.async_block_till_done() cancel_scanner() await hass.async_block_till_done() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_step_integration_discovery_remote_adapter_mac_fix( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, +) -> None: + """Test remote adapter corrects mac address via integration discovery.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + bluetooth_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: "AA:BB:CC:DD:EE:FF", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) + bluetooth_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_SOURCE: "AA:AA:AA:AA:AA:AA", + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: entry.entry_id, + CONF_SOURCE_DEVICE_ID: None, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert bluetooth_entry.unique_id == "AA:AA:AA:AA:AA:AA" + assert bluetooth_entry.data[CONF_SOURCE] == "AA:AA:AA:AA:AA:AA" diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 2c8c9e70e7f..de299c58b93 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -3300,3 +3300,52 @@ async def test_cleanup_orphened_remote_scanner_config_entry( assert not hass.config_entries.async_entry_for_domain_unique_id( "bluetooth", scanner.source ) + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_fix_incorrect_mac_remote_scanner_config_entry( + hass: HomeAssistant, +) -> None: + """Test the remote scanner config entries can replace a incorrect mac.""" + source_entry = MockConfigEntry(domain="test") + source_entry.add_to_hass(hass) + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:FF", "esp32", connector, True) + assert scanner.source == "AA:BB:CC:DD:EE:FF" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SOURCE: scanner.source, + CONF_SOURCE_DOMAIN: "test", + CONF_SOURCE_MODEL: "test", + CONF_SOURCE_CONFIG_ENTRY_ID: source_entry.entry_id, + }, + unique_id=scanner.source, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) + await hass.config_entries.async_unload(entry.entry_id) + + new_scanner = FakeRemoteScanner("AA:BB:CC:DD:EE:AA", "esp32", connector, True) + assert new_scanner.source == "AA:BB:CC:DD:EE:AA" + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_SOURCE: new_scanner.source}, + unique_id=new_scanner.source, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", new_scanner.source + ) + # Incorrect connection should be removed + assert not hass.config_entries.async_entry_for_domain_unique_id( + "bluetooth", scanner.source + ) From 0f615bbe4f25094c503192ff7e363e2ce8748090 Mon Sep 17 00:00:00 2001 From: Cameron Ring Date: Fri, 28 Feb 2025 11:50:39 -0800 Subject: [PATCH 1114/1941] Add OptionsFlowHandler test for Lutron (#139463) --- tests/components/lutron/test_config_flow.py | 42 ++++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/components/lutron/test_config_flow.py b/tests/components/lutron/test_config_flow.py index 47b2a4891cf..df861fafffe 100644 --- a/tests/components/lutron/test_config_flow.py +++ b/tests/components/lutron/test_config_flow.py @@ -6,11 +6,11 @@ from urllib.error import HTTPError import pytest -from homeassistant.components.lutron.const import DOMAIN +from homeassistant.components.lutron.const import CONF_DEFAULT_DIMMER_LEVEL, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from tests.common import MockConfigEntry @@ -146,3 +146,41 @@ MOCK_DATA_IMPORT = { CONF_USERNAME: "lutron", CONF_PASSWORD: "integration", } + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options flow.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA_STEP, + unique_id="12345678901", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + # Try to set an out of range dimmer level (260) + out_of_range_level = 260 + + # The voluptuous validation will raise an exception before the handler processes it + with pytest.raises(InvalidData): + await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: out_of_range_level}, + ) + + # Now try with a valid value + valid_level = 100 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_DIMMER_LEVEL: valid_level}, + ) + + # Verify that the flow finishes successfully with the valid value + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_DEFAULT_DIMMER_LEVEL: valid_level} From 32950df0b700a74346a5c43a90675dfe525be9ba Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 28 Feb 2025 20:51:56 +0100 Subject: [PATCH 1115/1941] 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 c21234672dc5f1ee169502a49a7a0f94789f2c10 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 28 Feb 2025 20:56:43 +0100 Subject: [PATCH 1116/1941] 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 ed06831e9d401d15b85d986bfff31bfdd30cdc90 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 1117/1941] 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 3f48826370431da963c16746455b35cfba731db6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 21:06:45 +0100 Subject: [PATCH 1118/1941] 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 dbaa1bd3b1e..049b307e399 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 693e9002389..dbec7989182 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 00b7c4f9ef74211e92f2f304312d5f7a39c66b41 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 22:30:57 +0100 Subject: [PATCH 1119/1941] 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 ac4c379a0ed98484ce88ca8317c8260964396731 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 21:42:33 +0000 Subject: [PATCH 1120/1941] 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 049b307e399..12fa6c7c7df 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 dbec7989182..d11597c908c 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 2d6068b8426238a2c39b794d2d874c5fe3176d91 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 22:58:35 +0100 Subject: [PATCH 1121/1941] Create device for the hub in SmartThings (#139545) * Create device for the hub in SmartThings * Create device for the hub in SmartThings * Create device for the hub in SmartThings --- .../components/smartthings/__init__.py | 12 +- .../components/smartthings/entity.py | 5 + .../fixtures/device_status/hub.json | 3 + .../aeotec_home_energy_meter_gen5.json | 1 - .../fixtures/devices/base_electric_meter.json | 1 - .../fixtures/devices/centralite.json | 1 - .../fixtures/devices/contact_sensor.json | 1 - .../fixtures/devices/fake_fan.json | 1 - .../devices/ge_in_wall_smart_dimmer.json | 1 - .../smartthings/fixtures/devices/hub.json | 718 ++++++++++++++++++ .../smartthings/fixtures/devices/iphone.json | 1 - .../fixtures/devices/multipurpose_sensor.json | 1 - .../fixtures/devices/smart_plug.json | 1 - .../fixtures/devices/sonos_player.json | 1 - .../yale_push_button_deadbolt_lock.json | 1 - .../smartthings/snapshots/test_init.ambr | 33 + tests/components/smartthings/test_init.py | 34 +- 17 files changed, 802 insertions(+), 14 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/hub.json create mode 100644 tests/components/smartthings/fixtures/devices/hub.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index d6de1d3d252..2bacd476332 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -99,6 +99,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) except SmartThingsAuthenticationFailedError as err: raise ConfigEntryAuthFailed from err + device_registry = dr.async_get(hass) + for dev in device_status.values(): + for component in dev.device.components: + if component.id == MAIN and Capability.BRIDGE in component.capabilities: + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, dev.device.device_id)}, + name=dev.device.label, + ) scenes = { scene.scene_id: scene for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) @@ -124,7 +133,6 @@ 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( @@ -132,7 +140,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for identifier in device_entry.identifiers if identifier[0] == DOMAIN ) - if device_id in entry.runtime_data.devices: + if device_id in device_status: continue device_registry.async_update_device( device_entry.id, remove_config_entry_id=entry.entry_id diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 0d6ee32b473..790f3672680 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -44,6 +44,11 @@ class SmartThingsEntity(Entity): identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, ) + if device.device.parent_device_id: + self._attr_device_info["via_device"] = ( + DOMAIN, + device.device.parent_device_id, + ) if (ocf := device.device.ocf) is not None: self._attr_device_info.update( { diff --git a/tests/components/smartthings/fixtures/device_status/hub.json b/tests/components/smartthings/fixtures/device_status/hub.json new file mode 100644 index 00000000000..98ff4c3a8b4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/hub.json @@ -0,0 +1,3 @@ +{ + "components": {} +} 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 index 5ef0e2fd9eb..ab2fe41c678 100644 --- a/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json +++ b/tests/components/smartthings/fixtures/devices/aeotec_home_energy_meter_gen5.json @@ -45,7 +45,6 @@ } ], "createTime": "2023-01-12T23:02:44.917Z", - "parentDeviceId": "6a2d07a4-dd77-48bc-9acf-017029aaf099", "profile": { "id": "6372c227-93c7-32ef-9be5-aef2221adff1" }, diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json index 9e0c130978c..4d00d6f169c 100644 --- a/tests/components/smartthings/fixtures/devices/base_electric_meter.json +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -37,7 +37,6 @@ } ], "createTime": "2023-06-03T16:23:57.284Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "d382796f-8ed5-3088-8735-eb03e962203b" }, diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json index 68cdbdf4499..dff2be78f70 100644 --- a/tests/components/smartthings/fixtures/devices/centralite.json +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -45,7 +45,6 @@ } ], "createTime": "2024-08-15T22:16:37.926Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "24195ea4-635c-3450-a235-71bc78ab3d1c" }, diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index a5de2e2cbfe..92fe6a8bbff 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -49,7 +49,6 @@ } ], "createTime": "2023-09-28T17:38:59.179Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "22aa5a07-ac33-365f-b2f1-5ecef8cdb0eb" }, diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json index 7b8e174d420..8656e290c8d 100644 --- a/tests/components/smartthings/fixtures/devices/fake_fan.json +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -36,7 +36,6 @@ } ], "createTime": "2023-01-12T23:02:44.917Z", - "parentDeviceId": "6a2dd7a4-dd77-48bc-9acf-017029aaf099", "profile": { "id": "6372cd27-93c7-32ef-9be5-aef2221adff1" }, 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 index 910eacec2cc..314586300b9 100644 --- a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -40,7 +40,6 @@ } ], "createTime": "2020-05-25T18:18:01Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "ec5458c2-c011-3479-a59b-82b42820c2f7" }, diff --git a/tests/components/smartthings/fixtures/devices/hub.json b/tests/components/smartthings/fixtures/devices/hub.json new file mode 100644 index 00000000000..4de0823d758 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/hub.json @@ -0,0 +1,718 @@ +{ + "items": [ + { + "deviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "name": "SmartThings v2 Hub", + "label": "Home Hub", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "63f1469e-dc4a-3689-8cc5-69e293c1eb21", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "bridge", + "version": 1 + } + ], + "categories": [ + { + "name": "Hub", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2016-11-13T18:18:07Z", + "childDevices": [ + { + "deviceId": "0781c9d0-92cb-4c7b-bb5b-2f2dbe0c41f3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "08ee0358-9f40-4afa-b5a0-3a6aba18c267", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "09076422-62cc-4b2d-8beb-b53bc451c704", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "0b5577db-5074-4b70-a2c5-efec286d264d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "115236ea-59e5-4cd4-bade-d67c409967bc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1691801c-ae59-438b-89dc-f2c761fe937d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "1a987293-0962-4447-99d4-aa82655ffb55", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2533fdd0-e064-4fa2-b77b-1e17260b58d7", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "265e653b-3c0b-4fa6-8e2a-f6a69c7040f0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "277e0a96-c8ec-41aa-b4cf-0bac57dc1cee", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "2d9a892b-1c93-45a5-84cb-0e81889498c6", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "37c0cdda-9158-41ad-9635-4ca32df9fe5b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "3f82e13c-bd39-4043-bb54-7432a4e47113", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4339f999-1ad2-46fb-9103-cb628b30a022", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4a59f635-9f0a-4a6c-a2f0-ffb7ef182a7c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4c3469c9-3556-4f19-a2e1-1c0a598341dc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "4fddedf0-2662-476e-b1fd-aceaec17ad3a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "550a1c72-65a0-4d55-b97b-75168e055398", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "630cf009-eb3b-409e-a77a-9b298540532f", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6356b240-c7d8-403c-883e-ae438d432abe", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "68e786a6-7f61-4c3a-9e13-70b803cf782b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6a2e5058-36f3-4668-aa43-49a66f8df93d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6b5535c7-c039-42ee-9970-8af86c6b0775", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6c1b7cfa-7429-4f35-9d02-ab1dfd2f1297", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6ca56087-481f-4e93-9727-fb91049fe396", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6e3e44b3-d84a-4efc-a97b-b5e0dae28ddc", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "6f4d2e72-7af4-4c96-97ab-d6b6a0d6bc4b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7111243f-39d6-4ed0-a277-f040e40a806d", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7b9d924a-de0c-44f9-ac5c-f15869c59411", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7bedac4c-5681-4897-a2ef-e9153cb19ba0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "7d246592-93db-4d72-a10d-5a51793ece8c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "803cb0d9-addd-4c2d-aaef-d4e20bf88228", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "829da938-6e92-4a93-8923-7c67f9663c03", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "84f1eaf0-592e-459a-a2b3-4fc43e004dae", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8eacf25f-aa33-4d9e-ba90-0e4ac3ceb8e0", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "8f873071-a9aa-4580-a736-8f5f696e044a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "91172212-e9ff-4ca6-9626-e7af0361c9ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "92138ee5-d3bf-4348-98e8-445dedc319cb", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "971b05df-6ed3-446e-b54f-5092eac01921", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9a9cb299-5279-4dea-9249-b5c153d22ba1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9b479ba0-81e1-4877-87c5-c301a87cbdab", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "9dd17f8f-cf5e-4647-a11c-d8f24cdf9b2a", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a1e6525c-1e24-403c-b18c-eecb65e22ccf", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9d42ef0-f972-44b0-86bc-efd6569a1aef", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "a9f587c5-5d8b-4273-8907-e7f609af5158", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "aaedaf28-2ae0-4c1d-b57e-87f6a420c298", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b3a84295-ac3c-4fb1-95e4-4a4bbb1b0bce", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "b90c085d-7d1f-4abc-a66d-d5ce3f96be02", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "bafc5147-2e48-498b-97ff-34c93fae7814", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c1107a0c-fa71-43c5-8ff9-a128ea6c4f20", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5209cd2-fcb5-46be-b685-5b05f22dcb2c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "c5699ff6-af09-4922-901d-bb81b8345bc3", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "cfcd9a21-a943-4519-9972-3c7890cd25b1", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d0268a69-abfb-4c92-a646-61cec2e510ad", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d20891e5-59b4-46ce-9184-b7fdf0c7ae4c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "d48848b9-25b0-4423-8fcf-96a022ac571e", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "ea2aa187-40fd-4140-9742-453e691c4469", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f27d0b27-24fd-4d8c-b003-d3d7aaba1e70", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3c18803-cbec-48e3-8f15-3c31f302d68b", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f3e184b2-631a-47b2-b583-32ac2fec9e3c", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + }, + { + "deviceId": "f4e0517a-d94f-4bd6-a464-222c8c413a66", + "profile": {}, + "allowed": null, + "executionContext": "CLOUD" + } + ], + "profile": { + "id": "d77ba2f6-c377-36f5-bb68-15db9d1aa0e1" + }, + "hub": { + "hubEui": "D052A872947A0001", + "firmwareVersion": "000.055.00005", + "hubDrivers": [ + { + "driverVersion": "2025-01-19T15:05:25.835006968", + "driverId": "00425c55-0932-416f-a1ba-78fae98ab614", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-17T18:00:36.611958104", + "driverId": "01976eca-e7ff-4d1b-91db-9c980ce668d7", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.572636846", + "driverId": "0f206d13-508e-4342-9cbb-937e02489141", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:07.735400483", + "driverId": "2cbf55e3-dbc2-48a2-8be5-4c3ce756b692", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-11-04T22:39:17.976631549", + "driverId": "3fb97b6c-f481-441b-a14e-f270d738764e", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:51.437710641", + "driverId": "408981c2-91d4-4dfc-bbfb-84ca0205d993", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:35.032104982", + "driverId": "4eb5b19a-7bbc-452f-859b-c6d7d857b2da", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-08-08T18:58:32.479650566", + "driverId": "4fb7ec02-2697-4d73-977d-2b1c65c4484f", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:47.743217473", + "driverId": "572a2641-2af8-47e4-bfe5-ad83748fd7a1", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2023-07-12T03:33:26.23424277", + "driverId": "5ad2cc83-5503-4040-a98b-b0fc9931b9fe", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-17T20:08:25.82515546", + "driverId": "5db3363a-d954-412f-93e0-2ee40572658b", + "channelId": "2423da55-101c-4b21-af58-0903656b85ca" + }, + { + "driverVersion": "2024-12-08T10:10:03.832334965", + "driverId": "6342be70-6da0-4535-afc1-ff6378d6c650", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-02-01T21:35:33.624882", + "driverId": "6a90f7a0-e275-4366-bbf2-2e8a502efc5d", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2024-09-28T21:56:32.002090649", + "driverId": "7333473f-722c-465d-9e5d-f3a6ca760489", + "channelId": "f8900c5e-d591-4979-9826-75a867e9e0bd" + }, + { + "driverVersion": "2025-02-03T22:38:47.582952919", + "driverId": "7beb8bc2-8dfa-45c2-8fdb-7373d4597b12", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-11-15T16:18:24.739596514", + "driverId": "7ca45ba9-7cfe-4547-b752-fe41a0efb848", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-02-06T21:13:39.427465986", + "driverId": "8bf71a5d-677b-4391-93c2-e76471f3d7eb", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-21T19:06:49.949052991", + "driverId": "9050ac53-358c-47a1-a927-9a70f5f28cee", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:29.754256377", + "driverId": "92f39ab3-7b2f-47ee-94a7-ba47c4ee8a47", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-17T18:00:21.846431345", + "driverId": "9870bccd-2b3d-4edf-8940-532fcb11e946", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-12-09T21:10:00.424854506", + "driverId": "a6994e70-93de-4a76-8b5d-42971a3427c9", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2022-01-03T08:19:45.80869", + "driverId": "a89371c4-8765-404b-9de9-e9882cc48bd8", + "channelId": "14bcc056-f80d-416b-9445-467b0db325e3" + }, + { + "driverVersion": "2025-01-11T20:03:43.842469565", + "driverId": "b1504ded-efa4-4ef0-acd5-ae24e7a92e6e", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-12-08T09:45:01.460678797", + "driverId": "bb1b3fd4-dcba-4d55-8d85-58ed7f1979fb", + "channelId": "c8bb99e1-04a3-426b-9d94-2d260134d624" + }, + { + "driverVersion": "2024-11-04T22:39:18.253781754", + "driverId": "c21a6c77-872c-474e-be5b-5f6f11a240ef", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-01-30T21:36:15.547412569", + "driverId": "c856a3fd-69ee-4478-a224-d7279b6d978f", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2025-01-13T18:55:57.509807915", + "driverId": "cd898d81-6c27-4d27-a529-dfadc8caae5a", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-12-17T18:00:48.892833142", + "driverId": "ce930ffd-8155-4dca-aaa9-6c4158fc4278", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T19:30:41.208767469", + "driverId": "d620900d-f7bc-4ab5-a171-6dd159872f7d", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2024-10-10T19:30:33.46670456", + "driverId": "d6b43c85-1561-446b-9e3e-15e2ad81a952", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2023-07-11T18:43:49.169154271", + "driverId": "d9c3f8b8-c3c3-4b77-9ddd-01d08102c84b", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2024-10-10T18:17:54.195543653", + "driverId": "dbe192cb-f6a1-4369-a843-d1c42e5c91ba", + "channelId": "15ea8adc-8be7-4ea6-8b51-4155f56dc6cf" + }, + { + "driverVersion": "2022-10-02T20:15:49.147522379", + "driverId": "e120daf2-8000-4a9d-93fa-653214ce70d1", + "channelId": "479886db-f6f5-41dd-979c-9c5f9366f070" + }, + { + "driverVersion": "2023-08-15T20:08:28.115440571", + "driverId": "e7947a05-947d-4bb5-92c4-2aafaff6d69c", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + }, + { + "driverVersion": "2025-02-05T18:49:13.3338494", + "driverId": "f2e891c6-00cc-446c-9192-8ebda63d9898", + "channelId": "b1373fea-da9b-434b-b674-6694ce5d08cc" + } + ], + "hubData": { + "zwaveStaticDsk": "13740-14339-50623-49310-29679-58685-46457-16097", + "zwaveS2": true, + "hardwareType": "V2_HUB", + "hardwareId": "000D", + "zigbeeFirmware": "5.7.10", + "zigbee3": true, + "zigbeeOta": "ENABLED_WITH_LIGHTS", + "otaEnable": "true", + "zigbeeUnsecureRejoin": true, + "zigbeeRequiresExternalHardware": false, + "threadRequiresExternalHardware": false, + "failoverAvailability": "Unsupported", + "primarySupportAvailability": "Unsupported", + "secondarySupportAvailability": "Unsupported", + "zigbeeAvailability": "Available", + "zwaveAvailability": "Available", + "lanAvailability": "Available", + "matterAvailability": "Available", + "localVirtualDeviceAvailability": "Available", + "childDeviceAvailability": "Unsupported", + "edgeDriversAvailability": "Available", + "hubReplaceAvailability": "Available", + "hubLocalApiAvailability": "Available", + "zigbeeManualFirmwareUpdateSupported": true, + "matterRendezvousHedgeSupported": true, + "matterSoftwareComponentVersion": "1.3-0", + "matterDeviceDiagnosticsAvailability": "Available", + "zigbeeDeviceDiagnosticsAvailability": "Available", + "hedgeTlsCertificate": "", + "zigbeeChannel": "14", + "zigbeePanId": "0EE7", + "zigbeeEui": "D052A872947A0001", + "zigbeeNodeID": "0000", + "zwaveNodeID": "01", + "zwaveHomeID": "CF0F089E", + "zwaveSucID": "01", + "zwaveVersion": "6.10", + "zwaveRegion": "US", + "macAddress": "D0:52:A8:72:91:02", + "localIP": "192.168.168.189", + "zigbeeRadioFunctional": true, + "zwaveRadioFunctional": true, + "zigbeeRadioEnabled": true, + "zwaveRadioEnabled": true, + "zigbeeRadioDetected": true, + "zwaveRadioDetected": true + } + }, + "type": "HUB", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + }, + { + "deviceId": "374ba6fa-5a08-4ea2-969c-1fa43d86e21f", + "name": "Multipurpose Sensor", + "label": "Mail Box", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", + "deviceManufacturerCode": "SmartThings", + "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", + "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", + "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "components": [ + { + "id": "main", + "label": "Mail Box", + "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": "MultiFunctionalSensor", + "categoryType": "user" + } + ] + } + ], + "createTime": "2022-08-16T21:08:09.983Z", + "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", + "profile": { + "id": "4471213f-121b-38fd-b022-51df37ac1d4c" + }, + "zigbee": { + "eui": "24FD5B00010A3A95", + "networkId": "E71B", + "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/iphone.json b/tests/components/smartthings/fixtures/devices/iphone.json index 3fc26307c90..1ae79aa06ef 100644 --- a/tests/components/smartthings/fixtures/devices/iphone.json +++ b/tests/components/smartthings/fixtures/devices/iphone.json @@ -27,7 +27,6 @@ } ], "createTime": "2021-12-02T16:14:24.394Z", - "parentDeviceId": "b8e11599-5297-4574-8e62-885995fcaa20", "profile": { "id": "21d0f660-98b4-3f7b-8114-fe62e555628e" }, diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json index 3770614a366..b056ecf007b 100644 --- a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -56,7 +56,6 @@ } ], "createTime": "2019-02-23T16:53:57Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "4471213f-121b-38fd-b022-51df37ac1d4c" }, diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json index 24d0fbc6e84..105ae43c3d0 100644 --- a/tests/components/smartthings/fixtures/devices/smart_plug.json +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -37,7 +37,6 @@ } ], "createTime": "2018-10-05T12:23:14Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "daeff874-075a-32e3-8b11-bdb99d8e67c7" }, diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json index 67d1ef24cf9..f7f54a01b49 100644 --- a/tests/components/smartthings/fixtures/devices/sonos_player.json +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -61,7 +61,6 @@ } ], "createTime": "2025-02-02T13:18:28.570Z", - "parentDeviceId": "2f7f7d2b-e683-48ae-86f7-e57df6a0bce2", "profile": { "id": "0443d359-3f76-383f-82a4-6fc4a879ef1d" }, 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 index e83a1be7644..117aa1344cb 100644 --- a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -45,7 +45,6 @@ } ], "createTime": "2016-11-18T23:01:19Z", - "parentDeviceId": "074fa784-8be8-4c70-8e22-6f5ed6f81b7e", "profile": { "id": "51b76691-3c3a-3fce-8c7c-4f9d50e5885a" }, diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0b5aeb57c18..f3ed12a9a9a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1022,3 +1022,36 @@ 'via_device_id': None, }) # --- +# name: test_hub_via_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '074fa784-8be8-4c70-8e22-6f5ed6f81b7e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Home Hub', + '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/test_init.py b/tests/components/smartthings/test_init.py index 372f23eec42..3ffe2c11a42 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from pysmartthings import DeviceResponse, DeviceStatus import pytest from syrupy import SnapshotAssertion @@ -11,7 +12,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture async def test_devices( @@ -50,3 +51,34 @@ async def test_removing_stale_devices( await hass.async_block_till_done() assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) + + +async def test_hub_via_device( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + mock_smartthings: AsyncMock, +) -> None: + """Test hub with child devices.""" + mock_smartthings.get_devices.return_value = DeviceResponse.from_json( + load_fixture("devices/hub.json", DOMAIN) + ).items + mock_smartthings.get_device_status.side_effect = [ + DeviceStatus.from_json( + load_fixture(f"device_status/{fixture}.json", DOMAIN) + ).components + for fixture in ("hub", "multipurpose_sensor") + ] + await setup_integration(hass, mock_config_entry) + + hub_device = device_registry.async_get_device( + {(DOMAIN, "074fa784-8be8-4c70-8e22-6f5ed6f81b7e")} + ) + assert hub_device == snapshot + assert ( + device_registry.async_get_device( + {(DOMAIN, "374ba6fa-5a08-4ea2-969c-1fa43d86e21f")} + ).via_device_id + == hub_device.id + ) From b43a7ff1fe5ea8d4f8099f6011e422ee58510d13 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2025 22:01:39 +0000 Subject: [PATCH 1122/1941] Stream the TTS result from webview (#139543) --- homeassistant/components/tts/__init__.py | 36 ++++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 5b2da44eae2..32c4ba20670 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncGenerator from dataclasses import dataclass from datetime import datetime import hashlib @@ -379,7 +380,7 @@ class ResultStream: """Class that will stream the result when available.""" # Streaming/conversion properties - url: str + token: str extension: str content_type: str @@ -391,6 +392,11 @@ class ResultStream: _manager: SpeechManager + @cached_property + def url(self) -> str: + """Get the URL to stream the result.""" + return f"/api/tts_proxy/{self.token}" + @cached_property def _result_cache_key(self) -> asyncio.Future[str]: """Get the future that returns the cache key.""" @@ -401,11 +407,11 @@ class ResultStream: """Set cache key for message to be streamed.""" self._result_cache_key.set_result(cache_key) - async def async_get_result(self) -> bytes: + async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" cache_key = await self._result_cache_key _extension, data = await self._manager.async_get_tts_audio(cache_key) - return data + yield data def _hash_options(options: dict) -> str: @@ -603,7 +609,7 @@ class SpeechManager: token = f"{secrets.token_urlsafe(16)}.{extension}" content, _ = mimetypes.guess_type(token) result_stream = ResultStream( - url=f"/api/tts_proxy/{token}", + token=token, extension=extension, content_type=content or "audio/mpeg", use_file_cache=use_file_cache, @@ -1027,20 +1033,32 @@ class TextToSpeechView(HomeAssistantView): """Initialize a tts view.""" self.manager = manager - async def get(self, request: web.Request, token: str) -> web.Response: + async def get(self, request: web.Request, token: str) -> web.StreamResponse: """Start a get request.""" stream = self.manager.token_to_stream.get(token) if stream is None: return web.Response(status=HTTPStatus.NOT_FOUND) + response: web.StreamResponse | None = None try: - data = await stream.async_get_result() - except HomeAssistantError as err: - _LOGGER.error("Error on get tts: %s", err) + async for data in stream.async_stream_result(): + if response is None: + response = web.StreamResponse() + response.content_type = stream.content_type + await response.prepare(request) + + await response.write(data) + # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 + _LOGGER.error("Error streaming tts: %s", err) + + # Empty result or exception happened + if response is None: return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) - return web.Response(body=data, content_type=stream.content_type) + await response.write_eof() + return response @websocket_api.websocket_command( From b1ee019e3a99a4ecee17b3edf8bbedfcbd794683 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:02:06 +0100 Subject: [PATCH 1123/1941] 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 12fa6c7c7df..bc33b7f17c7 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 d11597c908c..a4620bc21de 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 db05aa17d3675d82fc6fc28bcc442d114beb24ef Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:03:57 +0100 Subject: [PATCH 1124/1941] 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 790f3672680..0240549740f 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -58,6 +58,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 f3ed12a9a9a..7f0e5c17cf2 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 ee1fe2cae45f3b9524f48776182a7f397f4f8973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 22:17:44 +0000 Subject: [PATCH 1125/1941] Bump bleak-esphome to 2.9.0 (#139467) * Bump bleak-esphome to 2.9.0 changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.8.0...v2.9.0 * fixes --- .../components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/__init__.py | 6 +- homeassistant/components/esphome/const.py | 1 + .../components/esphome/diagnostics.py | 8 ++- homeassistant/components/esphome/manager.py | 29 +++++++-- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 15 +++++ tests/components/esphome/test_bluetooth.py | 20 +++--- tests/components/esphome/test_diagnostics.py | 7 ++- tests/components/esphome/test_manager.py | 63 +++++++++++++++++++ 12 files changed, 131 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 18dcbb5cb65..68781282d66 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.8.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.9.0"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index fee2531fa20..1e1a2763b59 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN +from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN from .dashboard import async_setup as async_setup_dashboard from .domain_data import DomainData @@ -87,6 +87,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None: """Remove an esphome config entry.""" - if mac_address := entry.unique_id: - async_remove_scanner(hass, mac_address.upper()) + if bluetooth_mac_address := entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS): + async_remove_scanner(hass, bluetooth_mac_address.upper()) await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index eb5f03c4495..a31f5441dbb 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -8,6 +8,7 @@ CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" CONF_SUBSCRIBE_LOGS = "subscribe_logs" CONF_DEVICE_NAME = "device_name" CONF_NOISE_PSK = "noise_psk" +CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address" DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index c68bd560791..0903e874a15 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -25,13 +25,17 @@ async def async_get_config_entry_diagnostics( diag["config"] = config_entry.as_dict() entry_data = config_entry.runtime_data + device_info = entry_data.device_info if (storage_data := await entry_data.store.async_load()) is not None: diag["storage_data"] = storage_data if ( - config_entry.unique_id - and (scanner := async_scanner_by_source(hass, config_entry.unique_id.upper())) + device_info + and ( + scanner_mac := device_info.bluetooth_mac_address or device_info.mac_address + ) + and (scanner := async_scanner_by_source(hass, scanner_mac.upper())) and (bluetooth_device := entry_data.bluetooth_device) ): diag["bluetooth"] = { diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index e32bb7d6ded..0a47fb66815 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -63,6 +63,7 @@ from homeassistant.util.async_ import create_eager_task from .bluetooth import async_connect_scanner from .const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, @@ -431,6 +432,13 @@ class ESPHomeManager: device_mac = format_mac(device_info.mac_address) mac_address_matches = unique_id == device_mac + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address}, + ) # # Migrate config entry to new unique ID if the current # unique id is not a mac address. @@ -498,7 +506,9 @@ class ESPHomeManager: ) ) else: - bluetooth.async_remove_scanner(hass, device_info.mac_address) + bluetooth.async_remove_scanner( + hass, device_info.bluetooth_mac_address or device_info.mac_address + ) if device_info.voice_assistant_feature_flags_compat(api_version) and ( Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms @@ -617,11 +627,22 @@ class ESPHomeManager: ) _setup_services(hass, entry_data, services) - if entry_data.device_info is not None and entry_data.device_info.name: - reconnect_logic.name = entry_data.device_info.name + if (device_info := entry_data.device_info) is not None: + if device_info.name: + reconnect_logic.name = device_info.name + if ( + bluetooth_mac_address := device_info.bluetooth_mac_address + ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address, + }, + ) if entry.unique_id is None: hass.config_entries.async_update_entry( - entry, unique_id=format_mac(entry_data.device_info.mac_address) + entry, unique_id=format_mac(device_info.mac_address) ) await reconnect_logic.start() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b4360077604..b97878d11b5 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.3.1", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.8.0" + "bleak-esphome==2.9.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index bc33b7f17c7..8c64934cc45 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.8.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4620bc21de..0a45a3c4015 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.8.0 +bleak-esphome==2.9.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 94f621b8646..2786ed8324c 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -30,6 +30,7 @@ from zeroconf import Zeroconf from homeassistant.components.esphome import dashboard from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_NOISE_PSK, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, @@ -578,6 +579,19 @@ async def mock_bluetooth_entry( async def _mock_bluetooth_entry( bluetooth_proxy_feature_flags: BluetoothProxyFeature, ) -> MockESPHomeDevice: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC", + }, + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS + }, + ) + entry.add_to_hass(hass) return await _mock_generic_device_entry( hass, mock_client, @@ -587,6 +601,7 @@ async def mock_bluetooth_entry( }, ([], []), [], + entry=entry, ) return _mock_bluetooth_entry diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py index 19bc5a2e7c7..dd7a8f59fe5 100644 --- a/tests/components/esphome/test_bluetooth.py +++ b/tests/components/esphome/test_bluetooth.py @@ -13,7 +13,7 @@ async def test_bluetooth_connect_with_raw_adv( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with raw advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -21,11 +21,11 @@ async def test_bluetooth_connect_with_raw_adv( await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_raw_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -33,7 +33,7 @@ async def test_bluetooth_connect_with_legacy_adv( hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice ) -> None: """Test bluetooth connect with legacy advertisements.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True assert scanner.scanning is True @@ -41,11 +41,11 @@ async def test_bluetooth_connect_with_legacy_adv( await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is None await mock_bluetooth_entry_with_legacy_adv.mock_connect() await hass.async_block_till_done() - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.scanning is True @@ -55,10 +55,10 @@ async def test_bluetooth_device_linked_via_device( device_registry: dr.DeviceRegistry, ) -> None: """Test the Bluetooth device is linked to the ESPHome device.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True entry = hass.config_entries.async_entry_for_domain_unique_id( - "bluetooth", "11:22:33:44:55:AA" + "bluetooth", "AA:BB:CC:DD:EE:FC" ) assert entry is not None esp_device = device_registry.async_get_device( @@ -71,7 +71,7 @@ async def test_bluetooth_device_linked_via_device( ) assert esp_device is not None device = device_registry.async_get_device( - connections={(dr.CONNECTION_BLUETOOTH, "11:22:33:44:55:AA")} + connections={(dr.CONNECTION_BLUETOOTH, "AA:BB:CC:DD:EE:FC")} ) assert device is not None assert device.via_device_id == esp_device.id @@ -81,7 +81,7 @@ async def test_bluetooth_cleanup_on_remove_entry( hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice ) -> None: """Test bluetooth is cleaned up on entry removal.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner.connectable is True await hass.config_entries.async_unload( mock_bluetooth_entry_with_raw_adv.entry.entry_id diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index a4b858ed7de..2d64170bc97 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -37,7 +37,7 @@ async def test_diagnostics_with_bluetooth( mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, ) -> None: """Test diagnostics for config entry with Bluetooth.""" - scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + scanner = bluetooth.async_scanner_by_source(hass, "AA:BB:CC:DD:EE:FC") assert scanner is not None assert scanner.connectable is True entry = mock_bluetooth_entry_with_raw_adv.entry @@ -55,9 +55,9 @@ async def test_diagnostics_with_bluetooth( "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, - "name": "test (11:22:33:44:55:AA)", + "name": "test (AA:BB:CC:DD:EE:FC)", "scanning": True, - "source": "11:22:33:44:55:AA", + "source": "AA:BB:CC:DD:EE:FC", "start_time": ANY, "time_since_last_device_detection": {}, "type": "ESPHomeScanner", @@ -66,6 +66,7 @@ async def test_diagnostics_with_bluetooth( "config": { "created_at": ANY, "data": { + "bluetooth_mac_address": "**REDACTED**", "device_name": "test", "host": "test.local", "password": "", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index b805b065d5a..ddb1babd8a4 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -25,6 +25,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_BLUETOOTH_MAC_ADDRESS, CONF_DEVICE_NAME, CONF_SUBSCRIBE_LOGS, DOMAIN, @@ -475,6 +476,39 @@ async def test_unique_id_updated_to_mac(hass: HomeAssistant, mock_client) -> Non assert entry.unique_id == "11:22:33:44:55:aa" +@pytest.mark.usefixtures("mock_zeroconf") +async def test_add_missing_bluetooth_mac_address( + hass: HomeAssistant, mock_client +) -> None: + """Test bluetooth mac is added if its missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="mock-config-name", + ) + entry.add_to_hass(hass) + subscribe_done = hass.loop.create_future() + + def async_subscribe_states(*args, **kwargs) -> None: + subscribe_done.set_result(None) + + mock_client.subscribe_states = async_subscribe_states + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455aa", + bluetooth_mac_address="AA:BB:CC:DD:EE:FF", + ) + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + async with asyncio.timeout(1): + await subscribe_done + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) == "AA:BB:CC:DD:EE:FF" + + @pytest.mark.usefixtures("mock_zeroconf") async def test_unique_id_not_updated_if_name_same_and_already_mac( hass: HomeAssistant, mock_client: APIClient @@ -1337,3 +1371,32 @@ async def test_entry_missing_unique_id( await mock_esphome_device(mock_client=mock_client, mock_storage=True) await hass.async_block_till_done() assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_entry_missing_bluetooth_mac_address( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test the bluetooth_mac_address is added if available.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=None, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + options={CONF_ALLOW_SERVICE_CALLS: True}, + ) + entry.add_to_hass(hass) + await mock_esphome_device( + mock_client=mock_client, + mock_storage=True, + device_info={"bluetooth_mac_address": "AA:BB:CC:DD:EE:FC"}, + ) + await hass.async_block_till_done() + assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC" From 577b22374a0bb00839c1770278df5b7d96168e78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2025 22:25:50 +0000 Subject: [PATCH 1126/1941] 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 d6750624ce020bd6a6ba2429cb4ecc3817db2f0a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 28 Feb 2025 23:32:09 +0100 Subject: [PATCH 1127/1941] Add SmartThings hub connections (#139549) --- homeassistant/components/smartthings/__init__.py | 6 ++++++ tests/components/smartthings/snapshots/test_init.ambr | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 2bacd476332..f3a95e57831 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -103,10 +103,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for dev in device_status.values(): for component in dev.device.components: if component.id == MAIN and Capability.BRIDGE in component.capabilities: + assert dev.device.hub device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, dev.device.device_id)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address) + }, name=dev.device.label, + sw_version=dev.device.hub.firmware_version, + model=dev.device.hub.hardware_type, ) scenes = { scene.scene_id: scene diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 7f0e5c17cf2..18bc802e2bc 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1029,6 +1029,10 @@ 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ + tuple( + 'mac', + 'd0:52:a8:72:91:02', + ), }), 'disabled_by': None, 'entry_type': None, @@ -1044,14 +1048,14 @@ 'labels': set({ }), 'manufacturer': None, - 'model': None, + 'model': 'V2_HUB', 'model_id': None, 'name': 'Home Hub', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '000.055.00005', 'via_device_id': None, }) # --- From ebd6daa31d0655b6269c6345e7148f830af9772b Mon Sep 17 00:00:00 2001 From: andylittle Date: Fri, 28 Feb 2025 14:47:40 -0800 Subject: [PATCH 1128/1941] Tuya tyd fix (#135558) Add support for tuya tyd light --- homeassistant/components/tuya/light.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index d94308ebd33..67a94c4e267 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -327,6 +327,18 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness_min=DPCode.BRIGHTNESS_MIN_1, ), ), + # Outdoor Flood Light + # Not documented + "tyd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + color_data=DPCode.COLOUR_DATA, + ), + ), # Solar Light # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 "tyndj": ( From 615d79b429ad814d5597567b70b95694d5f4d25f 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 1129/1941] 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 1dc6a94093e62d8e2a9a5c6301dd421db13398db Mon Sep 17 00:00:00 2001 From: Daniele Ricci Date: Sat, 1 Mar 2025 06:15:28 +0100 Subject: [PATCH 1130/1941] Fix caldav todo list not updating after adding items with Assist (#135980) caldav: fix todo list not updating after adding items with Assist --- homeassistant/components/caldav/todo.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index fada4693cf0..73f172dabec 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -138,6 +138,8 @@ class WebDavTodoListEntity(TodoListEntity): await self.hass.async_add_executor_job( partial(self._calendar.save_todo, **item_data), ) + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -172,6 +174,8 @@ class WebDavTodoListEntity(TodoListEntity): obj_type="todo", ), ) + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -195,3 +199,5 @@ class WebDavTodoListEntity(TodoListEntity): await self.hass.async_add_executor_job(item.delete) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV delete error: {err}") from err + # refreshing async otherwise it would take too much time + self.hass.async_create_task(self.async_update_ha_state(force_refresh=True)) From 8e7960fa0ebe4125f3cf03d05029744d4cb04613 Mon Sep 17 00:00:00 2001 From: Juan Grande Date: Sat, 1 Mar 2025 00:10:35 -0800 Subject: [PATCH 1131/1941] 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 a6e2dc485bc2d951c4a37dd00b35ff7a84395127 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 09:44:04 +0000 Subject: [PATCH 1132/1941] Bump orjson to 3.10.15 (#135223) --- 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 54401a12592..1f1cb3c4f4c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ ifaddr==0.2.0 Jinja2==3.1.5 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.12 +orjson==3.10.15 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.1.0 diff --git a/pyproject.toml b/pyproject.toml index 5ee20b96bfc..6a75ffa002b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ dependencies = [ "Pillow==11.1.0", "propcache==0.3.0", "pyOpenSSL==25.0.0", - "orjson==3.10.12", + "orjson==3.10.15", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index b378688106d..76c5059e29e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ cryptography==44.0.1 Pillow==11.1.0 propcache==0.3.0 pyOpenSSL==25.0.0 -orjson==3.10.12 +orjson==3.10.15 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From 18217a594f8c17aa89e75e657f42fc4828481741 Mon Sep 17 00:00:00 2001 From: Filip Agh Date: Sat, 1 Mar 2025 11:50:24 +0100 Subject: [PATCH 1133/1941] 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 66a17bd072094f74383530b8286577b2fbb20187 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:06:16 +0100 Subject: [PATCH 1134/1941] 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 8c64934cc45..7df4c3f7b44 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 0a45a3c4015..48dc3bb48d4 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 2c620f1f6082c60aaf153c6f2380954c5c5a9093 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 12:12:36 +0100 Subject: [PATCH 1135/1941] Improve description of `door` field in `subaru.unlock_specific_door` action (#139558) * Improve description of `door` field in `subaru.unlock_specific_door` action In the UI the `door` field of the `subaru.unlock_specific_door` action presents three radio buttons for the three possible choices 'all', 'driver' and 'tailgate'. Therefore the field description should no longer repeat those options to avoid over-translation that will not match the actual choices. In addition proper sentence-casing is applied to several title keys. * Fix sentence-casing in two title keys --- homeassistant/components/subaru/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 00da729dccd..7525e73f802 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Subaru Starlink Configuration", + "title": "Subaru Starlink configuration", "description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -49,7 +49,7 @@ "options": { "step": { "init": { - "title": "Subaru Starlink Options", + "title": "Subaru Starlink options", "description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).", "data": { "update_enabled": "Enable vehicle polling" @@ -106,7 +106,7 @@ "fields": { "door": { "name": "Door", - "description": "One of the following: 'all', 'driver', 'tailgate'." + "description": "Which door(s) to open." } } } From dfe19217371436a45761b08df4622fb3210ab9f8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 12:12:58 +0100 Subject: [PATCH 1136/1941] Improve description of `media_content_type` in `media_extractor.play_media` action (#139559) * Improve `media_content_type` in `media_extractor.play_media` action In the UI the `media_content_type` field of the `media_extractor.play_media` action already presents a selector with the choices MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC. Therefore these can be removed from the field description to avoid any over-translation that will create an unnecessary mismatch in the UI. * Fix casing of `media_extractor.play_media` action name --- homeassistant/components/media_extractor/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json index 125aa08337a..11b5a884e4d 100644 --- a/homeassistant/components/media_extractor/strings.json +++ b/homeassistant/components/media_extractor/strings.json @@ -17,12 +17,12 @@ }, "media_content_type": { "name": "Media content type", - "description": "The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC." + "description": "The type of the content to play." } } }, "extract_media_url": { - "name": "Get Media URL", + "name": "Get media URL", "description": "Extract media URL from a service.", "fields": { "url": { From 042e4d20c5f2fb45c623e1859e0fd67ca68f27c8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 1 Mar 2025 12:37:44 +0100 Subject: [PATCH 1137/1941] 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 7df4c3f7b44..efa3da8d3d3 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 48dc3bb48d4..527d9f654dc 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 fe5cd5c55c89f5a4b1d56a7b4a59743907b10983 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 12:47:58 +0100 Subject: [PATCH 1138/1941] 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 1852052dffa1175ea80cb4cfb987ee98d2fc4d00 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 13:05:58 +0100 Subject: [PATCH 1139/1941] Add suggested area to SmartThings (#139570) * Add suggested area to SmartThings * Add suggested areas to SmartThings --- .../components/smartthings/__init__.py | 9 ++ .../components/smartthings/binary_sensor.py | 10 +- .../components/smartthings/climate.py | 14 ++- homeassistant/components/smartthings/cover.py | 11 +- .../components/smartthings/entity.py | 9 +- homeassistant/components/smartthings/fan.py | 7 +- homeassistant/components/smartthings/light.py | 7 +- homeassistant/components/smartthings/lock.py | 2 +- .../components/smartthings/sensor.py | 12 ++- .../components/smartthings/switch.py | 4 +- tests/components/smartthings/conftest.py | 4 + .../fixtures/devices/base_electric_meter.json | 2 +- .../devices/c2c_arlo_pro_3_switch.json | 2 +- .../fixtures/devices/centralite.json | 2 +- .../fixtures/devices/contact_sensor.json | 2 +- .../fixtures/devices/da_ac_rac_000001.json | 2 +- .../fixtures/devices/da_ac_rac_01001.json | 2 +- .../devices/da_ks_microwave_0101x.json | 2 +- .../devices/da_ref_normal_000001.json | 2 +- .../devices/da_rvc_normal_000001.json | 2 +- .../fixtures/devices/da_wm_dw_000001.json | 2 +- .../fixtures/devices/da_wm_wd_000001.json | 2 +- .../fixtures/devices/da_wm_wm_000001.json | 2 +- .../fixtures/devices/fake_fan.json | 2 +- .../devices/ge_in_wall_smart_dimmer.json | 2 +- .../smartthings/fixtures/devices/hub.json | 2 +- .../fixtures/devices/multipurpose_sensor.json | 2 +- .../fixtures/devices/smart_plug.json | 2 +- .../fixtures/devices/sonos_player.json | 2 +- .../devices/vd_network_audio_002s.json | 2 +- .../fixtures/devices/vd_stv_2017_k.json | 2 +- .../fixtures/devices/virtual_thermostat.json | 2 +- .../fixtures/devices/virtual_valve.json | 2 +- .../devices/virtual_water_sensor.json | 2 +- .../yale_push_button_deadbolt_lock.json | 2 +- .../smartthings/fixtures/rooms.json | 17 +++ .../smartthings/snapshots/test_init.ambr | 100 +++++++++--------- 37 files changed, 163 insertions(+), 91 deletions(-) create mode 100644 tests/components/smartthings/fixtures/rooms.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index f3a95e57831..b7850bc9333 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -39,6 +39,7 @@ class SmartThingsData: devices: dict[str, FullDevice] scenes: dict[str, Scene] + rooms: dict[str, str] client: SmartThings @@ -92,6 +93,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) device_status: dict[str, FullDevice] = {} try: + rooms = { + room.room_id: room.name + for room in await client.get_rooms(location_id=entry.data[CONF_LOCATION_ID]) + } devices = await client.get_devices() for device in devices: status = process_status(await client.get_device_status(device.device_id)) @@ -113,6 +118,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) name=dev.device.label, sw_version=dev.device.hub.firmware_version, model=dev.device.hub.hardware_type, + suggested_area=( + rooms.get(dev.device.room_id) if dev.device.room_id else None + ), ) scenes = { scene.scene_id: scene @@ -127,6 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) }, client=client, scenes=scenes, + rooms=rooms, ) entry.async_create_background_task( diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 99cbd3f9353..080a90440be 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -109,7 +109,12 @@ async def async_setup_entry( entry_data = entry.runtime_data async_add_entities( SmartThingsBinarySensor( - entry_data.client, device, description, capability, attribute + entry_data.client, + device, + description, + entry_data.rooms, + capability, + attribute, ) for device in entry_data.devices.values() for capability, attribute_map in CAPABILITY_TO_SENSORS.items() @@ -128,11 +133,12 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsBinarySensorEntityDescription, + rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) + super().__init__(client, device, rooms, {capability}) self._attribute = attribute self.capability = capability self.entity_description = entity_description diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 2c3b8f3ac03..bfda5c00d5e 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -118,12 +118,12 @@ async def async_setup_entry( """Add climate entities for a config entry.""" entry_data = entry.runtime_data entities: list[ClimateEntity] = [ - SmartThingsAirConditioner(entry_data.client, device) + SmartThingsAirConditioner(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] entities.extend( - SmartThingsThermostat(entry_data.client, device) + SmartThingsThermostat(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if all( capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES @@ -137,11 +137,14 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): _attr_name = None - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" super().__init__( client, device, + rooms, { Capability.THERMOSTAT_FAN_MODE, Capability.THERMOSTAT_MODE, @@ -327,11 +330,14 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): _attr_name = None _attr_preset_mode = None - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" super().__init__( client, device, + rooms, { Capability.AIR_CONDITIONER_MODE, Capability.SWITCH, diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 0b0f03679eb..564de8443b1 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,7 +41,9 @@ 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(capability)) + SmartThingsCover( + entry_data.client, device, entry_data.rooms, Capability(capability) + ) for device in entry_data.devices.values() for capability in device.status[MAIN] if capability in CAPABILITIES @@ -55,12 +57,17 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): _state: CoverState | None = None def __init__( - self, client: SmartThings, device: FullDevice, capability: Capability + self, + client: SmartThings, + device: FullDevice, + rooms: dict[str, str], + capability: Capability, ) -> None: """Initialize the cover class.""" super().__init__( client, device, + rooms, { capability, Capability.BATTERY, diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 0240549740f..542401109ad 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -27,7 +27,11 @@ class SmartThingsEntity(Entity): _attr_has_entity_name = True def __init__( - self, client: SmartThings, device: FullDevice, capabilities: set[Capability] + self, + client: SmartThings, + device: FullDevice, + rooms: dict[str, str], + capabilities: set[Capability], ) -> None: """Initialize the instance.""" self.client = client @@ -43,6 +47,9 @@ class SmartThingsEntity(Entity): configuration_url="https://account.smartthings.com", identifiers={(DOMAIN, device.device.device_id)}, name=device.device.label, + suggested_area=( + rooms.get(device.device.room_id) if device.device.room_id else None + ), ) if device.device.parent_device_id: self._attr_device_info["via_device"] = ( diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 8edf01ec613..9aa467cbfa8 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -31,7 +31,7 @@ async def async_setup_entry( """Add fans for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(entry_data.client, device) + SmartThingsFan(entry_data.client, entry_data.rooms, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any( @@ -51,11 +51,14 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Init the class.""" super().__init__( client, device, + rooms, { Capability.SWITCH, Capability.FAN_SPEED, diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index aa3a8d35859..eee333f131f 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add lights for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLight(entry_data.client, device) + SmartThingsLight(entry_data.client, entry_data.rooms, 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) @@ -71,11 +71,14 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__(self, client: SmartThings, device: FullDevice) -> None: + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: """Initialize a SmartThingsLight.""" super().__init__( client, device, + rooms, { Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE, diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index f56ecd5d565..76a643e417e 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -33,7 +33,7 @@ async def async_setup_entry( """Add locks for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(entry_data.client, device, {Capability.LOCK}) + SmartThingsLock(entry_data.client, device, entry_data.rooms, {Capability.LOCK}) for device in entry_data.devices.values() if Capability.LOCK in device.status[MAIN] ) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 0a695876da4..ff6e7f252b0 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -962,7 +962,14 @@ async def async_setup_entry( """Add sensors for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsSensor(entry_data.client, device, description, capability, attribute) + SmartThingsSensor( + entry_data.client, + device, + description, + entry_data.rooms, + capability, + attribute, + ) for device in entry_data.devices.values() for capability, attributes in CAPABILITY_TO_SENSORS.items() if capability in device.status[MAIN] @@ -992,11 +999,12 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsSensorEntityDescription, + rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, {capability}) + super().__init__(client, device, rooms, {capability}) 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/switch.py b/homeassistant/components/smartthings/switch.py index 380005f1b93..f470a90bb39 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -37,7 +37,9 @@ async def async_setup_entry( """Add switches for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) + SmartThingsSwitch( + entry_data.client, device, entry_data.rooms, {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) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b7d0cb61607..a47f32d3a8b 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -8,6 +8,7 @@ from pysmartthings.models import ( DeviceResponse, DeviceStatus, LocationResponse, + RoomResponse, SceneResponse, ) import pytest @@ -78,6 +79,9 @@ def mock_smartthings() -> Generator[AsyncMock]: client.get_locations.return_value = LocationResponse.from_json( load_fixture("locations.json", DOMAIN) ).items + client.get_rooms.return_value = RoomResponse.from_json( + load_fixture("rooms.json", DOMAIN) + ).items yield client diff --git a/tests/components/smartthings/fixtures/devices/base_electric_meter.json b/tests/components/smartthings/fixtures/devices/base_electric_meter.json index 4d00d6f169c..a81ca788b29 100644 --- a/tests/components/smartthings/fixtures/devices/base_electric_meter.json +++ b/tests/components/smartthings/fixtures/devices/base_electric_meter.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "0086-0002-0009", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", 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 index a9e3bddb2ca..21d4d475e7a 100644 --- a/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json +++ b/tests/components/smartthings/fixtures/devices/c2c_arlo_pro_3_switch.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Arlo", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/centralite.json b/tests/components/smartthings/fixtures/devices/centralite.json index dff2be78f70..d94043efbc8 100644 --- a/tests/components/smartthings/fixtures/devices/centralite.json +++ b/tests/components/smartthings/fixtures/devices/centralite.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "CentraLite", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index 92fe6a8bbff..68070abbfc3 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Visonic", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "68b45114-9af8-4906-8636-b973a6faa271", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json index ec7f16b090a..d831e15a86b 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", - "roomId": "85a79db4-9cf2-4f09-a5b2-cd70a5c0cef0", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json index 8d9ebde5bcd..db6f8d09673 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_01001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", - "roomId": "1f66199a-1773-4d8f-97b7-44c312a62cf7", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json index f6599fee461..f636b069e38 100644 --- a/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json +++ b/tests/components/smartthings/fixtures/devices/da_ks_microwave_0101x.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "oic.d.microwave", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json index 67afc0ad32c..29372cac23c 100644 --- a/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "3a1f7e7c-4e59-4c29-adb0-0813be691efd", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Refrigerator", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json index b355eedb17a..b7f8ab2a42c 100644 --- a/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_rvc_normal_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "5d425f41-042a-4d9a-92c4-e43150a61bae", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Robot Vacuum", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json index 1c7024e153f..33392081bf5 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_dw_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "586e4602-34ab-4a22-993e-5f616b04604f", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "f4d03391-ab13-4c1d-b4dc-d6ddf86014a2", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Dishwasher", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json index b9a650718e2..ef47260a989 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Dryer", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json index 852a2afa932..4996eebab96 100644 --- a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "781d5f1e-c87e-455e-87f7-8e954879e91d", "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", - "roomId": "2a8637b2-77ad-475e-b537-7b6f7f97fff6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Washer", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/fake_fan.json b/tests/components/smartthings/fixtures/devices/fake_fan.json index 8656e290c8d..6a447ae7aff 100644 --- a/tests/components/smartthings/fixtures/devices/fake_fan.json +++ b/tests/components/smartthings/fixtures/devices/fake_fan.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "0086-0002-005F", "locationId": "6f11ddf5-f0cb-4516-a06a-3a2a6ec22bca", "ownerId": "9f257fc4-6471-2566-b06e-2fe72dd979fa", - "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", 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 index 314586300b9..646196fa980 100644 --- a/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json +++ b/tests/components/smartthings/fixtures/devices/ge_in_wall_smart_dimmer.json @@ -8,7 +8,7 @@ "presentationId": "31cf01ee-cb49-3d95-ac2d-2afab47f25c7", "deviceManufacturerCode": "0063-4944-3130", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", - "roomId": "e73dcd00-6953-431d-ae79-73fd2f2c528e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/hub.json b/tests/components/smartthings/fixtures/devices/hub.json index 4de0823d758..81046859db6 100644 --- a/tests/components/smartthings/fixtures/devices/hub.json +++ b/tests/components/smartthings/fixtures/devices/hub.json @@ -8,7 +8,7 @@ "presentationId": "63f1469e-dc4a-3689-8cc5-69e293c1eb21", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "f7f39cf6-ff3a-4bcb-8d1b-00a3324c016d", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json index b056ecf007b..c8088d6473d 100644 --- a/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json +++ b/tests/components/smartthings/fixtures/devices/multipurpose_sensor.json @@ -8,7 +8,7 @@ "presentationId": "c385e2bc-acb8-317b-be2a-6efd1f879720", "deviceManufacturerCode": "SmartThings", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", - "roomId": "b277a3c0-b8fe-44de-9133-c1108747810c", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/smart_plug.json b/tests/components/smartthings/fixtures/devices/smart_plug.json index 105ae43c3d0..e5ec6c38dad 100644 --- a/tests/components/smartthings/fixtures/devices/smart_plug.json +++ b/tests/components/smartthings/fixtures/devices/smart_plug.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "LEDVANCE", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/sonos_player.json b/tests/components/smartthings/fixtures/devices/sonos_player.json index f7f54a01b49..c84caf57475 100644 --- a/tests/components/smartthings/fixtures/devices/sonos_player.json +++ b/tests/components/smartthings/fixtures/devices/sonos_player.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Sonos", "locationId": "eed0e167-e793-459b-80cb-a0b02e2b86c2", "ownerId": "2c69cc36-85ae-c41a-9981-a4ee96cd9137", - "roomId": "105e6d1a-52a4-4797-a235-5a48d7d433c8", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json index 7fb07533810..20f4aa71fec 100644 --- a/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_002s.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4189ac1-208f-461a-8ab6-ea67937b3743", "ownerId": "85ea07e1-7063-f673-3ba5-125293f297c8", - "roomId": "db506ec3-83b1-4125-9c4c-eb597da5db6a", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF Network Audio Player", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json index 3c22a214495..42630f452d5 100644 --- a/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json +++ b/tests/components/smartthings/fixtures/devices/vd_stv_2017_k.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Samsung Electronics", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "deviceTypeName": "Samsung OCF TV", "components": [ { diff --git a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json index d5bf3b32a0c..1b7a55d779d 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_thermostat.json +++ b/tests/components/smartthings/fixtures/devices/virtual_thermostat.json @@ -8,7 +8,7 @@ "presentationId": "78906115-bf23-3c43-9cd6-f42ca3d5517a", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/virtual_valve.json b/tests/components/smartthings/fixtures/devices/virtual_valve.json index 1988617afad..e46b7846631 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_valve.json +++ b/tests/components/smartthings/fixtures/devices/virtual_valve.json @@ -8,7 +8,7 @@ "presentationId": "916408b6-c94e-38b8-9fbf-03c8a48af5c3", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json index ad3a45a0481..ffea2664c88 100644 --- a/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json +++ b/tests/components/smartthings/fixtures/devices/virtual_water_sensor.json @@ -8,7 +8,7 @@ "presentationId": "838ae989-b832-3610-968c-2940491600f6", "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", - "roomId": "58826afc-9f38-426a-b868-dc94776286e3", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", 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 index 117aa1344cb..20f0dd5ca26 100644 --- a/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json +++ b/tests/components/smartthings/fixtures/devices/yale_push_button_deadbolt_lock.json @@ -9,7 +9,7 @@ "deviceManufacturerCode": "Yale", "locationId": "c4d3b2a1-09f8-765e-4d3c-2b1a09f8e7d6 ", "ownerId": "d47f2b19-3a6e-4c8d-bf21-9e8a7c5d134e", - "roomId": "94be4a1e-382a-4b7f-a5ef-fdb1a7d9f9e6", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", "components": [ { "id": "main", diff --git a/tests/components/smartthings/fixtures/rooms.json b/tests/components/smartthings/fixtures/rooms.json new file mode 100644 index 00000000000..355db9a3423 --- /dev/null +++ b/tests/components/smartthings/fixtures/rooms.json @@ -0,0 +1,17 @@ +{ + "items": [ + { + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Theater", + "backgroundImage": null + }, + { + "roomId": "cdf080f0-0542-41d7-a606-aff69683e04c", + "locationId": "397678e5-9995-4a39-9d9f-ae6ba310236b", + "name": "Toilet", + "backgroundImage": null + } + ], + "_links": null +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 18bc802e2bc..fb856ae32d6 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'toilet', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -27,14 +27,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Toilet', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[base_electric_meter] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -60,14 +60,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[c2c_arlo_pro_3_switch] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -93,7 +93,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -133,7 +133,7 @@ # --- # name: test_devices[centralite] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -159,14 +159,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[contact_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -192,14 +192,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -225,14 +225,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '0.1.0', 'via_device_id': None, }) # --- # name: test_devices[da_ac_rac_01001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -258,14 +258,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'ARA-WW-TP1-22-COMMON_11240702', 'via_device_id': None, }) # --- # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -291,14 +291,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'AKS-WW-TP2-20-MICROWAVE-OTR_40230125', 'via_device_id': None, }) # --- # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -324,14 +324,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'A-RFWW-TP2-21-COMMON_20220110', 'via_device_id': None, }) # --- # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -357,14 +357,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '1.0', 'via_device_id': None, }) # --- # name: test_devices[da_wm_dw_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -390,14 +390,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_DW_A51_20_COMMON_30230714', 'via_device_id': None, }) # --- # name: test_devices[da_wm_wd_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -423,14 +423,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_WM_A51_20_COMMON_30230708', 'via_device_id': None, }) # --- # name: test_devices[da_wm_wm_000001] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -456,7 +456,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'DA_WM_TP2_20_COMMON_30230804', 'via_device_id': None, }) @@ -529,7 +529,7 @@ # --- # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -555,14 +555,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[ge_in_wall_smart_dimmer] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -588,7 +588,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -694,7 +694,7 @@ # --- # name: test_devices[multipurpose_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -720,7 +720,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) @@ -760,7 +760,7 @@ # --- # name: test_devices[smart_plug] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -786,14 +786,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[sonos_player] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -819,14 +819,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -852,14 +852,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'SAT-iMX8M23WWC-1010.5', 'via_device_id': None, }) # --- # name: test_devices[vd_stv_2017_k] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -885,14 +885,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': 'T-KTMAKUC-1290.3', 'via_device_id': None, }) # --- # name: test_devices[virtual_thermostat] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -918,14 +918,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[virtual_valve] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -951,14 +951,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[virtual_water_sensor] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -984,14 +984,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_devices[yale_push_button_deadbolt_lock] DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': 'https://account.smartthings.com', @@ -1017,14 +1017,14 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': None, 'via_device_id': None, }) # --- # name: test_hub_via_device DeviceRegistryEntrySnapshot({ - 'area_id': None, + 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , 'configuration_url': None, @@ -1054,7 +1054,7 @@ 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, - 'suggested_area': None, + 'suggested_area': 'Theater', 'sw_version': '000.055.00005', 'via_device_id': None, }) From 3edc7913deec2e16f463968935e4b46f65988273 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 1 Mar 2025 13:06:10 +0100 Subject: [PATCH 1140/1941] Fix blog post link in comment (#139568) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 98d9e3c760c..bfea2c29eac 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1635,7 +1635,7 @@ class ConfigEntriesFlowManager( # reconfigure to allow the user to change settings. # In case of non user visible flows, the integration should optionally # update the existing entry before aborting. - # see https://developers.home-assistant.io/blog/2025/01/16/config-flow-unique-id/ + # see https://developers.home-assistant.io/blog/2025/03/01/config-flow-unique-id/ report_usage( "creates a config entry when another entry with the same unique ID " "exists", From df9590200473a3dad4725c30b012c33acf14f598 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 13:08:28 +0100 Subject: [PATCH 1141/1941] 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 bfda5c00d5e..b2f8819601c 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -351,7 +351,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 43f48b85620c0f0ab9669a5ed48fe0d3d76937aa Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 1 Mar 2025 13:23:27 +0100 Subject: [PATCH 1142/1941] Bump azure_storage quality to platinum (#139452) --- homeassistant/components/azure_storage/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/azure_storage/manifest.json b/homeassistant/components/azure_storage/manifest.json index 8f2d8aeaca7..729334f851d 100644 --- a/homeassistant/components/azure_storage/manifest.json +++ b/homeassistant/components/azure_storage/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["azure-storage-blob"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["azure-storage-blob==12.24.0"] } From 91eba0855e0da6403b89cd16a9d82b99c9302614 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sat, 1 Mar 2025 13:29:50 +0100 Subject: [PATCH 1143/1941] 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 679b57e450df94711059c968c0b2ff4d0d5f32e3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 1 Mar 2025 14:22:14 +0100 Subject: [PATCH 1144/1941] Add strict typing to Vodafone Station (#139573) --- .strict-typing | 1 + .../components/vodafone_station/coordinator.py | 4 ++-- mypy.ini | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 1df49300b1e..4b2a94b2db4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -528,6 +528,7 @@ homeassistant.components.vallox.* homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* +homeassistant.components.vodafone_station.* homeassistant.components.wake_on_lan.* homeassistant.components.wake_word.* homeassistant.components.wallbox.* diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index cd640d10cb6..b7986d06c25 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from json.decoder import JSONDecodeError -from typing import Any +from typing import Any, cast from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions @@ -164,7 +164,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): @property def serial_number(self) -> str: """Device serial number.""" - return self.data.sensors["sys_serial_number"] + return cast(str, self.data.sensors["sys_serial_number"]) @property def device_info(self) -> DeviceInfo: diff --git a/mypy.ini b/mypy.ini index a6203993c87..0792f820965 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5039,6 +5039,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.vodafone_station.*] +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.wake_on_lan.*] check_untyped_defs = true disallow_incomplete_defs = true From c5e0418f7561ac89a800eafb15ee41ab5519bc4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:41:11 +0000 Subject: [PATCH 1145/1941] 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 efa3da8d3d3..b10d8372466 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 527d9f654dc..a1120979e69 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 2de941bc1125a682fe4fcbcfea6a76f7a71e920a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Mar 2025 19:35:39 +0100 Subject: [PATCH 1146/1941] 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 9a331de8789fe56382c7ce108d0fa4832a30ef69 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sat, 1 Mar 2025 19:45:07 +0100 Subject: [PATCH 1147/1941] Remove deprecated import from configuration.yaml from opentherm_gw (#139581) * Remove deprecated import from configuration.yaml in opentherm_gw * Remove tests for removed funcionality from opentherm_gw --- .../components/opentherm_gw/__init__.py | 60 +------------------ .../components/opentherm_gw/strings.json | 6 -- .../opentherm_gw/test_config_flow.py | 24 -------- tests/components/opentherm_gw/test_init.py | 29 +-------- 4 files changed, 2 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 8c92c70ab49..f16e9f186be 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -10,7 +10,7 @@ from serial import SerialException import voluptuous as vol from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DATE, ATTR_ID, @@ -21,9 +21,6 @@ from homeassistant.const import ( CONF_ID, CONF_NAME, EVENT_HOMEASSISTANT_STOP, - PRECISION_HALVES, - PRECISION_TENTHS, - PRECISION_WHOLE, Platform, ) from homeassistant.core import HomeAssistant, ServiceCall @@ -32,10 +29,8 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_CH_OVRD, @@ -44,9 +39,6 @@ from .const import ( ATTR_LEVEL, ATTR_TRANSP_ARG, ATTR_TRANSP_CMD, - CONF_CLIMATE, - CONF_FLOOR_TEMP, - CONF_PRECISION, CONF_TEMPORARY_OVRD_MODE, CONNECTION_TIMEOUT, DATA_GATEWAYS, @@ -70,29 +62,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -# *_SCHEMA required for deprecated import from configuration.yaml, can be removed in 2025.4.0 -CLIMATE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_PRECISION): vol.In( - [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] - ), - vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean, - } -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: cv.schema_with_slug_keys( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_CLIMATE, default={}): CLIMATE_SCHEMA, - vol.Optional(CONF_NAME): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -164,33 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the OpenTherm Gateway component.""" - if DOMAIN in config: - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_import_from_configuration_yaml", - breaks_in_ha_version="2025.4.0", - is_fixable=False, - is_persistent=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_import_from_configuration_yaml", - ) - if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config: - conf = config[DOMAIN] - for device_id, device_config in conf.items(): - device_config[CONF_ID] = device_id - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=device_config - ) - ) - return True - - def register_services(hass: HomeAssistant) -> None: """Register services for the component.""" service_reset_schema = vol.Schema( diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index b49dea4a267..cc57a7d9e0c 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -354,12 +354,6 @@ } } }, - "issues": { - "deprecated_import_from_configuration_yaml": { - "title": "Deprecated configuration", - "description": "Configuration of the OpenTherm Gateway integration through configuration.yaml is deprecated. Your configuration has been migrated to config entries. Please remove any OpenTherm Gateway configuration from your configuration.yaml." - } - }, "options": { "step": { "init": { diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 57bea4e55dc..99a2dde4acc 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -54,30 +54,6 @@ async def test_form_user( assert mock_pyotgw.return_value.disconnect.await_count == 1 -# Deprecated import from configuration.yaml, can be removed in 2025.4.0 -async def test_form_import( - hass: HomeAssistant, - mock_pyotgw: MagicMock, - mock_setup_entry: AsyncMock, -) -> None: - """Test import from existing config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_ID: "legacy_gateway", CONF_DEVICE: "/dev/ttyUSB1"}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "legacy_gateway" - assert result["data"] == { - CONF_NAME: "legacy_gateway", - CONF_DEVICE: "/dev/ttyUSB1", - CONF_ID: "legacy_gateway", - } - assert mock_pyotgw.return_value.connect.await_count == 1 - assert mock_pyotgw.return_value.disconnect.await_count == 1 - - async def test_form_duplicate_entries( hass: HomeAssistant, mock_pyotgw: MagicMock, diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 3e85afbf782..4085e25c614 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -4,18 +4,13 @@ from unittest.mock import MagicMock from pyotgw.vars import OTGW, OTGW_ABOUT -from homeassistant import setup from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import MOCK_GATEWAY_ID, VERSION_TEST @@ -153,25 +148,3 @@ async def test_climate_entity_migration( updated_entry.unique_id == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" ) - - -# Deprecation test, can be removed in 2025.4.0 -async def test_configuration_yaml_deprecation( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that existing configuration in configuration.yaml creates an issue.""" - - await setup.async_setup_component( - hass, DOMAIN, {DOMAIN: {"legacy_gateway": {"device": "/dev/null"}}} - ) - - await hass.async_block_till_done() - assert ( - issue_registry.async_get_issue( - DOMAIN, "deprecated_import_from_configuration_yaml" - ) - is not None - ) From 9fe08f292d05e3831a686984db1fc530db37faa5 Mon Sep 17 00:00:00 2001 From: M-A Date: Sat, 1 Mar 2025 13:58:45 -0500 Subject: [PATCH 1148/1941] 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 b10d8372466..c052c470bb6 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 a1120979e69..bc5586bf6c7 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 ee206938d8062461e005d95d1d7c0eaec67b5272 Mon Sep 17 00:00:00 2001 From: Joris Drenth Date: Sat, 1 Mar 2025 19:59:13 +0100 Subject: [PATCH 1149/1941] Update wallbox to 0.8.0 (#139553) Update Wallbox dependencies --- homeassistant/components/wallbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index 63102646508..d217a018303 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.7.0"] + "requirements": ["wallbox==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c052c470bb6..ffbab3d272d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3034,7 +3034,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.8.0 # homeassistant.components.folder_watcher watchdog==6.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc5586bf6c7..2ae0a20caab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2444,7 +2444,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.7.0 +wallbox==0.8.0 # homeassistant.components.folder_watcher watchdog==6.0.0 From d4099ab91732eff26ce56936875cf1a9ec7a0016 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 1 Mar 2025 20:16:11 +0100 Subject: [PATCH 1150/1941] Bump aiocomelit to 0.11.1 (#139589) --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 238dede8546..20d481e9a5b 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.10.1"] + "requirements": ["aiocomelit==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ffbab3d272d..4c1a15839e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.11.1 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ae0a20caab..a26b276bc02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -201,7 +201,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.10.1 +aiocomelit==0.11.1 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 From 4813da33d6841afc27069406c40e32948c664976 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 1 Mar 2025 20:16:32 +0100 Subject: [PATCH 1151/1941] 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 b1a2b89691a3ffc96d9d1899706b3e6c29fee7c2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 1 Mar 2025 20:18:52 +0100 Subject: [PATCH 1152/1941] Bump motionblinds to 0.6.26 (#139591) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index b327c146300..1654d5b5937 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.25"] + "requirements": ["motionblinds==0.6.26"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c1a15839e7..0554810837f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1432,7 +1432,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.26 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a26b276bc02..c86feb62135 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1204,7 +1204,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.25 +motionblinds==0.6.26 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 From 0c5766184b4b49d72302e31a99578c0a86cb937f 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 1153/1941] 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 51beb1c0a86881eb9b411e8b1417527da7b39310 Mon Sep 17 00:00:00 2001 From: Trevor Morgan <5444727+clever-trevor@users.noreply.github.com> Date: Sat, 1 Mar 2025 19:26:04 +0000 Subject: [PATCH 1154/1941] Add simplisafe OUTDOOR_ALARM_SECURITY_BELL_BOX device type (#134386) * Update binary_sensor.py to included OUTDOOR_ALARM_SECURITY_BELL_BOX device type Add support for DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX This is an external siren device in Simplisafe which is not currently discovered with the HA integration * Fixed formatting error --------- Co-authored-by: Franck Nijhof --- homeassistant/components/simplisafe/binary_sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index e1f69ed8113..38a80ddd354 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -34,6 +34,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.PANIC_BUTTON, DeviceTypes.REMOTE, DeviceTypes.SIREN, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX, DeviceTypes.SMOKE, DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, DeviceTypes.TEMPERATURE, @@ -47,6 +48,7 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, + DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, # Although this sensor can technically apply to both smoke and carbon, we use the # SMOKE device class for simplicity: From b3f14d72c05666d0a450774e603fdaf01d0a22c4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 1 Mar 2025 20:47:42 +0100 Subject: [PATCH 1155/1941] 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 dd21d48ae48c010b5dbb0468f872ce11c176b723 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sat, 1 Mar 2025 20:53:06 +0100 Subject: [PATCH 1156/1941] 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 913a4ee9ba38a5cede4b9dd94af47907a11521bb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Mar 2025 21:14:08 +0100 Subject: [PATCH 1157/1941] Improve certificate handling in MQTT config flow (#137234) * Improve mqtt broker certificate handling in config flow * Expand test cases --- homeassistant/components/mqtt/config_flow.py | 146 ++++++++- homeassistant/components/mqtt/strings.json | 8 +- tests/components/mqtt/test_config_flow.py | 296 +++++++++++++++++-- 3 files changed, 415 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 22568b0f2b8..ad188c50aa9 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5,14 +5,21 @@ from __future__ import annotations import asyncio from collections import OrderedDict from collections.abc import Callable, Mapping +from enum import IntEnum import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType from typing import TYPE_CHECKING, Any -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.x509 import load_pem_x509_certificate +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + load_der_private_key, + load_pem_private_key, +) +from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file @@ -105,6 +112,8 @@ _LOGGER = logging.getLogger(__name__) ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 5 +CONF_CLIENT_KEY_PASSWORD = "client_key_password" + MQTT_TIMEOUT = 5 ADVANCED_OPTIONS = "advanced_options" @@ -165,12 +174,14 @@ BROKER_VERIFICATION_SELECTOR = SelectSelector( # mime configuration from https://pki-tutorial.readthedocs.io/en/latest/mime.html CA_CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-ca-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-ca-cert") ) CERT_UPLOAD_SELECTOR = FileSelector( - FileSelectorConfig(accept=".crt,application/x-x509-user-cert") + FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-user-cert") +) +KEY_UPLOAD_SELECTOR = FileSelector( + FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8") ) -KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8")) REAUTH_SCHEMA = vol.Schema( { @@ -710,17 +721,88 @@ class MQTTOptionsFlowHandler(OptionsFlow): ) -async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str: - """Get file content from uploaded file.""" +@callback +def async_is_pem_data(data: bytes) -> bool: + """Return True if data is in PEM format.""" + return ( + b"-----BEGIN CERTIFICATE-----" in data + or b"-----BEGIN PRIVATE KEY-----" in data + or b"-----BEGIN RSA PRIVATE KEY-----" in data + or b"-----BEGIN ENCRYPTED PRIVATE KEY-----" in data + ) - def _proces_uploaded_file() -> str: + +class PEMType(IntEnum): + """Type of PEM data.""" + + CERTIFICATE = 1 + PRIVATE_KEY = 2 + + +@callback +def async_convert_to_pem( + data: bytes, pem_type: PEMType, password: str | None = None +) -> str | None: + """Convert data to PEM format.""" + try: + if async_is_pem_data(data): + if not password: + # Assume unencrypted PEM encoded private key + return data.decode(DEFAULT_ENCODING) + # Return decrypted PEM encoded private key + return ( + load_pem_private_key(data, password=password.encode(DEFAULT_ENCODING)) + .private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + .decode(DEFAULT_ENCODING) + ) + # Convert from DER encoding to PEM + if pem_type == PEMType.CERTIFICATE: + return ( + load_der_x509_certificate(data) + .public_bytes( + encoding=Encoding.PEM, + ) + .decode(DEFAULT_ENCODING) + ) + # Assume DER encoded private key + pem_key_data: bytes = load_der_private_key( + data, password.encode(DEFAULT_ENCODING) if password else None + ).private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption(), + ) + return pem_key_data.decode("utf-8") + except (TypeError, ValueError, SSLError): + _LOGGER.exception("Error converting %s file data to PEM format", pem_type.name) + return None + + +async def _get_uploaded_file(hass: HomeAssistant, id: str) -> bytes: + """Get file content from uploaded certificate or key file.""" + + def _proces_uploaded_file() -> bytes: with process_uploaded_file(hass, id) as file_path: - return file_path.read_text(encoding=DEFAULT_ENCODING) + return file_path.read_bytes() return await hass.async_add_executor_job(_proces_uploaded_file) -async def async_get_broker_settings( +def _validate_pki_file( + file_id: str | None, pem_data: str | None, errors: dict[str, str], error: str +) -> bool: + """Return False if uploaded file could not be converted to PEM format.""" + if file_id and not pem_data: + errors["base"] = error + return False + return True + + +async def async_get_broker_settings( # noqa: C901 flow: ConfigFlow | OptionsFlow, fields: OrderedDict[Any, Any], entry_config: MappingProxyType[str, Any] | None, @@ -768,6 +850,10 @@ async def async_get_broker_settings( validated_user_input.update(user_input) client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT) client_key_id: str | None = user_input.get(CONF_CLIENT_KEY) + # We do not store the private key password in the entry data + client_key_password: str | None = validated_user_input.pop( + CONF_CLIENT_KEY_PASSWORD, None + ) if (client_certificate_id and not client_key_id) or ( not client_certificate_id and client_key_id ): @@ -775,7 +861,14 @@ async def async_get_broker_settings( return False certificate_id: str | None = user_input.get(CONF_CERTIFICATE) if certificate_id: - certificate = await _get_uploaded_file(hass, certificate_id) + certificate_data_raw = await _get_uploaded_file(hass, certificate_id) + certificate = async_convert_to_pem( + certificate_data_raw, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + certificate_id, certificate, errors, "bad_certificate" + ): + return False # Return to form for file upload CA cert or client cert and key if ( @@ -797,9 +890,26 @@ async def async_get_broker_settings( return False if client_certificate_id: - client_certificate = await _get_uploaded_file(hass, client_certificate_id) + client_certificate_data = await _get_uploaded_file( + hass, client_certificate_id + ) + client_certificate = async_convert_to_pem( + client_certificate_data, PEMType.CERTIFICATE + ) + if not _validate_pki_file( + client_certificate_id, client_certificate, errors, "bad_client_cert" + ): + return False + if client_key_id: - client_key = await _get_uploaded_file(hass, client_key_id) + client_key_data = await _get_uploaded_file(hass, client_key_id) + client_key = async_convert_to_pem( + client_key_data, PEMType.PRIVATE_KEY, password=client_key_password + ) + if not _validate_pki_file( + client_key_id, client_key, errors, "client_key_error" + ): + return False certificate_data: dict[str, Any] = {} if certificate: @@ -956,6 +1066,14 @@ async def async_get_broker_settings( description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)}, ) ] = KEY_UPLOAD_SELECTOR + fields[ + vol.Optional( + CONF_CLIENT_KEY_PASSWORD, + description={ + "suggested_value": user_input_basic.get(CONF_CLIENT_KEY_PASSWORD) + }, + ) + ] = PASSWORD_SELECTOR verification_mode = current_config.get(SET_CA_CERT) or ( "off" if current_ca_certificate is None @@ -1060,7 +1178,7 @@ def check_certicate_chain() -> str | None: with open(private_key, "rb") as client_key_file: load_pem_private_key(client_key_file.read(), password=None) except (TypeError, ValueError): - return "bad_client_key" + return "client_key_error" # Check the certificate chain context = SSLContext(PROTOCOL_TLS_CLIENT) if client_certificate and private_key: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index fc316306d56..8805f447d69 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -26,6 +26,7 @@ "client_id": "Client ID (leave empty to randomly generated one)", "client_cert": "Upload client certificate file", "client_key": "Upload private key file", + "client_key_password": "[%key:common::config_flow::data::password%]", "keepalive": "The time between sending keep alive messages", "tls_insecure": "Ignore broker certificate validation", "protocol": "MQTT protocol", @@ -45,6 +46,7 @@ "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", "client_cert": "The client certificate to authenticate against your MQTT broker.", "client_key": "The private key file that belongs to your client certificate.", + "client_key_password": "The password for the private key file (if set).", "keepalive": "A value less than 90 seconds is advised.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", @@ -93,8 +95,8 @@ "bad_will": "Invalid will topic", "bad_discovery_prefix": "Invalid discovery prefix", "bad_certificate": "The CA certificate is invalid", - "bad_client_cert": "Invalid client certificate, ensure a PEM coded file is supplied", - "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password", + "bad_client_cert": "Invalid client certificate, ensure a valid file is supplied", + "client_key_error": "Invalid private key file or invalid password supplied", "bad_client_cert_key": "Client certificate and private key are not a valid pair", "bad_ws_headers": "Supply valid HTTP headers as a JSON object", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -207,7 +209,7 @@ "bad_discovery_prefix": "[%key:component::mqtt::config::error::bad_discovery_prefix%]", "bad_certificate": "[%key:component::mqtt::config::error::bad_certificate%]", "bad_client_cert": "[%key:component::mqtt::config::error::bad_client_cert%]", - "bad_client_key": "[%key:component::mqtt::config::error::bad_client_key%]", + "client_key_error": "[%key:component::mqtt::config::error::client_key_error%]", "bad_client_cert_key": "[%key:component::mqtt::config::error::bad_client_cert_key%]", "bad_ws_headers": "[%key:component::mqtt::config::error::bad_ws_headers%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index de70fd32763..f39e32a0d8b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -40,8 +40,37 @@ ADD_ON_DISCOVERY_INFO = { "protocol": "3.1.1", "ssl": False, } -MOCK_CLIENT_CERT = b"## mock client certificate file ##" -MOCK_CLIENT_KEY = b"## mock key file ##" + +MOCK_CA_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock CA certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_GENERIC_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock generic certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CA_CERT_DER = b"## mock DER formatted CA certificate file ##\n" +MOCK_CLIENT_CERT = ( + b"-----BEGIN CERTIFICATE-----\n" + b"## mock client certificate file ##" + b"\n-----END CERTIFICATE-----\n" +) +MOCK_CLIENT_CERT_DER = b"## mock DER formatted client certificate file ##\n" +MOCK_CLIENT_KEY = ( + b"-----BEGIN PRIVATE KEY-----\n" + b"## mock client key file ##" + b"\n-----END PRIVATE KEY-----" +) +MOCK_ENCRYPTED_CLIENT_KEY = ( + b"-----BEGIN ENCRYPTED PRIVATE KEY-----\n" + b"## mock client key file ##\n" + b"-----END ENCRYPTED PRIVATE KEY-----" +) +MOCK_CLIENT_KEY_DER = b"## mock DER formatted key file ##\n" +MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n" + MOCK_ENTRY_DATA = { mqtt.CONF_BROKER: "test-broker", @@ -102,15 +131,27 @@ def mock_ssl_context() -> Generator[dict[str, MagicMock]]: patch("homeassistant.components.mqtt.config_flow.SSLContext") as mock_context, patch( "homeassistant.components.mqtt.config_flow.load_pem_private_key" - ) as mock_key_check, + ) as mock_pem_key_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_private_key" + ) as mock_der_key_check, patch( "homeassistant.components.mqtt.config_flow.load_pem_x509_certificate" - ) as mock_cert_check, + ) as mock_pem_cert_check, + patch( + "homeassistant.components.mqtt.config_flow.load_der_x509_certificate" + ) as mock_der_cert_check, ): + mock_pem_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_pem_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT + mock_der_key_check().private_bytes.return_value = MOCK_CLIENT_KEY + mock_der_cert_check().public_bytes.return_value = MOCK_GENERIC_CERT yield { "context": mock_context, - "load_pem_x509_certificate": mock_cert_check, - "load_pem_private_key": mock_key_check, + "load_der_private_key": mock_der_key_check, + "load_der_x509_certificate": mock_der_cert_check, + "load_pem_private_key": mock_pem_key_check, + "load_pem_x509_certificate": mock_pem_cert_check, } @@ -180,9 +221,31 @@ def mock_try_connection_time_out() -> Generator[MagicMock]: yield mock_client() +@pytest.fixture +def mock_ca_cert() -> bytes: + """Mock the CA certificate.""" + return MOCK_CA_CERT + + +@pytest.fixture +def mock_client_cert() -> bytes: + """Mock the client certificate.""" + return MOCK_CLIENT_CERT + + +@pytest.fixture +def mock_client_key() -> bytes: + """Mock the client key.""" + return MOCK_CLIENT_KEY + + @pytest.fixture def mock_process_uploaded_file( - tmp_path: Path, mock_temp_dir: str + tmp_path: Path, + mock_ca_cert: bytes, + mock_client_cert: bytes, + mock_client_key: bytes, + mock_temp_dir: str, ) -> Generator[MagicMock]: """Mock upload certificate files.""" file_id_ca = str(uuid4()) @@ -195,15 +258,15 @@ def mock_process_uploaded_file( ) -> Iterator[Path | None]: if file_id == file_id_ca: with open(tmp_path / "ca.crt", "wb") as cafile: - cafile.write(b"## mock CA certificate file ##") + cafile.write(mock_ca_cert) yield tmp_path / "ca.crt" elif file_id == file_id_cert: with open(tmp_path / "client.crt", "wb") as certfile: - certfile.write(b"## mock client certificate file ##") + certfile.write(mock_client_cert) yield tmp_path / "client.crt" elif file_id == file_id_key: with open(tmp_path / "client.key", "wb") as keyfile: - keyfile.write(b"## mock key file ##") + keyfile.write(mock_client_key) yield tmp_path / "client.key" else: pytest.fail(f"Unexpected file_id: {file_id}") @@ -1024,12 +1087,37 @@ async def test_option_flow( assert yaml_mock.await_count +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) @pytest.mark.parametrize( "test_error", [ "bad_certificate", "bad_client_cert", - "bad_client_key", + "client_key_error", "bad_client_cert_key", "invalid_inclusion", None, @@ -1042,31 +1130,54 @@ async def test_bad_certificate( mock_ssl_context: dict[str, MagicMock], mock_process_uploaded_file: MagicMock, test_error: str | None, + client_key_password: str, + mock_ca_cert: bytes, ) -> None: """Test bad certificate tests.""" + + def _side_effect_on_client_cert(data: bytes) -> MagicMock: + """Raise on client cert only. + + The function is called twice, once for the CA chain + and once for the client cert. We only want to raise on a client cert. + """ + if data == MOCK_CLIENT_CERT_DER: + raise ValueError + mock_certificate_side_effect = MagicMock() + mock_certificate_side_effect().public_bytes.return_value = MOCK_GENERIC_CERT + return mock_certificate_side_effect + # Mock certificate files file_id = mock_process_uploaded_file.file_id + set_ca_cert = "custom" + set_client_cert = True + tls_insecure = False test_input = { mqtt.CONF_BROKER: "another-broker", CONF_PORT: 2345, mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], - "set_ca_cert": True, + "client_key_password": client_key_password, + "set_ca_cert": set_ca_cert, "set_client_cert": True, } - set_client_cert = True - set_ca_cert = "custom" - tls_insecure = False if test_error == "bad_certificate": # CA chain is not loading mock_ssl_context["context"]().load_verify_locations.side_effect = SSLError + # Fail on the CA cert if DER encoded + mock_ssl_context["load_der_x509_certificate"].side_effect = ValueError elif test_error == "bad_client_cert": # Client certificate is invalid mock_ssl_context["load_pem_x509_certificate"].side_effect = ValueError - elif test_error == "bad_client_key": + # Fail on the client cert if DER encoded + mock_ssl_context[ + "load_der_x509_certificate" + ].side_effect = _side_effect_on_client_cert + elif test_error == "client_key_error": # Client key file is invalid mock_ssl_context["load_pem_private_key"].side_effect = ValueError + mock_ssl_context["load_der_private_key"].side_effect = ValueError elif test_error == "bad_client_cert_key": # Client key file file and certificate do not pair mock_ssl_context["context"]().load_cert_chain.side_effect = SSLError @@ -2078,8 +2189,8 @@ async def test_setup_with_advanced_settings( CONF_USERNAME: "user", CONF_PASSWORD: "secret", mqtt.CONF_KEEPALIVE: 30, - mqtt.CONF_CLIENT_CERT: "## mock client certificate file ##", - mqtt.CONF_CLIENT_KEY: "## mock key file ##", + mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), "tls_insecure": True, mqtt.CONF_TRANSPORT: "websockets", mqtt.CONF_WS_PATH: "/custom_path/", @@ -2091,6 +2202,155 @@ async def test_setup_with_advanced_settings( } +@pytest.mark.usefixtures("mock_ssl_context") +@pytest.mark.parametrize( + ("mock_ca_cert", "mock_client_cert", "mock_client_key", "client_key_password"), + [ + (MOCK_GENERIC_CERT, MOCK_GENERIC_CERT, MOCK_CLIENT_KEY, ""), + ( + MOCK_GENERIC_CERT, + MOCK_GENERIC_CERT, + MOCK_ENCRYPTED_CLIENT_KEY, + "very*secret", + ), + (MOCK_CA_CERT_DER, MOCK_CLIENT_CERT_DER, MOCK_CLIENT_KEY_DER, ""), + ( + MOCK_CA_CERT_DER, + MOCK_CLIENT_CERT_DER, + MOCK_ENCRYPTED_CLIENT_KEY_DER, + "very*secret", + ), + ], + ids=[ + "pem_certs_private_key_no_password", + "pem_certs_private_key_with_password", + "der_certs_private_key_no_password", + "der_certs_private_key_with_password", + ], +) +async def test_setup_with_certificates( + hass: HomeAssistant, + mock_try_connection: MagicMock, + mock_process_uploaded_file: MagicMock, + client_key_password: str, +) -> None: + """Test config flow setup with PEM and DER encoded certificates.""" + file_id = mock_process_uploaded_file.file_id + + config_entry = MockConfigEntry( + domain=mqtt.DOMAIN, + version=mqtt.CONFIG_ENTRY_VERSION, + minor_version=mqtt.CONFIG_ENTRY_MINOR_VERSION, + ) + config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + config_entry, + data={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 1234, + }, + ) + + mock_try_connection.return_value = True + + result = await config_entry.start_reconfigure_flow(hass, show_advanced_options=True) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert result["data_schema"].schema["advanced_options"] + + # first iteration, basic settings + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + "advanced_options": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + assert mqtt.CONF_CLIENT_CERT not in result["data_schema"].schema + assert mqtt.CONF_CLIENT_KEY not in result["data_schema"].schema + + # second iteration, advanced settings with request for client cert + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + mqtt.CONF_TLS_INSECURE: False, + CONF_PROTOCOL: "3.1.1", + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "broker" + assert "advanced_options" not in result["data_schema"].schema + assert result["data_schema"].schema[CONF_CLIENT_ID] + assert result["data_schema"].schema[mqtt.CONF_KEEPALIVE] + assert result["data_schema"].schema["set_client_cert"] + assert result["data_schema"].schema["set_ca_cert"] + assert result["data_schema"].schema["client_key_password"] + assert result["data_schema"].schema[mqtt.CONF_TLS_INSECURE] + assert result["data_schema"].schema[CONF_PROTOCOL] + assert result["data_schema"].schema[mqtt.CONF_CERTIFICATE] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_CERT] + assert result["data_schema"].schema[mqtt.CONF_CLIENT_KEY] + assert result["data_schema"].schema[mqtt.CONF_TRANSPORT] + + # third iteration, advanced settings with client cert and key and CA certificate + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + "set_ca_cert": "custom", + "set_client_cert": True, + "client_key_password": client_key_password, + mqtt.CONF_CERTIFICATE: file_id[mqtt.CONF_CERTIFICATE], + mqtt.CONF_CLIENT_CERT: file_id[mqtt.CONF_CLIENT_CERT], + mqtt.CONF_CLIENT_KEY: file_id[mqtt.CONF_CLIENT_KEY], + mqtt.CONF_TLS_INSECURE: False, + mqtt.CONF_TRANSPORT: "tcp", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check config entry result + assert config_entry.data == { + mqtt.CONF_BROKER: "test-broker", + CONF_PORT: 2345, + CONF_USERNAME: "user", + CONF_PASSWORD: "secret", + mqtt.CONF_KEEPALIVE: 30, + mqtt.CONF_CLIENT_CERT: MOCK_GENERIC_CERT.decode(encoding="utf-8"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), + "tls_insecure": False, + mqtt.CONF_TRANSPORT: "tcp", + mqtt.CONF_CERTIFICATE: MOCK_GENERIC_CERT.decode(encoding="utf-8"), + } + + @pytest.mark.usefixtures("mock_ssl_context", "mock_process_uploaded_file") async def test_change_websockets_transport_to_tcp( hass: HomeAssistant, mock_try_connection: MagicMock From c1686953239e4f18819c45b59aafe1d92d411236 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 1 Mar 2025 20:18:30 +0000 Subject: [PATCH 1158/1941] Clean up squeezebox build_item_response part 1 (#139321) * initial * final * is internal change * test data coverage * Review fixes * final --- .../components/squeezebox/browse_media.py | 153 +++++++++++------- tests/components/squeezebox/conftest.py | 2 +- 2 files changed, 93 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 6bc1d2380cf..82fa55c7b2f 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -138,6 +138,8 @@ class BrowseItemResponse: child_media_class: dict[str, MediaClass | None] can_expand: bool can_play: bool + title: str + id: str def _add_new_command_to_browse_data( @@ -154,11 +156,12 @@ def _add_new_command_to_browse_data( def _build_response_apps_radios_category( - browse_data: BrowseData, - cmd: str | MediaType, + browse_data: BrowseData, cmd: str | MediaType, item: dict[str, Any] ) -> BrowseItemResponse: """Build item for App or radio category.""" return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], child_item_type=cmd, child_media_class=browse_data.content_type_media_class[cmd], can_expand=True, @@ -172,6 +175,8 @@ def _build_response_known_app( """Build item for app or radio.""" return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], 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")), @@ -179,6 +184,61 @@ def _build_response_known_app( ) +def _build_response_favorites(item: dict[str, Any]) -> BrowseItemResponse: + """Build item for Favorites.""" + if "album_id" in item: + return BrowseItemResponse( + id=str(item["album_id"]), + title=item["title"], + child_item_type=MediaType.ALBUM, + child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM], + can_expand=True, + can_play=True, + ) + if item["hasitems"] and not item["isaudio"]: + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type="Favorites", + child_media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"], + can_expand=True, + can_play=False, + ) + return BrowseItemResponse( + id=item.get("id", ""), + title=item["title"], + child_item_type="Favorites", + child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK], + can_expand=item["hasitems"], + can_play=bool(item["isaudio"] and item.get("url")), + ) + + +def _get_item_thumbnail( + item: dict[str, Any], + player: Player, + entity: MediaPlayerEntity, + item_type: str | MediaType | None, + search_type: str, + internal_request: bool, +) -> str | None: + """Construct path to thumbnail image.""" + item_thumbnail: str | None = None + if artwork_track_id := item.get("artwork_track_id"): + if internal_request: + item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) + elif item_type is not None: + item_thumbnail = entity.get_browse_image_url( + item_type, item.get("id", ""), artwork_track_id + ) + + elif search_type in ["Apps", "Radios"]: + item_thumbnail = player.generate_image_url(item["icon"]) + if item_thumbnail is None: + item_thumbnail = item.get("image_url") # will not be proxied by HA + return item_thumbnail + + async def build_item_response( entity: MediaPlayerEntity, player: Player, @@ -216,34 +276,12 @@ async def build_item_response( children = [] list_playable = [] for item in result["items"]: - 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] - can_expand = child_media_class["children"] is not None - can_play = True - if search_type == "Favorites": - if "album_id" in item: - item_id = str(item["album_id"]) - child_item_type = MediaType.ALBUM - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM] - can_expand = True - can_play = True - elif item["hasitems"] and not item["isaudio"]: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"] - can_expand = True - can_play = False - else: - child_item_type = "Favorites" - child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK] - can_expand = item["hasitems"] - can_play = item["isaudio"] and item.get("url") + browse_item_response = _build_response_favorites(item) - if search_type in ["Apps", "Radios"]: + elif 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: @@ -253,19 +291,12 @@ async def build_item_response( 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") + _add_new_command_to_browse_data(browse_data, app_cmd, "item_id") browse_item_response = _build_response_apps_radios_category( - browse_data, app_cmd + browse_data=browse_data, cmd=app_cmd, item=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 - elif search_type in browse_data.known_apps_radios: if ( item.get("title") in ["Search", None] @@ -278,39 +309,39 @@ async def build_item_response( 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 + elif item_type: + browse_item_response = BrowseItemResponse( + id=str(item.get("id", "")), + title=item["title"], + child_item_type=item_type, + child_media_class=CONTENT_TYPE_MEDIA_CLASS[item_type], + can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] + is not None, + can_play=True, + ) - if artwork_track_id := item.get("artwork_track_id"): - if internal_request: - item_thumbnail = player.generate_image_url_from_track_id( - artwork_track_id - ) - elif item_type is not None: - 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 + item_thumbnail = _get_item_thumbnail( + item=item, + player=player, + entity=entity, + item_type=item_type, + search_type=search_type, + internal_request=internal_request, + ) - assert child_media_class["item"] is not None + assert browse_item_response.child_media_class["item"] is not None children.append( BrowseMedia( - title=item["title"], - media_class=child_media_class["item"], - media_content_id=item_id, - media_content_type=child_item_type, - can_play=can_play, - can_expand=can_expand, + title=browse_item_response.title, + media_class=browse_item_response.child_media_class["item"], + media_content_id=browse_item_response.id, + media_content_type=browse_item_response.child_item_type, + can_play=browse_item_response.can_play, + can_expand=browse_item_response.can_expand, thumbnail=item_thumbnail, ) ) - list_playable.append(can_play) + list_playable.append(browse_item_response.can_play) if children is None: raise BrowseError(f"Media not found: {search_type} / {search_id}") diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 9ca750808c5..429c3b62087 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -163,7 +163,7 @@ async def mock_async_browse( "title": "Fake Item 2", "id": FAKE_VALID_ITEM_ID + "_2", "hasitems": media_type == "favorites", - "isaudio": True, + "isaudio": False, "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", From 2cce1b024e69186498f2b25d8f63d3a708258b0f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Mar 2025 15:43:00 -0500 Subject: [PATCH 1159/1941] Migrate Assist Pipeline to use TTS stream (#139542) * Migrate Pipeline to use TTS stream * Fix tests --- .../components/assist_pipeline/pipeline.py | 63 ++++----- homeassistant/components/tts/__init__.py | 35 +++-- .../assist_pipeline/snapshots/test_init.ambr | 24 ++++ .../snapshots/test_websocket.ambr | 90 +++++++++---- tests/components/assist_pipeline/test_init.py | 42 +++--- .../assist_pipeline/test_websocket.py | 121 +++++------------- tests/components/tts/test_init.py | 23 ---- 7 files changed, 196 insertions(+), 202 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 038874d1966..a028fa638df 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -19,14 +19,7 @@ import wave import hass_nabucasa import voluptuous as vol -from homeassistant.components import ( - conversation, - media_source, - stt, - tts, - wake_word, - websocket_api, -) +from homeassistant.components import conversation, stt, tts, wake_word, websocket_api from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) @@ -569,8 +562,7 @@ class PipelineRun: id: str = field(default_factory=ulid_util.ulid_now) stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) - tts_engine: str = field(init=False, repr=False) - tts_options: dict | None = field(init=False, default=None) + tts_stream: tts.ResultStream | None = field(init=False, default=None) wake_word_entity_id: str | None = field(init=False, default=None, repr=False) wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False) @@ -648,13 +640,18 @@ class PipelineRun: self._device_id = device_id self._start_debug_recording_thread() - data = { + data: dict[str, Any] = { "pipeline": self.pipeline.id, "language": self.language, "conversation_id": conversation_id, } if self.runner_data is not None: data["runner_data"] = self.runner_data + if self.tts_stream: + data["tts_output"] = { + "url": self.tts_stream.url, + "mime_type": self.tts_stream.content_type, + } self.process_event(PipelineEvent(PipelineEventType.RUN_START, data)) @@ -1246,36 +1243,31 @@ class PipelineRun: tts_options[tts.ATTR_PREFERRED_SAMPLE_BYTES] = SAMPLE_WIDTH try: - options_supported = await tts.async_support_options( - self.hass, - engine, - self.pipeline.tts_language, - tts_options, + self.tts_stream = tts.async_create_stream( + hass=self.hass, + engine=engine, + language=self.pipeline.tts_language, + options=tts_options, ) except HomeAssistantError as err: - raise TextToSpeechError( - code="tts-not-supported", - message=f"Text-to-speech engine '{engine}' not found", - ) from err - if not options_supported: raise TextToSpeechError( code="tts-not-supported", message=( f"Text-to-speech engine {engine} " - f"does not support language {self.pipeline.tts_language} or options {tts_options}" + f"does not support language {self.pipeline.tts_language} or options {tts_options}:" + f" {err}" ), - ) - - self.tts_engine = engine - self.tts_options = tts_options + ) from err async def text_to_speech(self, tts_input: str) -> None: """Run text-to-speech portion of pipeline.""" + assert self.tts_stream is not None + self.process_event( PipelineEvent( PipelineEventType.TTS_START, { - "engine": self.tts_engine, + "engine": self.tts_stream.engine, "language": self.pipeline.tts_language, "voice": self.pipeline.tts_voice, "tts_input": tts_input, @@ -1288,14 +1280,9 @@ class PipelineRun: tts_media_id = tts_generate_media_source_id( self.hass, tts_input, - engine=self.tts_engine, - language=self.pipeline.tts_language, - options=self.tts_options, - ) - tts_media = await media_source.async_resolve_media( - self.hass, - tts_media_id, - None, + engine=self.tts_stream.engine, + language=self.tts_stream.language, + options=self.tts_stream.options, ) except Exception as src_error: _LOGGER.exception("Unexpected error during text-to-speech") @@ -1304,10 +1291,12 @@ class PipelineRun: message="Unexpected error during text-to-speech", ) from src_error - _LOGGER.debug("TTS result %s", tts_media) + self.tts_stream.async_set_message(tts_input) + tts_output = { "media_id": tts_media_id, - **asdict(tts_media), + "url": self.tts_stream.url, + "mime_type": self.tts_stream.content_type, } self.process_event( diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 32c4ba20670..98ce76cafde 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -79,13 +79,13 @@ __all__ = [ "PLATFORM_SCHEMA", "PLATFORM_SCHEMA_BASE", "Provider", + "ResultStream", "SampleFormat", "TextToSpeechEntity", "TtsAudioType", "Voice", "async_default_engine", "async_get_media_source_audio", - "async_support_options", "generate_media_source_id", ] @@ -167,22 +167,19 @@ def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: return async_default_engine(hass) -async def async_support_options( +@callback +def async_create_stream( hass: HomeAssistant, engine: str, language: str | None = None, options: dict | None = None, -) -> bool: - """Return if an engine supports options.""" - if (engine_instance := get_engine_instance(hass, engine)) is None: - raise HomeAssistantError(f"Provider {engine} not found") - - try: - hass.data[DATA_TTS_MANAGER].process_options(engine_instance, language, options) - except HomeAssistantError: - return False - - return True +) -> ResultStream: + """Create a streaming URL where the rendered TTS can be retrieved.""" + return hass.data[DATA_TTS_MANAGER].async_create_result_stream( + engine=engine, + language=language, + options=options, + ) async def async_get_media_source_audio( @@ -407,6 +404,18 @@ class ResultStream: """Set cache key for message to be streamed.""" self._result_cache_key.set_result(cache_key) + @callback + def async_set_message(self, message: str) -> None: + """Set message to be generated.""" + cache_key = self._manager.async_cache_message_in_memory( + engine=self.engine, + message=message, + use_file_cache=self.use_file_cache, + language=self.language, + options=self.options, + ) + self._result_cache_key.set_result(cache_key) + async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" cache_key = await self._result_cache_key diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index f5e5f813db6..2375d48fcf9 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -6,6 +6,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -99,6 +103,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -192,6 +200,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -285,6 +297,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }), 'type': , }), @@ -402,6 +418,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }), 'type': , }), @@ -598,6 +618,10 @@ 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }), 'type': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 509f2072509..d937b5396d1 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -8,6 +8,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline.1 @@ -93,6 +97,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_debug.1 @@ -190,6 +198,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_enhancements.1 @@ -275,6 +287,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.1 @@ -382,6 +398,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/test_token.mp3', + }), }) # --- # name: test_audio_pipeline_with_wake_word_timeout.1 @@ -585,6 +605,10 @@ 'stt_binary_handler_id': None, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_pipeline_empty_tts_output.1 @@ -634,6 +658,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_different_ids.1 @@ -645,6 +673,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id @@ -656,6 +688,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_cooldown_same_id.1 @@ -667,6 +703,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed @@ -678,6 +718,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_stt_stream_failed.1 @@ -798,28 +842,6 @@ 'message': 'Timeout running pipeline', }) # --- -# name: test_tts_failed - dict({ - 'conversation_id': 'mock-ulid', - 'language': 'en', - 'pipeline': , - 'runner_data': dict({ - 'stt_binary_handler_id': None, - 'timeout': 300, - }), - }) -# --- -# name: test_tts_failed.1 - dict({ - 'engine': 'test', - 'language': 'en-US', - 'tts_input': 'Lights are on.', - 'voice': 'james_earl_jones', - }) -# --- -# name: test_tts_failed.2 - None -# --- # name: test_wake_word_cooldown_different_entities dict({ 'conversation_id': 'mock-ulid', @@ -829,6 +851,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.1 @@ -840,6 +866,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_entities.2 @@ -892,6 +922,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.1 @@ -903,6 +937,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_different_ids.2 @@ -958,6 +996,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.1 @@ -969,6 +1011,10 @@ 'stt_binary_handler_id': 1, 'timeout': 300, }), + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/mocked-token.mp3', + }), }) # --- # name: test_wake_word_cooldown_same_id.2 diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index e983e4a96e3..0e04d1f0cd2 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -43,13 +43,21 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture(autouse=True) -def mock_ulid() -> Generator[Mock]: - """Mock the ulid of chat sessions.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: yield mock_ulid_now +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: """Process events to remove dynamic values.""" processed = [] @@ -797,10 +805,16 @@ async def test_tts_audio_output( await pipeline_input.validate() # Verify TTS audio settings - assert pipeline_input.run.tts_options is not None - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) == 16000 - assert pipeline_input.run.tts_options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) == 1 + assert pipeline_input.run.tts_stream.options is not None + assert pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_RATE) + == 16000 + ) + assert ( + pipeline_input.run.tts_stream.options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS) + == 1 + ) with patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio: await pipeline_input.execute() @@ -809,9 +823,7 @@ async def test_tts_audio_output( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) # Ensure that no unsupported options were passed in assert mock_get_tts_audio.called @@ -875,9 +887,7 @@ async def test_tts_wav_preferred_format( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) assert mock_get_tts_audio.called options = mock_get_tts_audio.call_args_list[0].kwargs["options"] @@ -949,9 +959,7 @@ async def test_tts_dict_preferred_format( if event.type == assist_pipeline.PipelineEventType.TTS_END: # We must fetch the media URL to trigger the TTS assert event.data - media_id = event.data["tts_output"]["media_id"] - resolved = await media_source.async_resolve_media(hass, media_id, None) - await client.get(resolved.url) + await client.get(event.data["tts_output"]["url"]) assert mock_get_tts_audio.called options = mock_get_tts_audio.call_args_list[0].kwargs["options"] diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index f856bbe7f61..060c0dce660 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -20,6 +20,8 @@ from homeassistant.components.assist_pipeline.pipeline import ( DeviceAudioQueue, Pipeline, PipelineData, + async_get_pipelines, + async_update_pipeline, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -38,13 +40,21 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) -def mock_ulid() -> Generator[Mock]: - """Mock the ulid of chat sessions.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" +def mock_chat_session_id() -> Generator[Mock]: + """Mock the conversation ID of chat sessions.""" + with patch( + "homeassistant.helpers.chat_session.ulid_now", return_value="mock-ulid" + ) as mock_ulid_now: yield mock_ulid_now +@pytest.fixture(autouse=True) +def mock_tts_token() -> Generator[None]: + """Mock the TTS token for URLs.""" + with patch("secrets.token_urlsafe", return_value="mocked-token"): + yield + + @pytest.mark.parametrize( "extra_msg", [ @@ -825,74 +835,6 @@ async def test_stt_stream_failed( assert msg["result"] == {"events": events} -async def test_tts_failed( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - init_components, - snapshot: SnapshotAssertion, -) -> None: - """Test pipeline run with text-to-speech error.""" - events = [] - client = await hass_ws_client(hass) - - with patch( - "homeassistant.components.media_source.async_resolve_media", - side_effect=RuntimeError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) - - # result - msg = await client.receive_json() - assert msg["success"] - - # run start - msg = await client.receive_json() - assert msg["event"]["type"] == "run-start" - msg["event"]["data"]["pipeline"] = ANY - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts start - msg = await client.receive_json() - assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - # tts error - msg = await client.receive_json() - assert msg["event"]["type"] == "error" - assert msg["event"]["data"]["code"] == "tts-failed" - events.append(msg["event"]) - - # run end - msg = await client.receive_json() - assert msg["event"]["type"] == "run-end" - assert msg["event"]["data"] == snapshot - events.append(msg["event"]) - - pipeline_data: PipelineData = hass.data[DOMAIN] - pipeline_id = list(pipeline_data.pipeline_debug)[0] - pipeline_run_id = list(pipeline_data.pipeline_debug[pipeline_id])[0] - - await client.send_json_auto_id( - { - "type": "assist_pipeline/pipeline_debug/get", - "pipeline_id": pipeline_id, - "pipeline_run_id": pipeline_run_id, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == {"events": events} - - async def test_tts_provider_missing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -903,23 +845,22 @@ async def test_tts_provider_missing( """Test pipeline run with text-to-speech error.""" client = await hass_ws_client(hass) - with patch( - "homeassistant.components.tts.async_support_options", - side_effect=HomeAssistantError, - ): - await client.send_json_auto_id( - { - "type": "assist_pipeline/run", - "start_stage": "tts", - "end_stage": "tts", - "input": {"text": "Lights are on."}, - } - ) + pipelines = async_get_pipelines(hass) + await async_update_pipeline(hass, pipelines[0], tts_engine="unavailable") - # result - msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "tts-not-supported" + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "tts-not-supported" async def test_tts_provider_bad_options( @@ -933,8 +874,8 @@ async def test_tts_provider_bad_options( client = await hass_ws_client(hass) with patch( - "homeassistant.components.tts.async_support_options", - return_value=False, + "homeassistant.components.tts.SpeechManager.process_options", + side_effect=HomeAssistantError("Language not supported"), ): await client.send_json_auto_id( { diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 8dece920907..1b9692cc70c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1376,29 +1376,6 @@ def test_resolve_engine(hass: HomeAssistant, setup: str, engine_id: str) -> None assert tts.async_resolve_engine(hass, None) is None -@pytest.mark.parametrize( - ("setup", "engine_id"), - [ - ("mock_setup", "test"), - ("mock_config_entry_setup", "tts.test"), - ], - indirect=["setup"], -) -async def test_support_options(hass: HomeAssistant, setup: str, engine_id: str) -> None: - """Test supporting options.""" - assert await tts.async_support_options(hass, engine_id, "en_US") is True - assert await tts.async_support_options(hass, engine_id, "nl") is False - assert ( - await tts.async_support_options( - hass, engine_id, "en_US", {"invalid_option": "yo"} - ) - is False - ) - - with pytest.raises(HomeAssistantError): - await tts.async_support_options(hass, "non-existing") - - async def test_legacy_fetching_in_async( hass: HomeAssistant, hass_client: ClientSessionGenerator ) -> None: From 3588784f1ef77e50c5d2c044813df2b3e138335f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:27:31 +0100 Subject: [PATCH 1160/1941] Add create_reward action to Habitica integration (#139304) Add create_reward action to Habitica --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/icons.json | 6 + homeassistant/components/habitica/services.py | 82 +++++++++----- .../components/habitica/services.yaml | 21 +++- .../components/habitica/strings.json | 42 ++++++- tests/components/habitica/conftest.py | 3 + tests/components/habitica/test_services.py | 103 +++++++++++++++++- 7 files changed, 224 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 353bcbbd39d..bd1363ca979 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -56,6 +56,7 @@ SERVICE_SCORE_REWARD = "score_reward" SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" +SERVICE_CREATE_REWARD = "create_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 e119b063aa5..83df86f3945 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -224,6 +224,12 @@ "tag_options": "mdi:tag", "developer_options": "mdi:test-tube" } + }, + "create_reward": { + "service": "mdi:treasure-chest-outline", + "sections": { + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 57005cf2b72..1abe977681f 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -61,6 +61,7 @@ from .const import ( SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -112,18 +113,29 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema( } ) -SERVICE_UPDATE_TASK_SCHEMA = vol.Schema( +BASE_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_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( cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") ), - vol.Optional(ATTR_COST): vol.Coerce(float), + vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), + } +) + +SERVICE_UPDATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]), + } +) + +SERVICE_CREATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( + { + vol.Required(ATTR_NAME): cv.string, } ) @@ -539,33 +551,36 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result - async def update_task(call: ServiceCall) -> ServiceResponse: - """Update task action.""" + async def create_or_update_task(call: ServiceCall) -> ServiceResponse: + """Create or update task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() + is_update = call.service == SERVICE_UPDATE_REWARD + current_task = None - 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 + if is_update: + 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 not is_update: + data["type"] = TaskType.REWARD + + if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): + data["text"] = text if (notes := call.data.get(ATTR_NOTES)) is not None: data["notes"] = notes @@ -574,7 +589,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) if tags or remove_tags: - update_tags = set(current_task.tags) + update_tags = set(current_task.tags) if current_task else set() user_tags = { tag.name.lower(): tag.id for tag in coordinator.data.user.tags @@ -634,7 +649,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 data["value"] = cost try: - response = await coordinator.habitica.update_task(task_id, data) + if is_update: + if TYPE_CHECKING: + assert current_task + assert current_task.id + response = await coordinator.habitica.update_task(current_task.id, data) + else: + response = await coordinator.habitica.create_task(data) except TooManyRequestsError as e: raise HomeAssistantError( translation_domain=DOMAIN, @@ -659,10 +680,17 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_UPDATE_REWARD, - update_task, + create_or_update_task, schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_REWARD, + create_or_update_task, + schema=SERVICE_CREATE_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 7b486690ef5..b92b765e18c 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -147,14 +147,14 @@ update_reward: rename: selector: text: - notes: + notes: ¬es required: false selector: text: multiline: true cost: required: false - selector: + selector: &cost_selector number: min: 0 step: 0.01 @@ -163,7 +163,7 @@ update_reward: tag_options: collapsed: true fields: - tag: + tag: &tag required: false selector: text: @@ -173,10 +173,23 @@ update_reward: selector: text: multiple: true - developer_options: + developer_options: &developer_options collapsed: true fields: alias: required: false selector: text: +create_reward: + fields: + config_entry: *config_entry + name: + required: true + selector: + text: + notes: *notes + cost: + required: true + selector: *cost_selector + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 1bb2fcbd9d7..0658e594d07 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -23,7 +23,9 @@ "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." + "tag_options_description": "Add or remove tags from a task.", + "name_description": "The title for the Habitica task.", + "cost_name": "Cost" }, "config": { "abort": { @@ -707,7 +709,7 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "cost": { - "name": "Cost", + "name": "[%key:component::habitica::common::cost_name%]", "description": "Update the cost of a reward." } }, @@ -721,6 +723,42 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "create_reward": { + "name": "Create reward", + "description": "Adds a new custom reward.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a reward." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "cost": { + "name": "[%key:component::habitica::common::cost_name%]", + "description": "The cost of the reward." + } + }, + "sections": { + "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 45c33a9ebb6..efb4f7300bf 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -151,6 +151,9 @@ async def mock_habiticalib() -> Generator[AsyncMock]: client.create_tag.return_value = HabiticaTagResponse.from_json( load_fixture("create_tag.json", DOMAIN) ) + client.create_task.return_value = HabiticaTaskResponse.from_json( + load_fixture("task.json", DOMAIN) + ) client.habitipy.return_value = { "tasks": { "user": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index a4442016784..0b25dc4385e 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaTaskResponse, Skill, Task +from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType import pytest from syrupy.assertion import SnapshotAssertion @@ -30,6 +30,7 @@ from homeassistant.components.habitica.const import ( SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -41,6 +42,7 @@ from homeassistant.components.habitica.const import ( ) from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -943,6 +945,51 @@ async def test_update_task_exceptions( ) +@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_create_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, + expected_exception: Exception, + exception_msg: str, +) -> None: + """Test Habitica task create action exceptions.""" + + habitica.create_task.side_effect = exception + with pytest.raises(expected_exception, match=exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_NAME: "TITLE", + }, + return_response=True, + blocking=True, + ) + + @pytest.mark.usefixtures("habitica") async def test_task_not_found( hass: HomeAssistant, @@ -1024,6 +1071,60 @@ async def test_update_reward( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + ATTR_COST: 100, + }, + Task(type=TaskType.REWARD, text="TITLE", value=100), + ), + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.REWARD, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.REWARD, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.REWARD, text="TITLE", alias="ALIAS"), + ), + ], +) +async def test_create_reward( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create_reward action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_REWARD, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From 1786bb990376ba69a827e9392631024c021a6198 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 2 Mar 2025 00:28:48 +0300 Subject: [PATCH 1161/1941] Use model list to check anthropic API key (#139307) Anthropic model list --- homeassistant/components/anthropic/__init__.py | 11 ++++------- .../components/anthropic/config_flow.py | 7 +------ tests/components/anthropic/conftest.py | 6 ++---- tests/components/anthropic/test_config_flow.py | 4 ++-- .../components/anthropic/test_conversation.py | 18 +++++++++--------- tests/components/anthropic/test_init.py | 5 ++--- 6 files changed, 20 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 84c9054b476..a9745d1a6a5 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from .const import DOMAIN, LOGGER +from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -26,12 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) ) try: - await client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=1, - messages=[{"role": "user", "content": "Hi"}], - timeout=10.0, - ) + model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model = await client.models.retrieve(model_id=model_id, timeout=10.0) + LOGGER.debug("Anthropic model: %s", model.display_name) except anthropic.AuthenticationError as err: LOGGER.error("Invalid API key: %s", err) return False diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 63a70f31fea..5f1f4fdeea7 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -63,12 +63,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: 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, - messages=[{"role": "user", "content": "Hi"}], - timeout=10.0, - ) + await client.models.list(timeout=10.0) class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index ce6b98c480c..f8ab098cc09 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -1,7 +1,7 @@ """Tests helpers.""" from collections.abc import AsyncGenerator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -43,9 +43,7 @@ async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> AsyncGenerator[None]: """Initialize integration.""" - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() yield diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index a5a025b00d0..5973d9a3ee8 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -49,7 +49,7 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, ), patch( @@ -151,7 +151,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ) with patch( - "homeassistant.components.anthropic.config_flow.anthropic.resources.messages.AsyncMessages.create", + "homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock, side_effect=side_effect, ): diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index a35df281fb6..6c8244a59ba 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -127,9 +127,7 @@ async def test_entity( CONF_LLM_HASS_API: "assist", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock - ): + with patch("anthropic.resources.models.AsyncModels.retrieve"): await hass.config_entries.async_reload(mock_config_entry.entry_id) state = hass.states.get("conversation.claude") @@ -173,8 +171,11 @@ async def test_template_error( "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) - with patch( - "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), + patch( + "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock + ), ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -205,6 +206,7 @@ async def test_template_variables( }, ) with ( + patch("anthropic.resources.models.AsyncModels.retrieve"), patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock ) as mock_create, @@ -230,8 +232,8 @@ async def test_template_variables( result.response.speech["plain"]["speech"] == "Okay, let me take care of that for you." ) - assert "The user name is Test User." in mock_create.mock_calls[1][2]["system"] - assert "The user id is 12345." in mock_create.mock_calls[1][2]["system"] + assert "The user name is Test User." in mock_create.call_args.kwargs["system"] + assert "The user id is 12345." in mock_create.call_args.kwargs["system"] async def test_conversation_agent( @@ -497,9 +499,7 @@ async def test_unknown_hass_api( assert result == snapshot -@patch("anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock) async def test_conversation_id( - mock_create, hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index ee87bb708d0..305e442f52d 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -1,6 +1,6 @@ """Tests for the Anthropic integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from anthropic import ( APIConnectionError, @@ -55,8 +55,7 @@ async def test_init_error( ) -> None: """Test initialization errors.""" with patch( - "anthropic.resources.messages.AsyncMessages.create", - new_callable=AsyncMock, + "anthropic.resources.models.AsyncModels.retrieve", side_effect=side_effect, ): assert await async_setup_component(hass, "anthropic", {}) From 35825be12bab4d3805ffef4b4d9ce52922c19cf6 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:36:51 +0100 Subject: [PATCH 1162/1941] =?UTF-8?q?Update=20quality=20scale=20to=20plati?= =?UTF-8?q?num=20=F0=9F=8F=86=EF=B8=8F=20for=20pyLoad=20integration=20(#13?= =?UTF-8?q?8891)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add quality scale file to pyLoad integration * set strict-typing to done * set parallel-updates to done * docs * update docs * flow coverage done * set platinum quality scale --- .strict-typing | 1 + homeassistant/components/pyload/manifest.json | 1 + .../components/pyload/quality_scale.yaml | 82 +++++++++++++++++++ mypy.ini | 10 +++ script/hassfest/quality_scale.py | 2 - 5 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/pyload/quality_scale.yaml diff --git a/.strict-typing b/.strict-typing index 4b2a94b2db4..8d0d71e85fe 100644 --- a/.strict-typing +++ b/.strict-typing @@ -396,6 +396,7 @@ homeassistant.components.pure_energie.* homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* +homeassistant.components.pyload.* homeassistant.components.python_script.* homeassistant.components.qbus.* homeassistant.components.qnap_qsw.* diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json index 134865b9d93..feaa23af7de 100644 --- a/homeassistant/components/pyload/manifest.json +++ b/homeassistant/components/pyload/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["pyloadapi"], + "quality_scale": "platinum", "requirements": ["PyLoadAPI==1.4.2"] } diff --git a/homeassistant/components/pyload/quality_scale.yaml b/homeassistant/components/pyload/quality_scale.yaml new file mode 100644 index 00000000000..a9ce552961b --- /dev/null +++ b/homeassistant/components/pyload/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration registers no actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration registers no actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: The integration registers no events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration registers no actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + discovery: + status: exempt + comment: The integration is a web service, there are no discoverable devices. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: The integration is a web service, there are no devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: The integration is a web service, there are no devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: The integration has no repairs. + stale-devices: + status: exempt + comment: The integration is a web service, there are no devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/mypy.ini b/mypy.ini index 0792f820965..c69401b8605 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3716,6 +3716,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pyload.*] +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.python_script.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 5f90fff81d5..1e335eaeb49 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -812,7 +812,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", @@ -1890,7 +1889,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "pushsafer", "pvoutput", "pvpc_hourly_pricing", - "pyload", "qbittorrent", "qingping", "qld_bushfire", From 13918f07d8afdeebe49172f4259747f088b9ef03 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 1 Mar 2025 22:39:19 +0100 Subject: [PATCH 1163/1941] Switch cleanup for Shelly (part 2) (#138922) * Switch cleanup for Shelly (part 2) * apply review comment * Update tests/components/shelly/test_climate.py Co-authored-by: Maciej Bieniek * apply review comments --------- Co-authored-by: Maciej Bieniek --- homeassistant/components/shelly/switch.py | 103 +++++++++----------- homeassistant/components/shelly/utils.py | 11 +++ tests/components/shelly/conftest.py | 9 +- tests/components/shelly/test_climate.py | 1 + tests/components/shelly/test_coordinator.py | 7 ++ tests/components/shelly/test_init.py | 9 ++ tests/components/shelly/test_switch.py | 24 ++++- 7 files changed, 102 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 41826706945..68708a2cc2b 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -7,8 +7,9 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, MODEL_WALL_DISPLAY, RPC_GENERATIONS +from aioshelly.const import MODEL_2, MODEL_25, RPC_GENERATIONS +from homeassistant.components.climate import DOMAIN as CLIMATE_PLATFORM from homeassistant.components.switch import ( DOMAIN as SWITCH_PLATFORM, SwitchEntity, @@ -27,7 +28,6 @@ from .entity import ( RpcEntityDescription, ShellyBlockEntity, ShellyRpcAttributeEntity, - ShellyRpcEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, @@ -36,12 +36,9 @@ from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, get_device_entry_gen, - get_rpc_key_ids, get_virtual_component_ids, is_block_channel_type_light, - is_rpc_channel_type_light, - is_rpc_thermostat_internal_actuator, - is_rpc_thermostat_mode, + is_rpc_exclude_from_relay, ) @@ -67,6 +64,18 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): method_params_fn: Callable[[int | None, bool], dict] +RPC_RELAY_SWITCHES = { + "switch": RpcSwitchDescription( + key="switch", + sub_key="output", + removal_condition=is_rpc_exclude_from_relay, + is_on=lambda status: bool(status["output"]), + method_on="Switch.Set", + method_off="Switch.Set", + method_params_fn=lambda id, value: {"id": id, "on": value}, + ), +} + RPC_SWITCHES = { "boolean": RpcSwitchDescription( key="boolean", @@ -162,32 +171,10 @@ def async_setup_rpc_entry( """Set up entities for RPC device.""" coordinator = config_entry.runtime_data.rpc assert coordinator - switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch") - switch_ids = [] - for id_ in switch_key_ids: - if is_rpc_channel_type_light(coordinator.device.config, id_): - continue - - if coordinator.model == MODEL_WALL_DISPLAY: - # There are three configuration scenarios for WallDisplay: - # - relay mode (no thermostat) - # - thermostat mode using the internal relay as an actuator - # - thermostat mode using an external (from another device) relay as - # an actuator - if not is_rpc_thermostat_mode(id_, coordinator.device.status): - # The device is not in thermostat mode, we need to remove a climate - # entity - unique_id = f"{coordinator.mac}-thermostat:{id_}" - async_remove_shelly_entity(hass, "climate", unique_id) - elif is_rpc_thermostat_internal_actuator(coordinator.device.status): - # The internal relay is an actuator, skip this ID so as not to create - # a switch entity - continue - - switch_ids.append(id_) - unique_id = f"{coordinator.mac}-switch:{id_}" - async_remove_shelly_entity(hass, "light", unique_id) + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_RELAY_SWITCHES, RpcRelaySwitch + ) async_setup_entry_rpc( hass, config_entry, async_add_entities, RPC_SWITCHES, RpcSwitch @@ -218,10 +205,16 @@ def async_setup_rpc_entry( "script", ) - if not switch_ids: - return - - async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) + # if the climate is removed, from the device configuration, we need + # to remove orphaned entities + async_remove_orphaned_entities( + hass, + config_entry.entry_id, + coordinator.mac, + CLIMATE_PLATFORM, + coordinator.device.status, + "thermostat", + ) class BlockSleepingMotionSwitch( @@ -305,28 +298,6 @@ class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): super()._update_callback() -class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity): - """Entity that controls a relay on RPC based Shelly devices.""" - - def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: - """Initialize relay switch.""" - super().__init__(coordinator, f"switch:{id_}") - self._id = id_ - - @property - def is_on(self) -> bool: - """If switch is on.""" - return bool(self.status["output"]) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": True}) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off relay.""" - await self.call_rpc("Switch.Set", {"id": self._id, "on": False}) - - class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): """Entity that controls a switch on RPC based Shelly devices.""" @@ -351,3 +322,21 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): self.entity_description.method_off, self.entity_description.method_params_fn(self._id, False), ) + + +class RpcRelaySwitch(RpcSwitch): + """Entity that controls a switch on RPC based Shelly devices.""" + + # False to avoid double naming as True is inerithed from base class + _attr_has_entity_name = False + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + attribute: str, + description: RpcEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator, key, attribute, description) + self._attr_unique_id: str = f"{coordinator.mac}-{key}" diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 2e81f745819..d9e86427d0b 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -627,3 +627,14 @@ async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]: code_response = await device.script_getcode(id) matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"]) return sorted([*{str(event_type.group(1)) for event_type in matches}]) + + +def is_rpc_exclude_from_relay( + settings: dict[str, Any], status: dict[str, Any], channel: str +) -> bool: + """Return true if rpc channel should be excludeed from switch platform.""" + ch = int(channel.split(":")[1]) + if is_rpc_thermostat_internal_actuator(status): + return True + + return is_rpc_channel_type_light(settings, ch) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index a332d16f95d..0063c5c2697 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -101,6 +101,7 @@ MOCK_BLOCKS = [ "overpower": 0, "power": 53.4, "energy": 1234567.89, + "output": True, }, channel="0", type="relay", @@ -207,7 +208,7 @@ MOCK_CONFIG = { }, "sys": { "ui_data": {}, - "device": {"name": "Test name"}, + "device": {"name": "Test name", "mac": MOCK_MAC}, }, "wifi": {"sta": {"enable": True}, "sta1": {"enable": False}}, "ws": {"enable": False, "server": None}, @@ -312,7 +313,11 @@ MOCK_STATUS_COAP = { MOCK_STATUS_RPC = { - "switch:0": {"output": True}, + "switch:0": { + "id": 0, + "output": True, + "apower": 85.3, + }, "input:0": {"id": 0, "state": None}, "input:1": {"id": 1, "percent": 89, "xpercent": 8.9}, "input:2": { diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 040d67cb9c4..c78e87ebfce 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -751,6 +751,7 @@ async def test_wall_display_thermostat_mode_external_actuator( new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8c011e4ad0d..8de434d19d0 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -386,6 +386,8 @@ async def test_rpc_reload_on_cfg_change( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC reload on config change.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) # Generate config change from switch to light @@ -710,6 +712,8 @@ async def test_rpc_reconnect_error( exc: Exception, ) -> None: """Test RPC reconnect error.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON @@ -729,9 +733,12 @@ async def test_rpc_error_running_connected_events( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Test RPC error while running connected events.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) with patch( "homeassistant.components.shelly.coordinator.async_ensure_ble_enabled", side_effect=DeviceConnectionError, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index b05bce76728..f3ce807b655 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -366,8 +366,11 @@ async def test_entry_unload( entity_id: str, mock_block_device: Mock, mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test entry unload.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, gen) assert entry.state is ConfigEntryState.LOADED @@ -410,6 +413,9 @@ async def test_entry_unload_not_connected( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner" ) as mock_stop_scanner: @@ -435,6 +441,9 @@ async def test_entry_unload_not_connected_but_we_think_we_are( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test entry unload when not connected but we think we are still connected.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + with patch( "homeassistant.components.shelly.coordinator.async_stop_scanner", side_effect=DeviceConnectionError, diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 5aae9dfffc9..1e5ae9dd88c 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -288,6 +288,8 @@ async def test_rpc_device_services( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test RPC device turn on/off services.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) await hass.services.async_call( @@ -310,9 +312,14 @@ async def test_rpc_device_services( async def test_rpc_device_unique_ids( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, ) -> None: """Test RPC device unique_ids.""" + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) entry = entity_registry.async_get("switch.test_switch_0") @@ -340,6 +347,8 @@ async def test_rpc_set_state_errors( ) -> None: """Test RPC device set state connection/call errors.""" monkeypatch.setattr(mock_rpc_device, "call_rpc", AsyncMock(side_effect=exc)) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) with pytest.raises(HomeAssistantError): @@ -360,6 +369,8 @@ async def test_rpc_auth_error( "call_rpc", AsyncMock(side_effect=InvalidAuthError), ) + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, 2) assert entry.state is ConfigEntryState.LOADED @@ -409,15 +420,22 @@ async def test_wall_display_relay_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test Wall Display in relay mode.""" - climate_entity_id = "climate.test_name" + climate_entity_id = "climate.test_name_thermostat_0" switch_entity_id = "switch.test_switch_0" + config_entry = await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + assert hass.states.get(climate_entity_id) is not None + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 + new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False new_status.pop("thermostat:0") + new_status.pop("cover:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) - await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() # the climate entity should be removed assert hass.states.get(climate_entity_id) is None From f7927f9da1fd13b996475ac17d7909e6240de334 Mon Sep 17 00:00:00 2001 From: Tatham Oddie Date: Sun, 2 Mar 2025 07:54:48 +1000 Subject: [PATCH 1164/1941] Introduce demo valve (#138187) --- homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/valve.py | 89 +++++++++++++++++++++++ tests/components/demo/test_valve.py | 83 +++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 homeassistant/components/demo/valve.py create mode 100644 tests/components/demo/test_valve.py diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 9314fc211de..dbc65119bfa 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -48,6 +48,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.TIME, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, Platform.WEATHER, ] diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py new file mode 100644 index 00000000000..9c6acd45a8a --- /dev/null +++ b/homeassistant/components/demo/valve.py @@ -0,0 +1,89 @@ +"""Demo valve platform that implements valves.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + async_add_entities( + [ + DemoValve("Front Garden", ValveState.OPEN), + DemoValve("Orchard", ValveState.CLOSED), + ] + ) + + +class DemoValve(ValveEntity): + """Representation of a Demo valve.""" + + _attr_should_poll = False + + def __init__( + self, + name: str, + state: str, + moveable: bool = True, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + if moveable: + self._attr_supported_features = ( + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + ) + self._state = state + self._moveable = moveable + + @property + def is_open(self) -> bool: + """Return true if valve is open.""" + return self._state == ValveState.OPEN + + @property + def is_opening(self) -> bool: + """Return true if valve is opening.""" + return self._state == ValveState.OPENING + + @property + def is_closing(self) -> bool: + """Return true if valve is closing.""" + return self._state == ValveState.CLOSING + + @property + def is_closed(self) -> bool: + """Return true if valve is closed.""" + return self._state == ValveState.CLOSED + + @property + def reports_position(self) -> bool: + """Return True if entity reports position, False otherwise.""" + return False + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + self._state = ValveState.OPENING + self.async_write_ha_state() + await asyncio.sleep(OPEN_CLOSE_DELAY) + self._state = ValveState.OPEN + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close the valve.""" + self._state = ValveState.CLOSING + self.async_write_ha_state() + await asyncio.sleep(OPEN_CLOSE_DELAY) + self._state = ValveState.CLOSED + self.async_write_ha_state() diff --git a/tests/components/demo/test_valve.py b/tests/components/demo/test_valve.py new file mode 100644 index 00000000000..1057065ce70 --- /dev/null +++ b/tests/components/demo/test_valve.py @@ -0,0 +1,83 @@ +"""The tests for the Demo valve platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.demo import DOMAIN, valve as demo_valve +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + ValveState, +) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + +FRONT_GARDEN = "valve.front_garden" +ORCHARD = "valve.orchard" + + +@pytest.fixture +async def valve_only() -> None: + """Enable only the valve platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.VALVE], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, valve_only: None): + """Set up demo component.""" + assert await async_setup_component( + hass, VALVE_DOMAIN, {VALVE_DOMAIN: {"platform": DOMAIN}} + ) + await hass.async_block_till_done() + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_closing(hass: HomeAssistant) -> None: + """Test the closing of a valve.""" + state = hass.states.get(FRONT_GARDEN) + assert state.state == ValveState.OPEN + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: FRONT_GARDEN}, + blocking=False, + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == FRONT_GARDEN + assert state_changes[0].data["new_state"].state == ValveState.CLOSING + + assert state_changes[1].data["entity_id"] == FRONT_GARDEN + assert state_changes[1].data["new_state"].state == ValveState.CLOSED + + +@patch.object(demo_valve, "OPEN_CLOSE_DELAY", 0) +async def test_opening(hass: HomeAssistant) -> None: + """Test the opening of a valve.""" + state = hass.states.get(ORCHARD) + assert state.state == ValveState.CLOSED + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + VALVE_DOMAIN, SERVICE_OPEN_VALVE, {ATTR_ENTITY_ID: ORCHARD}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == ORCHARD + assert state_changes[0].data["new_state"].state == ValveState.OPENING + + assert state_changes[1].data["entity_id"] == ORCHARD + assert state_changes[1].data["new_state"].state == ValveState.OPEN From a2a11ad02ecee60fdb61ea895747435401313819 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 1 Mar 2025 22:55:49 +0100 Subject: [PATCH 1165/1941] =?UTF-8?q?Update=20quality=20scale=20to=20plati?= =?UTF-8?q?num=20=F0=9F=8F=86=EF=B8=8F=20for=20IronOS=20integration=20(#13?= =?UTF-8?q?8217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update status in iron_os quality_scale.yaml --- homeassistant/components/iron_os/manifest.json | 1 + homeassistant/components/iron_os/quality_scale.yaml | 10 +++++++--- script/hassfest/quality_scale.py | 1 - 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 462e75c5b6e..c9868791668 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -13,5 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", "loggers": ["pynecil"], + "quality_scale": "platinum", "requirements": ["pynecil==4.0.1"] } diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml index c80b8b5adfe..8f7eb5ff36a 100644 --- a/homeassistant/components/iron_os/quality_scale.yaml +++ b/homeassistant/components/iron_os/quality_scale.yaml @@ -21,8 +21,10 @@ rules: entity-unique-id: done has-entity-name: done runtime-data: done - test-before-configure: todo - test-before-setup: todo + test-before-configure: + status: exempt + comment: Device is set up from a Bluetooth discovery + test-before-setup: done unique-config-entry: done # Silver @@ -70,7 +72,9 @@ rules: repair-issues: status: exempt comment: no repairs/issues - stale-devices: todo + stale-devices: + status: exempt + comment: Stale devices are removed with the config entry as there is only one device per entry # Platinum async-dependency: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 1e335eaeb49..9ddce29a4f3 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1588,7 +1588,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "intellifire", "intesishome", "ios", - "iron_os", "iotawatt", "iotty", "iperf3", From 56ddfa9ff80a3c042a27da5541799fafa1980ff5 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 1 Mar 2025 23:05:55 +0100 Subject: [PATCH 1166/1941] 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 0554810837f..8e11909a56c 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 c86feb62135..99fdb680f63 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 89b655c192d55920ae755397499fd022d4ffa42d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 16:13:04 -0600 Subject: [PATCH 1167/1941] 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 cc8ed2c228cca6ca0fdc282075e286bf72c6feec Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 2 Mar 2025 00:29:42 +0200 Subject: [PATCH 1168/1941] Fix demo valve platform to use AddConfigEntryEntitiesCallback (#139602) --- homeassistant/components/demo/valve.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py index 9c6acd45a8a..03f0123dd96 100644 --- a/homeassistant/components/demo/valve.py +++ b/homeassistant/components/demo/valve.py @@ -8,7 +8,7 @@ from typing import Any from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend @@ -16,7 +16,7 @@ OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in fronte async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Demo config entry.""" async_add_entities( From 3e9304253d360af9303f3dbc1b3dac88a53e8776 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 2 Mar 2025 08:58:15 +1000 Subject: [PATCH 1169/1941] 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 8e11909a56c..b770799a778 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 99fdb680f63..31314c763ba 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 e3eb6051de652875f794e814f0396367898fd3de Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 2 Mar 2025 00:04:13 +0100 Subject: [PATCH 1170/1941] 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 55fd5fa86902918c670dbd5e97500b4dfe264c0f Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 2 Mar 2025 01:12:19 +0200 Subject: [PATCH 1171/1941] 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 b770799a778..8a8f0b51613 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 31314c763ba..5ead556907a 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 077ff63b38a92fdfeb884dccf7df8957a5c44d56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Mar 2025 17:51:09 -0600 Subject: [PATCH 1172/1941] 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 8a8f0b51613..f2da895114f 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 5ead556907a..c328478338d 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 4a7fd89abde2da04d596eeb0732b7fb2b4ce233d Mon Sep 17 00:00:00 2001 From: wittypluck Date: Sun, 2 Mar 2025 02:32:55 +0100 Subject: [PATCH 1173/1941] Bump pyopenweathermap to 0.2.2 and remove deprecated API version v2.5 (#139599) * Bump pyopenweathermap * Remove deprecated API mode v2.5 --- .../components/openweathermap/__init__.py | 6 +++--- homeassistant/components/openweathermap/const.py | 2 -- .../components/openweathermap/manifest.json | 2 +- homeassistant/components/openweathermap/weather.py | 3 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/openweathermap/test_weather.py | 14 +++++++------- 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index fa51b91dc6d..40ddf0ff37e 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant -from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS +from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS from .coordinator import WeatherUpdateCoordinator from .repairs import async_create_issue, async_delete_issue from .utils import build_data_and_options @@ -39,7 +39,7 @@ async def async_setup_entry( language = entry.options[CONF_LANGUAGE] mode = entry.options[CONF_MODE] - if mode == OWM_MODE_V25: + if mode not in OWM_MODES: async_create_issue(hass, entry.entry_id) else: async_delete_issue(hass, entry.entry_id) @@ -70,7 +70,7 @@ async def async_migrate_entry( _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) if version < 5: - combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25} + combined_data = {**data, **options, CONF_MODE: DEFAULT_OWM_MODE} new_data, new_options = build_data_and_options(combined_data) config_entries.async_update_entry( entry, diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index de317709f5b..fbd2cb1aee2 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -62,10 +62,8 @@ FORECAST_MODE_ONECALL_DAILY = "onecall_daily" OWM_MODE_FREE_CURRENT = "current" OWM_MODE_FREE_FORECAST = "forecast" OWM_MODE_V30 = "v3.0" -OWM_MODE_V25 = "v2.5" OWM_MODES = [ OWM_MODE_V30, - OWM_MODE_V25, OWM_MODE_FREE_CURRENT, OWM_MODE_FREE_FORECAST, ] diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json index 14313a5a77e..88510aaae8c 100644 --- a/homeassistant/components/openweathermap/manifest.json +++ b/homeassistant/components/openweathermap/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/openweathermap", "iot_class": "cloud_polling", "loggers": ["pyopenweathermap"], - "requirements": ["pyopenweathermap==0.2.1"] + "requirements": ["pyopenweathermap==0.2.2"] } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index a6ad163e1c8..12d883c871a 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -42,7 +42,6 @@ from .const import ( DOMAIN, MANUFACTURER, OWM_MODE_FREE_FORECAST, - OWM_MODE_V25, OWM_MODE_V30, ) from .coordinator import WeatherUpdateCoordinator @@ -106,7 +105,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina ) self.mode = mode - if mode in (OWM_MODE_V30, OWM_MODE_V25): + if mode == OWM_MODE_V30: self._attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY diff --git a/requirements_all.txt b/requirements_all.txt index f2da895114f..8946b355e03 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2179,7 +2179,7 @@ pyombi==0.1.10 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c328478338d..e0f26ae9e98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1778,7 +1778,7 @@ pyoctoprintapi==0.1.12 pyopenuv==2023.02.0 # homeassistant.components.openweathermap -pyopenweathermap==0.2.1 +pyopenweathermap==0.2.2 # homeassistant.components.opnsense pyopnsense==0.4.0 diff --git a/tests/components/openweathermap/test_weather.py b/tests/components/openweathermap/test_weather.py index 5d3565d6ca9..e9817e739ac 100644 --- a/tests/components/openweathermap/test_weather.py +++ b/tests/components/openweathermap/test_weather.py @@ -6,7 +6,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.openweathermap.const import ( DEFAULT_LANGUAGE, DOMAIN, - OWM_MODE_V25, + OWM_MODE_FREE_CURRENT, OWM_MODE_V30, ) from homeassistant.components.openweathermap.weather import SERVICE_GET_MINUTE_FORECAST @@ -52,9 +52,9 @@ def mock_config_entry(mode: str) -> MockConfigEntry: @pytest.fixture -def mock_config_entry_v25() -> MockConfigEntry: - """Create a mock OpenWeatherMap v2.5 config entry.""" - return mock_config_entry(OWM_MODE_V25) +def mock_config_entry_free_current() -> MockConfigEntry: + """Create a mock OpenWeatherMap FREE_CURRENT config entry.""" + return mock_config_entry(OWM_MODE_FREE_CURRENT) @pytest.fixture @@ -97,15 +97,15 @@ async def test_get_minute_forecast( @patch( - "pyopenweathermap.client.onecall_client.OWMOneCallClient.get_weather", + "pyopenweathermap.client.free_client.OWMFreeClient.get_weather", AsyncMock(return_value=static_weather_report), ) async def test_mode_fail( hass: HomeAssistant, - mock_config_entry_v25: MockConfigEntry, + mock_config_entry_free_current: MockConfigEntry, ) -> None: """Test that Minute forecasting fails when mode is not v3.0.""" - await setup_mock_config_entry(hass, mock_config_entry_v25) + await setup_mock_config_entry(hass, mock_config_entry_free_current) # Expect a ServiceValidationError when mode is not OWM_MODE_V30 with pytest.raises( From 7293ae5d51b8e4b38d982af05b6004f91099a0c7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Mar 2025 22:59:14 -0500 Subject: [PATCH 1174/1941] Fix type for ESPHome assist satellite events (#139618) --- homeassistant/components/esphome/assist_satellite.py | 6 +++--- tests/components/esphome/test_assist_satellite.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 0af74621153..fdd16d20d77 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -285,9 +285,9 @@ class EsphomeAssistSatellite( assert event.data is not None data_to_send = { "conversation_id": event.data["intent_output"]["conversation_id"], - "continue_conversation": event.data["intent_output"][ - "continue_conversation" - ], + "continue_conversation": str( + int(event.data["intent_output"]["continue_conversation"]) + ), } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 56914a0b829..3281a760c39 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -298,7 +298,7 @@ async def test_pipeline_api_audio( VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, { "conversation_id": conversation_id, - "continue_conversation": True, + "continue_conversation": "1", }, ) From 220509fd6c55b9f0400539bdb7ffec536504ae04 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 2 Mar 2025 05:00:22 +0100 Subject: [PATCH 1175/1941] 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 b2c7c5b1aa8e01e660256b0f696fe10464e6c601 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 2 Mar 2025 11:05:25 +0100 Subject: [PATCH 1176/1941] Treat "Core" as name, fix grammar in `reload_core_config` action (#139622) * Treat "Core" as name, fix grammar in `reload_core_config` action Change three occurrences of "core" to "Core" so they are not translated but kept as a name instead. Fix singular/plural mismatch in the field description of the `reload_core_config` action. * Change "us customary" to "US customary" --- homeassistant/components/homeassistant/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 590afd697b5..4ca56471452 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -12,7 +12,7 @@ }, "imperial_unit_system": { "title": "The imperial unit system is deprecated", - "description": "The imperial unit system is deprecated and your system is currently using us customary. Please update your configuration to use the us customary unit system and reload the core configuration to fix this issue." + "description": "The imperial unit system is deprecated and your system is currently using US customary. Please update your configuration to use the US customary unit system and reload the Core configuration to fix this issue." }, "deprecated_yaml": { "title": "The {integration_title} YAML configuration is being removed", @@ -111,8 +111,8 @@ "description": "Checks the Home Assistant YAML-configuration files for errors. Errors will be shown in the Home Assistant logs." }, "reload_core_config": { - "name": "Reload core configuration", - "description": "Reloads the core configuration from the YAML-configuration." + "name": "Reload Core configuration", + "description": "Reloads the Core configuration from the YAML-configuration." }, "restart": { "name": "[%key:common::action::restart%]", @@ -160,7 +160,7 @@ }, "update_entity": { "name": "Update entity", - "description": "Forces one or more entities to update its data.", + "description": "Forces one or more entities to update their data.", "fields": { "entity_id": { "name": "Entities to update", From e6c946b3f4ae7ee6f34d7faf5736fe099a3bb19a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 13:15:43 +0100 Subject: [PATCH 1177/1941] 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 8946b355e03..f98ec465f1b 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 e0f26ae9e98..87e301ae4a8 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 b0b5567316f4e063a0bf16ec518f67f465db42d2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 2 Mar 2025 14:04:13 +0100 Subject: [PATCH 1178/1941] Add `update_habit` action to Habitica integration (#139311) * Add update_habit action * icons --- homeassistant/components/habitica/const.py | 5 + homeassistant/components/habitica/icons.json | 7 ++ homeassistant/components/habitica/services.py | 48 +++++++- .../components/habitica/services.yaml | 65 +++++++++- .../components/habitica/strings.json | 72 ++++++++++++ tests/components/habitica/test_services.py | 111 +++++++++++++++++- 6 files changed, 299 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index bd1363ca979..ecaa66378f0 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -40,6 +40,10 @@ ATTR_ALIAS = "alias" ATTR_PRIORITY = "priority" ATTR_COST = "cost" ATTR_NOTES = "notes" +ATTR_UP_DOWN = "up_down" +ATTR_FREQUENCY = "frequency" +ATTR_COUNTER_UP = "counter_up" +ATTR_COUNTER_DOWN = "counter_down" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -57,6 +61,7 @@ SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" SERVICE_CREATE_REWARD = "create_reward" +SERVICE_UPDATE_HABIT = "update_habit" 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 83df86f3945..ca4795dd514 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -230,6 +230,13 @@ "sections": { "developer_options": "mdi:test-tube" } + }, + "update_habit": { + "service": "mdi:contrast-box", + "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 1abe977681f..3c4a59990a3 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -10,6 +10,7 @@ from uuid import UUID from aiohttp import ClientError from habiticalib import ( Direction, + Frequency, HabiticaException, NotAuthorizedError, NotFoundError, @@ -41,8 +42,11 @@ from .const import ( ATTR_ARGS, ATTR_CONFIG_ENTRY, ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DATA, ATTR_DIRECTION, + ATTR_FREQUENCY, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -54,6 +58,7 @@ from .const import ( ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UP_DOWN, DOMAIN, EVENT_API_CALL_SUCCESS, SERVICE_ABORT_QUEST, @@ -69,6 +74,7 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, ) from .coordinator import HabiticaConfigEntry @@ -123,6 +129,13 @@ BASE_TASK_SCHEMA = vol.Schema( cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$") ), vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)), + vol.Optional(ATTR_PRIORITY): vol.All( + vol.Upper, vol.In(TaskPriority._member_names_) + ), + vol.Optional(ATTR_UP_DOWN): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_COUNTER_UP): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_COUNTER_DOWN): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency), } ) @@ -173,6 +186,12 @@ ITEMID_MAP = { "shiny_seed": Skill.SHINY_SEED, } +SERVICE_TASK_TYPE_MAP = { + SERVICE_UPDATE_REWARD: TaskType.REWARD, + SERVICE_CREATE_REWARD: TaskType.REWARD, + SERVICE_UPDATE_HABIT: TaskType.HABIT, +} + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -551,12 +570,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 return result - async def create_or_update_task(call: ServiceCall) -> ServiceResponse: + async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 """Create or update task action.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() - is_update = call.service == SERVICE_UPDATE_REWARD + is_update = call.service in (SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT) current_task = None if is_update: @@ -565,7 +584,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 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 + and task.Type is SERVICE_TASK_TYPE_MAP[call.service] ) except StopIteration as e: raise ServiceValidationError( @@ -648,6 +667,22 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if (cost := call.data.get(ATTR_COST)) is not None: data["value"] = cost + if priority := call.data.get(ATTR_PRIORITY): + data["priority"] = TaskPriority[priority] + + if frequency := call.data.get(ATTR_FREQUENCY): + data["frequency"] = frequency + + if up_down := call.data.get(ATTR_UP_DOWN): + data["up"] = "up" in up_down + data["down"] = "down" in up_down + + if counter_up := call.data.get(ATTR_COUNTER_UP): + data["counterUp"] = counter_up + + if counter_down := call.data.get(ATTR_COUNTER_DOWN): + data["counterDown"] = counter_down + try: if is_update: if TYPE_CHECKING: @@ -684,6 +719,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_HABIT, + create_or_update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_CREATE_REWARD, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index b92b765e18c..f5a9c2b0032 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -144,7 +144,7 @@ update_reward: fields: config_entry: *config_entry task: *task - rename: + rename: &rename selector: text: notes: ¬es @@ -160,7 +160,7 @@ update_reward: step: 0.01 unit_of_measurement: "🪙" mode: box - tag_options: + tag_options: &tag_options collapsed: true fields: tag: &tag @@ -176,7 +176,7 @@ update_reward: developer_options: &developer_options collapsed: true fields: - alias: + alias: &alias required: false selector: text: @@ -193,3 +193,62 @@ create_reward: selector: *cost_selector tag: *tag developer_options: *developer_options +update_habit: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + up_down: + required: false + selector: + select: + options: + - value: up + label: "➕" + - value: down + label: "➖" + multiple: true + mode: list + priority: + required: false + selector: + select: + options: + - "trivial" + - "easy" + - "medium" + - "hard" + mode: dropdown + translation_key: "priority" + frequency: + required: false + selector: + select: + options: + - "daily" + - "weekly" + - "monthly" + translation_key: "frequency" + mode: dropdown + tag_options: *tag_options + developer_options: + collapsed: true + fields: + counter_up: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "➕" + mode: box + counter_down: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "➖" + mode: box + alias: *alias diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 0658e594d07..22ea44351da 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -759,6 +759,70 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "update_habit": { + "name": "Update a habit", + "description": "Updates a specific habit for the selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to update a habit." + }, + "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%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_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%]" + }, + "priority": { + "name": "Difficulty", + "description": "Update the difficulty of a task." + }, + "frequency": { + "name": "Counter reset", + "description": "Update when a habit's counter resets: daily resets at the start of a new day, weekly after Sunday night, and monthly at the beginning of a new month." + }, + "up_down": { + "name": "Rewards or losses", + "description": "Update if the habit is good and rewarding (positive), bad and penalizing (negative), or both." + }, + "counter_up": { + "name": "Adjust positive counter", + "description": "Update the up counter of a positive habit." + }, + "counter_down": { + "name": "Adjust negative counter", + "description": "Update the down counter of a negative habit." + } + }, + "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": { @@ -793,6 +857,14 @@ "medium": "Medium", "hard": "Hard" } + }, + "frequency": { + "options": { + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly", + "yearly": "Yearly" + } } } } diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 0b25dc4385e..10a8bc0a588 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -6,7 +6,15 @@ from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError -from habiticalib import Direction, HabiticaTaskResponse, Skill, Task, TaskType +from habiticalib import ( + Direction, + Frequency, + HabiticaTaskResponse, + Skill, + Task, + TaskPriority, + TaskType, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -14,7 +22,10 @@ from homeassistant.components.habitica.const import ( ATTR_ALIAS, ATTR_CONFIG_ENTRY, ATTR_COST, + ATTR_COUNTER_DOWN, + ATTR_COUNTER_UP, ATTR_DIRECTION, + ATTR_FREQUENCY, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -25,6 +36,7 @@ from homeassistant.components.habitica.const import ( ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UP_DOWN, DOMAIN, SERVICE_ABORT_QUEST, SERVICE_ACCEPT_QUEST, @@ -38,6 +50,7 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, ) from homeassistant.components.todo import ATTR_RENAME @@ -919,6 +932,13 @@ async def test_get_tasks( ), ], ) +@pytest.mark.parametrize( + ("service", "task_id"), + [ + (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), + (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), + ], +) @pytest.mark.usefixtures("habitica") async def test_update_task_exceptions( hass: HomeAssistant, @@ -927,15 +947,16 @@ async def test_update_task_exceptions( exception: Exception, expected_exception: Exception, exception_msg: str, + service: str, + task_id: 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, service_data={ ATTR_CONFIG_ENTRY: config_entry.entry_id, ATTR_TASK: task_id, @@ -1125,6 +1146,90 @@ async def test_create_reward( habitica.create_task.assert_awaited_with(call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_UP_DOWN: [""], + }, + Task(up=False, down=False), + ), + ( + { + ATTR_UP_DOWN: ["up"], + }, + Task(up=True, down=False), + ), + ( + { + ATTR_UP_DOWN: ["down"], + }, + Task(up=False, down=True), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_FREQUENCY: "daily", + }, + Task(frequency=Frequency.DAILY), + ), + ( + { + ATTR_COUNTER_UP: 1, + ATTR_COUNTER_DOWN: 2, + }, + Task(counterUp=1, counterDown=2), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +async def test_update_habit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica habit action.""" + task_id = "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_HABIT, + 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, From ee2b53ed0f23919cb8fd994a6b7d6d130ee81d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexey=20ALERT=20Rubash=D1=91ff?= Date: Sun, 2 Mar 2025 15:10:45 +0200 Subject: [PATCH 1179/1941] Bump pyoverkiz to 1.16.2 (#139623) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 14f69291be4..07ec02d76a6 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.1"], + "requirements": ["pyoverkiz==1.16.2"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index f98ec465f1b..696aef8b03b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.1 +pyoverkiz==1.16.2 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 87e301ae4a8..b9509b7fac3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.1 +pyoverkiz==1.16.2 # homeassistant.components.onewire pyownet==0.10.0.post1 From 29f680f9120e3f1ccf4c6bbd636d654d50ba85c9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Mar 2025 14:12:54 +0100 Subject: [PATCH 1180/1941] Add FrankEver virtual integration (#139629) * Add FranvEver virtual integration * Fix file name --- homeassistant/components/frankever/__init__.py | 1 + homeassistant/components/frankever/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/frankever/__init__.py create mode 100644 homeassistant/components/frankever/manifest.json diff --git a/homeassistant/components/frankever/__init__.py b/homeassistant/components/frankever/__init__.py new file mode 100644 index 00000000000..66eeecb1e59 --- /dev/null +++ b/homeassistant/components/frankever/__init__.py @@ -0,0 +1 @@ +"""FrankEver virtual integration.""" diff --git a/homeassistant/components/frankever/manifest.json b/homeassistant/components/frankever/manifest.json new file mode 100644 index 00000000000..37d7be765ef --- /dev/null +++ b/homeassistant/components/frankever/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "frankever", + "name": "FrankEver", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e3185251114..1db5de7ac69 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2046,6 +2046,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "frankever": { + "name": "FrankEver", + "integration_type": "virtual", + "supported_by": "shelly" + }, "free_mobile": { "name": "Free Mobile", "integration_type": "hub", From 3eadfcc01d3aae78e1eb82852ac5452d8eb54e4b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 14:17:56 +0100 Subject: [PATCH 1181/1941] 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 d922c723d4ceff3d565110455b5bafc82bf0b4c6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Mar 2025 14:19:52 +0100 Subject: [PATCH 1182/1941] Add LinkedGo virtual integration (#139625) --- homeassistant/components/linkedgo/__init__.py | 1 + homeassistant/components/linkedgo/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/linkedgo/__init__.py create mode 100644 homeassistant/components/linkedgo/manifest.json diff --git a/homeassistant/components/linkedgo/__init__.py b/homeassistant/components/linkedgo/__init__.py new file mode 100644 index 00000000000..e26fefa6b96 --- /dev/null +++ b/homeassistant/components/linkedgo/__init__.py @@ -0,0 +1 @@ +"""LinkedGo virtual integration.""" diff --git a/homeassistant/components/linkedgo/manifest.json b/homeassistant/components/linkedgo/manifest.json new file mode 100644 index 00000000000..03c650cac08 --- /dev/null +++ b/homeassistant/components/linkedgo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "linkedgo", + "name": "LinkedGo", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1db5de7ac69..a92311d31d0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3413,6 +3413,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "linkedgo": { + "name": "LinkedGo", + "integration_type": "virtual", + "supported_by": "shelly" + }, "linkplay": { "name": "LinkPlay", "integration_type": "hub", From 5b1f3d3e7f99600a04b374101621c96ee4b14c55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 07:23:40 -0600 Subject: [PATCH 1183/1941] 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 0694f9e1648b6025310ffd426ac754dad92bf4a2 Mon Sep 17 00:00:00 2001 From: Maghiel Dijksman Date: Sun, 2 Mar 2025 14:25:19 +0100 Subject: [PATCH 1184/1941] Fix Tuya unsupported Temperature & Humidity Sensors (with or without external probe) (#138542) * add category qxj for th sensor with external probe. partly fixes #136472 * add TEMP_CURRENT_EXTERNAL for th sensor with external probe. fixes #136472 * ruff format * add translation_key temperature_external for TEMP_CURRENT_EXTERNAL --------- Co-authored-by: Franck Nijhof --- .../components/tuya/binary_sensor.py | 3 ++ homeassistant/components/tuya/const.py | 6 +++ homeassistant/components/tuya/sensor.py | 41 +++++++++++++++++++ homeassistant/components/tuya/strings.json | 3 ++ homeassistant/components/tuya/switch.py | 9 ++++ 5 files changed, 62 insertions(+) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 1e13f101110..486dd6e1387 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -291,6 +291,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": (TAMPER_BINARY_SENSOR,), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": (TAMPER_BINARY_SENSOR,), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 08bdef474ef..a40260ed787 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -333,6 +333,12 @@ class DPCode(StrEnum): TEMP_CONTROLLER = "temp_controller" TEMP_CURRENT = "temp_current" # Current temperature in °C TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F + TEMP_CURRENT_EXTERNAL = ( + "temp_current_external" # Current external temperature in Celsius + ) + TEMP_CURRENT_EXTERNAL_F = ( + "temp_current_external_f" # Current external temperature in Fahrenheit + ) TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C TEMP_SET = "temp_set" # Set the temperature in °C TEMP_SET_F = "temp_set_f" # Set the temperature in °F diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 073202bed94..b1150be306a 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -715,6 +715,47 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL, + translation_key="temperature_external", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 8ec61cc8aa5..83847d32fb5 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -469,6 +469,9 @@ "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, + "temperature_external": { + "name": "Probe temperature" + }, "humidity": { "name": "[%key:component::sensor::entity_component::humidity::name%]" }, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 76d8b481a90..4000e8d9b24 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -612,6 +612,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), + # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Ceiling Light # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r "xdd": ( From b7bedd4b8fa836357f24b25e57b3a12b8d87ac42 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 1185/1941] 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 5ac3fe6ee12f7dda3296bb4f6e7db573f6323e79 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sun, 2 Mar 2025 14:38:56 +0100 Subject: [PATCH 1186/1941] Fibaro integration refactorings (#139624) * Fibaro integration refactorings * Fix execute_action * Add test * more tests * Add tests * Fix test * More tests --- homeassistant/components/fibaro/__init__.py | 18 +-- homeassistant/components/fibaro/climate.py | 96 +++++++------ homeassistant/components/fibaro/entity.py | 19 +-- tests/components/fibaro/conftest.py | 54 ++++++- tests/components/fibaro/test_climate.py | 150 +++++++++++++++++++- tests/components/fibaro/test_light.py | 28 +++- 6 files changed, 287 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 9a521e27486..33b2598a636 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -12,6 +12,7 @@ from pyfibaro.fibaro_client import ( FibaroClient, FibaroConnectFailed, ) +from pyfibaro.fibaro_data_helper import read_rooms from pyfibaro.fibaro_device import DeviceModel from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel @@ -83,7 +84,7 @@ class FibaroController: # Whether to import devices from plugins self._import_plugins = import_plugins # Mapping roomId to room object - self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()} + self._room_map = read_rooms(fibaro_client) self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list @@ -269,9 +270,7 @@ class FibaroController: def get_room_name(self, room_id: int) -> str | None: """Get the room name by room id.""" - assert self._room_map - room = self._room_map.get(room_id) - return room.name if room else None + return self._room_map.get(room_id) def read_scenes(self) -> list[SceneModel]: """Return list of scenes.""" @@ -294,20 +293,17 @@ class FibaroController: for device in devices: try: device.fibaro_controller = self - if device.room_id == 0: + room_name = self.get_room_name(device.room_id) + if not room_name: room_name = "Unknown" - else: - room_name = self._room_map[device.room_id].name device.room_name = room_name device.friendly_name = f"{room_name} {device.name}" device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) if device.enabled and (not device.is_plugin or self._import_plugins): - device.mapped_platform = self._map_device_to_platform(device) - else: - device.mapped_platform = None - if (platform := device.mapped_platform) is None: + platform = self._map_device_to_platform(device) + if platform is None: continue device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}" self._create_device_info(device, devices) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index d601450a70f..7a8cc3fd2a9 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -129,13 +129,13 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) - self._temp_sensor_device: FibaroEntity | None = None - self._target_temp_device: FibaroEntity | None = None - self._op_mode_device: FibaroEntity | None = None - self._fan_mode_device: FibaroEntity | None = None + self._temp_sensor_device: DeviceModel | None = None + self._target_temp_device: DeviceModel | None = None + self._op_mode_device: DeviceModel | None = None + self._fan_mode_device: DeviceModel | None = None self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - siblings = fibaro_device.fibaro_controller.get_siblings(fibaro_device) + siblings = self.controller.get_siblings(fibaro_device) _LOGGER.debug("%s siblings: %s", fibaro_device.ha_id, siblings) tempunit = "C" for device in siblings: @@ -147,23 +147,23 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): and (device.value.has_value or device.has_heating_thermostat_setpoint) and device.unit in ("C", "F") ): - self._temp_sensor_device = FibaroEntity(device) + self._temp_sensor_device = device tempunit = device.unit if any( action for action in TARGET_TEMP_ACTIONS if action in device.actions ): - self._target_temp_device = FibaroEntity(device) + self._target_temp_device = device self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE if device.has_unit: tempunit = device.unit if any(action for action in OP_MODE_ACTIONS if action in device.actions): - self._op_mode_device = FibaroEntity(device) + self._op_mode_device = device self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE if "setFanMode" in device.actions: - self._fan_mode_device = FibaroEntity(device) + self._fan_mode_device = device self._attr_supported_features |= ClimateEntityFeature.FAN_MODE if tempunit == "F": @@ -172,7 +172,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): self._attr_temperature_unit = UnitOfTemperature.CELSIUS if self._fan_mode_device: - fan_modes = self._fan_mode_device.fibaro_device.supported_modes + fan_modes = self._fan_mode_device.supported_modes self._attr_fan_modes = [] for mode in fan_modes: if mode not in FANMODES: @@ -184,7 +184,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if self._op_mode_device: self._attr_preset_modes = [] self._attr_hvac_modes: list[HVACMode] = [] - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_supported_thermostat_modes: for mode in device.supported_thermostat_modes: try: @@ -222,15 +222,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): "- _fan_mode_device %s" ), self.ha_id, - self._temp_sensor_device.ha_id if self._temp_sensor_device else "None", - self._target_temp_device.ha_id if self._target_temp_device else "None", - self._op_mode_device.ha_id if self._op_mode_device else "None", - self._fan_mode_device.ha_id if self._fan_mode_device else "None", + self._temp_sensor_device.fibaro_id if self._temp_sensor_device else "None", + self._target_temp_device.fibaro_id if self._target_temp_device else "None", + self._op_mode_device.fibaro_id if self._op_mode_device else "None", + self._fan_mode_device.fibaro_id if self._fan_mode_device else "None", ) await super().async_added_to_hass() # Register update callback for child devices - siblings = self.fibaro_device.fibaro_controller.get_siblings(self.fibaro_device) + siblings = self.controller.get_siblings(self.fibaro_device) for device in siblings: if device != self.fibaro_device: self.controller.register(device.fibaro_id, self._update_callback) @@ -240,14 +240,14 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): """Return the fan setting.""" if not self._fan_mode_device: return None - mode = self._fan_mode_device.fibaro_device.mode + mode = self._fan_mode_device.mode return FANMODES[mode] def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if not self._fan_mode_device: return - self._fan_mode_device.action("setFanMode", HA_FANMODES[fan_mode]) + self._fan_mode_device.execute_action("setFanMode", [HA_FANMODES[fan_mode]]) @property def fibaro_op_mode(self) -> str | int: @@ -255,7 +255,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return HA_OPMODES_HVAC[HVACMode.AUTO] - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_operating_mode: return device.operating_mode @@ -281,17 +281,17 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return - if "setOperatingMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode]) - elif "setThermostatMode" in self._op_mode_device.fibaro_device.actions: - device = self._op_mode_device.fibaro_device + device = self._op_mode_device + if "setOperatingMode" in device.actions: + device.execute_action("setOperatingMode", [HA_OPMODES_HVAC[hvac_mode]]) + elif "setThermostatMode" in device.actions: if device.has_supported_thermostat_modes: for mode in device.supported_thermostat_modes: if mode.lower() == hvac_mode: - self._op_mode_device.action("setThermostatMode", mode) + device.execute_action("setThermostatMode", [mode]) break - elif "setMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setMode", HA_OPMODES_HVAC[hvac_mode]) + elif "setMode" in device.actions: + device.execute_action("setMode", [HA_OPMODES_HVAC[hvac_mode]]) @property def hvac_action(self) -> HVACAction | None: @@ -299,7 +299,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return None - device = self._op_mode_device.fibaro_device + device = self._op_mode_device if device.has_thermostat_operating_state: with suppress(ValueError): return HVACAction(device.thermostat_operating_state.lower()) @@ -315,15 +315,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if not self._op_mode_device: return None - if self._op_mode_device.fibaro_device.has_thermostat_mode: - mode = self._op_mode_device.fibaro_device.thermostat_mode + if self._op_mode_device.has_thermostat_mode: + mode = self._op_mode_device.thermostat_mode if self.preset_modes is not None and mode in self.preset_modes: return mode return None - if self._op_mode_device.fibaro_device.has_operating_mode: - mode = self._op_mode_device.fibaro_device.operating_mode + if self._op_mode_device.has_operating_mode: + mode = self._op_mode_device.operating_mode else: - mode = self._op_mode_device.fibaro_device.mode + mode = self._op_mode_device.mode if mode not in OPMODES_PRESET: return None @@ -334,20 +334,22 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): if self._op_mode_device is None: return - if "setThermostatMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setThermostatMode", preset_mode) - elif "setOperatingMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action( - "setOperatingMode", HA_OPMODES_PRESET[preset_mode] + if "setThermostatMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action("setThermostatMode", [preset_mode]) + elif "setOperatingMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action( + "setOperatingMode", [HA_OPMODES_PRESET[preset_mode]] + ) + elif "setMode" in self._op_mode_device.actions: + self._op_mode_device.execute_action( + "setMode", [HA_OPMODES_PRESET[preset_mode]] ) - elif "setMode" in self._op_mode_device.fibaro_device.actions: - self._op_mode_device.action("setMode", HA_OPMODES_PRESET[preset_mode]) @property def current_temperature(self) -> float | None: """Return the current temperature.""" if self._temp_sensor_device: - device = self._temp_sensor_device.fibaro_device + device = self._temp_sensor_device if device.has_heating_thermostat_setpoint: return device.heating_thermostat_setpoint return device.value.float_value() @@ -357,7 +359,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self._target_temp_device: - device = self._target_temp_device.fibaro_device + device = self._target_temp_device if device.has_heating_thermostat_setpoint_future: return device.heating_thermostat_setpoint_future return device.target_level @@ -368,9 +370,11 @@ class FibaroThermostat(FibaroEntity, ClimateEntity): temperature = kwargs.get(ATTR_TEMPERATURE) target = self._target_temp_device if target is not None and temperature is not None: - if "setThermostatSetpoint" in target.fibaro_device.actions: - target.action("setThermostatSetpoint", self.fibaro_op_mode, temperature) - elif "setHeatingThermostatSetpoint" in target.fibaro_device.actions: - target.action("setHeatingThermostatSetpoint", temperature) + if "setThermostatSetpoint" in target.actions: + target.execute_action( + "setThermostatSetpoint", [self.fibaro_op_mode, temperature] + ) + elif "setHeatingThermostatSetpoint" in target.actions: + target.execute_action("setHeatingThermostatSetpoint", [temperature]) else: - target.action("setTargetLevel", temperature) + target.execute_action("setTargetLevel", [temperature]) diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index 6a8e12136c8..5375b058315 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -11,6 +11,8 @@ from pyfibaro.fibaro_device import DeviceModel from homeassistant.const import ATTR_ARMED, ATTR_BATTERY_LEVEL from homeassistant.helpers.entity import Entity +from . import FibaroController + _LOGGER = logging.getLogger(__name__) @@ -22,7 +24,7 @@ class FibaroEntity(Entity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the device.""" self.fibaro_device = fibaro_device - self.controller = fibaro_device.fibaro_controller + self.controller: FibaroController = fibaro_device.fibaro_controller self.ha_id = fibaro_device.ha_id self._attr_name = fibaro_device.friendly_name self._attr_unique_id = fibaro_device.unique_id_str @@ -54,15 +56,6 @@ class FibaroEntity(Entity): return self.fibaro_device.value_2.int_value() return None - def dont_know_message(self, cmd: str) -> None: - """Make a warning in case we don't know how to perform an action.""" - _LOGGER.warning( - "Not sure how to %s: %s (available actions: %s)", - cmd, - str(self.ha_id), - str(self.fibaro_device.actions), - ) - def set_level(self, level: int) -> None: """Set the level of Fibaro device.""" self.action("setValue", level) @@ -97,11 +90,7 @@ class FibaroEntity(Entity): def action(self, cmd: str, *args: Any) -> None: """Perform an action on the Fibaro HC.""" - if cmd in self.fibaro_device.actions: - self.fibaro_device.execute_action(cmd, args) - _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), str(cmd), str(args)) - else: - self.dont_know_message(cmd) + self.fibaro_device.execute_action(cmd, args) @property def current_binary_state(self) -> bool: diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 17357e34198..55b7e35132c 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -157,12 +157,31 @@ def mock_thermostat() -> Mock: return climate +@pytest.fixture +def mock_thermostat_parent() -> Mock: + """Fixture for a thermostat.""" + climate = Mock() + climate.fibaro_id = 5 + climate.parent_fibaro_id = 0 + climate.name = "Test climate" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.device" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = [] + return climate + + @pytest.fixture def mock_thermostat_with_operating_mode() -> Mock: """Fixture for a thermostat.""" climate = Mock() - climate.fibaro_id = 4 - climate.parent_fibaro_id = 0 + climate.fibaro_id = 6 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 climate.name = "Test climate" climate.room_id = 1 climate.dead = False @@ -171,20 +190,47 @@ def mock_thermostat_with_operating_mode() -> Mock: climate.type = "com.fibaro.thermostatDanfoss" climate.base_type = "com.fibaro.device" climate.properties = {"manufacturer": ""} - climate.actions = {"setOperationMode": 1} + climate.actions = {"setOperatingMode": 1, "setTargetLevel": 1} climate.supported_features = {} climate.has_supported_operating_modes = True climate.supported_operating_modes = [0, 1, 15] climate.has_operating_mode = True climate.operating_mode = 15 + climate.has_supported_thermostat_modes = False climate.has_thermostat_mode = False + climate.has_unit = True + climate.unit = "C" + climate.has_heating_thermostat_setpoint = False + climate.has_heating_thermostat_setpoint_future = False + climate.target_level = 23 value_mock = Mock() value_mock.has_value = True - value_mock.int_value.return_value = 20 + value_mock.float_value.return_value = 20 climate.value = value_mock return climate +@pytest.fixture +def mock_fan_device() -> Mock: + """Fixture for a fan endpoint of a thermostat device.""" + climate = Mock() + climate.fibaro_id = 7 + climate.endpoint_id = 1 + climate.parent_fibaro_id = 5 + climate.name = "Test fan" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.fan" + climate.base_type = "com.fibaro.device" + climate.properties = {"manufacturer": ""} + climate.actions = {"setFanMode": 1} + climate.supported_modes = [0, 1, 2] + climate.mode = 1 + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_climate.py b/tests/components/fibaro/test_climate.py index 31022e19a08..339d9d23077 100644 --- a/tests/components/fibaro/test_climate.py +++ b/tests/components/fibaro/test_climate.py @@ -130,5 +130,153 @@ async def test_hvac_mode_with_operation_mode_support( # Act await init_integration(hass, mock_config_entry) # Assert - state = hass.states.get("climate.room_1_test_climate_4") + state = hass.states.get("climate.room_1_test_climate_6") assert state.state == HVACMode.AUTO + + +async def test_set_hvac_mode_with_operation_mode_support( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_with_operating_mode: Mock, + mock_room: Mock, +) -> None: + """Test that set_hvac_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_thermostat_with_operating_mode] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.room_1_test_climate_6", "hvac_mode": HVACMode.HEAT}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() + + +async def test_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_6") + assert state.attributes["fan_mode"] == "low" + assert state.attributes["fan_modes"] == ["off", "low", "auto_high"] + + +async def test_set_fan_mode( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.room_1_test_climate_6", "fan_mode": "off"}, + blocking=True, + ) + + # Assert + mock_fan_device.execute_action.assert_called_once() + + +async def test_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that operating mode works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("climate.room_1_test_climate_6") + assert state.attributes["temperature"] == 23 + + +async def test_set_target_temperature( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_thermostat_parent: Mock, + mock_thermostat_with_operating_mode: Mock, + mock_fan_device: Mock, + mock_room: Mock, +) -> None: + """Test that set_fan_mode() works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [ + mock_thermostat_parent, + mock_thermostat_with_operating_mode, + mock_fan_device, + ] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.CLIMATE]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": "climate.room_1_test_climate_6", "temperature": 25.5}, + blocking=True, + ) + + # Assert + mock_thermostat_with_operating_mode.execute_action.assert_called_once() diff --git a/tests/components/fibaro/test_light.py b/tests/components/fibaro/test_light.py index d0a24e009b7..88576e86dc6 100644 --- a/tests/components/fibaro/test_light.py +++ b/tests/components/fibaro/test_light.py @@ -2,7 +2,8 @@ from unittest.mock import Mock, patch -from homeassistant.const import Platform +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -55,3 +56,28 @@ async def test_light_brightness( state = hass.states.get("light.room_1_test_light_3") assert state.attributes["brightness"] == 51 assert state.state == "on" + + +async def test_light_turn_off( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test activate scene is called.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.room_1_test_light_3"}, + blocking=True, + ) + # Assert + assert mock_light.execute_action.call_count == 1 From 0c803520a33af3b528756e8b099daeaecc3a957f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 2 Mar 2025 14:40:28 +0100 Subject: [PATCH 1187/1941] Motion blind type list (#139590) * Add blind_type_list * fix * styling * fix typing * Bump motionblinds to 0.6.26 --- homeassistant/components/motion_blinds/__init__.py | 14 +++++++++++++- .../components/motion_blinds/config_flow.py | 1 + homeassistant/components/motion_blinds/const.py | 1 + homeassistant/components/motion_blinds/gateway.py | 9 +++++++-- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index df06ffb75fc..2abcc273e23 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import ( + CONF_BLIND_TYPE_LIST, CONF_INTERFACE, CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, @@ -39,6 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_API_KEY] multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE) wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH) + blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST) # Create multicast Listener async with setup_lock: @@ -81,7 +83,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Connect to motion gateway multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] connect_gateway_class = ConnectMotionGateway(hass, multicast) - if not await connect_gateway_class.async_connect_gateway(host, key): + if not await connect_gateway_class.async_connect_gateway( + host, key, blind_type_list + ): raise ConfigEntryNotReady motion_gateway = connect_gateway_class.gateway_device api_lock = asyncio.Lock() @@ -95,6 +99,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, _LOGGER, coordinator_info ) + # store blind type list for next time + if entry.data.get(CONF_BLIND_TYPE_LIST) != motion_gateway.blind_type_list: + data = { + **entry.data, + CONF_BLIND_TYPE_LIST: motion_gateway.blind_type_list, + } + hass.config_entries.async_update_entry(entry, data=data) + # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index d8d1e7c21f1..a7bb34af1e6 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -156,6 +156,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: key = user_input[CONF_API_KEY] + assert self._host connect_gateway_class = ConnectMotionGateway(self.hass) if not await connect_gateway_class.async_connect_gateway(self._host, key): diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 96067d7ceb0..950fa3ab4c7 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -8,6 +8,7 @@ DEFAULT_GATEWAY_NAME = "Motionblinds Gateway" PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR] +CONF_BLIND_TYPE_LIST = "blind_type_list" CONF_WAIT_FOR_PUSH = "wait_for_push" CONF_INTERFACE = "interface" DEFAULT_WAIT_FOR_PUSH = False diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index 44f7caa74b2..9826557919c 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -42,11 +42,16 @@ class ConnectMotionGateway: for blind in self.gateway_device.device_list.values(): blind.Update_from_cache() - async def async_connect_gateway(self, host, key): + async def async_connect_gateway( + self, + host: str, + key: str, + blind_type_list: dict[str, int] | None = None, + ) -> bool: """Connect to the Motion Gateway.""" _LOGGER.debug("Initializing with host %s (key %s)", host, key[:3]) self._gateway_device = MotionGateway( - ip=host, key=key, multicast=self._multicast + ip=host, key=key, multicast=self._multicast, blind_type_list=blind_type_list ) try: # update device info and get the connected sub devices From c9abe760237c44c7a83d0c52fc3fd602809469cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:13:06 -0600 Subject: [PATCH 1188/1941] 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 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 1189/1941] 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 1190/1941] 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 1191/1941] 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 1192/1941] 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 1193/1941] 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 1194/1941] 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 1195/1941] 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 1196/1941] 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 1197/1941] 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 1198/1941] 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 1199/1941] 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 1200/1941] 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 1201/1941] 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 1202/1941] 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 1203/1941] 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 1204/1941] 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 1205/1941] 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 1206/1941] 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 1207/1941] 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 1208/1941] 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 1209/1941] 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 1210/1941] 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 1211/1941] 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 1212/1941] 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 1213/1941] 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 1214/1941] 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 1215/1941] 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 1216/1941] 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 1217/1941] 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 1218/1941] 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 1219/1941] 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 1220/1941] 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 1221/1941] 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 1222/1941] 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 1223/1941] 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 1224/1941] 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 1225/1941] 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 1226/1941] 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 1227/1941] 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 1228/1941] 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 0a3562aca31cbcdcf934ab0fafeed5862dbe9ffc Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 2 Mar 2025 16:45:57 +0100 Subject: [PATCH 1229/1941] Add prefix path support to pyLoad integration (#139139) * Add prefix path configuration support * fix typo * formatting * uppercase * changes * redact host --- homeassistant/components/pyload/__init__.py | 37 +++++++++++-- .../components/pyload/config_flow.py | 55 +++++++++++-------- .../components/pyload/diagnostics.py | 13 +++-- homeassistant/components/pyload/strings.json | 22 +++----- tests/components/pyload/conftest.py | 34 +++++++++--- .../pyload/snapshots/test_diagnostics.ambr | 4 +- tests/components/pyload/test_init.py | 20 +++++++ 7 files changed, 126 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index cf8e922d70e..ca7bbb0c1dc 100644 --- a/homeassistant/components/pyload/__init__.py +++ b/homeassistant/components/pyload/__init__.py @@ -2,14 +2,18 @@ from __future__ import annotations +import logging + from aiohttp import CookieJar from pyloadapi import PyLoadAPI +from yarl import URL from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, Platform, @@ -19,17 +23,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import PyLoadConfigEntry, PyLoadCoordinator +_LOGGER = logging.getLogger(__name__) + PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Set up pyLoad from a config entry.""" - url = ( - f"{'https' if entry.data[CONF_SSL] else 'http'}://" - f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/" - ) - session = async_create_clientsession( hass, verify_ssl=entry.data[CONF_VERIFY_SSL], @@ -37,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo ) pyloadapi = PyLoadAPI( session, - api_url=url, + api_url=URL(entry.data[CONF_URL]), username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], ) @@ -55,3 +56,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool: + """Migrate config entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", entry.version, entry.minor_version + ) + + if entry.version == 1 and entry.minor_version == 0: + url = URL.build( + scheme="https" if entry.data[CONF_SSL] else "http", + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ).human_repr() + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_URL: url}, minor_version=1, version=1 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + entry.version, + entry.minor_version, + ) + return True diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index bc3bbc6cb34..50d354d345d 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -9,19 +9,17 @@ from typing import Any from aiohttp import CookieJar from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, - CONF_PORT, - CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( TextSelector, @@ -29,15 +27,18 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_HOST): str, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Required(CONF_SSL, default=False): cv.boolean, + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), vol.Required(CONF_VERIFY_SSL, default=True): bool, vol.Required(CONF_USERNAME): TextSelector( TextSelectorConfig( @@ -80,14 +81,9 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non user_input[CONF_VERIFY_SSL], cookie_jar=CookieJar(unsafe=True), ) - - url = ( - f"{'https' if user_input[CONF_SSL] else 'http'}://" - f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/" - ) pyload = PyLoadAPI( session, - api_url=url, + api_url=URL(user_input[CONF_URL]), username=user_input[CONF_USERNAME], password=user_input[CONF_PASSWORD], ) @@ -99,6 +95,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for pyLoad.""" VERSION = 1 + MINOR_VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -106,9 +103,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - self._async_abort_entries_match( - {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} - ) + url = URL(user_input[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) try: await validate_input(self.hass, user_input) except (CannotConnect, ParserError): @@ -120,7 +116,14 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: title = DEFAULT_NAME - return self.async_create_entry(title=title, data=user_input) + + return self.async_create_entry( + title=title, + data={ + **user_input, + CONF_URL: url, + }, + ) return self.async_show_form( step_id="user", @@ -144,9 +147,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - new_input = reauth_entry.data | user_input try: - await validate_input(self.hass, new_input) + await validate_input(self.hass, {**reauth_entry.data, **user_input}) except (CannotConnect, ParserError): errors["base"] = "cannot_connect" except InvalidAuth: @@ -155,7 +157,9 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_update_reload_and_abort(reauth_entry, data=new_input) + return self.async_update_reload_and_abort( + reauth_entry, data_updates=user_input + ) return self.async_show_form( step_id="reauth_confirm", @@ -191,15 +195,18 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): else: return self.async_update_reload_and_abort( reconfig_entry, - data=user_input, + data={ + **user_input, + CONF_URL: URL(user_input[CONF_URL]).human_repr(), + }, reload_even_if_entry_is_unchanged=False, ) - + suggested_values = user_input if user_input else reconfig_entry.data return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_USER_DATA_SCHEMA, - user_input or reconfig_entry.data, + suggested_values, ), description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, diff --git a/homeassistant/components/pyload/diagnostics.py b/homeassistant/components/pyload/diagnostics.py index 105a9a953e2..98fab38da1d 100644 --- a/homeassistant/components/pyload/diagnostics.py +++ b/homeassistant/components/pyload/diagnostics.py @@ -5,13 +5,15 @@ 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_HOST, CONF_PASSWORD, CONF_USERNAME +from yarl import URL + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from .coordinator import PyLoadConfigEntry, PyLoadData -TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_HOST} +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_URL} async def async_get_config_entry_diagnostics( @@ -21,6 +23,9 @@ async def async_get_config_entry_diagnostics( pyload_data: PyLoadData = config_entry.runtime_data.data return { - "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), + "config_entry_data": { + **async_redact_data(dict(config_entry.data), TO_REDACT), + CONF_URL: URL(config_entry.data[CONF_URL]).with_host(REDACTED).human_repr(), + }, "pyload_data": asdict(pyload_data), } diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index ed15a438c28..9414f7f7bb8 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -3,38 +3,30 @@ "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "The hostname or IP address of the device running your pyLoad instance.", + "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `https://example.com:8000/path`", "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": { "data": { - "host": "[%key:common::config_flow::data::host%]", + "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "port": "[%key:common::config_flow::data::port%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { - "host": "[%key:component::pyload::config::step::user::data_description::host%]", - "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]", + "url": "[%key:component::pyload::config::step::user::data_description::url%]", "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%]" + "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]" } }, "reauth_confirm": { diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 46144771cc1..9b410a5fdd6 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -19,10 +20,8 @@ from homeassistant.const import ( from tests.common import MockConfigEntry USER_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "test-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "test-username", CONF_VERIFY_SSL: False, } @@ -33,10 +32,8 @@ REAUTH_INPUT = { } NEW_INPUT = { - CONF_HOST: "pyload.local", + CONF_URL: "https://pyload.local:8000/prefix", CONF_PASSWORD: "new-password", - CONF_PORT: 8000, - CONF_SSL: True, CONF_USERNAME: "new-username", CONF_VERIFY_SSL: False, } @@ -97,5 +94,28 @@ def mock_pyloadapi() -> Generator[MagicMock]: def mock_config_entry() -> MockConfigEntry: """Mock pyLoad configuration entry.""" return MockConfigEntry( - domain=DOMAIN, title=DEFAULT_NAME, data=USER_INPUT, entry_id="XXXXXXXXXXXXXX" + domain=DOMAIN, + title=DEFAULT_NAME, + data=USER_INPUT, + entry_id="XXXXXXXXXXXXXX", + ) + + +@pytest.fixture(name="config_entry_migrate") +def mock_config_entry_migrate() -> MockConfigEntry: + """Mock pyLoad configuration entry for migration.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={ + CONF_HOST: "pyload.local", + CONF_PASSWORD: "test-password", + CONF_PORT: 8000, + CONF_SSL: True, + CONF_USERNAME: "test-username", + CONF_VERIFY_SSL: False, + }, + version=1, + minor_version=0, + entry_id="XXXXXXXXXXXXXX", ) diff --git a/tests/components/pyload/snapshots/test_diagnostics.ambr b/tests/components/pyload/snapshots/test_diagnostics.ambr index e2b51ad184a..81a5d750bc0 100644 --- a/tests/components/pyload/snapshots/test_diagnostics.ambr +++ b/tests/components/pyload/snapshots/test_diagnostics.ambr @@ -2,10 +2,8 @@ # name: test_diagnostics dict({ 'config_entry_data': dict({ - 'host': '**REDACTED**', 'password': '**REDACTED**', - 'port': 8000, - 'ssl': True, + 'url': 'https://**redacted**:8000/prefix', 'username': '**REDACTED**', 'verify_ssl': False, }), diff --git a/tests/components/pyload/test_init.py b/tests/components/pyload/test_init.py index 00b1f0aa3a8..5c85979b9df 100644 --- a/tests/components/pyload/test_init.py +++ b/tests/components/pyload/test_init.py @@ -8,6 +8,7 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_PATH, CONF_URL from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed @@ -88,3 +89,22 @@ async def test_coordinator_update_invalid_auth( await hass.async_block_till_done() assert any(config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_migration( + hass: HomeAssistant, + config_entry_migrate: MockConfigEntry, +) -> None: + """Test config entry migration.""" + + config_entry_migrate.add_to_hass(hass) + assert config_entry_migrate.data.get(CONF_PATH) is None + + await hass.config_entries.async_setup(config_entry_migrate.entry_id) + await hass.async_block_till_done() + + assert config_entry_migrate.state is ConfigEntryState.LOADED + assert config_entry_migrate.version == 1 + assert config_entry_migrate.minor_version == 1 + assert config_entry_migrate.data[CONF_URL] == "https://pyload.local:8000/" From 8d6178ffa6ddc93b03bd75e1b4cd2b66acdba2b1 Mon Sep 17 00:00:00 2001 From: MarioZG Date: Sun, 2 Mar 2025 15:48:57 +0000 Subject: [PATCH 1230/1941] Add last updated attribute to UK transport train sensor (#139352) added last updated attribute to train sensor Co-authored-by: Franck Nijhof --- homeassistant/components/uk_transport/sensor.py | 6 +++++- tests/components/uk_transport/test_sensor.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index b06d0e24891..594d46c74ab 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -32,6 +32,7 @@ ATTR_NEXT_BUSES = "next_buses" ATTR_STATION_CODE = "station_code" ATTR_CALLING_AT = "calling_at" ATTR_NEXT_TRAINS = "next_trains" +ATTR_LAST_UPDATED = "last_updated" CONF_API_APP_KEY = "app_key" CONF_API_APP_ID = "app_id" @@ -199,7 +200,9 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor): def extra_state_attributes(self) -> dict[str, Any] | None: """Return other details about the sensor state.""" if self._data is not None: - attrs = {ATTR_NEXT_BUSES: self._next_buses} + attrs = { + ATTR_NEXT_BUSES: self._next_buses, + } for key in ( ATTR_ATCOCODE, ATTR_LOCALITY, @@ -272,6 +275,7 @@ class UkTransportLiveTrainTimeSensor(UkTransportSensor): attrs = { ATTR_STATION_CODE: self._station_code, ATTR_CALLING_AT: self._calling_at, + ATTR_LAST_UPDATED: self._data[ATTR_REQUEST_TIME], } if self._next_trains: attrs[ATTR_NEXT_TRAINS] = self._next_trains diff --git a/tests/components/uk_transport/test_sensor.py b/tests/components/uk_transport/test_sensor.py index a4a9aea18c8..ba547c5eecc 100644 --- a/tests/components/uk_transport/test_sensor.py +++ b/tests/components/uk_transport/test_sensor.py @@ -8,6 +8,7 @@ import requests_mock from homeassistant.components.uk_transport.sensor import ( ATTR_ATCOCODE, ATTR_CALLING_AT, + ATTR_LAST_UPDATED, ATTR_LOCALITY, ATTR_NEXT_BUSES, ATTR_NEXT_TRAINS, @@ -90,3 +91,4 @@ async def test_train(hass: HomeAssistant) -> None: == "London Waterloo" ) assert train_state.attributes[ATTR_NEXT_TRAINS][0]["estimated"] == "06:13" + assert train_state.attributes[ATTR_LAST_UPDATED] == "2017-07-10T06:10:05+01:00" From 4c8a58f7cc4135ccbb578145615c607fc26fb5ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 08:50:35 -0700 Subject: [PATCH 1231/1941] 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 a31f5441dbb..18d15d0fbbd 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -19,6 +19,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 ddb1babd8a4..905a3f6bdc7 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -29,6 +29,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 ( @@ -366,7 +367,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 d006d33dc0b940aecbf3bdf4526b1e1d62aaf9b7 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 2 Mar 2025 16:52:25 +0100 Subject: [PATCH 1232/1941] Remove deprecated device migration from opentherm_gw (#139612) --- .../components/opentherm_gw/__init__.py | 17 ------ tests/components/opentherm_gw/test_init.py | 53 ------------------- 2 files changed, 70 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index f16e9f186be..8a0a2412c25 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -87,23 +87,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b gateway = OpenThermGatewayHub(hass, config_entry) hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway - # Migration can be removed in 2025.4.0 - dev_reg = dr.async_get(hass) - if ( - migrate_device := dev_reg.async_get_device( - {(DOMAIN, config_entry.data[CONF_ID])} - ) - ) is not None: - dev_reg.async_update_device( - migrate_device.id, - new_identifiers={ - ( - DOMAIN, - f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}", - ) - }, - ) - # Migration can be removed in 2025.4.0 ent_reg = er.async_get(hass) if ( diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 4085e25c614..e97e6d87f7c 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -71,59 +71,6 @@ async def test_device_registry_update( assert gw_dev.sw_version == VERSION_NEW -# Device migration test can be removed in 2025.4.0 -async def test_device_migration( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the device registry is updated correctly.""" - mock_config_entry.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=mock_config_entry.entry_id, - identifiers={ - (DOMAIN, MOCK_GATEWAY_ID), - }, - name="Mock Gateway", - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - sw_version=VERSION_TEST, - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert ( - device_registry.async_get_device(identifiers={(DOMAIN, MOCK_GATEWAY_ID)}) - is None - ) - - gw_dev = device_registry.async_get_device( - identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} - ) - assert gw_dev is not None - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.BOILER}") - } - ) - is not None - ) - - assert ( - device_registry.async_get_device( - identifiers={ - (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.THERMOSTAT}") - } - ) - is not None - ) - - # Entity migration test can be removed in 2025.4.0 async def test_climate_entity_migration( hass: HomeAssistant, From de4540c68e3e52f45f2542b59b0b69f97163e826 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 2 Mar 2025 17:28:11 +0100 Subject: [PATCH 1233/1941] Remove deprecated entity migration from opentherm_gw (#139641) --- .../components/opentherm_gw/__init__.py | 19 +----------- tests/components/opentherm_gw/test_init.py | 29 +------------------ 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 8a0a2412c25..87da159872d 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -9,7 +9,6 @@ import pyotgw.vars as gw_vars from serial import SerialException import voluptuous as vol -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DATE, @@ -25,11 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_registry as er, -) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -87,18 +82,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b gateway = OpenThermGatewayHub(hass, config_entry) hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway - # Migration can be removed in 2025.4.0 - ent_reg = er.async_get(hass) - if ( - entity_id := ent_reg.async_get_entity_id( - CLIMATE_DOMAIN, DOMAIN, config_entry.data[CONF_ID] - ) - ) is not None: - ent_reg.async_update_entity( - entity_id, - new_unique_id=f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity", - ) - config_entry.add_update_listener(options_updated) try: diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index e97e6d87f7c..84629137ce1 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -8,9 +8,8 @@ from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) -from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from .conftest import MOCK_GATEWAY_ID, VERSION_TEST @@ -69,29 +68,3 @@ async def test_device_registry_update( ) assert gw_dev is not None assert gw_dev.sw_version == VERSION_NEW - - -# Entity migration test can be removed in 2025.4.0 -async def test_climate_entity_migration( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_config_entry: MockConfigEntry, - mock_pyotgw: MagicMock, -) -> None: - """Test that the climate entity unique_id gets migrated correctly.""" - mock_config_entry.add_to_hass(hass) - entry = entity_registry.async_get_or_create( - domain="climate", - platform="opentherm_gw", - unique_id=mock_config_entry.data[CONF_ID], - ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - updated_entry = entity_registry.async_get(entry.entity_id) - assert updated_entry is not None - assert ( - updated_entry.unique_id - == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" - ) From 40099547ef6dbd59caf811cafb85df276ba17ccd Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 2 Mar 2025 17:36:37 +0100 Subject: [PATCH 1234/1941] Add typing/async to NMBS (#139002) * Add typing/async to NMBS * Fix tests * Boolean fields * Update homeassistant/components/nmbs/sensor.py Co-authored-by: Jorim Tielemans --------- Co-authored-by: Shay Levy Co-authored-by: Jorim Tielemans --- homeassistant/components/nmbs/__init__.py | 9 +- homeassistant/components/nmbs/config_flow.py | 50 ++++---- homeassistant/components/nmbs/const.py | 8 +- homeassistant/components/nmbs/manifest.json | 2 +- homeassistant/components/nmbs/sensor.py | 126 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nmbs/__init__.py | 19 --- tests/components/nmbs/conftest.py | 5 +- tests/components/nmbs/test_config_flow.py | 4 +- 10 files changed, 101 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 7d06baf37b6..4a2783143ca 100644 --- a/homeassistant/components/nmbs/__init__.py +++ b/homeassistant/components/nmbs/__init__.py @@ -8,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -22,13 +23,13 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NMBS component.""" - api_client = iRail() + api_client = iRail(session=async_get_clientsession(hass)) hass.data.setdefault(DOMAIN, {}) - station_response = await hass.async_add_executor_job(api_client.get_stations) - if station_response == -1: + station_response = await api_client.get_stations() + if station_response is None: return False - hass.data[DOMAIN] = station_response["station"] + hass.data[DOMAIN] = station_response.stations return True diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py index e45b2d9adeb..60ab015e22b 100644 --- a/homeassistant/components/nmbs/config_flow.py +++ b/homeassistant/components/nmbs/config_flow.py @@ -3,11 +3,13 @@ from typing import Any from pyrail import iRail +from pyrail.models import StationDetails import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import Platform from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( BooleanSelector, SelectOptionDict, @@ -31,17 +33,15 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize.""" - self.api_client = iRail() - self.stations: list[dict[str, Any]] = [] + self.stations: list[StationDetails] = [] - async def _fetch_stations(self) -> list[dict[str, Any]]: + async def _fetch_stations(self) -> list[StationDetails]: """Fetch the stations.""" - stations_response = await self.hass.async_add_executor_job( - self.api_client.get_stations - ) - if stations_response == -1: + api_client = iRail(session=async_get_clientsession(self.hass)) + stations_response = await api_client.get_stations() + if stations_response is None: raise CannotConnect("The API is currently unavailable.") - return stations_response["station"] + return stations_response.stations async def _fetch_stations_choices(self) -> list[SelectOptionDict]: """Fetch the stations options.""" @@ -50,7 +50,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): self.stations = await self._fetch_stations() return [ - SelectOptionDict(value=station["id"], label=station["standardname"]) + SelectOptionDict(value=station.id, label=station.standard_name) for station in self.stations ] @@ -72,12 +72,12 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): [station_from] = [ station for station in self.stations - if station["id"] == user_input[CONF_STATION_FROM] + if station.id == user_input[CONF_STATION_FROM] ] [station_to] = [ station for station in self.stations - if station["id"] == user_input[CONF_STATION_TO] + if station.id == user_input[CONF_STATION_TO] ] vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else "" await self.async_set_unique_id( @@ -85,7 +85,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - config_entry_name = f"Train from {station_from['standardname']} to {station_to['standardname']}" + config_entry_name = f"Train from {station_from.standard_name} to {station_to.standard_name}" return self.async_create_entry( title=config_entry_name, data=user_input, @@ -127,18 +127,18 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): station_live = None for station in self.stations: if user_input[CONF_STATION_FROM] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_from = station if user_input[CONF_STATION_TO] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_to = station if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in ( - station["standardname"], - station["name"], + station.standard_name, + station.name, ): station_live = station @@ -148,29 +148,29 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="same_station") # config flow uses id and not the standard name - user_input[CONF_STATION_FROM] = station_from["id"] - user_input[CONF_STATION_TO] = station_to["id"] + user_input[CONF_STATION_FROM] = station_from.id + user_input[CONF_STATION_TO] = station_to.id if station_live: - user_input[CONF_STATION_LIVE] = station_live["id"] + user_input[CONF_STATION_LIVE] = station_live.id entity_registry = er.async_get(self.hass) prefix = "live" vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else "" if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, - f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}", + f"{prefix}_{station_live.standard_name}_{station_from.standard_name}_{station_to.standard_name}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) if entity_id := entity_registry.async_get_entity_id( Platform.SENSOR, DOMAIN, - f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}", + f"{prefix}_{station_live.name}_{station_from.name}_{station_to.name}", ): - new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}" + new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}" entity_registry.async_update_entity( entity_id, new_unique_id=new_unique_id ) diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py index fddb7365501..04c8beb327d 100644 --- a/homeassistant/components/nmbs/const.py +++ b/homeassistant/components/nmbs/const.py @@ -19,11 +19,7 @@ CONF_SHOW_ON_MAP = "show_on_map" def find_station_by_name(hass: HomeAssistant, station_name: str): """Find given station_name in the station list.""" return next( - ( - s - for s in hass.data[DOMAIN] - if station_name in (s["standardname"], s["name"]) - ), + (s for s in hass.data[DOMAIN] if station_name in (s.standard_name, s.name)), None, ) @@ -31,6 +27,6 @@ def find_station_by_name(hass: HomeAssistant, station_name: str): def find_station(hass: HomeAssistant, station_name: str): """Find given station_id in the station list.""" return next( - (s for s in hass.data[DOMAIN] if station_name in s["id"]), + (s for s in hass.data[DOMAIN] if station_name in s.id), None, ) diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json index 9016eff11f8..37ff9429a54 100644 --- a/homeassistant/components/nmbs/manifest.json +++ b/homeassistant/components/nmbs/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pyrail"], "quality_scale": "legacy", - "requirements": ["pyrail==0.0.3"] + "requirements": ["pyrail==0.4.1"] } diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index c6dea2d0843..822b0236dd0 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -2,10 +2,12 @@ from __future__ import annotations +from datetime import datetime import logging from typing import Any from pyrail import iRail +from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails import voluptuous as vol from homeassistant.components.sensor import ( @@ -23,6 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -44,8 +47,6 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) -API_FAILURE = -1 - DEFAULT_NAME = "NMBS" DEFAULT_ICON = "mdi:train" @@ -63,12 +64,12 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( ) -def get_time_until(departure_time=None): +def get_time_until(departure_time: datetime | None = None): """Calculate the time between now and a train's departure time.""" if departure_time is None: return 0 - delta = dt_util.utc_from_timestamp(int(departure_time)) - dt_util.now() + delta = dt_util.as_utc(departure_time) - dt_util.utcnow() return round(delta.total_seconds() / 60) @@ -77,11 +78,9 @@ def get_delay_in_minutes(delay=0): return round(int(delay) / 60) -def get_ride_duration(departure_time, arrival_time, delay=0): +def get_ride_duration(departure_time: datetime, arrival_time: datetime, delay=0): """Calculate the total travel time in minutes.""" - duration = dt_util.utc_from_timestamp( - int(arrival_time) - ) - dt_util.utc_from_timestamp(int(departure_time)) + duration = arrival_time - departure_time duration_time = int(round(duration.total_seconds() / 60)) return duration_time + get_delay_in_minutes(delay) @@ -157,7 +156,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NMBS sensor entities based on a config entry.""" - api_client = iRail() + api_client = iRail(session=async_get_clientsession(hass)) name = config_entry.data.get(CONF_NAME, None) show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False) @@ -189,9 +188,9 @@ class NMBSLiveBoard(SensorEntity): def __init__( self, api_client: iRail, - live_station: dict[str, Any], - station_from: dict[str, Any], - station_to: dict[str, Any], + live_station: StationDetails, + station_from: StationDetails, + station_to: StationDetails, excl_vias: bool, ) -> None: """Initialize the sensor for getting liveboard data.""" @@ -201,7 +200,8 @@ class NMBSLiveBoard(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs: dict[str, Any] | None = {} + self._attrs: LiveboardDeparture | None = None + self._state: str | None = None self.entity_registry_enabled_default = False @@ -209,22 +209,20 @@ class NMBSLiveBoard(SensorEntity): @property def name(self) -> str: """Return the sensor default name.""" - return f"Trains in {self._station['standardname']}" + return f"Trains in {self._station.standard_name}" @property def unique_id(self) -> str: """Return the unique ID.""" - unique_id = ( - f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}" - ) + unique_id = f"{self._station.id}_{self._station_from.id}_{self._station_to.id}" vias = "_excl_vias" if self._excl_vias else "" return f"nmbs_live_{unique_id}{vias}" @property def icon(self) -> str: """Return the default icon or an alert icon if delays.""" - if self._attrs and int(self._attrs["delay"]) > 0: + if self._attrs and int(self._attrs.delay) > 0: return DEFAULT_ICON_ALERT return DEFAULT_ICON @@ -240,15 +238,15 @@ class NMBSLiveBoard(SensorEntity): if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["delay"]) - departure = get_time_until(self._attrs["time"]) + delay = get_delay_in_minutes(self._attrs.delay) + departure = get_time_until(self._attrs.time) attrs = { "departure": f"In {departure} minutes", "departure_minutes": departure, - "extra_train": int(self._attrs["isExtra"]) > 0, - "vehicle_id": self._attrs["vehicle"], - "monitored_station": self._station["standardname"], + "extra_train": self._attrs.is_extra, + "vehicle_id": self._attrs.vehicle, + "monitored_station": self._station.standard_name, } if delay > 0: @@ -257,28 +255,26 @@ class NMBSLiveBoard(SensorEntity): return attrs - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state equal to the next departure.""" - liveboard = self._api_client.get_liveboard(self._station["id"]) + liveboard = await self._api_client.get_liveboard(self._station.id) - if liveboard == API_FAILURE: + if liveboard is None: _LOGGER.warning("API failed in NMBSLiveBoard") return - if not (departures := liveboard.get("departures")): + if not (departures := liveboard.departures): _LOGGER.warning("API returned invalid departures: %r", liveboard) return _LOGGER.debug("API returned departures: %r", departures) - if departures["number"] == "0": + if len(departures) == 0: # No trains are scheduled return - next_departure = departures["departure"][0] + next_departure = departures[0] self._attrs = next_departure - self._state = ( - f"Track {next_departure['platform']} - {next_departure['station']}" - ) + self._state = f"Track {next_departure.platform} - {next_departure.station}" class NMBSSensor(SensorEntity): @@ -292,8 +288,8 @@ class NMBSSensor(SensorEntity): api_client: iRail, name: str, show_on_map: bool, - station_from: dict[str, Any], - station_to: dict[str, Any], + station_from: StationDetails, + station_to: StationDetails, excl_vias: bool, ) -> None: """Initialize the NMBS connection sensor.""" @@ -304,13 +300,13 @@ class NMBSSensor(SensorEntity): self._station_to = station_to self._excl_vias = excl_vias - self._attrs: dict[str, Any] | None = {} + self._attrs: ConnectionDetails | None = None self._state = None @property def unique_id(self) -> str: """Return the unique ID.""" - unique_id = f"{self._station_from['id']}_{self._station_to['id']}" + unique_id = f"{self._station_from.id}_{self._station_to.id}" vias = "_excl_vias" if self._excl_vias else "" return f"nmbs_connection_{unique_id}{vias}" @@ -319,14 +315,14 @@ class NMBSSensor(SensorEntity): def name(self) -> str: """Return the name of the sensor.""" if self._name is None: - return f"Train from {self._station_from['standardname']} to {self._station_to['standardname']}" + return f"Train from {self._station_from.standard_name} to {self._station_to.standard_name}" return self._name @property def icon(self) -> str: """Return the sensor default icon or an alert icon if any delay.""" if self._attrs: - delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) if delay > 0: return "mdi:alert-octagon" @@ -338,19 +334,19 @@ class NMBSSensor(SensorEntity): if self._state is None or not self._attrs: return None - delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) - departure = get_time_until(self._attrs["departure"]["time"]) - canceled = int(self._attrs["departure"]["canceled"]) + delay = get_delay_in_minutes(self._attrs.departure.delay) + departure = get_time_until(self._attrs.departure.time) + canceled = self._attrs.departure.canceled attrs = { - "destination": self._attrs["departure"]["station"], - "direction": self._attrs["departure"]["direction"]["name"], - "platform_arriving": self._attrs["arrival"]["platform"], - "platform_departing": self._attrs["departure"]["platform"], - "vehicle_id": self._attrs["departure"]["vehicle"], + "destination": self._attrs.departure.station, + "direction": self._attrs.departure.direction.name, + "platform_arriving": self._attrs.arrival.platform, + "platform_departing": self._attrs.departure.platform, + "vehicle_id": self._attrs.departure.vehicle, } - if canceled != 1: + if not canceled: attrs["departure"] = f"In {departure} minutes" attrs["departure_minutes"] = departure attrs["canceled"] = False @@ -364,14 +360,14 @@ class NMBSSensor(SensorEntity): attrs[ATTR_LONGITUDE] = self.station_coordinates[1] if self.is_via_connection and not self._excl_vias: - via = self._attrs["vias"]["via"][0] + via = self._attrs.vias.via[0] - attrs["via"] = via["station"] - attrs["via_arrival_platform"] = via["arrival"]["platform"] - attrs["via_transfer_platform"] = via["departure"]["platform"] + attrs["via"] = via.station + attrs["via_arrival_platform"] = via.arrival.platform + attrs["via_transfer_platform"] = via.departure.platform attrs["via_transfer_time"] = get_delay_in_minutes( - via["timebetween"] - ) + get_delay_in_minutes(via["departure"]["delay"]) + via.timebetween + ) + get_delay_in_minutes(via.departure.delay) if delay > 0: attrs["delay"] = f"{delay} minutes" @@ -390,8 +386,8 @@ class NMBSSensor(SensorEntity): if self._state is None or not self._attrs: return [] - latitude = float(self._attrs["departure"]["stationinfo"]["locationY"]) - longitude = float(self._attrs["departure"]["stationinfo"]["locationX"]) + latitude = float(self._attrs.departure.station_info.latitude) + longitude = float(self._attrs.departure.station_info.longitude) return [latitude, longitude] @property @@ -400,24 +396,24 @@ class NMBSSensor(SensorEntity): if not self._attrs: return False - return "vias" in self._attrs and int(self._attrs["vias"]["number"]) > 0 + return self._attrs.vias is not None and len(self._attrs.vias) > 0 - def update(self) -> None: + async def async_update(self, **kwargs: Any) -> None: """Set the state to the duration of a connection.""" - connections = self._api_client.get_connections( - self._station_from["id"], self._station_to["id"] + connections = await self._api_client.get_connections( + self._station_from.id, self._station_to.id ) - if connections == API_FAILURE: + if connections is None: _LOGGER.warning("API failed in NMBSSensor") return - if not (connection := connections.get("connection")): + if not (connection := connections.connections): _LOGGER.warning("API returned invalid connection: %r", connections) return _LOGGER.debug("API returned connection: %r", connection) - if int(connection[0]["departure"]["left"]) > 0: + if connection[0].departure.left: next_connection = connection[1] else: next_connection = connection[0] @@ -431,9 +427,9 @@ class NMBSSensor(SensorEntity): return duration = get_ride_duration( - next_connection["departure"]["time"], - next_connection["arrival"]["time"], - next_connection["departure"]["delay"], + next_connection.departure.time, + next_connection.arrival.time, + next_connection.departure.delay, ) self._state = duration diff --git a/requirements_all.txt b/requirements_all.txt index 696aef8b03b..5d274a3ba6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2244,7 +2244,7 @@ pyqvrpro==0.52 pyqwikswitch==0.93 # homeassistant.components.nmbs -pyrail==0.0.3 +pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9509b7fac3..19e143e3975 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1834,7 +1834,7 @@ pyps4-2ndscreen==1.3.1 pyqwikswitch==0.93 # homeassistant.components.nmbs -pyrail==0.0.3 +pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 diff --git a/tests/components/nmbs/__init__.py b/tests/components/nmbs/__init__.py index 91226950aba..3d284e5bb77 100644 --- a/tests/components/nmbs/__init__.py +++ b/tests/components/nmbs/__init__.py @@ -1,20 +1 @@ """Tests for the NMBS integration.""" - -import json -from typing import Any - -from tests.common import load_fixture - - -def mock_api_unavailable() -> dict[str, Any]: - """Mock for unavailable api.""" - return -1 - - -def mock_station_response() -> dict[str, Any]: - """Mock for valid station response.""" - dummy_stations_response: dict[str, Any] = json.loads( - load_fixture("stations.json", "nmbs") - ) - - return dummy_stations_response diff --git a/tests/components/nmbs/conftest.py b/tests/components/nmbs/conftest.py index 69200fc4c98..a39334ba62c 100644 --- a/tests/components/nmbs/conftest.py +++ b/tests/components/nmbs/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from pyrail.models import StationsApiResponse import pytest from homeassistant.components.nmbs.const import ( @@ -38,8 +39,8 @@ def mock_nmbs_client() -> Generator[AsyncMock]: ), ): client = mock_client.return_value - client.get_stations.return_value = load_json_object_fixture( - "stations.json", DOMAIN + client.get_stations.return_value = StationsApiResponse.from_dict( + load_json_object_fixture("stations.json", DOMAIN) ) yield client diff --git a/tests/components/nmbs/test_config_flow.py b/tests/components/nmbs/test_config_flow.py index ff4c5bdf72a..7e0f087607b 100644 --- a/tests/components/nmbs/test_config_flow.py +++ b/tests/components/nmbs/test_config_flow.py @@ -142,7 +142,7 @@ async def test_unavailable_api( hass: HomeAssistant, mock_nmbs_client: AsyncMock ) -> None: """Test starting a flow by user and api is unavailable.""" - mock_nmbs_client.get_stations.return_value = -1 + mock_nmbs_client.get_stations.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -203,7 +203,7 @@ async def test_unavailable_api_import( hass: HomeAssistant, mock_nmbs_client: AsyncMock ) -> None: """Test starting a flow by import and api is unavailable.""" - mock_nmbs_client.get_stations.return_value = -1 + mock_nmbs_client.get_stations.return_value = None result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, From 1226354823913b646667d3126b06761f43fc662e Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 2 Mar 2025 17:37:48 +0100 Subject: [PATCH 1235/1941] Finish removing import from configuration.yaml support from opentherm_gw (#139643) --- .../components/opentherm_gw/config_flow.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index bcbf279f3f7..a100dcb730f 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -95,19 +95,6 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): """Handle manual initiation of the config flow.""" return await self.async_step_init(user_input) - # Deprecated import from configuration.yaml, can be removed in 2025.4.0 - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import an OpenTherm Gateway device as a config entry. - - This flow is triggered by `async_setup` for configured devices. - """ - formatted_config = { - CONF_NAME: import_data.get(CONF_NAME, import_data[CONF_ID]), - CONF_DEVICE: import_data[CONF_DEVICE], - CONF_ID: import_data[CONF_ID], - } - return await self.async_step_init(info=formatted_config) - def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult: """Show the config flow form with possible errors.""" return self.async_show_form( From fca4ef3b1eb75af770fb5d7e01295930886dbd27 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 19:52:37 +0100 Subject: [PATCH 1236/1941] 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 05e23f0fc70bb6ecbd5cbe7252a0650da90b95d9 Mon Sep 17 00:00:00 2001 From: martin12as <86385658+martin12as@users.noreply.github.com> Date: Sun, 2 Mar 2025 16:00:05 -0300 Subject: [PATCH 1237/1941] Add nut commands to turn off/on outlet 1 & 2 (#139044) * Update const.py * Update strings.json * Update homeassistant/components/nut/strings.json Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com> * Update homeassistant/components/nut/strings.json Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com> --------- Co-authored-by: tdfountain <174762217+tdfountain@users.noreply.github.com> --- homeassistant/components/nut/const.py | 8 ++++++++ homeassistant/components/nut/strings.json | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 6db40a910a0..924c591e783 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -63,6 +63,10 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop" COMMAND_TEST_PANEL_START = "test.panel.start" COMMAND_TEST_PANEL_STOP = "test.panel.stop" COMMAND_TEST_SYSTEM_START = "test.system.start" +COMMAND_OUTLET1_OFF = "outlet.1.load.off" +COMMAND_OUTLET1_ON = "outlet.1.load.on" +COMMAND_OUTLET2_OFF = "outlet.2.load.off" +COMMAND_OUTLET2_ON = "outlet.2.load.on" INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_BEEPER_DISABLE, @@ -91,4 +95,8 @@ INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_TEST_PANEL_START, COMMAND_TEST_PANEL_STOP, COMMAND_TEST_SYSTEM_START, + COMMAND_OUTLET1_OFF, + COMMAND_OUTLET1_ON, + COMMAND_OUTLET2_OFF, + COMMAND_OUTLET2_ON, } diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index b9485a320fb..4242ac9d9b2 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -74,7 +74,11 @@ "test_failure_stop": "Stop simulating a power failure", "test_panel_start": "Start testing the UPS panel", "test_panel_stop": "Stop a UPS panel test", - "test_system_start": "Start a system test" + "test_system_start": "Start a system test", + "outlet_1_load_on": "Power outlet 1 on", + "outlet_1_load_off": "Power outlet 1 off", + "outlet_2_load_on": "Power outlet 2 on", + "outlet_2_load_off": "Power outlet 1 off" } }, "entity": { From e63b17cd5832abf43fd57b757016147f69b3c1ad Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 2 Mar 2025 20:04:53 +0100 Subject: [PATCH 1238/1941] Make spelling of "All-Link" consistent in Insteon integration (#139651) "All-Link" is a fixed term in the Insteon integration that should be kept in translations. To clarify that this commit makes all occurrences in the Insteon integration consistent (plus fixing one typo). On the other end the word "database" is sentence-cased as this can be translated, just as "record" etc. Finally the description of the "Load All-Link database" action is made consistent using descriptive third-person singular as all other actions do. --- homeassistant/components/insteon/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 538107dd816..3a15d667ca7 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -111,7 +111,7 @@ }, "services": { "add_all_link": { - "name": "Add all link", + "name": "Add All-Link", "description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", "fields": { "group": { @@ -120,13 +120,13 @@ }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Linking mode controller - IM is controller responder - IM is responder." + "description": "Linking mode of the Insteon Modem." } } }, "delete_all_link": { - "name": "Delete all link", - "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.", + "name": "Delete All-Link", + "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.", "fields": { "group": { "name": "Group", @@ -135,8 +135,8 @@ } }, "load_all_link_database": { - "name": "Load all link database", - "description": "Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.", + "name": "Load All-Link database", + "description": "Loads the All-Link database for a device. WARNING - Loading a device All-Link database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.", "fields": { "entity_id": { "name": "Entity", @@ -149,8 +149,8 @@ } }, "print_all_link_database": { - "name": "Print all link database", - "description": "Prints the All-Link Database for a device. Requires that the All-Link Database is loaded into memory.", + "name": "Print All-Link database", + "description": "Prints the All-Link database for a device. Requires that the All-Link database is loaded into memory.", "fields": { "entity_id": { "name": "Entity", @@ -159,8 +159,8 @@ } }, "print_im_all_link_database": { - "name": "Print IM all link database", - "description": "Prints the All-Link Database for the INSTEON Modem (IM)." + "name": "Print IM All-Link database", + "description": "Prints the All-Link database for the INSTEON Modem (IM)." }, "x10_all_units_off": { "name": "X10 all units off", From f76e295204fa1b67bd3c625dc37552e38f8c12fd Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 2 Mar 2025 12:24:27 -0700 Subject: [PATCH 1239/1941] Add fault event to balboa (#138623) * Add fault sensor to balboa * Use an event instead of sensor for faults * Don't set fault initially in conftest * Use event type per fault message code * Set fault to None in conftest --- homeassistant/components/balboa/__init__.py | 2 +- homeassistant/components/balboa/event.py | 91 +++++++++++++++++++ homeassistant/components/balboa/strings.json | 29 ++++++ tests/components/balboa/conftest.py | 2 + .../balboa/snapshots/test_event.ambr | 90 ++++++++++++++++++ tests/components/balboa/test_event.py | 82 +++++++++++++++++ 6 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/balboa/event.py create mode 100644 tests/components/balboa/snapshots/test_event.ambr create mode 100644 tests/components/balboa/test_event.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 207826d136e..54ae569bb78 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.EVENT, Platform.FAN, Platform.LIGHT, Platform.SELECT, @@ -28,7 +29,6 @@ PLATFORMS = [ Platform.TIME, ] - KEEP_ALIVE_INTERVAL = timedelta(minutes=1) SYNC_TIME_INTERVAL = timedelta(hours=1) diff --git a/homeassistant/components/balboa/event.py b/homeassistant/components/balboa/event.py new file mode 100644 index 00000000000..57263c34783 --- /dev/null +++ b/homeassistant/components/balboa/event.py @@ -0,0 +1,91 @@ +"""Support for Balboa events.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from pybalboa import EVENT_UPDATE, SpaClient + +from homeassistant.components.event import EventEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval + +from . import BalboaConfigEntry +from .entity import BalboaEntity + +FAULT = "fault" +FAULT_DATE = "fault_date" +REQUEST_FAULT_LOG_INTERVAL = timedelta(minutes=5) + +FAULT_MESSAGE_CODE_MAP: dict[int, str] = { + 15: "sensor_out_of_sync", + 16: "low_flow", + 17: "flow_failed", + 18: "settings_reset", + 19: "priming_mode", + 20: "clock_failed", + 21: "settings_reset", + 22: "memory_failure", + 26: "service_sensor_sync", + 27: "heater_dry", + 28: "heater_may_be_dry", + 29: "water_too_hot", + 30: "heater_too_hot", + 31: "sensor_a_fault", + 32: "sensor_b_fault", + 34: "pump_stuck", + 35: "hot_fault", + 36: "gfci_test_failed", + 37: "standby_mode", +} +FAULT_EVENT_TYPES = sorted(set(FAULT_MESSAGE_CODE_MAP.values())) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's events.""" + async_add_entities([BalboaEventEntity(entry.runtime_data)]) + + +class BalboaEventEntity(BalboaEntity, EventEntity): + """Representation of a Balboa event entity.""" + + _attr_event_types = FAULT_EVENT_TYPES + _attr_translation_key = FAULT + + def __init__(self, spa: SpaClient) -> None: + """Initialize a Balboa event entity.""" + super().__init__(spa, FAULT) + + @callback + def _async_handle_event(self) -> None: + """Handle the fault event.""" + if not (fault := self._client.fault): + return + fault_date = fault.fault_datetime.isoformat() + if self.state_attributes.get(FAULT_DATE) != fault_date: + self._trigger_event( + FAULT_MESSAGE_CODE_MAP.get(fault.message_code, fault.message), + {FAULT_DATE: fault_date, "code": fault.message_code}, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.async_on_remove(self._client.on(EVENT_UPDATE, self._async_handle_event)) + + async def request_fault_log(now: datetime | None = None) -> None: + """Request the most recent fault log.""" + await self._client.request_fault_log() + + await request_fault_log() + self.async_on_remove( + async_track_time_interval( + self.hass, request_fault_log, REQUEST_FAULT_LOG_INTERVAL + ) + ) diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 9779984b182..784ce8533a8 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -57,6 +57,35 @@ } } }, + "event": { + "fault": { + "name": "Fault", + "state_attributes": { + "event_type": { + "state": { + "sensor_out_of_sync": "Sensors are out of sync", + "low_flow": "The water flow is low", + "flow_failed": "The water flow has failed", + "settings_reset": "The settings have been reset", + "priming_mode": "Priming mode", + "clock_failed": "The clock has failed", + "memory_failure": "Program memory failure", + "service_sensor_sync": "Sensors are out of sync -- call for service", + "heater_dry": "The heater is dry", + "heater_may_be_dry": "The heater may be dry", + "water_too_hot": "The water is too hot", + "heater_too_hot": "The heater is too hot", + "sensor_a_fault": "Sensor A fault", + "sensor_b_fault": "Sensor B fault", + "pump_stuck": "A pump may be stuck on", + "hot_fault": "Hot fault", + "gfci_test_failed": "The GFCI test failed", + "standby_mode": "Standby mode (hold mode)" + } + } + } + } + }, "fan": { "pump": { "name": "Pump {index}" diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 90f8fdc3d6e..18639b0c9be 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -68,4 +68,6 @@ def client_fixture() -> Generator[MagicMock]: client.pumps = [] client.temperature_range.state = LowHighRange.LOW + client.fault = None + yield client diff --git a/tests/components/balboa/snapshots/test_event.ambr b/tests/components/balboa/snapshots/test_event.ambr new file mode 100644 index 00000000000..fc8f591a9fc --- /dev/null +++ b/tests/components/balboa/snapshots/test_event.ambr @@ -0,0 +1,90 @@ +# serializer version: 1 +# name: test_events[event.fakespa_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.fakespa_fault', + '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': 'Fault', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fault', + 'unique_id': 'FakeSpa-fault-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_events[event.fakespa_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'clock_failed', + 'flow_failed', + 'gfci_test_failed', + 'heater_dry', + 'heater_may_be_dry', + 'heater_too_hot', + 'hot_fault', + 'low_flow', + 'memory_failure', + 'priming_mode', + 'pump_stuck', + 'sensor_a_fault', + 'sensor_b_fault', + 'sensor_out_of_sync', + 'service_sensor_sync', + 'settings_reset', + 'standby_mode', + 'water_too_hot', + ]), + 'friendly_name': 'FakeSpa Fault', + }), + 'context': , + 'entity_id': 'event.fakespa_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/balboa/test_event.py b/tests/components/balboa/test_event.py new file mode 100644 index 00000000000..04f25f6cfa0 --- /dev/null +++ b/tests/components/balboa/test_event.py @@ -0,0 +1,82 @@ +"""Tests of the events of the balboa integration.""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + +ENTITY_EVENT = "event.fakespa_fault" +FAULT_DATE = "fault_date" + + +async def test_events( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa events.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.EVENT]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_event(hass: HomeAssistant, client: MagicMock) -> None: + """Test spa fault event.""" + await init_integration(hass) + + # check the state is unknown + state = hass.states.get(ENTITY_EVENT) + assert state.state == STATE_UNKNOWN + + # set a fault + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 13, 0), message_code=16 + ) + client.emit("") + await hass.async_block_till_done() + + # check new state is what we expect + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to None + client.fault = None + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 + + # set fault to an unknown one + client.fault = MagicMock( + fault_datetime=datetime(2025, 2, 15, 14, 0), message_code=-1 + ) + # validate a ValueError is raises + with pytest.raises(ValueError): + client.emit("") + await hass.async_block_till_done() + + # validate state remains unchanged + state = hass.states.get(ENTITY_EVENT) + assert state.attributes[ATTR_EVENT_TYPE] == "low_flow" + assert state.attributes[FAULT_DATE] == "2025-02-15T13:00:00" + assert state.attributes["code"] == 16 From 18b0f54a3e5dc8af0e5baab2808f53f8ccf9821f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 2 Mar 2025 20:49:19 +0100 Subject: [PATCH 1240/1941] Fix typo in `outlet_2_load_off` of NUT integration (#139656) Fix typo in `outlet_2_load_off` Fix small copy & paste error in https://github.com/home-assistant/core/pull/139044 --- homeassistant/components/nut/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 4242ac9d9b2..1cd5415b0d6 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -78,7 +78,7 @@ "outlet_1_load_on": "Power outlet 1 on", "outlet_1_load_off": "Power outlet 1 off", "outlet_2_load_on": "Power outlet 2 on", - "outlet_2_load_off": "Power outlet 1 off" + "outlet_2_load_off": "Power outlet 2 off" } }, "entity": { From 387bf83ba8427bf8babd60f10201e5f37d6eaae7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Mar 2025 12:53:45 -0700 Subject: [PATCH 1241/1941] Bump aioesphomeapi to 29.3.2 (#139653) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.3.1...v29.3.2 --- 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 b97878d11b5..26c4b21d565 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.3.1", + "aioesphomeapi==29.3.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.9.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5d274a3ba6a..45484a6f2d0 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.3.1 +aioesphomeapi==29.3.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19e143e3975..d2be4b80bfb 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.3.1 +aioesphomeapi==29.3.2 # homeassistant.components.flo aioflo==2021.11.0 From 8536f2b4cbcceb6c4bd4def6d37d26e5181b74fa Mon Sep 17 00:00:00 2001 From: Niklas Neesen Date: Sun, 2 Mar 2025 20:57:13 +0100 Subject: [PATCH 1242/1941] 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 fa40d02a07fc9369a08b0c4503a25b7be2dcddd9 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 2 Mar 2025 12:15:37 -0800 Subject: [PATCH 1243/1941] Add model_id filter to device selector (#135646) * Add model_id filter to device selector * Rerun CI --- homeassistant/helpers/selector.py | 3 +++ tests/helpers/test_selector.py | 1 + 2 files changed, 4 insertions(+) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 025b8de8896..dd2fd8a677c 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -164,6 +164,8 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("manufacturer"): str, # Model of device vol.Optional("model"): str, + # Model ID of device + vol.Optional("model_id"): str, # Device has to contain entities matching this selector vol.Optional("entity"): vol.All( cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] @@ -178,6 +180,7 @@ class DeviceFilterSelectorConfig(TypedDict, total=False): integration: str manufacturer: str model: str + model_id: str class ActionSelectorConfig(TypedDict): diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index d07bb7458e9..a977a70973d 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -88,6 +88,7 @@ def _test_selector( ({"integration": "zha"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf"}, ("abc123",), (None,)), ({"model": "mock-model"}, ("abc123",), (None,)), + ({"model_id": "mock-model_id"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf", "model": "mock-model"}, ("abc123",), (None,)), ( {"integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model"}, From 14e66ffef4456bbee0b524d923f95dcbe93048fb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Mar 2025 21:21:47 +0100 Subject: [PATCH 1244/1941] Fetch integration list from next branch for analytics insights (#137250) Fetch integration list from next branch --- homeassistant/components/analytics_insights/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index da77a35f789..b2648f7c13c 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -8,7 +8,7 @@ from python_homeassistant_analytics import ( HomeassistantAnalyticsClient, HomeassistantAnalyticsConnectionError, ) -from python_homeassistant_analytics.models import IntegrationType +from python_homeassistant_analytics.models import Environment, IntegrationType import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow @@ -81,7 +81,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) try: addons = await client.get_addons() - integrations = await client.get_integrations() + integrations = await client.get_integrations(Environment.NEXT) custom_integrations = await client.get_custom_integrations() except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") @@ -165,7 +165,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): ) try: addons = await client.get_addons() - integrations = await client.get_integrations() + integrations = await client.get_integrations(Environment.NEXT) custom_integrations = await client.get_custom_integrations() except HomeassistantAnalyticsConnectionError: LOGGER.exception("Error connecting to Home Assistant analytics") From 23644a60ac7dd42dfcab85d5de6624b014e20df2 Mon Sep 17 00:00:00 2001 From: Trevor Warwick <49233676+trevorwarwick@users.noreply.github.com> Date: Sun, 2 Mar 2025 20:26:54 +0000 Subject: [PATCH 1245/1941] Improve Linkplay device unavailability detection (#138457) * Dampen reachability changes Retry a few times before declaring player is unavailable * Fix ruff-format complaint Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> * Fix ruff-format complaint Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> * Fix ruff-format complaint Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> * Fix duplicated change Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --------- Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- homeassistant/components/linkplay/media_player.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 2986db76520..b27616f1e09 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -125,6 +125,8 @@ SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema( } ) +RETRY_POLL_MAXIMUM = 3 + async def async_setup_entry( hass: HomeAssistant, @@ -156,6 +158,7 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): super().__init__(bridge) self._attr_unique_id = bridge.device.uuid + self._retry_count = 0 self._attr_source_list = [ SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support @@ -166,9 +169,12 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): """Update the state of the media player.""" try: await self._bridge.player.update_status() + self._retry_count = 0 self._update_properties() except LinkPlayRequestException: - self._attr_available = False + self._retry_count += 1 + if self._retry_count >= RETRY_POLL_MAXIMUM: + self._attr_available = False @exception_wrap async def async_select_source(self, source: str) -> None: From c782a6ab63ad4aa84e273af1237e122470fb6944 Mon Sep 17 00:00:00 2001 From: martin12as <86385658+martin12as@users.noreply.github.com> Date: Sun, 2 Mar 2025 17:38:12 -0300 Subject: [PATCH 1246/1941] Improve outlet constant naming for NUT (#139660) * Update const.py Fixed to match string.json * Update const.py --- homeassistant/components/nut/const.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 924c591e783..e67299aa9a3 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -63,10 +63,10 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop" COMMAND_TEST_PANEL_START = "test.panel.start" COMMAND_TEST_PANEL_STOP = "test.panel.stop" COMMAND_TEST_SYSTEM_START = "test.system.start" -COMMAND_OUTLET1_OFF = "outlet.1.load.off" -COMMAND_OUTLET1_ON = "outlet.1.load.on" -COMMAND_OUTLET2_OFF = "outlet.2.load.off" -COMMAND_OUTLET2_ON = "outlet.2.load.on" +COMMAND_OUTLET_1_LOAD_OFF = "outlet.1.load.off" +COMMAND_OUTLET_1_LOAD_ON = "outlet.1.load.on" +COMMAND_OUTLET_2_LOAD_OFF = "outlet.2.load.off" +COMMAND_OUTLET_2_LOAD_ON = "outlet.2.load.on" INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_BEEPER_DISABLE, @@ -95,8 +95,8 @@ INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_TEST_PANEL_START, COMMAND_TEST_PANEL_STOP, COMMAND_TEST_SYSTEM_START, - COMMAND_OUTLET1_OFF, - COMMAND_OUTLET1_ON, - COMMAND_OUTLET2_OFF, - COMMAND_OUTLET2_ON, + COMMAND_OUTLET_1_LOAD_OFF, + COMMAND_OUTLET_1_LOAD_ON, + COMMAND_OUTLET_2_LOAD_OFF, + COMMAND_OUTLET_2_LOAD_ON, } From 53bc5ff029631331e041b5f93f0823e98ef9f1f6 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Sun, 2 Mar 2025 21:41:38 +0100 Subject: [PATCH 1247/1941] Keep entered values in form when connecting to Epson projector fails (#135402) Add suggested values to form --- homeassistant/components/epson/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/epson/config_flow.py b/homeassistant/components/epson/config_flow.py index c54bff2eea9..077b9cc31f7 100644 --- a/homeassistant/components/epson/config_flow.py +++ b/homeassistant/components/epson/config_flow.py @@ -72,5 +72,7 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN): if projector: projector.close() return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input), + errors=errors, ) From 4602c0a1c32339235508d82f4dd90a536a03ae3f Mon Sep 17 00:00:00 2001 From: hydazz <53986978+hydazz@users.noreply.github.com> Date: Mon, 3 Mar 2025 07:59:44 +1100 Subject: [PATCH 1248/1941] Add Night mode and `HVACAction` to Advantage Air (#137475) * add night mode toggle * populate AC's action * set hvac action on zones * update tests * show zones as off if AC is off --------- Co-authored-by: Franck Nijhof --- .../components/advantage_air/climate.py | 37 +++++++++++++++++++ .../components/advantage_air/const.py | 1 + .../components/advantage_air/switch.py | 29 +++++++++++++++ .../advantage_air/snapshots/test_climate.ambr | 1 + 4 files changed, 68 insertions(+) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index c023d4cf8f3..1d593c5c3c8 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +from decimal import Decimal import logging from typing import Any @@ -14,6 +15,7 @@ from homeassistant.components.climate import ( FAN_MEDIUM, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature @@ -49,6 +51,14 @@ ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled" ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp" ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" ADVANTAGE_AIR_MYFAN = "autoAA" +ADVANTAGE_AIR_MYAUTO_MODE_SET = "myAutoModeCurrentSetMode" + +HVAC_ACTIONS = { + "cool": HVACAction.COOLING, + "heat": HVACAction.HEATING, + "vent": HVACAction.FAN, + "dry": HVACAction.DRYING, +} HVAC_MODES = [ HVACMode.OFF, @@ -175,6 +185,17 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): return ADVANTAGE_AIR_HVAC_MODES.get(self._ac["mode"]) return HVACMode.OFF + @property + def hvac_action(self) -> HVACAction | None: + """Return the current running HVAC action.""" + if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF: + return HVACAction.OFF + if self._ac["mode"] == "myauto": + return HVAC_ACTIONS.get( + self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET, HVACAction.OFF) + ) + return HVAC_ACTIONS.get(self._ac["mode"]) + @property def fan_mode(self) -> str | None: """Return the current fan modes.""" @@ -273,6 +294,22 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): return HVACMode.HEAT_COOL return HVACMode.OFF + @property + def hvac_action(self) -> HVACAction | None: + """Return the HVAC action, inheriting from master AC if zone is open but idle if air is <= 5%.""" + if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF: + return HVACAction.OFF + master_action = HVAC_ACTIONS.get(self._ac["mode"], HVACAction.OFF) + if self._ac["mode"] == "myauto": + master_action = HVAC_ACTIONS.get( + str(self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET)), HVACAction.OFF + ) + if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: + if self._zone["value"] <= Decimal(5): + return HVACAction.IDLE + return master_action + return HVACAction.OFF + @property def current_temperature(self) -> float | None: """Return the current temperature.""" diff --git a/homeassistant/components/advantage_air/const.py b/homeassistant/components/advantage_air/const.py index 6ae0a0e06d5..103ca57f6ef 100644 --- a/homeassistant/components/advantage_air/const.py +++ b/homeassistant/components/advantage_air/const.py @@ -7,3 +7,4 @@ ADVANTAGE_AIR_STATE_CLOSE = "close" ADVANTAGE_AIR_STATE_ON = "on" ADVANTAGE_AIR_STATE_OFF = "off" ADVANTAGE_AIR_AUTOFAN_ENABLED = "aaAutoFanModeEnabled" +ADVANTAGE_AIR_NIGHT_MODE_ENABLED = "quietNightModeEnabled" diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 5c4528b44c6..8560c9a9138 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -9,6 +9,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry from .const import ( ADVANTAGE_AIR_AUTOFAN_ENABLED, + ADVANTAGE_AIR_NIGHT_MODE_ENABLED, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, ) @@ -32,6 +33,8 @@ async def async_setup_entry( entities.append(AdvantageAirFreshAir(instance, ac_key)) if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]: entities.append(AdvantageAirMyFan(instance, ac_key)) + if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]: + entities.append(AdvantageAirNightMode(instance, ac_key)) if things := instance.coordinator.data.get("myThings"): entities.extend( AdvantageAirRelay(instance, thing) @@ -93,6 +96,32 @@ class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity): await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: False}) +class AdvantageAirNightMode(AdvantageAirAcEntity, SwitchEntity): + """Representation of Advantage 'MySleep$aver' Mode control.""" + + _attr_icon = "mdi:weather-night" + _attr_name = "MySleep$aver" + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + """Initialize an Advantage Air Night Mode control.""" + super().__init__(instance, ac_key) + self._attr_unique_id += "-nightmode" + + @property + def is_on(self) -> bool: + """Return the Night Mode status.""" + return self._ac[ADVANTAGE_AIR_NIGHT_MODE_ENABLED] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn Night Mode on.""" + await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn Night Mode off.""" + await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: False}) + + class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity): """Representation of Advantage Air Thing.""" diff --git a/tests/components/advantage_air/snapshots/test_climate.ambr b/tests/components/advantage_air/snapshots/test_climate.ambr index bd1fb431ae1..b2559b5bdfd 100644 --- a/tests/components/advantage_air/snapshots/test_climate.ambr +++ b/tests/components/advantage_air/snapshots/test_climate.ambr @@ -30,6 +30,7 @@ 'auto', ]), 'friendly_name': 'myauto', + 'hvac_action': , 'hvac_modes': list([ , , From 5ae7109561c76e784801059df6c5b434d4754f23 Mon Sep 17 00:00:00 2001 From: Elias Wernicke Date: Sun, 2 Mar 2025 22:04:25 +0100 Subject: [PATCH 1249/1941] Increase test coverage for todo intent (#135960) * move intent tests to file * add tests for errors --- tests/components/todo/test_init.py | 116 +----------------- tests/components/todo/test_intent.py | 176 +++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 115 deletions(-) create mode 100644 tests/components/todo/test_intent.py diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 8e8c010f758..11ef3d6f044 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -7,8 +7,6 @@ import zoneinfo import pytest import voluptuous as vol -from homeassistant.components import conversation -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.todo import ( ATTR_DESCRIPTION, ATTR_DUE_DATE, @@ -22,7 +20,6 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, TodoServices, - intent as todo_intent, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES @@ -32,10 +29,9 @@ from homeassistant.exceptions import ( ServiceNotSupported, ServiceValidationError, ) -from homeassistant.helpers import intent from homeassistant.setup import async_setup_component -from . import MockTodoListEntity, create_mock_platform +from . import create_mock_platform from tests.typing import WebSocketGenerator @@ -989,116 +985,6 @@ async def test_move_item_unsupported( assert resp.get("error", {}).get("code") == "not_supported" -async def test_add_item_intent( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test adding items to lists using an intent.""" - assert await async_setup_component(hass, "homeassistant", {}) - await todo_intent.async_setup_intents(hass) - - entity1 = MockTodoListEntity() - entity1._attr_name = "List 1" - entity1.entity_id = "todo.list_1" - - entity2 = MockTodoListEntity() - entity2._attr_name = "List 2" - entity2.entity_id = "todo.list_2" - - await create_mock_platform(hass, [entity1, entity2]) - - # Add to first list - response = await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {ATTR_ITEM: {"value": " beer "}, "name": {"value": "list 1"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.success_results[0].name == "list 1" - assert response.success_results[0].type == intent.IntentResponseTargetType.ENTITY - assert response.success_results[0].id == entity1.entity_id - - assert len(entity1.items) == 1 - assert len(entity2.items) == 0 - assert entity1.items[0].summary == "beer" # summary is trimmed - assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION - entity1.items.clear() - - # Add to second list - response = await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {ATTR_ITEM: {"value": "cheese"}, "name": {"value": "List 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.ACTION_DONE - - assert len(entity1.items) == 0 - assert len(entity2.items) == 1 - assert entity2.items[0].summary == "cheese" - assert entity2.items[0].status == TodoItemStatus.NEEDS_ACTION - - # List name is case insensitive - response = await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {ATTR_ITEM: {"value": "wine"}, "name": {"value": "lIST 2"}}, - assistant=conversation.DOMAIN, - ) - assert response.response_type == intent.IntentResponseType.ACTION_DONE - - assert len(entity1.items) == 0 - assert len(entity2.items) == 2 - assert entity2.items[1].summary == "wine" - assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION - - # Should fail if lists are not exposed - async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) - async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) - with pytest.raises(intent.MatchFailedError) as err: - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": "cookies"}, "name": {"value": "list 1"}}, - assistant=conversation.DOMAIN, - ) - assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT - - # Missing list - with pytest.raises(intent.MatchFailedError): - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, - assistant=conversation.DOMAIN, - ) - - # Fail with empty name/item - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": "wine"}, "name": {"value": ""}}, - assistant=conversation.DOMAIN, - ) - - with pytest.raises(intent.InvalidSlotInfo): - await intent.async_handle( - hass, - "test", - todo_intent.INTENT_LIST_ADD_ITEM, - {"item": {"value": ""}, "name": {"value": "list 1"}}, - assistant=conversation.DOMAIN, - ) - - async def test_remove_completed_items_service( hass: HomeAssistant, test_entity: TodoListEntity, diff --git a/tests/components/todo/test_intent.py b/tests/components/todo/test_intent.py new file mode 100644 index 00000000000..cd074816e7e --- /dev/null +++ b/tests/components/todo/test_intent.py @@ -0,0 +1,176 @@ +"""Tests for the todo intents.""" + +import pytest + +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.components.todo import ( + ATTR_ITEM, + DOMAIN, + TodoItemStatus, + TodoListEntity, + intent as todo_intent, +) +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component + +from . import MockTodoListEntity, create_mock_platform + +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_intents(hass: HomeAssistant) -> None: + """Set up the intents.""" + assert await async_setup_component(hass, "homeassistant", {}) + await todo_intent.async_setup_intents(hass) + + +async def test_add_item_intent( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test adding items to lists using an intent.""" + assert await async_setup_component(hass, "homeassistant", {}) + await todo_intent.async_setup_intents(hass) + + entity1 = MockTodoListEntity() + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + entity2 = MockTodoListEntity() + entity2._attr_name = "List 2" + entity2.entity_id = "todo.list_2" + + await create_mock_platform(hass, [entity1, entity2]) + + # Add to first list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {ATTR_ITEM: {"value": " beer "}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert response.success_results[0].name == "list 1" + assert response.success_results[0].type == intent.IntentResponseTargetType.ENTITY + assert response.success_results[0].id == entity1.entity_id + + assert len(entity1.items) == 1 + assert len(entity2.items) == 0 + assert entity1.items[0].summary == "beer" # summary is trimmed + assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION + entity1.items.clear() + + # Add to second list + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {ATTR_ITEM: {"value": "cheese"}, "name": {"value": "List 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 1 + assert entity2.items[0].summary == "cheese" + assert entity2.items[0].status == TodoItemStatus.NEEDS_ACTION + + # List name is case insensitive + response = await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {ATTR_ITEM: {"value": "wine"}, "name": {"value": "lIST 2"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 0 + assert len(entity2.items) == 2 + assert entity2.items[1].summary == "wine" + assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION + + # Should fail if lists are not exposed + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "cookies"}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Missing list + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, + assistant=conversation.DOMAIN, + ) + + # Fail with empty name/item + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": ""}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + + +async def test_add_item_intent_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test errors with the add item intent.""" + test_entity._attr_name = "List 1" + await create_mock_platform(hass, [test_entity]) + + # Try to add item in list that does not exist + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "This list does not exist"}, + }, + assistant=conversation.DOMAIN, + ) + + # Mock the get_entity method to return None + hass.data[DOMAIN].get_entity = lambda entity_id: None + + # Try to add item in a list that exists but get_entity returns None + with pytest.raises(intent.IntentHandleError, match="No to-do list: List 1"): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "List 1"}, + }, + assistant=conversation.DOMAIN, + ) From 7e4432e321cedeedbfe4d7a45eb40b7078e5f5e9 Mon Sep 17 00:00:00 2001 From: andresb5555 Date: Sun, 2 Mar 2025 23:07:35 +0200 Subject: [PATCH 1250/1941] Do not force logfile to roll over when using TimedRotatingFileHandler (#128301) Do not force log file to roll over when using TimedRotatingFileHandler --- homeassistant/bootstrap.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e25bfbe358c..cf8e5e1ea09 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -664,11 +664,10 @@ def _create_log_file( err_handler = _RotatingFileHandlerWithoutShouldRollOver( err_log_path, backupCount=1 ) - - try: - err_handler.doRollover() - except OSError as err: - _LOGGER.error("Error rolling over log file: %s", err) + try: + err_handler.doRollover() + except OSError as err: + _LOGGER.error("Error rolling over log file: %s", err) return err_handler From 3c363eb5ce02655778568dc4de1df6658e46e961 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Mar 2025 10:17:13 +0100 Subject: [PATCH 1251/1941] Adjust type hints in update entity (#129387) * Adjust type hints in update entity * Update allowed return type of update_percentage --------- Co-authored-by: Franck Nijhof --- homeassistant/components/update/__init__.py | 6 +++--- pylint/plugins/hass_enforce_type_hints.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 0ff8c448197..47cc5aa369b 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -226,7 +226,7 @@ class UpdateEntity( _attr_installed_version: str | None = None _attr_device_class: UpdateDeviceClass | None _attr_display_precision: int - _attr_in_progress: bool | int = False + _attr_in_progress: bool = False _attr_latest_version: str | None = None _attr_release_summary: str | None = None _attr_release_url: str | None = None @@ -295,7 +295,7 @@ class UpdateEntity( ) @cached_property - def in_progress(self) -> bool | int | None: + def in_progress(self) -> bool | None: """Update installation progress. Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used. @@ -442,7 +442,7 @@ class UpdateEntity( in_progress = self.in_progress update_percentage = self.update_percentage if in_progress else None if type(in_progress) is not bool and isinstance(in_progress, int): - update_percentage = in_progress + update_percentage = in_progress # type: ignore[unreachable] in_progress = True else: in_progress = self.__in_progress diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a4590207294..ca7777da959 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2568,7 +2568,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="in_progress", - return_type=["bool", "int", None], + return_type=["bool", None], ), TypeHintMatch( function_name="latest_version", @@ -2590,6 +2590,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="title", return_type=["str", None], ), + TypeHintMatch( + function_name="update_percentage", + return_type=["int", "float", None], + ), TypeHintMatch( function_name="install", arg_types={1: "str | None", 2: "bool"}, From 572534b306620627dee300424b028c9d3de33a80 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Mar 2025 10:18:30 +0100 Subject: [PATCH 1252/1941] Fix missing camel-case in one "ElevenLabs" string (#139686) --- homeassistant/components/elevenlabs/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json index b346f94a963..8b0205a9e9a 100644 --- a/homeassistant/components/elevenlabs/strings.json +++ b/homeassistant/components/elevenlabs/strings.json @@ -6,7 +6,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "Your Elevenlabs API key." + "api_key": "Your ElevenLabs API key." } } }, From 5472345f458518b5f561b1446f479fd72a139194 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 3 Mar 2025 20:45:04 +1000 Subject: [PATCH 1253/1941] Add additional garage door code to Advantage Air (#139687) add Garage door --- homeassistant/components/advantage_air/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index b5b982597f0..e764d484128 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -41,7 +41,7 @@ async def async_setup_entry( entities.append( AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND) ) - elif thing["channelDipState"] == 3: # 3 = "Garage door" + elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door" entities.append( AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE) ) From aee891434f6deee9879659b6b78999b9aa141a0e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Mar 2025 05:46:40 -0500 Subject: [PATCH 1254/1941] 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 ee486c269c4fb7ab151a4b219d90329b26be4939 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 1255/1941] 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 20e48054cf12c34c14aa7c7b1529b9cba45b501f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Mar 2025 16:08:39 +0100 Subject: [PATCH 1256/1941] Fix stale docstrings in onboarding tests (#139696) --- tests/components/onboarding/test_views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index b7189bda6cc..d0a6afa50b5 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -762,7 +762,7 @@ async def test_onboarding_backup_info( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, ) -> None: - """Test returning installation type during onboarding.""" + """Test backup info.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) @@ -879,7 +879,7 @@ async def test_onboarding_backup_restore( params: dict[str, Any], expected_kwargs: dict[str, Any], ) -> None: - """Test returning installation type during onboarding.""" + """Test restore backup.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) @@ -976,7 +976,7 @@ async def test_onboarding_backup_restore_error( expected_json: str, restore_calls: int, ) -> None: - """Test returning installation type during onboarding.""" + """Test restore backup fails.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) @@ -1020,7 +1020,7 @@ async def test_onboarding_backup_restore_unexpected_error( expected_message: str, restore_calls: int, ) -> None: - """Test returning installation type during onboarding.""" + """Test restore backup fails.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) @@ -1046,7 +1046,7 @@ async def test_onboarding_backup_upload( hass_storage: dict[str, Any], hass_client: ClientSessionGenerator, ) -> None: - """Test returning installation type during onboarding.""" + """Test upload backup.""" mock_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) From b17ee78dec603f6ab7a8f51cd81079ea50ae8cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 3 Mar 2025 16:51:04 +0100 Subject: [PATCH 1257/1941] Bump hass-nabucasa from 0.92.0 to 0.94.0 (#139697) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 4e99d08afb5..7f448f2f614 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.92.0"], + "requirements": ["hass-nabucasa==0.94.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1f1cb3c4f4c..f6181d214e2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.6 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.24.1 -hass-nabucasa==0.92.0 +hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250228.0 diff --git a/pyproject.toml b/pyproject.toml index 6a75ffa002b..7c60a931c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "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", + "hass-nabucasa==0.94.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 76c5059e29e..aef3fdb0f09 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.6 -hass-nabucasa==0.92.0 +hass-nabucasa==0.94.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 45484a6f2d0..16b72934e6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ habiticalib==0.3.7 habluetooth==3.24.1 # homeassistant.components.cloud -hass-nabucasa==0.92.0 +hass-nabucasa==0.94.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2be4b80bfb..535aa75c62e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ habiticalib==0.3.7 habluetooth==3.24.1 # homeassistant.components.cloud -hass-nabucasa==0.92.0 +hass-nabucasa==0.94.0 # homeassistant.components.conversation hassil==2.2.3 From aaecb47125099bac51cd18f081a9988ba9f4aa4b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Mar 2025 17:57:42 +0100 Subject: [PATCH 1258/1941] Add strict typing to Comelit (#139455) * Add quality scale and strict typing to Comelit * mypy * fix strings * remove quality scale * revert quality scale changes * improve typing * letfover * update typing based on new lib * align to platform * cleanup * apply review comments (part 1) * apply review comment ( part 2) * apply review comments * align * align test data * TypedDict * better casting --- .strict-typing | 1 + .../components/comelit/alarm_control_panel.py | 6 +- .../components/comelit/binary_sensor.py | 9 +- homeassistant/components/comelit/climate.py | 120 +++++++----------- .../components/comelit/coordinator.py | 44 +++++-- .../components/comelit/humidifier.py | 73 ++++------- homeassistant/components/comelit/sensor.py | 19 +-- homeassistant/components/comelit/switch.py | 5 +- mypy.ini | 10 ++ tests/components/comelit/const.py | 6 +- .../comelit/snapshots/test_diagnostics.ambr | 4 +- 11 files changed, 140 insertions(+), 157 deletions(-) diff --git a/.strict-typing b/.strict-typing index 8d0d71e85fe..56d3e299281 100644 --- a/.strict-typing +++ b/.strict-typing @@ -136,6 +136,7 @@ homeassistant.components.clicksend.* homeassistant.components.climate.* homeassistant.components.cloud.* homeassistant.components.co2signal.* +homeassistant.components.comelit.* homeassistant.components.command_line.* homeassistant.components.config.* homeassistant.components.configurator.* diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 6ea4e97f12e..0a01dd957a6 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -6,7 +6,7 @@ import logging from typing import cast from aiocomelit.api import ComelitVedoAreaObject -from aiocomelit.const import ALARM_AREAS, AlarmAreaState +from aiocomelit.const import AlarmAreaState from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -56,7 +56,7 @@ async def async_setup_entry( async_add_entities( ComelitAlarmEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[ALARM_AREAS].values() + for device in coordinator.data["alarm_areas"].values() ) @@ -92,7 +92,7 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel @property def _area(self) -> ComelitVedoAreaObject: """Return area object.""" - return self.coordinator.data[ALARM_AREAS][self._area_index] + return self.coordinator.data["alarm_areas"][self._area_index] @property def available(self) -> bool: diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index a895f8dc511..c17057d19d1 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import cast from aiocomelit import ComelitVedoZoneObject -from aiocomelit.const import ALARM_ZONES from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -29,7 +28,7 @@ async def async_setup_entry( async_add_entities( ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[ALARM_ZONES].values() + for device in coordinator.data["alarm_zones"].values() ) @@ -49,7 +48,7 @@ class ComelitVedoBinarySensorEntity( ) -> None: """Init sensor entity.""" self._api = coordinator.api - self._zone = zone + self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available @@ -59,4 +58,6 @@ class ComelitVedoBinarySensorEntity( @property def is_on(self) -> bool: """Presence detected.""" - return self.coordinator.data[ALARM_ZONES][self._zone.index].status_api == "0001" + return ( + self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001" + ) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 6906c9bf735..3433d1bdf04 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Any, cast +from typing import Any, TypedDict, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import CLIMATE @@ -16,7 +16,8 @@ from homeassistant.components.climate import ( UnitOfTemperature, ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -42,22 +43,23 @@ class ClimaComelitCommand(StrEnum): AUTO = "auto" -API_STATUS: dict[str, dict[str, Any]] = { - ClimaComelitMode.OFF: { - "action": "off", - "hvac_mode": HVACMode.OFF, - "hvac_action": HVACAction.OFF, - }, - ClimaComelitMode.LOWER: { - "action": "lower", - "hvac_mode": HVACMode.COOL, - "hvac_action": HVACAction.COOLING, - }, - ClimaComelitMode.UPPER: { - "action": "upper", - "hvac_mode": HVACMode.HEAT, - "hvac_action": HVACAction.HEATING, - }, +class ClimaComelitApiStatus(TypedDict): + """Comelit Clima API status.""" + + hvac_mode: HVACMode + hvac_action: HVACAction + + +API_STATUS: dict[str, ClimaComelitApiStatus] = { + ClimaComelitMode.OFF: ClimaComelitApiStatus( + hvac_mode=HVACMode.OFF, hvac_action=HVACAction.OFF + ), + ClimaComelitMode.LOWER: ClimaComelitApiStatus( + hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING + ), + ClimaComelitMode.UPPER: ClimaComelitApiStatus( + hvac_mode=HVACMode.HEAT, hvac_action=HVACAction.HEATING + ), } MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = { @@ -114,69 +116,41 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device, device.type) - @property - def _clima(self) -> list[Any]: - """Return clima device data.""" + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = self.coordinator.data[CLIMATE][self._device.index] + if not isinstance(device.val, list): + raise HomeAssistantError("Invalid clima data") + # CLIMATE has a 2 item tuple: # - first for Clima # - second for Humidifier - return self.coordinator.data[CLIMATE][self._device.index].val[0] + values = device.val[0] - @property - def _api_mode(self) -> str: - """Return device mode.""" - # Values from API: "O", "L", "U" - return self._clima[2] + _active = values[1] + _mode = values[2] # Values from API: "O", "L", "U" + _automatic = values[3] == ClimaComelitMode.AUTO - @property - def _api_active(self) -> bool: - "Return device active/idle." - return self._clima[1] + self._attr_current_temperature = values[0] / 10 - @property - def _api_automatic(self) -> bool: - """Return device in automatic/manual mode.""" - return self._clima[3] == ClimaComelitMode.AUTO + self._attr_hvac_action = None + if _mode == ClimaComelitMode.OFF: + self._attr_hvac_action = HVACAction.OFF + if not _active: + self._attr_hvac_action = HVACAction.IDLE + if _mode in API_STATUS: + self._attr_hvac_action = API_STATUS[_mode]["hvac_action"] - @property - def target_temperature(self) -> float: - """Set target temperature.""" - return self._clima[4] / 10 + self._attr_hvac_mode = None + if _mode == ClimaComelitMode.OFF: + self._attr_hvac_mode = HVACMode.OFF + if _automatic: + self._attr_hvac_mode = HVACMode.AUTO + if _mode in API_STATUS: + self._attr_hvac_mode = API_STATUS[_mode]["hvac_mode"] - @property - def current_temperature(self) -> float: - """Return current temperature.""" - return self._clima[0] / 10 - - @property - def hvac_mode(self) -> HVACMode | None: - """HVAC current mode.""" - - if self._api_mode == ClimaComelitMode.OFF: - return HVACMode.OFF - - if self._api_automatic: - return HVACMode.AUTO - - if self._api_mode in API_STATUS: - return API_STATUS[self._api_mode]["hvac_mode"] - - return None - - @property - def hvac_action(self) -> HVACAction | None: - """HVAC current action.""" - - if self._api_mode == ClimaComelitMode.OFF: - return HVACAction.OFF - - if not self._api_active: - return HVACAction.IDLE - - if self._api_mode in API_STATUS: - return API_STATUS[self._api_mode]["hvac_action"] - - return None + self._attr_target_temperature = values[4] / 10 async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index fcb149b21d6..a569a397c85 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -2,7 +2,7 @@ from abc import abstractmethod from datetime import timedelta -from typing import Any +from typing import TypedDict, TypeVar, cast from aiocomelit import ( ComeliteSerialBridgeApi, @@ -13,7 +13,7 @@ from aiocomelit import ( exceptions, ) from aiocomelit.api import ComelitCommonApi -from aiocomelit.const import BRIDGE, VEDO +from aiocomelit.const import ALARM_AREAS, ALARM_ZONES, BRIDGE, VEDO from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -26,7 +26,20 @@ from .const import _LOGGER, DOMAIN type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] -class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class AlarmDataObject(TypedDict): + """TypedDict for Alarm data objects.""" + + alarm_areas: dict[int, ComelitVedoAreaObject] + alarm_zones: dict[int, ComelitVedoZoneObject] + + +T = TypeVar( + "T", + bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject, +) + + +class ComelitBaseCoordinator(DataUpdateCoordinator[T]): """Base coordinator for Comelit Devices.""" _hw_version: str @@ -81,7 +94,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): hw_version=self._hw_version, ) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> T: """Update device data.""" _LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host) try: @@ -93,11 +106,13 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise ConfigEntryAuthFailed from err @abstractmethod - async def _async_update_system_data(self) -> dict[str, Any]: + async def _async_update_system_data(self) -> T: """Class method for updating data.""" -class ComelitSerialBridge(ComelitBaseCoordinator): +class ComelitSerialBridge( + ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]] +): """Queries Comelit Serial Bridge.""" _hw_version = "20003101" @@ -115,12 +130,14 @@ class ComelitSerialBridge(ComelitBaseCoordinator): self.api = ComeliteSerialBridgeApi(host, port, pin) super().__init__(hass, entry, BRIDGE, host) - async def _async_update_system_data(self) -> dict[str, Any]: + async def _async_update_system_data( + self, + ) -> dict[str, dict[int, ComelitSerialBridgeObject]]: """Specific method for updating data.""" return await self.api.get_all_devices() -class ComelitVedoSystem(ComelitBaseCoordinator): +class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): """Queries Comelit VEDO system.""" _hw_version = "VEDO IP" @@ -138,6 +155,13 @@ class ComelitVedoSystem(ComelitBaseCoordinator): self.api = ComelitVedoApi(host, port, pin) super().__init__(hass, entry, VEDO, host) - async def _async_update_system_data(self) -> dict[str, Any]: + async def _async_update_system_data( + self, + ) -> AlarmDataObject: """Specific method for updating data.""" - return await self.api.get_all_areas_and_zones() + data = await self.api.get_all_areas_and_zones() + + return AlarmDataObject( + alarm_areas=cast(dict[int, ComelitVedoAreaObject], data[ALARM_AREAS]), + alarm_zones=cast(dict[int, ComelitVedoZoneObject], data[ALARM_ZONES]), + ) diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index 5daf2297782..da6d44b1bbe 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -16,8 +16,8 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityFeature, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -122,61 +122,32 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier self._active_action = active_action self._set_command = set_command - @property - def _humidifier(self) -> list[Any]: - """Return humidifier device data.""" + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = self.coordinator.data[CLIMATE][self._device.index] + if not isinstance(device.val, list): + raise HomeAssistantError("Invalid clima data") + # CLIMATE has a 2 item tuple: # - first for Clima # - second for Humidifier - return self.coordinator.data[CLIMATE][self._device.index].val[1] + values = device.val[1] - @property - def _api_mode(self) -> str: - """Return device mode.""" - # Values from API: "O", "L", "U" - return self._humidifier[2] + _active = values[1] + _mode = values[2] # Values from API: "O", "L", "U" + _automatic = values[3] == HumidifierComelitMode.AUTO - @property - def _api_active(self) -> bool: - "Return device active/idle." - return self._humidifier[1] + self._attr_action = HumidifierAction.IDLE + if _mode == HumidifierComelitMode.OFF: + self._attr_action = HumidifierAction.OFF + if _active and _mode == self._active_mode: + self._attr_action = self._active_action - @property - def _api_automatic(self) -> bool: - """Return device in automatic/manual mode.""" - return self._humidifier[3] == HumidifierComelitMode.AUTO - - @property - def target_humidity(self) -> float: - """Set target humidity.""" - return self._humidifier[4] / 10 - - @property - def current_humidity(self) -> float: - """Return current humidity.""" - return self._humidifier[0] / 10 - - @property - def is_on(self) -> bool | None: - """Return true is humidifier is on.""" - return self._api_mode == self._active_mode - - @property - def mode(self) -> str | None: - """Return current mode.""" - return MODE_AUTO if self._api_automatic else MODE_NORMAL - - @property - def action(self) -> HumidifierAction | None: - """Return current action.""" - - if self._api_mode == HumidifierComelitMode.OFF: - return HumidifierAction.OFF - - if self._api_active and self._api_mode == self._active_mode: - return self._active_action - - return HumidifierAction.IDLE + self._attr_current_humidity = values[0] / 10 + self._attr_is_on = _mode == self._active_mode + self._attr_mode = MODE_AUTO if _automatic else MODE_NORMAL + self._attr_target_humidity = values[4] / 10 async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 9200d99262f..3d57d9dca9c 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Final, cast from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject -from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState +from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState from homeassistant.components.sensor import ( SensorDeviceClass, @@ -82,7 +82,7 @@ async def async_setup_vedo_entry( coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) entities: list[ComelitVedoSensorEntity] = [] - for device in coordinator.data[ALARM_ZONES].values(): + for device in coordinator.data["alarm_zones"].values(): entities.extend( ComelitVedoSensorEntity( coordinator, device, config_entry.entry_id, sensor_desc @@ -119,9 +119,12 @@ class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEn @property def native_value(self) -> StateType: """Sensor value.""" - return getattr( - self.coordinator.data[OTHER][self._device.index], - self.entity_description.key, + return cast( + StateType, + getattr( + self.coordinator.data[OTHER][self._device.index], + self.entity_description.key, + ), ) @@ -139,7 +142,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity ) -> None: """Init sensor entity.""" self._api = coordinator.api - self._zone = zone + self._zone_index = zone.index super().__init__(coordinator) # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available @@ -151,7 +154,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity @property def _zone_object(self) -> ComelitVedoZoneObject: """Zone object.""" - return self.coordinator.data[ALARM_ZONES][self._zone.index] + return self.coordinator.data["alarm_zones"][self._zone_index] @property def available(self) -> bool: @@ -164,4 +167,4 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN: return None - return status.value + return cast(str, status.value) diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index e89ee74c1be..f6e5b192c38 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -77,7 +77,4 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): @property def is_on(self) -> bool: """Return True if switch is on.""" - return ( - self.coordinator.data[self._device.type][self._device.index].status - == STATE_ON - ) + return self.coordinator.data[OTHER][self._device.index].status == STATE_ON diff --git a/mypy.ini b/mypy.ini index c69401b8605..520fad7d738 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1115,6 +1115,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.comelit.*] +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.command_line.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 3151b83d175..20324765a0b 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -6,6 +6,8 @@ from aiocomelit import ( ComelitVedoZoneObject, ) from aiocomelit.const import ( + ALARM_AREAS, + ALARM_ZONES, CLIMATE, COVER, IRRIGATION, @@ -63,7 +65,7 @@ BRIDGE_DEVICE_QUERY = { } VEDO_DEVICE_QUERY = { - "aree": { + ALARM_AREAS: { 0: ComelitVedoAreaObject( index=0, name="Area0", @@ -80,7 +82,7 @@ VEDO_DEVICE_QUERY = { human_status=AlarmAreaState.UNKNOWN, ) }, - "zone": { + ALARM_ZONES: { 0: ComelitVedoZoneObject( index=0, name="Zone0", diff --git a/tests/components/comelit/snapshots/test_diagnostics.ambr b/tests/components/comelit/snapshots/test_diagnostics.ambr index b9891eb3209..c4544f38f52 100644 --- a/tests/components/comelit/snapshots/test_diagnostics.ambr +++ b/tests/components/comelit/snapshots/test_diagnostics.ambr @@ -86,7 +86,7 @@ 'device_info': dict({ 'devices': list([ dict({ - 'aree': list([ + 'alarm_areas': list([ dict({ '0': dict({ 'alarm': False, @@ -106,7 +106,7 @@ ]), }), dict({ - 'zone': list([ + 'alarm_zones': list([ dict({ '0': dict({ 'human_status': 'rest', From 229407d6852af81aa4fb177bf2f52d08e8673814 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Mar 2025 18:25:18 +0100 Subject: [PATCH 1259/1941] Fix missing sentence-casing in three Fully Kiosk Browser strings (#139705) Fix missing sentence-casing in Fully Kiosk Browser strings --- homeassistant/components/fully_kiosk/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index a4b466926f0..5841456c034 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -1,6 +1,6 @@ { "common": { - "data_description_password": "The Remote Admin Password from the Fully Kiosk Browser app settings.", + "data_description_password": "The Remote Admin password from the Fully Kiosk Browser app settings.", "data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?", "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates." }, @@ -151,7 +151,7 @@ } }, "set_config": { - "name": "Set Configuration", + "name": "Set configuration", "description": "Sets a configuration parameter on Fully Kiosk Browser.", "fields": { "key": { @@ -165,7 +165,7 @@ } }, "start_application": { - "name": "Start Application", + "name": "Start application", "description": "Starts an application on the device running Fully Kiosk Browser.", "fields": { "application": { From 1b15df3075843d37b1ad9b13a29e07e08642f08b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Mar 2025 10:44:49 -0700 Subject: [PATCH 1260/1941] 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 18d15d0fbbd..c7cd7fdcdf0 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -14,7 +14,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 62b6be900fa7341a318ad150d0f3ebb877dbee16 Mon Sep 17 00:00:00 2001 From: Elias Wernicke Date: Mon, 3 Mar 2025 19:16:43 +0100 Subject: [PATCH 1261/1941] Add complete item intent function for todo component (#127806) * add complete item intent * fix error and add tests * fix merge conflict * improve error tests * improve error tests * add response_key * add check for non completed --------- Co-authored-by: Michael Hansen --- homeassistant/components/todo/intent.py | 84 ++++++++++++++++- tests/components/todo/__init__.py | 7 ++ tests/components/todo/test_intent.py | 116 ++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index c678408a576..d679a57bf96 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -11,11 +11,13 @@ from . import TodoItem, TodoItemStatus, TodoListEntity from .const import DATA_COMPONENT, DOMAIN INTENT_LIST_ADD_ITEM = "HassListAddItem" +INTENT_LIST_COMPLETE_ITEM = "HassListCompleteItem" async def async_setup_intents(hass: HomeAssistant) -> None: """Set up the todo intents.""" intent.async_register(hass, ListAddItemIntent()) + intent.async_register(hass, ListCompleteItemIntent()) class ListAddItemIntent(intent.IntentHandler): @@ -53,14 +55,92 @@ class ListAddItemIntent(intent.IntentHandler): match_result.states[0].entity_id ) if target_list is None: - raise intent.IntentHandleError(f"No to-do list: {list_name}") + raise intent.IntentHandleError( + f"No to-do list: {list_name}", "list_not_found" + ) # Add to list await target_list.async_create_todo_item( TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) ) - response = intent_obj.create_response() + response: intent.IntentResponse = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + [ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + name=list_name, + id=match_result.states[0].entity_id, + ) + ] + ) + return response + + +class ListCompleteItemIntent(intent.IntentHandler): + """Handle ListCompleteItem intents.""" + + intent_type = INTENT_LIST_COMPLETE_ITEM + description = "Complete item on a todo list" + slot_schema = { + vol.Required("item"): intent.non_empty_string, + vol.Required("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) + item = slots["item"]["value"] + list_name = slots["name"]["value"] + + target_list: TodoListEntity | None = None + + # Find matching list + match_constraints = intent.MatchTargetsConstraints( + name=list_name, 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 + ) + + target_list = hass.data[DATA_COMPONENT].get_entity( + match_result.states[0].entity_id + ) + if target_list is None: + raise intent.IntentHandleError( + f"No to-do list: {list_name}", "list_not_found" + ) + + # Find item in list + matching_item = None + for todo_item in target_list.todo_items or (): + if ( + item in (todo_item.uid, todo_item.summary) + and todo_item.status == TodoItemStatus.NEEDS_ACTION + ): + matching_item = todo_item + break + if not matching_item or not matching_item.uid: + raise intent.IntentHandleError( + f"Item '{item}' not found on list", "item_not_found" + ) + + # Mark as completed + await target_list.async_update_todo_item( + TodoItem( + uid=matching_item.uid, + summary=matching_item.summary, + status=TodoItemStatus.COMPLETED, + ) + ) + + response: intent.IntentResponse = intent_obj.create_response() response.response_type = intent.IntentResponseType.ACTION_DONE response.async_set_results( [ diff --git a/tests/components/todo/__init__.py b/tests/components/todo/__init__.py index 53772ab144e..239b586d366 100644 --- a/tests/components/todo/__init__.py +++ b/tests/components/todo/__init__.py @@ -34,6 +34,13 @@ class MockTodoListEntity(TodoListEntity): """Delete an item in the To-do list.""" self._attr_todo_items = [item for item in self.items if item.uid not in uids] + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item in the To-do list.""" + for idx, existing_item in enumerate(self.items): + if existing_item.uid == item.uid: + self._attr_todo_items[idx] = item + break + async def create_mock_platform( hass: HomeAssistant, diff --git a/tests/components/todo/test_intent.py b/tests/components/todo/test_intent.py index cd074816e7e..3f86347d1b7 100644 --- a/tests/components/todo/test_intent.py +++ b/tests/components/todo/test_intent.py @@ -1,5 +1,7 @@ """Tests for the todo intents.""" +from unittest.mock import patch + import pytest from homeassistant.components import conversation @@ -7,10 +9,12 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose from homeassistant.components.todo import ( ATTR_ITEM, DOMAIN, + TodoItem, TodoItemStatus, TodoListEntity, intent as todo_intent, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -18,6 +22,7 @@ from homeassistant.setup import async_setup_component from . import MockTodoListEntity, create_mock_platform +from tests.common import async_mock_service from tests.typing import WebSocketGenerator @@ -174,3 +179,114 @@ async def test_add_item_intent_errors( }, assistant=conversation.DOMAIN, ) + + +async def test_complete_item_intent( + hass: HomeAssistant, +) -> None: + """Test the complete item intent.""" + entity1 = MockTodoListEntity( + [ + TodoItem(summary="beer", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="wine", uid="2", status=TodoItemStatus.NEEDS_ACTION), + ] + ) + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + # Add entities to hass + config_entry = await create_mock_platform(hass, [entity1]) + assert config_entry.state is ConfigEntryState.LOADED + + assert len(entity1.items) == 2 + assert entity1.items[0].status == TodoItemStatus.NEEDS_ACTION + + # Complete item + async_mock_service(hass, DOMAIN, todo_intent.INTENT_LIST_COMPLETE_ITEM) + response = await intent.async_handle( + hass, + DOMAIN, + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + assert len(entity1.items) == 2 + assert entity1.items[0].status == TodoItemStatus.COMPLETED + + +async def test_complete_item_intent_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test errors with the complete item intent.""" + entity1 = MockTodoListEntity( + [ + TodoItem(summary="beer", uid="1", status=TodoItemStatus.COMPLETED), + ] + ) + entity1._attr_name = "List 1" + entity1.entity_id = "todo.list_1" + + # Add entities to hass + await create_mock_platform(hass, [entity1]) + + # Try to complete item in list that does not exist + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_COMPLETE_ITEM, + { + ATTR_ITEM: {"value": "wine"}, + ATTR_NAME: {"value": "This list does not exist"}, + }, + assistant=conversation.DOMAIN, + ) + + # Try to complete item that does not exist + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "bread"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + + # Item is already completed + with pytest.raises(intent.IntentHandleError): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "beer"}, ATTR_NAME: {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + + +async def test_complete_item_intent_ha_errors( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test error handling of HA errors with the complete item intent.""" + test_entity._attr_name = "List 1" + test_entity.entity_id = "todo.list_1" + await create_mock_platform(hass, [test_entity]) + + # Mock the get_entity method to return None + with ( + patch( + "homeassistant.helpers.entity_component.EntityComponent.get_entity", + return_value=None, + ), + pytest.raises(intent.IntentHandleError), + ): + await intent.async_handle( + hass, + DOMAIN, + todo_intent.INTENT_LIST_COMPLETE_ITEM, + {ATTR_ITEM: {"value": "wine"}, ATTR_NAME: {"value": "List 1"}}, + assistant=conversation.DOMAIN, + ) From f248901ea82db566bf31750c438f93cae8a29424 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Mar 2025 19:55:47 +0100 Subject: [PATCH 1262/1941] Grammar fixes in user-facing strings of the LinkPlay integration (#139709) Grammar fixes in user-facing string of the LinkPlay integration Fix spelling of "set up", "media player", "ID" and improve the descriptions of the `play_preset` action. --- homeassistant/components/linkplay/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json index 31b4649e131..5d68754879c 100644 --- a/homeassistant/components/linkplay/strings.json +++ b/homeassistant/components/linkplay/strings.json @@ -11,7 +11,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {name}?" + "description": "Do you want to set up {name}?" } }, "abort": { @@ -26,11 +26,11 @@ "services": { "play_preset": { "name": "Play preset", - "description": "Play the preset number on the device.", + "description": "Plays a preset on the device.", "fields": { "preset_number": { "name": "Preset number", - "description": "The preset number on the device to play." + "description": "The number of the preset to play." } } } @@ -44,7 +44,7 @@ }, "exceptions": { "invalid_grouping_entity": { - "message": "Entity with id {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay mediaplayer?" + "message": "Entity with ID {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay media player?" } } } From 2c44043e6af150bbcaf1bc03b126b4c210654359 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 3 Mar 2025 18:57:30 +0000 Subject: [PATCH 1263/1941] Bump mastodon.py to 2.0.1 (#139701) * Bump mastodon to 2.0.1 * Fix mypy --- homeassistant/components/mastodon/manifest.json | 2 +- homeassistant/components/mastodon/notify.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 20c506e7766..d7b21ad3a0c 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["mastodon"], - "requirements": ["Mastodon.py==1.8.1"] + "requirements": ["Mastodon.py==2.0.1"] } diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 8e7e9dc1947..8af98ec3ab1 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -52,7 +52,7 @@ async def async_get_service( if discovery_info is None: return None - client: Mastodon = discovery_info.get("client") + client = cast(Mastodon, discovery_info.get("client")) return MastodonNotificationService(hass, client) diff --git a/requirements_all.txt b/requirements_all.txt index 16b72934e6b..a82ae4d16e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==1.8.1 +Mastodon.py==2.0.1 # homeassistant.components.doods # homeassistant.components.generic diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 535aa75c62e..b3eff2acd7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -22,7 +22,7 @@ HAP-python==4.9.2 HATasmota==0.10.0 # homeassistant.components.mastodon -Mastodon.py==1.8.1 +Mastodon.py==2.0.1 # homeassistant.components.doods # homeassistant.components.generic From e47e15125922c9078285b3144fbc0bf963589448 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 3 Mar 2025 11:02:45 -0800 Subject: [PATCH 1264/1941] 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 dcd2d428940ef1f46cac738ae2b085dff9c64486 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 3 Mar 2025 20:07:07 +0100 Subject: [PATCH 1265/1941] 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 e28e4d210fa92cdc0d85afe29bb6e4ce8cc992b5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Mar 2025 20:19:09 +0100 Subject: [PATCH 1266/1941] Bump aiocomelit to 0.11.2 (#139707) --- .../components/comelit/coordinator.py | 29 ++++++------------- .../components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/comelit/const.py | 13 ++++----- 5 files changed, 18 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index a569a397c85..b3be3a47825 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -2,18 +2,19 @@ from abc import abstractmethod from datetime import timedelta -from typing import TypedDict, TypeVar, cast +from typing import TypeVar -from aiocomelit import ( +from aiocomelit.api import ( + AlarmDataObject, + ComelitCommonApi, ComeliteSerialBridgeApi, ComelitSerialBridgeObject, ComelitVedoApi, ComelitVedoAreaObject, ComelitVedoZoneObject, - exceptions, ) -from aiocomelit.api import ComelitCommonApi -from aiocomelit.const import ALARM_AREAS, ALARM_ZONES, BRIDGE, VEDO +from aiocomelit.const import BRIDGE, VEDO +from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -26,13 +27,6 @@ from .const import _LOGGER, DOMAIN type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator] -class AlarmDataObject(TypedDict): - """TypedDict for Alarm data objects.""" - - alarm_areas: dict[int, ComelitVedoAreaObject] - alarm_zones: dict[int, ComelitVedoZoneObject] - - T = TypeVar( "T", bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject, @@ -100,9 +94,9 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]): try: await self.api.login() return await self._async_update_system_data() - except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err: + except (CannotConnect, CannotRetrieveData) as err: raise UpdateFailed(repr(err)) from err - except exceptions.CannotAuthenticate as err: + except CannotAuthenticate as err: raise ConfigEntryAuthFailed from err @abstractmethod @@ -159,9 +153,4 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): self, ) -> AlarmDataObject: """Specific method for updating data.""" - data = await self.api.get_all_areas_and_zones() - - return AlarmDataObject( - alarm_areas=cast(dict[int, ComelitVedoAreaObject], data[ALARM_AREAS]), - alarm_zones=cast(dict[int, ComelitVedoZoneObject], data[ALARM_ZONES]), - ) + return await self.api.get_all_areas_and_zones() diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 20d481e9a5b..8836af4e8dd 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.11.1"] + "requirements": ["aiocomelit==0.11.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a82ae4d16e2..cc9761d0137 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.11.1 +aiocomelit==0.11.2 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3eff2acd7f..0c9f5a29b3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -201,7 +201,7 @@ aiobafi6==0.9.0 aiobotocore==2.13.1 # homeassistant.components.comelit -aiocomelit==0.11.1 +aiocomelit==0.11.2 # homeassistant.components.dhcp aiodhcpwatcher==1.1.1 diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 20324765a0b..f353ec97628 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,13 +1,12 @@ """Common stuff for Comelit SimpleHome tests.""" -from aiocomelit import ( +from aiocomelit.api import ( + AlarmDataObject, ComelitSerialBridgeObject, ComelitVedoAreaObject, ComelitVedoZoneObject, ) from aiocomelit.const import ( - ALARM_AREAS, - ALARM_ZONES, CLIMATE, COVER, IRRIGATION, @@ -64,8 +63,8 @@ BRIDGE_DEVICE_QUERY = { SCENARIO: {}, } -VEDO_DEVICE_QUERY = { - ALARM_AREAS: { +VEDO_DEVICE_QUERY = AlarmDataObject( + alarm_areas={ 0: ComelitVedoAreaObject( index=0, name="Area0", @@ -82,7 +81,7 @@ VEDO_DEVICE_QUERY = { human_status=AlarmAreaState.UNKNOWN, ) }, - ALARM_ZONES: { + alarm_zones={ 0: ComelitVedoZoneObject( index=0, name="Zone0", @@ -91,4 +90,4 @@ VEDO_DEVICE_QUERY = { human_status=AlarmZoneState.REST, ) }, -} +) From 890c672f8c83dc2a65714fb54b6fe7aad6e12258 Mon Sep 17 00:00:00 2001 From: StaleLoafOfBread <45444205+StaleLoafOfBread@users.noreply.github.com> Date: Mon, 3 Mar 2025 14:21:05 -0500 Subject: [PATCH 1267/1941] Add charging binary_sensor so front end can render battery icon properly (#139684) * Add charging binary sensor * Add charging binary sensor test --- homeassistant/components/roborock/binary_sensor.py | 10 +++++++++- tests/components/roborock/test_binary_sensor.py | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index db557f055dc..f2b1564c7b5 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from roborock.containers import RoborockStateCode from roborock.roborock_typing import DeviceProp from homeassistant.components.binary_sensor import ( @@ -12,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -63,6 +64,13 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.in_cleaning, ), + RoborockBinarySensorDescription( + key=ATTR_BATTERY_CHARGING, + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.state + in (RoborockStateCode.charging, RoborockStateCode.charging_complete), + ), ] diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 0e4b338f469..6a234d735e5 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -18,7 +18,7 @@ async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: """Test binary sensors and check test values are correctly set.""" - assert len(hass.states.async_all("binary_sensor")) == 8 + assert len(hass.states.async_all("binary_sensor")) == 10 assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state @@ -28,3 +28,4 @@ async def test_binary_sensors( hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" ) assert hass.states.get("binary_sensor.roborock_s7_maxv_cleaning").state == "off" + assert hass.states.get("binary_sensor.roborock_s7_maxv_charging").state == "on" From 9dc04cb0888637f9147bb1c73d6c07e631cc0f1f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 3 Mar 2025 11:23:29 -0800 Subject: [PATCH 1268/1941] 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 07a93dade20f197c8e32104be804b5d8d93d80e8 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 3 Mar 2025 20:24:36 +0100 Subject: [PATCH 1269/1941] Add translations for switch state by device class (#139693) --- homeassistant/components/switch/strings.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 0663384fe2c..b73cf8f849d 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -25,10 +25,18 @@ } }, "switch": { - "name": "[%key:component::switch::title%]" + "name": "[%key:component::switch::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } }, "outlet": { - "name": "Outlet" + "name": "Outlet", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "services": { From 139072bb590e08d7fa79ed626bf39e42830d7652 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 3 Mar 2025 20:47:38 +0100 Subject: [PATCH 1270/1941] 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 cc9761d0137..404fc67a899 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 0c9f5a29b3b..341adf7362e 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 b6f2d8f30bcf1031542791d64e26ec4a2d76c410 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 3 Mar 2025 22:26:16 +0200 Subject: [PATCH 1271/1941] 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 404fc67a899..265b72dd9a6 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 341adf7362e..e5a293e8dda 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 9ea582de26b6f964f3ed4cae585e354cc8c1295f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Mar 2025 14:20:25 -0700 Subject: [PATCH 1272/1941] 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 265b72dd9a6..ca4feec308e 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 e5a293e8dda..bd1478f945c 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 a7780929416f895ab7f19ccee6e37aef4ab3020b Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Tue, 4 Mar 2025 10:05:20 +1030 Subject: [PATCH 1273/1941] Support up to 8 AUX outputs in Ness Alarm (#139718) Support up to 8 AUX outputs --- homeassistant/components/ness_alarm/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ness_alarm/services.yaml b/homeassistant/components/ness_alarm/services.yaml index b02d5e36805..aed1e1836bd 100644 --- a/homeassistant/components/ness_alarm/services.yaml +++ b/homeassistant/components/ness_alarm/services.yaml @@ -7,7 +7,7 @@ aux: selector: number: min: 1 - max: 4 + max: 8 state: default: true selector: From 890d3f4af41a1a133641edd17589bcabae5a33c0 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 4 Mar 2025 01:23:05 -0500 Subject: [PATCH 1274/1941] Add a base class for template entities to inherit from (#139645) * add-abstract-template-entity-base-class * review 1 changes --- CODEOWNERS | 4 +- .../template/alarm_control_panel.py | 90 +++------- homeassistant/components/template/button.py | 12 +- homeassistant/components/template/cover.py | 90 +++++----- homeassistant/components/template/entity.py | 66 ++++++++ homeassistant/components/template/fan.py | 87 +++++----- homeassistant/components/template/light.py | 158 ++++++++++-------- homeassistant/components/template/lock.py | 30 ++-- .../components/template/manifest.json | 2 +- homeassistant/components/template/number.py | 8 +- homeassistant/components/template/select.py | 8 +- homeassistant/components/template/switch.py | 39 ++--- .../components/template/template_entity.py | 53 ++---- homeassistant/components/template/vacuum.py | 116 +++++-------- tests/components/template/test_entity.py | 17 ++ .../template/test_template_entity.py | 2 +- 16 files changed, 384 insertions(+), 398 deletions(-) create mode 100644 homeassistant/components/template/entity.py create mode 100644 tests/components/template/test_entity.py diff --git a/CODEOWNERS b/CODEOWNERS index 3366bfb0885..4e8f78ca873 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1529,8 +1529,8 @@ build.json @home-assistant/supervisor /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike -/homeassistant/components/template/ @PhracturedBlue @home-assistant/core -/tests/components/template/ @PhracturedBlue @home-assistant/core +/homeassistant/components/template/ @Petro31 @PhracturedBlue @home-assistant/core +/tests/components/template/ @Petro31 @PhracturedBlue @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_wall_connector/ @einarhauks diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 0a468994295..40206a5ccbb 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -36,7 +36,6 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, ) from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify @@ -199,70 +198,31 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore name = self._attr_name assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) - self._disarm_script = None self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] self._attr_code_format = config[CONF_CODE_FORMAT].value - if (disarm_action := config.get(CONF_DISARM_ACTION)) is not None: - self._disarm_script = Script(hass, disarm_action, name, DOMAIN) - self._arm_away_script = None - if (arm_away_action := config.get(CONF_ARM_AWAY_ACTION)) is not None: - self._arm_away_script = Script(hass, arm_away_action, name, DOMAIN) - self._arm_home_script = None - if (arm_home_action := config.get(CONF_ARM_HOME_ACTION)) is not None: - self._arm_home_script = Script(hass, arm_home_action, name, DOMAIN) - self._arm_night_script = None - if (arm_night_action := config.get(CONF_ARM_NIGHT_ACTION)) is not None: - self._arm_night_script = Script(hass, arm_night_action, name, DOMAIN) - self._arm_vacation_script = None - if (arm_vacation_action := config.get(CONF_ARM_VACATION_ACTION)) is not None: - self._arm_vacation_script = Script(hass, arm_vacation_action, name, DOMAIN) - self._arm_custom_bypass_script = None - if ( - arm_custom_bypass_action := config.get(CONF_ARM_CUSTOM_BYPASS_ACTION) - ) is not None: - self._arm_custom_bypass_script = Script( - hass, arm_custom_bypass_action, name, DOMAIN - ) - self._trigger_script = None - if (trigger_action := config.get(CONF_TRIGGER_ACTION)) is not None: - self._trigger_script = Script(hass, trigger_action, name, DOMAIN) + + self._attr_supported_features = AlarmControlPanelEntityFeature(0) + for action_id, supported_feature in ( + (CONF_DISARM_ACTION, 0), + (CONF_ARM_AWAY_ACTION, AlarmControlPanelEntityFeature.ARM_AWAY), + (CONF_ARM_HOME_ACTION, AlarmControlPanelEntityFeature.ARM_HOME), + (CONF_ARM_NIGHT_ACTION, AlarmControlPanelEntityFeature.ARM_NIGHT), + (CONF_ARM_VACATION_ACTION, AlarmControlPanelEntityFeature.ARM_VACATION), + ( + CONF_ARM_CUSTOM_BYPASS_ACTION, + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + ), + (CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER), + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._state: AlarmControlPanelState | None = None self._attr_device_info = async_device_info_to_link_from_device_id( hass, config.get(CONF_DEVICE_ID), ) - supported_features = AlarmControlPanelEntityFeature(0) - if self._arm_night_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_NIGHT - ) - - if self._arm_home_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_HOME - ) - - if self._arm_away_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_AWAY - ) - - if self._arm_vacation_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_VACATION - ) - - if self._arm_custom_bypass_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - ) - - if self._trigger_script is not None: - supported_features = ( - supported_features | AlarmControlPanelEntityFeature.TRIGGER - ) - self._attr_supported_features = supported_features async def async_added_to_hass(self) -> None: """Restore last state.""" @@ -330,7 +290,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Away.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_AWAY, - script=self._arm_away_script, + script=self._action_scripts.get(CONF_ARM_AWAY_ACTION), code=code, ) @@ -338,7 +298,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Home.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_HOME, - script=self._arm_home_script, + script=self._action_scripts.get(CONF_ARM_HOME_ACTION), code=code, ) @@ -346,7 +306,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Night.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_NIGHT, - script=self._arm_night_script, + script=self._action_scripts.get(CONF_ARM_NIGHT_ACTION), code=code, ) @@ -354,7 +314,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Vacation.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_VACATION, - script=self._arm_vacation_script, + script=self._action_scripts.get(CONF_ARM_VACATION_ACTION), code=code, ) @@ -362,20 +322,22 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore """Arm the panel to Custom Bypass.""" await self._async_alarm_arm( AlarmControlPanelState.ARMED_CUSTOM_BYPASS, - script=self._arm_custom_bypass_script, + script=self._action_scripts.get(CONF_ARM_CUSTOM_BYPASS_ACTION), code=code, ) async def async_alarm_disarm(self, code: str | None = None) -> None: """Disarm the panel.""" await self._async_alarm_arm( - AlarmControlPanelState.DISARMED, script=self._disarm_script, code=code + AlarmControlPanelState.DISARMED, + script=self._action_scripts.get(CONF_DISARM_ACTION), + code=code, ) async def async_alarm_trigger(self, code: str | None = None) -> None: """Trigger the panel.""" await self._async_alarm_arm( AlarmControlPanelState.TRIGGERED, - script=self._trigger_script, + script=self._action_scripts.get(CONF_TRIGGER_ACTION), code=code, ) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index f43fc242bba..7a205446585 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -23,7 +23,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN @@ -121,11 +120,8 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Initialize the button.""" super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None - self._command_press = ( - Script(hass, config.get(CONF_PRESS), self._attr_name, DOMAIN) - if config.get(CONF_PRESS, None) is not None - else None - ) + if action := config.get(CONF_PRESS): + self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None self._attr_device_info = async_device_info_to_link_from_device_id( @@ -135,5 +131,5 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" - if self._command_press: - await self.async_run_script(self._command_press, context=self._context) + if script := self._action_scripts.get(CONF_PRESS): + await self.async_run_script(script, context=self._context) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 306b4405c6a..ef5e6bc5758 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -30,7 +30,6 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -103,7 +102,7 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config): """Create the Template cover.""" covers = [] @@ -141,11 +140,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: dict[str, Any], unique_id, - ): + ) -> None: """Initialize the Template cover.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -153,45 +152,40 @@ class CoverTemplate(TemplateEntity, CoverEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) self._position_template = config.get(CONF_POSITION_TEMPLATE) self._tilt_template = config.get(CONF_TILT_TEMPLATE) self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._open_script = None - if (open_action := config.get(OPEN_ACTION)) is not None: - self._open_script = Script(hass, open_action, friendly_name, DOMAIN) - self._close_script = None - if (close_action := config.get(CLOSE_ACTION)) is not None: - self._close_script = Script(hass, close_action, friendly_name, DOMAIN) - self._stop_script = None - if (stop_action := config.get(STOP_ACTION)) is not None: - self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN) - self._position_script = None - if (position_action := config.get(POSITION_ACTION)) is not None: - self._position_script = Script(hass, position_action, friendly_name, DOMAIN) - self._tilt_script = None - if (tilt_action := config.get(TILT_ACTION)) is not None: - self._tilt_script = Script(hass, tilt_action, friendly_name, DOMAIN) + + # The config requires (open and close scripts) or a set position script, + # therefore the base supported features will always include them. + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + for action_id, supported_feature in ( + (OPEN_ACTION, 0), + (CLOSE_ACTION, 0), + (STOP_ACTION, CoverEntityFeature.STOP), + (POSITION_ACTION, CoverEntityFeature.SET_POSITION), + (TILT_ACTION, TILT_FEATURES), + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or ( optimistic is None and not self._template and not self._position_template ) tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC) self._tilt_optimistic = tilt_optimistic or not self._tilt_template - self._position = None + self._position: int | None = None self._is_opening = False self._is_closing = False - self._tilt_value = None - - supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - if self._stop_script is not None: - supported_features |= CoverEntityFeature.STOP - if self._position_script is not None: - supported_features |= CoverEntityFeature.SET_POSITION - if self._tilt_script is not None: - supported_features |= TILT_FEATURES - self._attr_supported_features = supported_features + self._tilt_value: int | None = None @callback def _async_setup_templates(self) -> None: @@ -317,7 +311,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): None is unknown, 0 is closed, 100 is fully open. """ - if self._position_template or self._position_script: + if self._position_template or self._action_scripts.get(POSITION_ACTION): return self._position return None @@ -331,11 +325,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" - if self._open_script: - await self.async_run_script(self._open_script, context=self._context) - elif self._position_script: + if (open_script := self._action_scripts.get(OPEN_ACTION)) is not None: + await self.async_run_script(open_script, context=self._context) + elif (position_script := self._action_scripts.get(POSITION_ACTION)) is not None: await self.async_run_script( - self._position_script, + position_script, run_variables={"position": 100}, context=self._context, ) @@ -345,11 +339,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" - if self._close_script: - await self.async_run_script(self._close_script, context=self._context) - elif self._position_script: + if (close_script := self._action_scripts.get(CLOSE_ACTION)) is not None: + await self.async_run_script(close_script, context=self._context) + elif (position_script := self._action_scripts.get(POSITION_ACTION)) is not None: await self.async_run_script( - self._position_script, + position_script, run_variables={"position": 0}, context=self._context, ) @@ -359,14 +353,14 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" - if self._stop_script: - await self.async_run_script(self._stop_script, context=self._context) + if (stop_script := self._action_scripts.get(STOP_ACTION)) is not None: + await self.async_run_script(stop_script, context=self._context) async def async_set_cover_position(self, **kwargs: Any) -> None: """Set cover position.""" self._position = kwargs[ATTR_POSITION] await self.async_run_script( - self._position_script, + self._action_scripts[POSITION_ACTION], run_variables={"position": self._position}, context=self._context, ) @@ -377,7 +371,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): """Tilt the cover open.""" self._tilt_value = 100 await self.async_run_script( - self._tilt_script, + self._action_scripts[TILT_ACTION], run_variables={"tilt": self._tilt_value}, context=self._context, ) @@ -388,7 +382,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): """Tilt the cover closed.""" self._tilt_value = 0 await self.async_run_script( - self._tilt_script, + self._action_scripts[TILT_ACTION], run_variables={"tilt": self._tilt_value}, context=self._context, ) @@ -399,7 +393,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] await self.async_run_script( - self._tilt_script, + self._action_scripts[TILT_ACTION], run_variables={"tilt": self._tilt_value}, context=self._context, ) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py new file mode 100644 index 00000000000..dd8623060be --- /dev/null +++ b/homeassistant/components/template/entity.py @@ -0,0 +1,66 @@ +"""Template entity base class.""" + +from collections.abc import Sequence +from typing import Any + +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.template import TemplateStateFromEntityId + + +class AbstractTemplateEntity(Entity): + """Actions linked to a template entity.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the entity.""" + + self.hass = hass + self._action_scripts: dict[str, Script] = {} + + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + raise NotImplementedError + + @callback + def _render_script_variables(self) -> dict: + """Render configured variables.""" + raise NotImplementedError + + def add_script( + self, + script_id: str, + config: Sequence[dict[str, Any]], + name: str, + domain: str, + ): + """Add an action script.""" + + # Cannot use self.hass because it may be None in child class + # at instantiation. + self._action_scripts[script_id] = Script( + self.hass, + config, + f"{name} {script_id}", + domain, + ) + + async def async_run_script( + self, + script: Script, + *, + run_variables: _VarsType | None = None, + context: Context | None = None, + ) -> None: + """Run an action script.""" + if run_variables is None: + run_variables = {} + await script.async_run( + run_variables={ + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **self._render_script_variables(), + **run_variables, + }, + context=context, + ) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 6ed525fd45f..2ca05681f7f 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -32,7 +32,6 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -89,7 +88,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config): """Create the Template Fans.""" fans = [] @@ -127,11 +126,11 @@ class TemplateFan(TemplateEntity, FanEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: dict[str, Any], unique_id, - ): + ) -> None: """Initialize the fan.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -140,7 +139,9 @@ class TemplateFan(TemplateEntity, FanEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) self._percentage_template = config.get(CONF_PERCENTAGE_TEMPLATE) @@ -148,44 +149,28 @@ class TemplateFan(TemplateEntity, FanEntity): self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE) self._direction_template = config.get(CONF_DIRECTION_TEMPLATE) - self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN) - self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN) - - self._set_percentage_script = None - if set_percentage_action := config.get(CONF_SET_PERCENTAGE_ACTION): - self._set_percentage_script = Script( - hass, set_percentage_action, friendly_name, DOMAIN - ) - - self._set_preset_mode_script = None - if set_preset_mode_action := config.get(CONF_SET_PRESET_MODE_ACTION): - self._set_preset_mode_script = Script( - hass, set_preset_mode_action, friendly_name, DOMAIN - ) - - self._set_oscillating_script = None - if set_oscillating_action := config.get(CONF_SET_OSCILLATING_ACTION): - self._set_oscillating_script = Script( - hass, set_oscillating_action, friendly_name, DOMAIN - ) - - self._set_direction_script = None - if set_direction_action := config.get(CONF_SET_DIRECTION_ACTION): - self._set_direction_script = Script( - hass, set_direction_action, friendly_name, DOMAIN - ) + for action_id in ( + CONF_ON_ACTION, + CONF_OFF_ACTION, + CONF_SET_PERCENTAGE_ACTION, + CONF_SET_PRESET_MODE_ACTION, + CONF_SET_OSCILLATING_ACTION, + CONF_SET_DIRECTION_ACTION, + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) self._state: bool | None = False - self._percentage = None - self._preset_mode = None - self._oscillating = None - self._direction = None + self._percentage: int | None = None + self._preset_mode: str | None = None + self._oscillating: bool | None = None + self._direction: str | None = None # Number of valid speeds self._speed_count = config.get(CONF_SPEED_COUNT) # List of valid preset modes - self._preset_modes = config.get(CONF_PRESET_MODES) + self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) if self._percentage_template: self._attr_supported_features |= FanEntityFeature.SET_SPEED @@ -207,7 +192,7 @@ class TemplateFan(TemplateEntity, FanEntity): return self._speed_count or 100 @property - def preset_modes(self) -> list[str]: + def preset_modes(self) -> list[str] | None: """Get the list of available preset modes.""" return self._preset_modes @@ -244,7 +229,7 @@ class TemplateFan(TemplateEntity, FanEntity): ) -> None: """Turn on the fan.""" await self.async_run_script( - self._on_script, + self._action_scripts[CONF_ON_ACTION], run_variables={ ATTR_PERCENTAGE: percentage, ATTR_PRESET_MODE: preset_mode, @@ -263,7 +248,9 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - await self.async_run_script(self._off_script, context=self._context) + await self.async_run_script( + self._action_scripts[CONF_OFF_ACTION], context=self._context + ) if self._template is None: self._state = False @@ -273,9 +260,9 @@ class TemplateFan(TemplateEntity, FanEntity): """Set the percentage speed of the fan.""" self._percentage = percentage - if self._set_percentage_script: + if (script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION)) is not None: await self.async_run_script( - self._set_percentage_script, + script, run_variables={ATTR_PERCENTAGE: self._percentage}, context=self._context, ) @@ -288,9 +275,11 @@ class TemplateFan(TemplateEntity, FanEntity): """Set the preset_mode of the fan.""" self._preset_mode = preset_mode - if self._set_preset_mode_script: + if ( + script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION) + ) is not None: await self.async_run_script( - self._set_preset_mode_script, + script, run_variables={ATTR_PRESET_MODE: self._preset_mode}, context=self._context, ) @@ -301,25 +290,25 @@ class TemplateFan(TemplateEntity, FanEntity): async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation of the fan.""" - if self._set_oscillating_script is None: + if (script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION)) is None: return self._oscillating = oscillating await self.async_run_script( - self._set_oscillating_script, + script, run_variables={ATTR_OSCILLATING: self.oscillating}, context=self._context, ) async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - if self._set_direction_script is None: + if (script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION)) is None: return if direction in _VALID_DIRECTIONS: self._direction = direction await self.async_run_script( - self._set_direction_script, + script, run_variables={ATTR_DIRECTION: direction}, context=self._context, ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 206703ddcce..3369bf3ce0f 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -39,7 +39,6 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util @@ -127,7 +126,7 @@ PLATFORM_SCHEMA = vol.All( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config): """Create the Template Lights.""" lights = [] @@ -164,11 +163,11 @@ class LightTemplate(TemplateEntity, LightEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: dict[str, Any], unique_id, - ): + ) -> None: """Initialize the light.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -176,52 +175,31 @@ class LightTemplate(TemplateEntity, LightEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + self._template = config.get(CONF_VALUE_TEMPLATE) - self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN) - self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN) - self._level_script = None - if (level_action := config.get(CONF_LEVEL_ACTION)) is not None: - self._level_script = Script(hass, level_action, friendly_name, DOMAIN) self._level_template = config.get(CONF_LEVEL_TEMPLATE) - self._temperature_script = None - if (temperature_action := config.get(CONF_TEMPERATURE_ACTION)) is not None: - self._temperature_script = Script( - hass, temperature_action, friendly_name, DOMAIN - ) self._temperature_template = config.get(CONF_TEMPERATURE_TEMPLATE) - self._color_script = None - if (color_action := config.get(CONF_COLOR_ACTION)) is not None: - self._color_script = Script(hass, color_action, friendly_name, DOMAIN) self._color_template = config.get(CONF_COLOR_TEMPLATE) - self._hs_script = None - if (hs_action := config.get(CONF_HS_ACTION)) is not None: - self._hs_script = Script(hass, hs_action, friendly_name, DOMAIN) self._hs_template = config.get(CONF_HS_TEMPLATE) - self._rgb_script = None - if (rgb_action := config.get(CONF_RGB_ACTION)) is not None: - self._rgb_script = Script(hass, rgb_action, friendly_name, DOMAIN) self._rgb_template = config.get(CONF_RGB_TEMPLATE) - self._rgbw_script = None - if (rgbw_action := config.get(CONF_RGBW_ACTION)) is not None: - self._rgbw_script = Script(hass, rgbw_action, friendly_name, DOMAIN) self._rgbw_template = config.get(CONF_RGBW_TEMPLATE) - self._rgbww_script = None - if (rgbww_action := config.get(CONF_RGBWW_ACTION)) is not None: - self._rgbww_script = Script(hass, rgbww_action, friendly_name, DOMAIN) self._rgbww_template = config.get(CONF_RGBWW_TEMPLATE) - self._effect_script = None - if (effect_action := config.get(CONF_EFFECT_ACTION)) is not None: - self._effect_script = Script(hass, effect_action, friendly_name, DOMAIN) self._effect_list_template = config.get(CONF_EFFECT_LIST_TEMPLATE) self._effect_template = config.get(CONF_EFFECT_TEMPLATE) self._max_mireds_template = config.get(CONF_MAX_MIREDS_TEMPLATE) self._min_mireds_template = config.get(CONF_MIN_MIREDS_TEMPLATE) self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) + for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._state = False self._brightness = None - self._temperature = None + self._temperature: int | None = None self._hs_color = None self._rgb_color = None self._rgbw_color = None @@ -235,21 +213,18 @@ class LightTemplate(TemplateEntity, LightEntity): self._supported_color_modes = None color_modes = {ColorMode.ONOFF} - if self._level_script is not None: - color_modes.add(ColorMode.BRIGHTNESS) - if self._temperature_script is not None: - color_modes.add(ColorMode.COLOR_TEMP) - if self._hs_script is not None: - color_modes.add(ColorMode.HS) - if self._color_script is not None: - color_modes.add(ColorMode.HS) - if self._rgb_script is not None: - color_modes.add(ColorMode.RGB) - if self._rgbw_script is not None: - color_modes.add(ColorMode.RGBW) - if self._rgbww_script is not None: - color_modes.add(ColorMode.RGBWW) - + for action_id, color_mode in ( + (CONF_TEMPERATURE_ACTION, ColorMode.COLOR_TEMP), + (CONF_LEVEL_ACTION, ColorMode.BRIGHTNESS), + (CONF_COLOR_ACTION, ColorMode.HS), + (CONF_HS_ACTION, ColorMode.HS), + (CONF_RGB_ACTION, ColorMode.RGB), + (CONF_RGBW_ACTION, ColorMode.RGBW), + (CONF_RGBWW_ACTION, ColorMode.RGBWW), + ): + if (action_config := config.get(action_id)) is not None: + self.add_script(action_id, action_config, name, DOMAIN) + color_modes.add(color_mode) self._supported_color_modes = filter_supported_color_modes(color_modes) if len(self._supported_color_modes) > 1: self._color_mode = ColorMode.UNKNOWN @@ -257,7 +232,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._color_mode = next(iter(self._supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) - if self._effect_script is not None: + if self._action_scripts.get(CONF_EFFECT_ACTION) is not None: self._attr_supported_features |= LightEntityFeature.EFFECT if self._supports_transition is True: self._attr_supported_features |= LightEntityFeature.TRANSITION @@ -321,12 +296,12 @@ class LightTemplate(TemplateEntity, LightEntity): return self._effect_list @property - def color_mode(self): + def color_mode(self) -> ColorMode | None: """Return current color mode.""" return self._color_mode @property - def supported_color_modes(self): + def supported_color_modes(self) -> set[ColorMode] | None: """Flag supported color modes.""" return self._supported_color_modes @@ -555,17 +530,28 @@ class LightTemplate(TemplateEntity, LightEntity): if ATTR_TRANSITION in kwargs and self._supports_transition is True: common_params["transition"] = kwargs[ATTR_TRANSITION] - if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temperature_script: + if ( + ATTR_COLOR_TEMP_KELVIN in kwargs + and ( + temperature_script := self._action_scripts.get(CONF_TEMPERATURE_ACTION) + ) + is not None + ): common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] ) await self.async_run_script( - self._temperature_script, + temperature_script, run_variables=common_params, context=self._context, ) - elif ATTR_EFFECT in kwargs and self._effect_script: + elif ( + ATTR_EFFECT in kwargs + and (effect_script := self._action_scripts.get(CONF_EFFECT_ACTION)) + is not None + ): + assert self._effect_list is not None effect = kwargs[ATTR_EFFECT] if effect not in self._effect_list: _LOGGER.error( @@ -579,27 +565,38 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["effect"] = effect await self.async_run_script( - self._effect_script, run_variables=common_params, context=self._context + effect_script, run_variables=common_params, context=self._context ) - elif ATTR_HS_COLOR in kwargs and self._color_script: + elif ( + ATTR_HS_COLOR in kwargs + and (color_script := self._action_scripts.get(CONF_COLOR_ACTION)) + is not None + ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value common_params["h"] = int(hs_value[0]) common_params["s"] = int(hs_value[1]) await self.async_run_script( - self._color_script, run_variables=common_params, context=self._context + color_script, run_variables=common_params, context=self._context ) - elif ATTR_HS_COLOR in kwargs and self._hs_script: + elif ( + ATTR_HS_COLOR in kwargs + and (hs_script := self._action_scripts.get(CONF_HS_ACTION)) is not None + ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value common_params["h"] = int(hs_value[0]) common_params["s"] = int(hs_value[1]) await self.async_run_script( - self._hs_script, run_variables=common_params, context=self._context + hs_script, run_variables=common_params, context=self._context ) - elif ATTR_RGBWW_COLOR in kwargs and self._rgbww_script: + elif ( + ATTR_RGBWW_COLOR in kwargs + and (rgbww_script := self._action_scripts.get(CONF_RGBWW_ACTION)) + is not None + ): rgbww_value = kwargs[ATTR_RGBWW_COLOR] common_params["rgbww"] = rgbww_value common_params["rgb"] = ( @@ -614,9 +611,12 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["ww"] = int(rgbww_value[4]) await self.async_run_script( - self._rgbww_script, run_variables=common_params, context=self._context + rgbww_script, run_variables=common_params, context=self._context ) - elif ATTR_RGBW_COLOR in kwargs and self._rgbw_script: + elif ( + ATTR_RGBW_COLOR in kwargs + and (rgbw_script := self._action_scripts.get(CONF_RGBW_ACTION)) is not None + ): rgbw_value = kwargs[ATTR_RGBW_COLOR] common_params["rgbw"] = rgbw_value common_params["rgb"] = ( @@ -630,9 +630,12 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["w"] = int(rgbw_value[3]) await self.async_run_script( - self._rgbw_script, run_variables=common_params, context=self._context + rgbw_script, run_variables=common_params, context=self._context ) - elif ATTR_RGB_COLOR in kwargs and self._rgb_script: + elif ( + ATTR_RGB_COLOR in kwargs + and (rgb_script := self._action_scripts.get(CONF_RGB_ACTION)) is not None + ): rgb_value = kwargs[ATTR_RGB_COLOR] common_params["rgb"] = rgb_value common_params["r"] = int(rgb_value[0]) @@ -640,15 +643,21 @@ class LightTemplate(TemplateEntity, LightEntity): common_params["b"] = int(rgb_value[2]) await self.async_run_script( - self._rgb_script, run_variables=common_params, context=self._context + rgb_script, run_variables=common_params, context=self._context ) - elif ATTR_BRIGHTNESS in kwargs and self._level_script: + elif ( + ATTR_BRIGHTNESS in kwargs + and (level_script := self._action_scripts.get(CONF_LEVEL_ACTION)) + is not None + ): await self.async_run_script( - self._level_script, run_variables=common_params, context=self._context + level_script, run_variables=common_params, context=self._context ) else: await self.async_run_script( - self._on_script, run_variables=common_params, context=self._context + self._action_scripts[CONF_ON_ACTION], + run_variables=common_params, + context=self._context, ) if optimistic_set: @@ -656,14 +665,15 @@ class LightTemplate(TemplateEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" + off_script = self._action_scripts[CONF_OFF_ACTION] if ATTR_TRANSITION in kwargs and self._supports_transition is True: await self.async_run_script( - self._off_script, + off_script, run_variables={"transition": kwargs[ATTR_TRANSITION]}, context=self._context, ) else: - await self.async_run_script(self._off_script, context=self._context) + await self.async_run_script(off_script, context=self._context) if self._template is None: self._state = False self.async_write_ha_state() @@ -1013,7 +1023,7 @@ class LightTemplate(TemplateEntity, LightEntity): if render in (None, "None", ""): self._supports_transition = False return - self._attr_supported_features &= ~LightEntityFeature.TRANSITION + self._attr_supported_features &= LightEntityFeature.EFFECT self._supports_transition = bool(render) if self._supports_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 0804f92e46d..b19cadff26c 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -90,13 +89,18 @@ class TemplateLock(TemplateEntity, LockEntity): ) self._state: LockState | None = None name = self._attr_name - assert name + if TYPE_CHECKING: + assert name is not None + self._state_template = config.get(CONF_VALUE_TEMPLATE) - self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN) - self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN) - if CONF_OPEN in config: - self._command_open = Script(hass, config[CONF_OPEN], name, DOMAIN) - self._attr_supported_features |= LockEntityFeature.OPEN + for action_id, supported_feature in ( + (CONF_LOCK, 0), + (CONF_UNLOCK, 0), + (CONF_OPEN, LockEntityFeature.OPEN), + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE) self._code_format: str | None = None self._code_format_template_error: TemplateError | None = None @@ -210,7 +214,9 @@ class TemplateLock(TemplateEntity, LockEntity): tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} await self.async_run_script( - self._command_lock, run_variables=tpl_vars, context=self._context + self._action_scripts[CONF_LOCK], + run_variables=tpl_vars, + context=self._context, ) async def async_unlock(self, **kwargs: Any) -> None: @@ -226,7 +232,9 @@ class TemplateLock(TemplateEntity, LockEntity): tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} await self.async_run_script( - self._command_unlock, run_variables=tpl_vars, context=self._context + self._action_scripts[CONF_UNLOCK], + run_variables=tpl_vars, + context=self._context, ) async def async_open(self, **kwargs: Any) -> None: @@ -242,7 +250,9 @@ class TemplateLock(TemplateEntity, LockEntity): tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None} await self.async_run_script( - self._command_open, run_variables=tpl_vars, context=self._context + self._action_scripts[CONF_OPEN], + run_variables=tpl_vars, + context=self._context, ) def _raise_template_error_if_available(self): diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index f1225f74f06..32bfd8ce02e 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -2,7 +2,7 @@ "domain": "template", "name": "Template", "after_dependencies": ["group"], - "codeowners": ["@PhracturedBlue", "@home-assistant/core"], + "codeowners": ["@Petro31", "@PhracturedBlue", "@home-assistant/core"], "config_flow": true, "dependencies": ["blueprint"], "documentation": "https://www.home-assistant.io/integrations/template", diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 661dbb45dc1..6661afc619c 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -157,9 +157,7 @@ class TemplateNumber(TemplateEntity, NumberEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - self._command_set_value = Script( - hass, config[CONF_SET_VALUE], self._attr_name, DOMAIN - ) + self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN) self._step_template = config[CONF_STEP] self._min_value_template = config[CONF_MIN] @@ -210,9 +208,9 @@ class TemplateNumber(TemplateEntity, NumberEntity): if self._optimistic: self._attr_native_value = value self.async_write_ha_state() - if self._command_set_value: + if (set_value := self._action_scripts.get(CONF_SET_VALUE)) is not None: await self.async_run_script( - self._command_set_value, + set_value, run_variables={ATTR_VALUE: value}, context=self._context, ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index a42ee3d0612..d3b879a695d 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -143,8 +143,8 @@ class TemplateSelect(TemplateEntity, SelectEntity): assert self._attr_name is not None self._value_template = config[CONF_STATE] if (selection_option := config.get(CONF_SELECT_OPTION)) is not None: - self._command_select_option = Script( - hass, selection_option, self._attr_name, DOMAIN + self.add_script( + CONF_SELECT_OPTION, selection_option, self._attr_name, DOMAIN ) self._options_template = config[ATTR_OPTIONS] self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) @@ -177,9 +177,9 @@ class TemplateSelect(TemplateEntity, SelectEntity): if self._optimistic: self._attr_current_option = option self.async_write_ha_state() - if self._command_select_option: + if (select_option := self._action_scripts.get(CONF_SELECT_OPTION)) is not None: await self.async_run_script( - self._command_select_option, + select_option, run_variables={ATTR_OPTION: option}, context=self._context, ) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 756866cfd44..148648a7a3c 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -33,7 +33,6 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, ) from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN @@ -74,7 +73,7 @@ SWITCH_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config: ConfigType): """Create the Template switches.""" switches = [] @@ -134,11 +133,11 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: ConfigType, unique_id, - ): + ) -> None: """Initialize the Template switch.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -147,18 +146,16 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) - self._on_script = ( - Script(hass, config.get(CONF_TURN_ON), friendly_name, DOMAIN) - if config.get(CONF_TURN_ON) is not None - else None - ) - self._off_script = ( - Script(hass, config.get(CONF_TURN_OFF), friendly_name, DOMAIN) - if config.get(CONF_TURN_OFF) is not None - else None - ) + + if on_action := config.get(CONF_TURN_ON): + self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) + if off_action := config.get(CONF_TURN_OFF): + self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN) + self._state: bool | None = False self._attr_assumed_state = self._template is None self._attr_device_info = async_device_info_to_link_from_device_id( @@ -209,16 +206,16 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Fire the on action.""" - if self._on_script: - await self.async_run_script(self._on_script, context=self._context) + if (on_script := self._action_scripts.get(CONF_TURN_ON)) is not None: + await self.async_run_script(on_script, context=self._context) if self._template is None: self._state = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Fire the off action.""" - if self._off_script: - await self.async_run_script(self._off_script, context=self._context) + if (off_script := self._action_scripts.get(CONF_TURN_OFF)) is not None: + await self.async_run_script(off_script, context=self._context) if self._template is None: self._state = False self.async_write_ha_state() diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 8f9edca5976..93ba1fa7471 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -24,7 +24,6 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, - Context, Event, EventStateChangedData, HomeAssistant, @@ -41,7 +40,7 @@ from homeassistant.helpers.event import ( TrackTemplateResultInfo, async_track_template_result, ) -from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.start import async_at_start from homeassistant.helpers.template import ( Template, @@ -61,6 +60,7 @@ from .const import ( CONF_AVAILABILITY_TEMPLATE, CONF_PICTURE, ) +from .entity import AbstractTemplateEntity _LOGGER = logging.getLogger(__name__) @@ -248,7 +248,7 @@ class _TemplateAttribute: return -class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module +class TemplateEntity(AbstractTemplateEntity): # pylint: disable=hass-enforce-class-module """Entity that uses templates to calculate attributes.""" _attr_available = True @@ -268,6 +268,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module unique_id: str | None = None, ) -> None: """Template Entity.""" + super().__init__(hass) self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} @@ -285,6 +286,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module ] | None ) = None + self._run_variables: ScriptVariables | dict if config is None: self._attribute_templates = attribute_templates self._availability_template = availability_template @@ -339,18 +341,6 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module variables=variables, parse_result=False ) - @callback - def _render_variables(self) -> dict: - if isinstance(self._run_variables, dict): - return self._run_variables - - return self._run_variables.async_render( - self.hass, - { - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - }, - ) - @callback def _update_available(self, result: str | TemplateError) -> None: if isinstance(result, TemplateError): @@ -387,6 +377,18 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module return None return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) + def _render_script_variables(self) -> dict[str, Any]: + """Render configured variables.""" + if isinstance(self._run_variables, dict): + return self._run_variables + + return self._run_variables.async_render( + self.hass, + { + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + }, + ) + def add_template_attribute( self, attribute: str, @@ -488,7 +490,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module variables = { "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **self._render_variables(), + **self._render_script_variables(), } for template, attributes in self._template_attrs.items(): @@ -581,22 +583,3 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module """Call for forced update.""" assert self._template_result_info self._template_result_info.async_refresh() - - async def async_run_script( - self, - script: Script, - *, - run_variables: _VarsType | None = None, - context: Context | None = None, - ) -> None: - """Run an action script.""" - if run_variables is None: - run_variables = {} - await script.async_run( - run_variables={ - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **self._render_variables(), - **run_variables, - }, - context=context, - ) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index b977f4e659a..ba7c330dad2 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -33,7 +33,6 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN @@ -90,7 +89,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -async def _async_create_entities(hass, config): +async def _async_create_entities(hass: HomeAssistant, config: ConfigType): """Create the Template Vacuums.""" vacuums = [] @@ -127,11 +126,11 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): def __init__( self, - hass, + hass: HomeAssistant, object_id, - config, + config: ConfigType, unique_id, - ): + ) -> None: """Initialize the vacuum.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -139,7 +138,9 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - friendly_name = self._attr_name + name = self._attr_name + if TYPE_CHECKING: + assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) self._battery_level_template = config.get(CONF_BATTERY_LEVEL_TEMPLATE) @@ -148,43 +149,18 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): VacuumEntityFeature.START | VacuumEntityFeature.STATE ) - self._start_script = Script(hass, config[SERVICE_START], friendly_name, DOMAIN) - - self._pause_script = None - if pause_action := config.get(SERVICE_PAUSE): - self._pause_script = Script(hass, pause_action, friendly_name, DOMAIN) - self._attr_supported_features |= VacuumEntityFeature.PAUSE - - self._stop_script = None - if stop_action := config.get(SERVICE_STOP): - self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN) - self._attr_supported_features |= VacuumEntityFeature.STOP - - self._return_to_base_script = None - if return_to_base_action := config.get(SERVICE_RETURN_TO_BASE): - self._return_to_base_script = Script( - hass, return_to_base_action, friendly_name, DOMAIN - ) - self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - - self._clean_spot_script = None - if clean_spot_action := config.get(SERVICE_CLEAN_SPOT): - self._clean_spot_script = Script( - hass, clean_spot_action, friendly_name, DOMAIN - ) - self._attr_supported_features |= VacuumEntityFeature.CLEAN_SPOT - - self._locate_script = None - if locate_action := config.get(SERVICE_LOCATE): - self._locate_script = Script(hass, locate_action, friendly_name, DOMAIN) - self._attr_supported_features |= VacuumEntityFeature.LOCATE - - self._set_fan_speed_script = None - if set_fan_speed_action := config.get(SERVICE_SET_FAN_SPEED): - self._set_fan_speed_script = Script( - hass, set_fan_speed_action, friendly_name, DOMAIN - ) - self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + for action_id, supported_feature in ( + (SERVICE_START, 0), + (SERVICE_PAUSE, VacuumEntityFeature.PAUSE), + (SERVICE_STOP, VacuumEntityFeature.STOP), + (SERVICE_RETURN_TO_BASE, VacuumEntityFeature.RETURN_HOME), + (SERVICE_CLEAN_SPOT, VacuumEntityFeature.CLEAN_SPOT), + (SERVICE_LOCATE, VacuumEntityFeature.LOCATE), + (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED), + ): + if action_config := config.get(action_id): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature self._state = None self._battery_level = None @@ -203,62 +179,50 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): async def async_start(self) -> None: """Start or resume the cleaning task.""" - await self.async_run_script(self._start_script, context=self._context) + await self.async_run_script( + self._action_scripts[SERVICE_START], context=self._context + ) async def async_pause(self) -> None: """Pause the cleaning task.""" - if self._pause_script is None: - return - - await self.async_run_script(self._pause_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_PAUSE)) is not None: + await self.async_run_script(script, context=self._context) async def async_stop(self, **kwargs: Any) -> None: """Stop the cleaning task.""" - if self._stop_script is None: - return - - await self.async_run_script(self._stop_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_STOP)) is not None: + await self.async_run_script(script, context=self._context) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" - if self._return_to_base_script is None: - return - - await self.async_run_script(self._return_to_base_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_RETURN_TO_BASE)) is not None: + await self.async_run_script(script, context=self._context) async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" - if self._clean_spot_script is None: - return - - await self.async_run_script(self._clean_spot_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_CLEAN_SPOT)) is not None: + await self.async_run_script(script, context=self._context) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" - if self._locate_script is None: - return - - await self.async_run_script(self._locate_script, context=self._context) + if (script := self._action_scripts.get(SERVICE_LOCATE)) is not None: + await self.async_run_script(script, context=self._context) async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - if self._set_fan_speed_script is None: - return - - if fan_speed in self._attr_fan_speed_list: - self._attr_fan_speed = fan_speed - await self.async_run_script( - self._set_fan_speed_script, - run_variables={ATTR_FAN_SPEED: fan_speed}, - context=self._context, - ) - else: + if fan_speed not in self._attr_fan_speed_list: _LOGGER.error( "Received invalid fan speed: %s for entity %s. Expected: %s", fan_speed, self.entity_id, self._attr_fan_speed_list, ) + return + + if (script := self._action_scripts.get(SERVICE_SET_FAN_SPEED)) is not None: + await self.async_run_script( + script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context + ) @callback def _async_setup_templates(self) -> None: diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py new file mode 100644 index 00000000000..67a85839982 --- /dev/null +++ b/tests/components/template/test_entity.py @@ -0,0 +1,17 @@ +"""Test abstract template entity.""" + +import pytest + +from homeassistant.components.template import entity as abstract_entity +from homeassistant.core import HomeAssistant + + +async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: + """Test abstract template entity raises not implemented error.""" + + entity = abstract_entity.AbstractTemplateEntity(None) + with pytest.raises(NotImplementedError): + _ = entity.referenced_blueprint + + with pytest.raises(NotImplementedError): + entity._render_script_variables() diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index c09a09750fe..d66fc2710c9 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(hass) + entity = template_entity.TemplateEntity(None) with pytest.raises(ValueError, match="^hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello")) From cd0a983850519dc2dee30dd5f4290b71b36ead6a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Mar 2025 00:28:10 -0800 Subject: [PATCH 1275/1941] 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 ca4feec308e..ab96cadb5ab 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 bd1478f945c..438e296b8d7 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 c6a9472fdb14a42987913b3a68cc393c289412bc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 4 Mar 2025 00:46:56 -0800 Subject: [PATCH 1276/1941] 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 d87c963db54ea90925496d502886cce49a96715a 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 1277/1941] 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 9f780a5308472e4c5371ca22cef761543d57a3b3 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 4 Mar 2025 09:56:42 +0100 Subject: [PATCH 1278/1941] 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 13001faf514b32bdbf9cf38e8b7b83049018d866 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 4 Mar 2025 09:57:38 +0100 Subject: [PATCH 1279/1941] Improve strings in `openai_conversation.generate_image` action (#139736) Use descriptive wording, fix sentence-casing. --- homeassistant/components/openai_conversation/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index b8768f8abbe..aba4fdc3d40 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -48,10 +48,10 @@ "services": { "generate_image": { "name": "Generate image", - "description": "Turn a prompt into an image", + "description": "Turns a prompt into an image", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "The config entry to use for this action" }, "prompt": { From 973fee9fe15f5e9b5b9e67912fe59758e9478f60 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 1280/1941] 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 4f36bbdfe6cc22d046c959347c6a3a0ccaafb4e4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 11:33:27 +0100 Subject: [PATCH 1281/1941] Fix regression in template flag introduced by #139645 (#139742) --- homeassistant/components/template/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 3369bf3ce0f..c7188f380bc 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -1023,7 +1023,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 From 32f59bfd256527e0af573fec015bbe365a8a6709 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 11:39:35 +0100 Subject: [PATCH 1282/1941] Remove unused constant from recorder (#139741) --- homeassistant/components/recorder/core.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index eaf72b74cdc..62afa0e7b04 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -123,8 +123,6 @@ from .util import ( _LOGGER = logging.getLogger(__name__) -DEFAULT_URL = "sqlite:///{hass_config_path}" - # Controls how often we clean up # States and Events objects EXPIRE_AFTER_COMMITS = 120 From 23dac3933f881df404e6a30522a2671af4f313fb Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 4 Mar 2025 11:40:36 +0100 Subject: [PATCH 1283/1941] 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 50cec420ef736f43cb056589fff0cc160f169eff Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 4 Mar 2025 11:43:41 +0100 Subject: [PATCH 1284/1941] Upload test results to codecov (#138512) * Upload test results to codecov * Upload tests results in single job --- .github/workflows/ci.yaml | 53 +++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + 2 files changed, 54 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 829888f3fe2..f0b117ab54a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -962,6 +962,7 @@ jobs: if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then cov_params+=(--cov="homeassistant") cov_params+=(--cov-report=xml) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi echo "Test group ${{ matrix.group }}: $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt)" @@ -992,6 +993,12 @@ jobs: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' + uses: actions/upload-artifact@v4.6.0 + with: + name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} + path: junit.xml - name: Remove pytest_buckets run: rm pytest_buckets.txt - name: Check dirty @@ -1088,6 +1095,7 @@ jobs: cov_params+=(--cov="homeassistant.components.recorder") cov_params+=(--cov-report=xml) cov_params+=(--cov-report=term-missing) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi python3 -b -X dev -m pytest \ @@ -1122,6 +1130,13 @@ jobs: steps.pytest-partial.outputs.mariadb }} path: coverage.xml overwrite: true + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' + uses: actions/upload-artifact@v4.6.0 + with: + name: test-results-mariadb-${{ matrix.python-version }}-${{ + steps.pytest-partial.outputs.mariadb }} + path: junit.xml - name: Check dirty run: | ./script/check_dirty @@ -1218,6 +1233,7 @@ jobs: cov_params+=(--cov="homeassistant.components.recorder") cov_params+=(--cov-report=xml) cov_params+=(--cov-report=term-missing) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi python3 -b -X dev -m pytest \ @@ -1253,6 +1269,13 @@ jobs: steps.pytest-partial.outputs.postgresql }} path: coverage.xml overwrite: true + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' + uses: actions/upload-artifact@v4.6.0 + with: + name: test-results-postgres-${{ matrix.python-version }}-${{ + steps.pytest-partial.outputs.postgresql }} + path: junit.xml - name: Check dirty run: | ./script/check_dirty @@ -1365,6 +1388,7 @@ jobs: cov_params+=(--cov="homeassistant.components.${{ matrix.group }}") cov_params+=(--cov-report=xml) cov_params+=(--cov-report=term-missing) + cov_params+=(--junitxml=junit.xml -o junit_family=legacy) fi python3 -b -X dev -m pytest \ @@ -1394,6 +1418,12 @@ jobs: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true + - name: Upload test results artifact + if: needs.info.outputs.skip_coverage != 'true' + uses: actions/upload-artifact@v4.6.0 + with: + name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} + path: junit.xml - name: Check dirty run: | ./script/check_dirty @@ -1419,3 +1449,26 @@ jobs: with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} + + upload-test-results: + name: Upload test results to Codecov + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() + runs-on: ubuntu-24.04 + needs: + - info + - pytest-partial + - pytest-full + - pytest-postgres + - pytest-mariadb + timeout-minutes: 10 + steps: + - name: Download all coverage artifacts + uses: actions/download-artifact@v4.1.8 + with: + pattern: test-results-* + - name: Upload test results to Codecov + uses: codecov/test-results-action@v1 + with: + fail_ci_if_error: true + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 241255253c5..5aa51c9d762 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ test-reports/ test-results.xml test-output.xml pytest-*.txt +junit.xml # Translations *.mo 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 1285/1941] 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 1286/1941] 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 1287/1941] 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 1288/1941] 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 1289/1941] 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 1290/1941] 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 1291/1941] 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 1292/1941] 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 1293/1941] 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 1294/1941] 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 1295/1941] 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 1296/1941] 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 1297/1941] 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 1298/1941] 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 1299/1941] 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 1300/1941] 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 1301/1941] 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 1302/1941] 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 1303/1941] 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 d38e046494cb4a0272fee5102dea2de844a5ffc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 00:49:44 -1000 Subject: [PATCH 1304/1941] Bump bleak-esphome to 2.10.2 (#139731) * Bump bleak-esphome to 2.10.0 changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.9.0...v2.10.0 * again for wheel fix * disable name check since its a binary now --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/bluetooth/test_client.py | 4 +++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 68781282d66..f106868679b 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.9.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.10.2"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 26c4b21d565..d9ac746924f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.3.2", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.9.0" + "bleak-esphome==2.10.2" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ab96cadb5ab..9f90db99999 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.9.0 +bleak-esphome==2.10.2 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 438e296b8d7..3913acfcedb 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.9.0 +bleak-esphome==2.10.2 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 77d315f096d..554f1725f4b 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -6,7 +6,9 @@ from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceIn from bleak.exc import BleakError from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData from bleak_esphome.backend.device import ESPHomeBluetoothDevice -from bleak_esphome.backend.scanner import ESPHomeScanner +from bleak_esphome.backend.scanner import ( # pylint: disable=no-name-in-module + ESPHomeScanner, +) import pytest from homeassistant.components.bluetooth import HaBluetoothConnector From d5ba55d2fcdd9a0f332416c3e3666f1c8f59bda2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 4 Mar 2025 13:27:51 +0100 Subject: [PATCH 1305/1941] Disable test results upload on forks (#139749) Disable test result uploads on forks --- .github/workflows/ci.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f0b117ab54a..8a999d21b2e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1452,7 +1452,9 @@ jobs: upload-test-results: name: Upload test results to Codecov - if: needs.info.outputs.skip_coverage != 'true' && !cancelled() + # codecov/test-results-action currently doesn't support tokenless uploads + # therefore we can't run it on forks + if: github.repository_owner == 'home-assistant' && needs.info.outputs.skip_coverage != 'true' && !cancelled() runs-on: ubuntu-24.04 needs: - info From 8a97c2bfca1c7787ca19b67c8d77a3b9a12647d3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Mar 2025 08:02:58 -0500 Subject: [PATCH 1306/1941] VoIP block non-TTS announcements (#139658) * VoIP block non-TTS announcements * Migrate VoIP to use pipeline token --- homeassistant/components/voip/assist_satellite.py | 7 +++++++ homeassistant/components/voip/strings.json | 5 +++++ tests/components/voip/test_voip.py | 14 ++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index a0aeaaf38d3..6d18d8254f2 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -28,6 +28,7 @@ from homeassistant.components.assist_satellite import ( from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -193,6 +194,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol Optionally run a voice pipeline after the announcement has finished. """ + if announcement.media_id_source != "tts": + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="non_tts_announcement", + ) + self._announcement_future = asyncio.Future() self._run_pipeline_after_announce = run_pipeline_after diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 96c902bf39a..4f37ad1d6f7 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -58,5 +58,10 @@ } } } + }, + "exceptions": { + "non_tts_announcement": { + "message": "VoIP does not currently support non-TTS announcements" + } } } diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index 3e3e5337417..d971591c79a 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -22,6 +22,7 @@ from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import EntityComponent from homeassistant.setup import async_setup_component @@ -862,6 +863,19 @@ async def test_announce( & assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + "assist_satellite", + "announce", + service_data={"media_id": "http://example.com"}, + blocking=True, + target={ + "entity_id": satellite.entity_id, + }, + ) + assert err.value.translation_domain == "voip" + assert err.value.translation_key == "non_tts_announcement" + announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, From e69b4f389f49e11d5d4e4e99b74f3f409802508e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:07:27 +0100 Subject: [PATCH 1307/1941] Simplify lint-only job config [ci] (#139748) --- .github/workflows/ci.yaml | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8a999d21b2e..d0a214814ee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -89,6 +89,7 @@ jobs: test_groups: ${{ steps.info.outputs.test_groups }} tests_glob: ${{ steps.info.outputs.tests_glob }} tests: ${{ steps.info.outputs.tests }} + lint_only: ${{ steps.info.outputs.lint_only }} skip_coverage: ${{ steps.info.outputs.skip_coverage }} runs-on: ubuntu-24.04 steps: @@ -142,6 +143,7 @@ jobs: test_group_count=10 tests="[]" tests_glob="" + lint_only="" skip_coverage="" if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]]; @@ -192,6 +194,15 @@ jobs: test_full_suite="true" fi + if [[ "${{ github.event.inputs.lint-only }}" == "true" ]] \ + || [[ "${{ github.event.inputs.pylint-only }}" == "true" ]] \ + || [[ "${{ github.event.inputs.mypy-only }}" == "true" ]] \ + || [[ "${{ github.event.inputs.audit-licenses-only }}" == "true" ]]; + then + lint_only="true" + skip_coverage="true" + fi + if [[ "${{ github.event.inputs.skip-coverage }}" == "true" ]] \ || [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-skip-coverage') }}" == "true" ]]; then @@ -217,6 +228,8 @@ jobs: echo "tests=${tests}" >> $GITHUB_OUTPUT echo "tests_glob: ${tests_glob}" echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT + echo "lint_only": ${lint_only} + echo "lint_only=${lint_only}" >> $GITHUB_OUTPUT echo "skip_coverage: ${skip_coverage}" echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT @@ -830,10 +843,7 @@ jobs: runs-on: ubuntu-24.04 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -887,10 +897,7 @@ jobs: runs-on: ubuntu-24.04 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -1017,10 +1024,7 @@ jobs: options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.mariadb_groups != '[]' needs: - info @@ -1153,10 +1157,7 @@ jobs: options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.postgresql_groups != '[]' needs: - info @@ -1309,10 +1310,7 @@ jobs: runs-on: ubuntu-24.04 if: | (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && github.event.inputs.lint-only != 'true' - && github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - && github.event.inputs.audit-licenses-only != 'true' + && needs.info.outputs.lint_only != 'true' && needs.info.outputs.tests_glob && needs.info.outputs.test_full_suite == 'false' needs: From d9690507a48241b84f077dffa4bcf412ec860770 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Mar 2025 15:08:14 +0100 Subject: [PATCH 1308/1941] 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 a92311d31d0..916087075cc 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 74ea553b636e9968828f7272cdde8f37c355f66f 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 1309/1941] 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 9f90db99999..a56afd43961 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 3913acfcedb..40311657000 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 7fb949dff7a29ec80a465e38e8a2ec90a914d8db Mon Sep 17 00:00:00 2001 From: Anthony Hou Date: Tue, 4 Mar 2025 22:25:47 +0800 Subject: [PATCH 1310/1941] 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 c51a2317e1f05ed9ff935a009651381987bc9a73 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Mar 2025 09:48:10 -0500 Subject: [PATCH 1311/1941] Add timer support to VoIP (#139763) --- .../components/voip/assist_satellite.py | 34 +++++++++- homeassistant/components/voip/manifest.json | 2 +- .../components/voip/test_assist_satellite.py | 62 +++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 tests/components/voip/test_assist_satellite.py diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 6d18d8254f2..2c0a3b9641a 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import timedelta from enum import IntFlag from functools import partial import io @@ -16,7 +17,7 @@ import wave from voip_utils import SIP_PORT, RtpDatagramProtocol from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint -from homeassistant.components import tts +from homeassistant.components import intent, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteAnnouncement, @@ -25,6 +26,7 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteEntityDescription, AssistSatelliteEntityFeature, ) +from homeassistant.components.intent import TimerEventType, TimerInfo from homeassistant.components.network import async_get_source_ip from homeassistant.config_entries import ConfigEntry from homeassistant.core import Context, HomeAssistant, callback @@ -161,6 +163,13 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol await super().async_added_to_hass() self.voip_device.protocol = self + assert self.device_entry is not None + self.async_on_remove( + intent.async_register_timer_handler( + self.hass, self.device_entry.id, self.async_handle_timer_event + ) + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -174,6 +183,29 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Get the current satellite configuration.""" raise NotImplementedError + @callback + def async_handle_timer_event( + self, + event_type: TimerEventType, + timer_info: TimerInfo, + ) -> None: + """Handle timer event.""" + if event_type != TimerEventType.FINISHED: + return + + if timer_info.name: + message = f"{timer_info.name} finished" + else: + message = f"{timedelta(seconds=timer_info.created_seconds)} timer finished" + + async def announce_message(): + announcement = await self._resolve_announcement_media_id(message, None) + await self.async_announce(announcement) + + self.config_entry.async_create_background_task( + self.hass, announce_message(), "voip_announce_timer" + ) + async def async_set_configuration( self, config: AssistSatelliteConfiguration ) -> None: diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index e3b2861dbe5..1e4c249c720 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -3,7 +3,7 @@ "name": "Voice over IP", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, - "dependencies": ["assist_pipeline", "assist_satellite", "network"], + "dependencies": ["assist_pipeline", "assist_satellite", "intent", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", diff --git a/tests/components/voip/test_assist_satellite.py b/tests/components/voip/test_assist_satellite.py new file mode 100644 index 00000000000..f3e2611631e --- /dev/null +++ b/tests/components/voip/test_assist_satellite.py @@ -0,0 +1,62 @@ +"""Test the Assist Satellite platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.voip.devices import VoIPDevice +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent as intent_helper + + +@pytest.mark.parametrize( + ("intent_args", "message"), + [ + ( + {}, + "0:02:00 timer finished", + ), + ( + {"name": {"value": "pizza"}}, + "pizza finished", + ), + ], +) +async def test_timer_events( + hass: HomeAssistant, voip_device: VoIPDevice, intent_args: dict, message: str +) -> None: + """Test for timer events.""" + + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "minutes": {"value": 2}, + } + | intent_args, + device_id=voip_device.device_id, + ) + + with ( + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._resolve_announcement_media_id", + ) as mock_resolve, + patch( + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite.async_announce", + ) as mock_announce, + ): + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_DECREASE_TIMER, + { + "minutes": {"value": 2}, + }, + device_id=voip_device.device_id, + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(mock_resolve.mock_calls) == 1 + assert len(mock_announce.mock_calls) == 1 + assert mock_resolve.mock_calls[0][1][0] == message From e55757dc820849ce8548e6ca7738899615ceaa06 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 15:56:12 +0100 Subject: [PATCH 1312/1941] Simplify error handling in BackupAgent when a backup is not found (#139754) Simplify error handling in BackupAgent when backup is not found --- homeassistant/components/backup/agent.py | 9 ++++++- homeassistant/components/backup/backup.py | 11 +++----- homeassistant/components/backup/http.py | 11 +++++--- homeassistant/components/backup/manager.py | 29 +++++++++++++++++++--- tests/components/backup/common.py | 15 +++++++---- 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 9530f386c7b..0a2531900ae 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -41,6 +41,8 @@ class BackupAgent(abc.ABC): ) -> AsyncIterator[bytes]: """Download a backup file. + Raises BackupNotFound if the backup does not exist. + :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ @@ -67,6 +69,8 @@ class BackupAgent(abc.ABC): ) -> None: """Delete a backup file. + Raises BackupNotFound if the backup does not exist. + :param backup_id: The ID of the backup that was returned in async_list_backups. """ @@ -80,7 +84,10 @@ class BackupAgent(abc.ABC): backup_id: str, **kwargs: Any, ) -> AgentBackup | None: - """Return a backup.""" + """Return a backup. + + Raises BackupNotFound if the backup does not exist. + """ class LocalBackupAgent(BackupAgent): diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index c3a46a6ab1f..de2cfecb1a5 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -88,13 +88,13 @@ class CoreLocalBackupAgent(LocalBackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" if not self._loaded_backups: await self._load_backups() if backup_id not in self._backups: - return None + raise BackupNotFound(f"Backup {backup_id} not found") backup, backup_path = self._backups[backup_id] if not await self._hass.async_add_executor_job(backup_path.exists): @@ -107,7 +107,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): backup_path, ) self._backups.pop(backup_id) - return None + raise BackupNotFound(f"Backup {backup_id} not found") return backup @@ -130,10 +130,7 @@ class CoreLocalBackupAgent(LocalBackupAgent): if not self._loaded_backups: await self._load_backups() - try: - backup_path = self.get_backup_path(backup_id) - except BackupNotFound: - return + backup_path = self.get_backup_path(backup_id) 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/http.py b/homeassistant/components/backup/http.py index 58f44d4a449..20ad613933b 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -59,10 +59,13 @@ class DownloadBackupView(HomeAssistantView): if agent_id not in manager.backup_agents: return Response(status=HTTPStatus.BAD_REQUEST) agent = manager.backup_agents[agent_id] - backup = await agent.async_get_backup(backup_id) + try: + backup = await agent.async_get_backup(backup_id) + except BackupNotFound: + return Response(status=HTTPStatus.NOT_FOUND) - # We don't need to check if the path exists, aiohttp.FileResponse will handle - # that + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 if backup is None: return Response(status=HTTPStatus.NOT_FOUND) @@ -92,6 +95,8 @@ class DownloadBackupView(HomeAssistantView): ) -> StreamResponse | FileResponse | Response: if agent_id in manager.local_backup_agents: local_agent = manager.local_backup_agents[agent_id] + # We don't need to check if the path exists, aiohttp.FileResponse will + # handle that path = local_agent.get_backup_path(backup_id) return FileResponse(path=path.as_posix(), headers=headers) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index c8b515e3aee..4f3ea8b296c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -64,6 +64,7 @@ from .models import ( AgentBackup, BackupError, BackupManagerError, + BackupNotFound, BackupReaderWriterError, BaseBackup, Folder, @@ -648,6 +649,8 @@ class BackupManager: ) for idx, result in enumerate(get_backup_results): agent_id = agent_ids[idx] + if isinstance(result, BackupNotFound): + continue if isinstance(result, BackupAgentError): agent_errors[agent_id] = result continue @@ -659,6 +662,8 @@ class BackupManager: continue if isinstance(result, BaseException): raise result # unexpected error + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 if not result: continue if backup is None: @@ -723,6 +728,8 @@ class BackupManager: ) for idx, result in enumerate(delete_backup_results): agent_id = agent_ids[idx] + if isinstance(result, BackupNotFound): + continue if isinstance(result, BackupAgentError): agent_errors[agent_id] = result continue @@ -832,7 +839,7 @@ class BackupManager: agent_errors = { backup_id: error for backup_id, error in zip(backup_ids, delete_results, strict=True) - if error + if error and not isinstance(error, BackupNotFound) } if agent_errors: LOGGER.error( @@ -1264,7 +1271,15 @@ class BackupManager: ) -> None: """Initiate restoring a backup.""" agent = self.backup_agents[agent_id] - if not await agent.async_get_backup(backup_id): + try: + backup = await agent.async_get_backup(backup_id) + except BackupNotFound as err: + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) from err + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 + if not backup: raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) @@ -1352,7 +1367,15 @@ class BackupManager: agent = self.backup_agents[agent_id] except KeyError as err: raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err - if not await agent.async_get_backup(backup_id): + try: + backup = await agent.async_get_backup(backup_id) + except BackupNotFound as err: + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) from err + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 + if not backup: raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index e41da5c1bad..e6e4b2f8a50 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -67,15 +67,20 @@ async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock: """Create a mock backup agent.""" + async def delete_backup(backup_id: str, **kwargs: Any) -> None: + """Mock delete.""" + get_backup(backup_id) + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: """Mock download.""" - if not await get_backup(backup_id): - raise BackupNotFound return aiter_from_iter((backups_data.get(backup_id, b"backup data"),)) - async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None: + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: """Get a backup.""" - return next((b for b in backups if b.backup_id == backup_id), None) + backup = next((b for b in backups if b.backup_id == backup_id), None) + if backup is None: + raise BackupNotFound + return backup async def upload_backup( *, @@ -99,7 +104,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo mock_agent.unique_id = name type(mock_agent).agent_id = BackupAgent.agent_id mock_agent.async_delete_backup = AsyncMock( - spec_set=[BackupAgent.async_delete_backup] + side_effect=delete_backup, spec_set=[BackupAgent.async_delete_backup] ) mock_agent.async_download_backup = AsyncMock( side_effect=download_backup, spec_set=[BackupAgent.async_download_backup] From 0eb087ba3f5d99f0181896bf23107d0350102234 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Mar 2025 15:59:38 +0100 Subject: [PATCH 1313/1941] Bump pysmartthings to 2.5.0 (#139758) * Bump pysmartthings to 2.5.0 * Bump pysmartthings to 2.5.0 --- homeassistant/components/smartthings/__init__.py | 8 +++++--- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index b7850bc9333..969df42bed9 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -112,9 +112,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, dev.device.device_id)}, - connections={ - (dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address) - }, + connections=( + {(dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address)} + if dev.device.hub.mac_address + else set() + ), name=dev.device.label, sw_version=dev.device.hub.firmware_version, model=dev.device.hub.hardware_type, 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 a56afd43961..e6e91eabbe3 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 40311657000..3f0115f78f4 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 ec100e5a6cefc4a0fedf8a1fae7df311fc137a0c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 16:10:33 +0100 Subject: [PATCH 1314/1941] Align azure_storage with changes in BackupAgent (#139765) --- homeassistant/components/azure_storage/backup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py index 6f39295761d..4d897126d3d 100644 --- a/homeassistant/components/azure_storage/backup.py +++ b/homeassistant/components/azure_storage/backup.py @@ -141,7 +141,7 @@ class AzureStorageBackupAgent(BackupAgent): """Delete a backup file.""" blob = await self._find_blob_by_backup_id(backup_id) if blob is None: - return + raise BackupNotFound(f"Backup {backup_id} not found") await self._client.delete_blob(blob.name) @handle_backup_errors @@ -163,11 +163,11 @@ class AzureStorageBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" blob = await self._find_blob_by_backup_id(backup_id) if blob is None: - return None + raise BackupNotFound(f"Backup {backup_id} not found") return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"])) From e3a90831bf150c5d27646df7fd7be593273b5aa4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 16:32:47 +0100 Subject: [PATCH 1315/1941] Align onedrive with changes in BackupAgent (#139769) --- homeassistant/components/onedrive/backup.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index 9c7371bee4b..41a244506ea 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -138,7 +138,7 @@ class OneDriveBackupAgent(BackupAgent): """Download a backup file.""" backups = await self._list_cached_backups() if backup_id not in backups: - raise BackupNotFound("Backup not found") + raise BackupNotFound(f"Backup {backup_id} not found") stream = await self._client.download_drive_item( backups[backup_id].backup_file_id, timeout=TIMEOUT @@ -201,7 +201,7 @@ class OneDriveBackupAgent(BackupAgent): """Delete a backup file.""" backups = await self._list_cached_backups() if backup_id not in backups: - return + raise BackupNotFound(f"Backup {backup_id} not found") backup = backups[backup_id] @@ -221,12 +221,12 @@ class OneDriveBackupAgent(BackupAgent): ] @handle_backup_errors - async def async_get_backup( - self, backup_id: str, **kwargs: Any - ) -> AgentBackup | None: + async def async_get_backup(self, backup_id: str, **kwargs: Any) -> AgentBackup: """Return a backup.""" backups = await self._list_cached_backups() - return backups[backup_id].backup if backup_id in backups else None + if backup_id not in backups: + raise BackupNotFound(f"Backup {backup_id} not found") + return backups[backup_id].backup async def _list_cached_backups(self) -> dict[str, OneDriveBackup]: """List backups with a cache.""" From 0ebdb1c2a8d06011aa8e559ae78f599c07a17968 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 16:38:03 +0100 Subject: [PATCH 1316/1941] Align kitchen_sink with changes in BackupAgent (#139768) --- homeassistant/components/kitchen_sink/backup.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py index 44ac0456105..46b204845ad 100644 --- a/homeassistant/components/kitchen_sink/backup.py +++ b/homeassistant/components/kitchen_sink/backup.py @@ -7,7 +7,13 @@ from collections.abc import AsyncIterator, Callable, Coroutine import logging from typing import Any -from homeassistant.components.backup import AddonInfo, AgentBackup, BackupAgent, Folder +from homeassistant.components.backup import ( + AddonInfo, + AgentBackup, + BackupAgent, + BackupNotFound, + Folder, +) from homeassistant.core import HomeAssistant, callback from . import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @@ -110,9 +116,9 @@ class KitchenSinkBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" for backup in self._uploads: if backup.backup_id == backup_id: return backup - return None + raise BackupNotFound(f"Backup {backup_id} not found") From 46ac44c248c39bad401ef415a3f5c2a1be4720fe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 17:44:26 +0100 Subject: [PATCH 1317/1941] Align webdav with changes in BackupAgent (#139771) --- homeassistant/components/webdav/backup.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index f810547022b..a5cf2c56182 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -144,8 +144,6 @@ class WebDavBackupAgent(BackupAgent): :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)}", @@ -215,8 +213,6 @@ class WebDavBackupAgent(BackupAgent): :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}" @@ -243,7 +239,7 @@ class WebDavBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" return await self._find_backup_by_id(backup_id) @@ -269,13 +265,13 @@ class WebDavBackupAgent(BackupAgent): if (backup_id := _backup_id_from_properties(properties)) } - async def _find_backup_by_id(self, backup_id: str) -> AgentBackup | None: + async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: """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 + raise BackupNotFound(f"Backup {backup_id} not found") async def _download_metadata(self, path: str) -> AgentBackup: """Download metadata file.""" From 95fbba1d746d91099e7d3249a0d7c93a93abca48 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 17:46:13 +0100 Subject: [PATCH 1318/1941] Align cloud with changes in BackupAgent (#139766) --- homeassistant/components/cloud/backup.py | 27 +++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index b31fe16fbe9..b83c4725663 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -18,7 +18,12 @@ from hass_nabucasa.cloud_api import ( ) from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5 -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -90,9 +95,7 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: An async iterator that yields bytes. """ - if not (backup := await self._async_get_backup(backup_id)): - raise BackupAgentError("Backup not found") - + backup = await self._async_get_backup(backup_id) try: content = await self._cloud.files.download( storage_type=StorageType.BACKUP, @@ -171,9 +174,7 @@ class CloudBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - if not (backup := await self._async_get_backup(backup_id)): - return - + backup = await self._async_get_backup(backup_id) try: await async_files_delete_file( self._cloud, @@ -204,16 +205,12 @@ class CloudBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" - if not (backup := await self._async_get_backup(backup_id)): - return None + backup = await self._async_get_backup(backup_id) return AgentBackup.from_dict(backup["Metadata"]) - async def _async_get_backup( - self, - backup_id: str, - ) -> FilesHandlerListEntry | None: + async def _async_get_backup(self, backup_id: str) -> FilesHandlerListEntry: """Return a backup.""" backups = await self._async_list_backups() @@ -221,4 +218,4 @@ class CloudBackupAgent(BackupAgent): if backup["Metadata"]["backup_id"] == backup_id: return backup - return None + raise BackupNotFound(f"Backup {backup_id} not found") From e1127fc78c3c9b91be65e2be64ad28605eb6a7d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 07:01:40 -1000 Subject: [PATCH 1319/1941] 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 e6e91eabbe3..1664f8c5299 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 3f0115f78f4..299dfbb107e 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 e86fc88631fdb531ccc15ca2c25cebfcce127178 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 18:20:55 +0100 Subject: [PATCH 1320/1941] Minor improvement of hassio backup tests (#139775) --- tests/components/hassio/test_backup.py | 264 ++++++++++++++++--------- 1 file changed, 168 insertions(+), 96 deletions(-) diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 6e4fe4dd428..07a68b158d3 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -6,6 +6,7 @@ from collections.abc import ( Callable, Coroutine, Generator, + Iterable, ) from dataclasses import replace from datetime import datetime @@ -38,6 +39,7 @@ from homeassistant.components.backup import ( AgentBackup, BackupAgent, BackupAgentPlatformProtocol, + BackupNotFound, Folder, store as backup_store, ) @@ -326,43 +328,70 @@ async def setup_backup_integration( await hass.async_block_till_done() -class BackupAgentTest(BackupAgent): - """Test backup agent.""" +async def aiter_from_iter(iterable: Iterable) -> AsyncIterator: + """Convert an iterable to an async iterator.""" + for i in iterable: + yield i - def __init__(self, name: str, domain: str = "test") -> None: - """Initialize the backup agent.""" - self.domain = domain - self.name = name - self.unique_id = name - async def async_download_backup( - self, backup_id: str, **kwargs: Any - ) -> AsyncIterator[bytes]: - """Download a backup file.""" - return AsyncMock(spec_set=["__aiter__"]) +def mock_backup_agent( + name: str, domain: str = "test", backups: list[AgentBackup] | None = None +) -> Mock: + """Create a mock backup agent.""" - async def async_upload_backup( - self, + async def delete_backup(backup_id: str, **kwargs: Any) -> None: + """Mock delete.""" + get_backup(backup_id) + + async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]: + """Mock download.""" + return aiter_from_iter((backups_data.get(backup_id, b"backup data"),)) + + async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup: + """Get a backup.""" + backup = next((b for b in backups if b.backup_id == backup_id), None) + if backup is None: + raise BackupNotFound + return backup + + async def upload_backup( *, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], backup: AgentBackup, **kwargs: Any, ) -> None: """Upload a backup.""" - await open_stream() + backups.append(backup) + backup_stream = await open_stream() + backup_data = bytearray() + async for chunk in backup_stream: + backup_data += chunk + backups_data[backup.backup_id] = backup_data - async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: - """List backups.""" - return [] - - async def async_get_backup( - self, backup_id: str, **kwargs: Any - ) -> AgentBackup | None: - """Return a backup.""" - return None - - async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: - """Delete a backup file.""" + backups = backups or [] + backups_data: dict[str, bytes] = {} + mock_agent = Mock(spec=BackupAgent) + mock_agent.domain = domain + mock_agent.name = name + mock_agent.unique_id = name + type(mock_agent).agent_id = BackupAgent.agent_id + mock_agent.async_delete_backup = AsyncMock( + side_effect=delete_backup, spec_set=[BackupAgent.async_delete_backup] + ) + mock_agent.async_download_backup = AsyncMock( + side_effect=download_backup, spec_set=[BackupAgent.async_download_backup] + ) + mock_agent.async_get_backup = AsyncMock( + side_effect=get_backup, spec_set=[BackupAgent.async_get_backup] + ) + mock_agent.async_list_backups = AsyncMock( + return_value=backups, spec_set=[BackupAgent.async_list_backups] + ) + mock_agent.async_upload_backup = AsyncMock( + side_effect=upload_backup, + spec_set=[BackupAgent.async_upload_backup], + ) + return mock_agent async def _setup_backup_platform( @@ -383,7 +412,7 @@ async def _setup_backup_platform( [ ( MountsInfo(default_backup_mount=None, mounts=[]), - [BackupAgentTest("local", DOMAIN)], + [mock_backup_agent("local", DOMAIN)], ), ( MountsInfo( @@ -401,7 +430,7 @@ async def _setup_backup_platform( ) ], ), - [BackupAgentTest("local", DOMAIN), BackupAgentTest("test", DOMAIN)], + [mock_backup_agent("local", DOMAIN), mock_backup_agent("test", DOMAIN)], ), ( MountsInfo( @@ -419,7 +448,7 @@ async def _setup_backup_platform( ) ], ), - [BackupAgentTest("local", DOMAIN)], + [mock_backup_agent("local", DOMAIN)], ), ], ) @@ -576,40 +605,13 @@ async def test_agent_upload( ) -> None: """Test agent upload backup.""" client = await hass_client() - backup_id = "test-backup" supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS - test_backup = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id=backup_id, - database_included=True, - date="1970-01-01T00:00:00.000Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=False, - size=0, - ) supervisor_client.backups.reload.assert_not_called() - with ( - patch("pathlib.Path.mkdir"), - patch("pathlib.Path.open"), - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("shutil.copy"), - ): - fetch_backup.return_value = test_backup - resp = await client.post( - "/api/backup/upload?agent_id=hassio.local", - data={"file": StringIO("test")}, - ) + resp = await client.post( + "/api/backup/upload?agent_id=hassio.local", + data={"file": StringIO("test")}, + ) assert resp.status == 201 supervisor_client.backups.reload.assert_not_called() @@ -1551,7 +1553,7 @@ async def test_reader_writer_create_download_remove_error( method_mock = getattr(supervisor_client.backups, method) method_mock.side_effect = exception - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1636,7 +1638,7 @@ async def test_reader_writer_create_info_error( supervisor_client.backups.backup_info.side_effect = exception supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1713,7 +1715,7 @@ async def test_reader_writer_create_remote_backup( 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") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1861,24 +1863,10 @@ async def test_agent_receive_remote_backup( ) -> None: """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_5 supervisor_client.backups.upload_backup.return_value = "test_slug" - test_backup = AgentBackup( - addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], - backup_id=backup_id, - database_included=True, - date="1970-01-01T00:00:00.000Z", - extra_metadata={}, - folders=[Folder.MEDIA, Folder.SHARE], - homeassistant_included=True, - homeassistant_version="2024.12.0", - name="Test", - protected=False, - size=0.0, - ) - remote_agent = BackupAgentTest("remote") + remote_agent = mock_backup_agent("remote") await _setup_backup_platform( hass, domain="test", @@ -1889,23 +1877,10 @@ async def test_agent_receive_remote_backup( ) supervisor_client.backups.reload.assert_not_called() - with ( - patch("pathlib.Path.mkdir"), - patch("pathlib.Path.open"), - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("shutil.copy"), - ): - fetch_backup.return_value = test_backup - resp = await client.post( - "/api/backup/upload?agent_id=test.remote", - data={"file": StringIO("test")}, - ) + resp = await client.post( + "/api/backup/upload?agent_id=test.remote", + data={"file": StringIO("test")}, + ) assert resp.status == 201 @@ -1996,6 +1971,103 @@ async def test_reader_writer_restore( assert response["result"] is None +@pytest.mark.usefixtures("hassio_client", "setup_backup_integration") +async def test_reader_writer_restore_remote_backup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup from a remote agent.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = UUID(TEST_JOB_ID) + supervisor_client.backups.list.return_value = [TEST_BACKUP_5] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5 + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + backup_id = "abc123" + test_backup = AgentBackup( + addons=[AddonInfo(name="Test", slug="test", version="1.0.0")], + backup_id=backup_id, + database_included=True, + date="1970-01-01T00:00:00.000Z", + extra_metadata={}, + folders=[Folder.MEDIA, Folder.SHARE], + homeassistant_included=True, + homeassistant_version="2024.12.0", + name="Test", + protected=False, + size=0.0, + ) + remote_agent = mock_backup_agent("remote", backups=[test_backup]) + await _setup_backup_platform( + hass, + domain="test", + platform=Mock( + async_get_backup_agents=AsyncMock(return_value=[remote_agent]), + spec_set=BackupAgentPlatformProtocol, + ), + ) + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "idle", + } + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "test.remote", "backup_id": backup_id} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + remote_agent.async_download_backup.assert_called_once_with(backup_id) + assert len(remote_agent.async_get_backup.mock_calls) == 2 + for call in remote_agent.async_get_backup.mock_calls: + assert call.args[0] == backup_id + supervisor_client.backups.partial_restore.assert_called_once_with( + backup_id, + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=LOCATION_CLOUD_BACKUP, + password=None, + ), + ) + + await client.send_json_auto_id( + { + "type": "supervisor/event", + "data": {"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}, + } + ) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + @pytest.mark.usefixtures("hassio_client", "setup_backup_integration") async def test_reader_writer_restore_report_progress( hass: HomeAssistant, From c0d882e3059e9e4918504a78100f5318e214b1c6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 4 Mar 2025 19:19:38 +0100 Subject: [PATCH 1321/1941] Upload test result artifacts always (#139776) Upload test results artificats always --- .github/workflows/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0a214814ee..cf7b80540a1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1001,7 +1001,7 @@ jobs: path: coverage.xml overwrite: true - name: Upload test results artifact - if: needs.info.outputs.skip_coverage != 'true' + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.0 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} @@ -1135,7 +1135,7 @@ jobs: path: coverage.xml overwrite: true - name: Upload test results artifact - if: needs.info.outputs.skip_coverage != 'true' + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.0 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ @@ -1271,7 +1271,7 @@ jobs: path: coverage.xml overwrite: true - name: Upload test results artifact - if: needs.info.outputs.skip_coverage != 'true' + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.0 with: name: test-results-postgres-${{ matrix.python-version }}-${{ @@ -1417,7 +1417,7 @@ jobs: path: coverage.xml overwrite: true - name: Upload test results artifact - if: needs.info.outputs.skip_coverage != 'true' + if: needs.info.outputs.skip_coverage != 'true' && !cancelled() uses: actions/upload-artifact@v4.6.0 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} From 344cfedd6ff88152668462758d69f24791d56134 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 19:22:18 +0100 Subject: [PATCH 1322/1941] Align synology_dsm with changes in BackupAgent (#139770) --- homeassistant/components/synology_dsm/backup.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index 670c4c9bef0..c4b44542059 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -120,8 +120,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. :return: A tuple of tar_filename and meta_filename """ - if await self.async_get_backup(backup_id) is None: - raise BackupNotFound + await self.async_get_backup(backup_id) base_name = self.backup_base_names[backup_id] return (f"{base_name}.tar", f"{base_name}_meta.json") @@ -195,13 +194,7 @@ class SynologyDSMBackupAgent(BackupAgent): :param backup_id: The ID of the backup that was returned in async_list_backups. """ - try: - (filename_tar, filename_meta) = await self._async_backup_filenames( - backup_id - ) - except BackupAgentError: - # backup meta data could not be found, so we can't delete the backup - return + (filename_tar, filename_meta) = await self._async_backup_filenames(backup_id) for filename in (filename_tar, filename_meta): try: @@ -269,7 +262,9 @@ class SynologyDSMBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" backups = await self._async_list_backups() - return backups.get(backup_id) + if backup_id not in backups: + raise BackupNotFound(f"Backup {backup_id} not found") + return backups[backup_id] From e8099fd3b2f19106fe8fd53da3c44a7062fdc14e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 4 Mar 2025 19:26:20 +0100 Subject: [PATCH 1323/1941] 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 be3d678f23bdea5db1286d9ce92401ff4d6ab026 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 20:20:49 +0100 Subject: [PATCH 1324/1941] Align hassio with changes in BackupAgent (#139780) --- homeassistant/components/hassio/backup.py | 36 +++++++++++++---------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index fe69b9e08e5..20f1ec82a7a 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping +from contextlib import suppress import logging import os from pathlib import Path, PurePath @@ -173,7 +174,7 @@ class SupervisorBackupAgent(BackupAgent): ), ) except SupervisorNotFoundError as err: - raise BackupNotFound from err + raise BackupNotFound(f"Backup {backup_id} not found") from err async def async_upload_backup( self, @@ -186,13 +187,14 @@ class SupervisorBackupAgent(BackupAgent): 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 + with suppress(BackupNotFound): + 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}, @@ -218,14 +220,14 @@ class SupervisorBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" try: details = await self._client.backups.backup_info(backup_id) - except SupervisorNotFoundError: - return None + except SupervisorNotFoundError as err: + raise BackupNotFound(f"Backup {backup_id} not found") from err if self.location not in details.location_attributes: - return None + raise BackupNotFound(f"Backup {backup_id} not found") return _backup_details_to_agent_backup(details, self.location) async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None: @@ -237,8 +239,8 @@ class SupervisorBackupAgent(BackupAgent): location={self.location} ), ) - except SupervisorNotFoundError: - _LOGGER.debug("Backup %s does not exist", backup_id) + except SupervisorNotFoundError as err: + raise BackupNotFound(f"Backup {backup_id} not found") from err class SupervisorBackupReaderWriter(BackupReaderWriter): @@ -492,10 +494,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) -> None: """Restore a backup.""" manager = self._hass.data[DATA_MANAGER] - # The backup manager has already checked that the backup exists so we don't need to - # check that here. + # The backup manager has already checked that the backup exists so we don't + # need to catch BackupNotFound here. backup = await manager.backup_agents[agent_id].async_get_backup(backup_id) if ( + # Check for None to be backwards compatible with the old BackupAgent API, + # this can be removed in HA Core 2025.10 backup and restore_homeassistant and restore_database != backup.database_included From 7359013db0923054a8502995076a678450e650ac Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 4 Mar 2025 20:24:36 +0100 Subject: [PATCH 1325/1941] Move ForkedDaapdUpdater setup to __init__ module (#139733) * Move ForkedDaapdUpdater setup to __init__ module * Adjust tests * One more --- .../components/forked_daapd/__init__.py | 18 ++++++++++++++- .../components/forked_daapd/coordinator.py | 5 ++++ .../components/forked_daapd/media_player.py | 23 ++++++------------- .../forked_daapd/test_browse_media.py | 8 +++---- .../forked_daapd/test_config_flow.py | 19 ++++++++------- .../forked_daapd/test_media_player.py | 4 ++-- 6 files changed, 44 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/forked_daapd/__init__.py b/homeassistant/components/forked_daapd/__init__.py index 2172e60ba38..16fd96ee365 100644 --- a/homeassistant/components/forked_daapd/__init__.py +++ b/homeassistant/components/forked_daapd/__init__.py @@ -1,16 +1,32 @@ """The forked_daapd component.""" +from pyforked_daapd import ForkedDaapdAPI + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, HASS_DATA_UPDATER_KEY +from .coordinator import ForkedDaapdUpdater PLATFORMS = [Platform.MEDIA_PLAYER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up forked-daapd from a config entry by forwarding to platform.""" + host: str = entry.data[CONF_HOST] + port: int = entry.data[CONF_PORT] + password: str = entry.data[CONF_PASSWORD] + forked_daapd_api = ForkedDaapdAPI( + async_get_clientsession(hass), host, port, password + ) + forked_daapd_updater = ForkedDaapdUpdater(hass, forked_daapd_api, entry.entry_id) + if not hass.data.get(DOMAIN): + hass.data[DOMAIN] = {entry.entry_id: {}} + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})[ + HASS_DATA_UPDATER_KEY + ] = forked_daapd_updater await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py index 2db0a75c429..246ad1caa7d 100644 --- a/homeassistant/components/forked_daapd/coordinator.py +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -39,6 +39,11 @@ class ForkedDaapdUpdater: self._all_output_ids: set[str] = set() self._entry_id = entry_id + @property + def api(self) -> ForkedDaapdAPI: + """Return the API object.""" + return self._api + async def async_init(self) -> None: """Perform async portion of class initialization.""" if not (server_config := await self._api.get_request("config")): diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 8cbf33460aa..90a04dbc177 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -7,7 +7,6 @@ from collections import defaultdict import logging from typing import Any -from pyforked_daapd import ForkedDaapdAPI from pylibrespot_java import LibrespotJavaAPI from homeassistant.components import media_source @@ -29,7 +28,7 @@ from homeassistant.components.spotify import ( spotify_uri_from_media_browser_url, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -85,12 +84,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up forked-daapd from a config entry.""" + forked_daapd_updater: ForkedDaapdUpdater = hass.data[DOMAIN][config_entry.entry_id][ + HASS_DATA_UPDATER_KEY + ] + host: str = config_entry.data[CONF_HOST] - port: int = config_entry.data[CONF_PORT] - password: str = config_entry.data[CONF_PASSWORD] - forked_daapd_api = ForkedDaapdAPI( - async_get_clientsession(hass), host, port, password - ) + forked_daapd_api = forked_daapd_updater.api forked_daapd_master = ForkedDaapdMaster( clientsession=async_get_clientsession(hass), api=forked_daapd_api, @@ -111,16 +110,8 @@ async def async_setup_entry( ) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) - if not hass.data.get(DOMAIN): - hass.data[DOMAIN] = {config_entry.entry_id: {}} - async_add_entities([forked_daapd_master], False) - forked_daapd_updater = ForkedDaapdUpdater( - hass, forked_daapd_api, config_entry.entry_id - ) - hass.data[DOMAIN][config_entry.entry_id][HASS_DATA_UPDATER_KEY] = ( - forked_daapd_updater - ) + await forked_daapd_updater.async_init() diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py index cbd278128ae..88b29c2bbba 100644 --- a/tests/components/forked_daapd/test_browse_media.py +++ b/tests/components/forked_daapd/test_browse_media.py @@ -34,7 +34,7 @@ async def test_async_browse_media( await hass.async_block_till_done() with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} @@ -214,7 +214,7 @@ async def test_async_browse_media_not_found( await hass.async_block_till_done() with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} @@ -375,7 +375,7 @@ async def test_async_browse_image( """Test browse media images.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} @@ -430,7 +430,7 @@ async def test_async_browse_image_missing( """Test browse media images with no image available.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = {"websocket_port": 2} diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index 8bf5de31da2..ba1f0e6c227 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -1,7 +1,7 @@ """The config flow tests for the forked_daapd media player platform.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -12,12 +12,10 @@ from homeassistant.components.forked_daapd.const import ( CONF_TTS_VOLUME, DOMAIN, ) -from homeassistant.components.forked_daapd.media_player import async_setup_entry -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry @@ -75,7 +73,7 @@ async def test_config_flow(hass: HomeAssistant, config_entry: MockConfigEntry) - new=AsyncMock(), ) as mock_test_connection, patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI.get_request", + "homeassistant.components.forked_daapd.ForkedDaapdAPI.get_request", autospec=True, ) as mock_get_request, ): @@ -232,7 +230,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) """Test config flow options.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI.get_request", + "homeassistant.components.forked_daapd.ForkedDaapdAPI.get_request", autospec=True, ) as mock_get_request: mock_get_request.return_value = SAMPLE_CONFIG @@ -256,17 +254,18 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) async def test_async_setup_entry_not_ready( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture ) -> None: """Test that a PlatformNotReady exception is thrown during platform setup.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = None config_entry.add_to_hass(hass) - with pytest.raises(PlatformNotReady): - await async_setup_entry(hass, config_entry, MagicMock()) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() mock_api.return_value.get_request.assert_called_once() + assert "Platform forked_daapd not ready yet" in caplog.text + assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index 6d7d267eb63..8f0105d48d7 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -313,7 +313,7 @@ async def mock_api_object_fixture( return get_request_return_values[update_type] with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.side_effect = get_request_side_effect @@ -808,7 +808,7 @@ async def test_invalid_websocket_port( ) -> None: """Test invalid websocket port on async_init.""" with patch( - "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + "homeassistant.components.forked_daapd.ForkedDaapdAPI", autospec=True, ) as mock_api: mock_api.return_value.get_request.return_value = SAMPLE_CONFIG_NO_WEBSOCKET From 3b9bb9678408da7c5d6b0f22c276ef9742637a58 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 4 Mar 2025 20:45:10 +0100 Subject: [PATCH 1326/1941] Align google_drive with changes in BackupAgent (#139767) --- homeassistant/components/google_drive/backup.py | 15 +++++++++++---- tests/components/google_drive/test_backup.py | 4 ++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 73e5902f8f5..a4b7fc956ce 100644 --- a/homeassistant/components/google_drive/backup.py +++ b/homeassistant/components/google_drive/backup.py @@ -8,7 +8,12 @@ from typing import Any from google_drive_api.exceptions import GoogleDriveApiError -from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator @@ -93,13 +98,13 @@ class GoogleDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup.""" backups = await self.async_list_backups() for backup in backups: if backup.backup_id == backup_id: return backup - return None + raise BackupNotFound(f"Backup {backup_id} not found") async def async_download_backup( self, @@ -120,7 +125,7 @@ class GoogleDriveBackupAgent(BackupAgent): return ChunkAsyncStreamIterator(stream) except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: raise BackupAgentError(f"Failed to download backup: {err}") from err - raise BackupAgentError("Backup not found") + raise BackupNotFound(f"Backup {backup_id} not found") async def async_delete_backup( self, @@ -138,5 +143,7 @@ class GoogleDriveBackupAgent(BackupAgent): _LOGGER.debug("Deleting file_id: %s", file_id) await self._client.async_delete(file_id) _LOGGER.debug("Deleted backup_id: %s", backup_id) + return except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err: raise BackupAgentError(f"Failed to delete backup: {err}") from err + raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index 2da397def5b..9cf86a280bd 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -247,9 +247,9 @@ async def test_agents_download_file_not_found( resp = await client.get( f"/api/backup/download/{TEST_AGENT_BACKUP.backup_id}?agent_id={TEST_AGENT_ID}" ) - assert resp.status == 500 + assert resp.status == 404 content = await resp.content.read() - assert "Backup not found" in content.decode() + assert content == b"" async def test_agents_download_metadata_not_found( From 1456d9d800624b961eacb6c55f113bba63030de5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 4 Mar 2025 21:00:51 +0100 Subject: [PATCH 1327/1941] Capitalize "Suez Water" and "ID" in user-facing strings (#139782) --- homeassistant/components/suez_water/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index be2d4849e76..a8632fcb24a 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -5,21 +5,21 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "counter_id": "Meter id" + "counter_id": "Meter ID" }, "data_description": { "username": "Enter your login associated with your {tout_sur_mon_eau} account", "password": "Enter your password associated with your {tout_sur_mon_eau} account", - "counter_id": "Enter your meter id (ex: 12345678). Should be found automatically during setup, if not see integration documentation for more information" + "counter_id": "Enter your meter ID (ex: 12345678). Should be found automatically during setup, if not see integration documentation for more information" }, - "description": "Connect your suez water {tout_sur_mon_eau} account to retrieve your water consumption" + "description": "Connect your Suez Water {tout_sur_mon_eau} account to retrieve your water consumption" } }, "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%]", - "counter_not_found": "Could not find meter id automatically" + "counter_not_found": "Could not find meter ID automatically" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 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 1328/1941] 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 1329/1941] 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 1330/1941] 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 1331/1941] 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 1332/1941] 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 1333/1941] 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 1334/1941] 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 50ba93042be79abcd0c927abcfe095f1b1e67729 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 4 Mar 2025 22:43:49 +0100 Subject: [PATCH 1335/1941] Add create_habit action to Habitica integration (#139673) --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/icons.json | 6 ++ homeassistant/components/habitica/services.py | 11 ++- .../components/habitica/services.yaml | 18 +++- .../components/habitica/strings.json | 70 ++++++++++++--- tests/components/habitica/test_services.py | 90 ++++++++++++++++++- 6 files changed, 180 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index ecaa66378f0..049f2beb370 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -62,6 +62,7 @@ SERVICE_TRANSFORMATION = "transformation" SERVICE_UPDATE_REWARD = "update_reward" SERVICE_CREATE_REWARD = "create_reward" SERVICE_UPDATE_HABIT = "update_habit" +SERVICE_CREATE_HABIT = "create_habit" 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 ca4795dd514..af4a20acab6 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -237,6 +237,12 @@ "tag_options": "mdi:tag", "developer_options": "mdi:test-tube" } + }, + "create_habit": { + "service": "mdi:contrast-box", + "sections": { + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 3c4a59990a3..78f3002c89d 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -66,6 +66,7 @@ from .const import ( SERVICE_API_CALL, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, @@ -190,6 +191,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_UPDATE_REWARD: TaskType.REWARD, SERVICE_CREATE_REWARD: TaskType.REWARD, SERVICE_UPDATE_HABIT: TaskType.HABIT, + SERVICE_CREATE_HABIT: TaskType.HABIT, } @@ -596,7 +598,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 data = Task() if not is_update: - data["type"] = TaskType.REWARD + data["type"] = SERVICE_TASK_TYPE_MAP[call.service] if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): data["text"] = text @@ -733,6 +735,13 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_CREATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_CREATE_HABIT, + create_or_update_task, + schema=SERVICE_CREATE_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 f5a9c2b0032..ed3ae4516e5 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -183,7 +183,7 @@ update_reward: create_reward: fields: config_entry: *config_entry - name: + name: &name required: true selector: text: @@ -199,7 +199,7 @@ update_habit: task: *task rename: *rename notes: *notes - up_down: + up_down: &up_down required: false selector: select: @@ -210,7 +210,7 @@ update_habit: label: "➖" multiple: true mode: list - priority: + priority: &priority required: false selector: select: @@ -221,7 +221,7 @@ update_habit: - "hard" mode: dropdown translation_key: "priority" - frequency: + frequency: &frequency required: false selector: select: @@ -252,3 +252,13 @@ update_habit: unit_of_measurement: "➖" mode: box alias: *alias +create_habit: + fields: + config_entry: *config_entry + name: *name + notes: *notes + up_down: *up_down + priority: *priority + frequency: *frequency + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 22ea44351da..1f9424eafe1 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -11,9 +11,9 @@ "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.", - "notes_name": "Update notes", - "notes_description": "The new notes for the Habitica task.", + "rename_description": "The title for the Habitica task.", + "notes_name": "Notes", + "notes_description": "The 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", @@ -25,7 +25,13 @@ "tag_options_name": "Tags", "tag_options_description": "Add or remove tags from a task.", "name_description": "The title for the Habitica task.", - "cost_name": "Cost" + "cost_name": "Cost", + "difficulty_name": "Difficulty", + "difficulty_description": "The difficulty of the task.", + "frequency_name": "Counter reset", + "frequency_description": "The frequency at which the habit's counter resets: daily at the start of a new day, weekly after Sunday night, or monthly at the beginning of a new month.", + "up_down_name": "Rewards or losses", + "up_down_description": "Whether the habit is good and rewarding (positive), bad and penalizing (negative), or both." }, "config": { "abort": { @@ -793,16 +799,16 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "priority": { - "name": "Difficulty", - "description": "Update the difficulty of a task." + "name": "[%key:component::habitica::common::difficulty_name%]", + "description": "[%key:component::habitica::common::difficulty_description%]" }, "frequency": { - "name": "Counter reset", - "description": "Update when a habit's counter resets: daily resets at the start of a new day, weekly after Sunday night, and monthly at the beginning of a new month." + "name": "[%key:component::habitica::common::frequency_name%]", + "description": "[%key:component::habitica::common::frequency_description%]" }, "up_down": { - "name": "Rewards or losses", - "description": "Update if the habit is good and rewarding (positive), bad and penalizing (negative), or both." + "name": "[%key:component::habitica::common::up_down_name%]", + "description": "[%key:component::habitica::common::up_down_description%]" }, "counter_up": { "name": "Adjust positive counter", @@ -823,6 +829,50 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "create_habit": { + "name": "Create habit", + "description": "Adds a new habit.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a habit." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::difficulty_name%]", + "description": "[%key:component::habitica::common::difficulty_description%]" + }, + "frequency": { + "name": "[%key:component::habitica::common::frequency_name%]", + "description": "[%key:component::habitica::common::frequency_description%]" + }, + "up_down": { + "name": "[%key:component::habitica::common::up_down_name%]", + "description": "[%key:component::habitica::common::up_down_description%]" + } + }, + "sections": { + "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/test_services.py b/tests/components/habitica/test_services.py index 10a8bc0a588..00ad7e6b2e9 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -42,6 +42,7 @@ from homeassistant.components.habitica.const import ( SERVICE_ACCEPT_QUEST, SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, @@ -986,6 +987,10 @@ async def test_update_task_exceptions( ), ], ) +@pytest.mark.parametrize( + "service", + [SERVICE_CREATE_REWARD, SERVICE_CREATE_HABIT], +) @pytest.mark.usefixtures("habitica") async def test_create_task_exceptions( hass: HomeAssistant, @@ -994,6 +999,7 @@ async def test_create_task_exceptions( exception: Exception, expected_exception: Exception, exception_msg: str, + service: str, ) -> None: """Test Habitica task create action exceptions.""" @@ -1001,7 +1007,7 @@ async def test_create_task_exceptions( with pytest.raises(expected_exception, match=exception_msg): await hass.services.async_call( DOMAIN, - SERVICE_CREATE_REWARD, + service, service_data={ ATTR_CONFIG_ENTRY: config_entry.entry_id, ATTR_NAME: "TITLE", @@ -1230,6 +1236,88 @@ async def test_update_habit( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.HABIT, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.HABIT, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_UP_DOWN: [""], + }, + Task(type=TaskType.HABIT, text="TITLE", up=False, down=False), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_UP_DOWN: ["up"], + }, + Task(type=TaskType.HABIT, text="TITLE", up=True, down=False), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_UP_DOWN: ["down"], + }, + Task(type=TaskType.HABIT, text="TITLE", up=False, down=True), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_PRIORITY: "trivial", + }, + Task(type=TaskType.HABIT, text="TITLE", priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_FREQUENCY: "daily", + }, + Task(type=TaskType.HABIT, text="TITLE", frequency=Frequency.DAILY), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.HABIT, text="TITLE", alias="ALIAS"), + ), + ], +) +async def test_create_habit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create_habit action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From c671862d3f678a3eadb60b32c78ae7a67a518bc3 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 5 Mar 2025 00:45:58 +0100 Subject: [PATCH 1336/1941] Improve Home Connect appliances test fixture (#139787) Improve Home Connect appliances fixture --- tests/components/home_connect/__init__.py | 5 +- tests/components/home_connect/conftest.py | 211 ++++++++------- .../home_connect/fixtures/appliances.json | 240 +++++++++--------- .../home_connect/test_coordinator.py | 36 ++- 4 files changed, 267 insertions(+), 225 deletions(-) diff --git a/tests/components/home_connect/__init__.py b/tests/components/home_connect/__init__.py index 47a438fd218..8c256cb23f3 100644 --- a/tests/components/home_connect/__init__.py +++ b/tests/components/home_connect/__init__.py @@ -2,13 +2,10 @@ from typing import Any -from aiohomeconnect.model import ArrayOfHomeAppliances, ArrayOfStatus +from aiohomeconnect.model import 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( diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 396fe8c5665..c0caf2b2bdd 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,6 +11,7 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfCommands, ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, @@ -39,15 +40,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import ( - MOCK_APPLIANCES, - MOCK_AVAILABLE_COMMANDS, - MOCK_PROGRAMS, - MOCK_SETTINGS, - MOCK_STATUS, -) +from . import MOCK_AVAILABLE_COMMANDS, MOCK_PROGRAMS, MOCK_SETTINGS, MOCK_STATUS -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -148,14 +143,6 @@ async def mock_integration_setup( return run -def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: - """Get specific appliance side effect.""" - for appliance in copy.deepcopy(MOCK_APPLIANCES).homeappliances: - if appliance.ha_id == ha_id: - return appliance - raise HomeConnectApiError("error.key", "error description") - - def _get_set_program_side_effect( event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey ): @@ -271,68 +258,12 @@ def _get_set_program_options_side_effect( return set_program_options_side_effect -async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: - """Get all 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 ArrayOfPrograms( - [ - EnumerateProgram.from_dict(program) - for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] - ], - Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), - Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), - ) - - -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": []}) - ) - - -async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): - """Get setting.""" - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.ha_id == ha_id: - settings = MOCK_SETTINGS.get( - next( - appliance - for appliance in MOCK_APPLIANCES.homeappliances - if appliance.ha_id == ha_id - ).type, - {}, - ).get("data", {"settings": []}) - for setting_dict in cast(list[dict], settings["settings"]): - if setting_dict["key"] == setting_key: - return GetSetting.from_dict(setting_dict) - 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: +def mock_client( + appliances: list[HomeAppliance], + appliance: HomeAppliance | None, + request: pytest.FixtureRequest, +) -> MagicMock: """Fixture to mock Client from HomeConnect.""" mock = MagicMock( @@ -369,17 +300,78 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ] ) + appliances = [appliance] if appliance else appliances + 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=copy.deepcopy(MOCK_APPLIANCES)) + mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances)) + + def _get_specific_appliance_side_effect(ha_id: str) -> HomeAppliance: + """Get specific appliance side effect.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id: + return appliance_ + raise HomeConnectApiError("error.key", "error description") + mock.get_specific_appliance = AsyncMock( side_effect=_get_specific_appliance_side_effect ) mock.stream_all_events = stream_all_events + + async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: + """Get all programs.""" + appliance_type = next( + appliance for appliance in appliances if appliance.ha_id == ha_id + ).type + if appliance_type not in MOCK_PROGRAMS: + raise HomeConnectApiError("error.key", "error description") + + return ArrayOfPrograms( + [ + EnumerateProgram.from_dict(program) + for program in MOCK_PROGRAMS[appliance_type]["data"]["programs"] + ], + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + Program.from_dict(MOCK_PROGRAMS[appliance_type]["data"]["programs"][0]), + ) + + async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings: + """Get settings.""" + return ArrayOfSettings.from_dict( + MOCK_SETTINGS.get( + next( + appliance for appliance in appliances if appliance.ha_id == ha_id + ).type, + {}, + ).get("data", {"settings": []}) + ) + + async def _get_setting_side_effect(ha_id: str, setting_key: SettingKey): + """Get setting.""" + for appliance_ in appliances: + if appliance_.ha_id == ha_id: + settings = MOCK_SETTINGS.get( + appliance_.type, + {}, + ).get("data", {"settings": []}) + for setting_dict in cast(list[dict], settings["settings"]): + if setting_dict["key"] == setting_key: + return GetSetting.from_dict(setting_dict) + raise HomeConnectApiError("error.key", "error description") + + async def _get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + """Get available commands.""" + for appliance_ in appliances: + 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") + mock.start_program = AsyncMock( side_effect=_get_set_program_side_effect( event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM @@ -431,7 +423,11 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: @pytest.fixture(name="client_with_exception") -def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: +def mock_client_with_exception( + appliances: list[HomeAppliance], + appliance: HomeAppliance | None, + request: pytest.FixtureRequest, +) -> MagicMock: """Fixture to mock Client from HomeConnect that raise exceptions.""" mock = MagicMock( autospec=HomeConnectClient, @@ -449,7 +445,8 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: for event in await event_queue.get(): yield event - mock.get_home_appliances = AsyncMock(return_value=copy.deepcopy(MOCK_APPLIANCES)) + appliances = [appliance] if appliance else appliances + mock.get_home_appliances = AsyncMock(return_value=ArrayOfHomeAppliances(appliances)) mock.stream_all_events = stream_all_events mock.start_program = AsyncMock(side_effect=exception) @@ -477,12 +474,52 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: @pytest.fixture(name="appliance_ha_id") -def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str: - """Fixture to mock Appliance.""" - app = "Washer" +def mock_appliance_ha_id( + appliances: list[HomeAppliance], request: pytest.FixtureRequest +) -> str: + """Fixture to get the ha_id of an appliance.""" + appliance_type = "Washer" if hasattr(request, "param") and request.param: - app = request.param - for appliance in MOCK_APPLIANCES.homeappliances: - if appliance.type == app: + appliance_type = request.param + for appliance in appliances: + if appliance.type == appliance_type: return appliance.ha_id - raise ValueError(f"Appliance {app} not found") + raise ValueError(f"Appliance {appliance_type} not found") + + +@pytest.fixture(name="appliances") +def mock_appliances( + appliances_data: str, request: pytest.FixtureRequest +) -> list[HomeAppliance]: + """Fixture to mock the returned appliances.""" + appliances = ArrayOfHomeAppliances.from_json(appliances_data).homeappliances + appliance_types = {appliance.type for appliance in appliances} + if hasattr(request, "param") and request.param: + appliance_types = request.param + return [appliance for appliance in appliances if appliance.type in appliance_types] + + +@pytest.fixture(name="appliance") +def mock_appliance( + appliances_data: str, request: pytest.FixtureRequest +) -> HomeAppliance | None: + """Fixture to mock a single specific appliance to return.""" + appliance_type = None + if hasattr(request, "param") and request.param: + appliance_type = request.param + return next( + ( + appliance + for appliance in ArrayOfHomeAppliances.from_json( + appliances_data + ).homeappliances + if appliance.type == appliance_type + ), + None, + ) + + +@pytest.fixture(name="appliances_data") +def appliances_data_fixture() -> str: + """Fixture to return a the string for an array of appliances.""" + return load_fixture("appliances.json", integration=DOMAIN) diff --git a/tests/components/home_connect/fixtures/appliances.json b/tests/components/home_connect/fixtures/appliances.json index ada18b3482c..081dd44764f 100644 --- a/tests/components/home_connect/fixtures/appliances.json +++ b/tests/components/home_connect/fixtures/appliances.json @@ -1,123 +1,121 @@ { - "data": { - "homeappliances": [ - { - "name": "FridgeFreezer", - "brand": "SIEMENS", - "vib": "HCS05FRF1", - "connected": true, - "type": "FridgeFreezer", - "enumber": "HCS05FRF1/03", - "haId": "SIEMENS-HCS05FRF1-304F4F9E541D" - }, - { - "name": "Dishwasher", - "brand": "SIEMENS", - "vib": "HCS02DWH1", - "connected": true, - "type": "Dishwasher", - "enumber": "HCS02DWH1/03", - "haId": "SIEMENS-HCS02DWH1-6BE58C26DCC1" - }, - { - "name": "Oven", - "brand": "BOSCH", - "vib": "HCS01OVN1", - "connected": true, - "type": "Oven", - "enumber": "HCS01OVN1/03", - "haId": "BOSCH-HCS01OVN1-43E0065FE245" - }, - { - "name": "Washer", - "brand": "SIEMENS", - "vib": "HCS03WCH1", - "connected": true, - "type": "Washer", - "enumber": "HCS03WCH1/03", - "haId": "SIEMENS-HCS03WCH1-7BC6383CF794" - }, - { - "name": "Dryer", - "brand": "BOSCH", - "vib": "HCS04DYR1", - "connected": true, - "type": "Dryer", - "enumber": "HCS04DYR1/03", - "haId": "BOSCH-HCS04DYR1-831694AE3C5A" - }, - { - "name": "CoffeeMaker", - "brand": "BOSCH", - "vib": "HCS06COM1", - "connected": true, - "type": "CoffeeMaker", - "enumber": "HCS06COM1/03", - "haId": "BOSCH-HCS06COM1-D70390681C2C" - }, - { - "name": "WasherDryer", - "brand": "BOSCH", - "vib": "HCS000001", - "connected": true, - "type": "WasherDryer", - "enumber": "HCS000000/01", - "haId": "BOSCH-HCS000000-D00000000001" - }, - { - "name": "Refrigerator", - "brand": "BOSCH", - "vib": "HCS000002", - "connected": true, - "type": "Refrigerator", - "enumber": "HCS000000/02", - "haId": "BOSCH-HCS000000-D00000000002" - }, - { - "name": "Freezer", - "brand": "BOSCH", - "vib": "HCS000003", - "connected": true, - "type": "Freezer", - "enumber": "HCS000000/03", - "haId": "BOSCH-HCS000000-D00000000003" - }, - { - "name": "Hood", - "brand": "BOSCH", - "vib": "HCS000004", - "connected": true, - "type": "Hood", - "enumber": "HCS000000/04", - "haId": "BOSCH-HCS000000-D00000000004" - }, - { - "name": "Hob", - "brand": "BOSCH", - "vib": "HCS000005", - "connected": true, - "type": "Hob", - "enumber": "HCS000000/05", - "haId": "BOSCH-HCS000000-D00000000005" - }, - { - "name": "CookProcessor", - "brand": "BOSCH", - "vib": "HCS000006", - "connected": true, - "type": "CookProcessor", - "enumber": "HCS000000/06", - "haId": "BOSCH-HCS000000-D00000000006" - }, - { - "name": "DNE", - "brand": "BOSCH", - "vib": "HCS000000", - "connected": true, - "type": "DNE", - "enumber": "HCS000000/00", - "haId": "BOSCH-000000000-000000000000" - } - ] - } + "homeappliances": [ + { + "name": "FridgeFreezer", + "brand": "SIEMENS", + "vib": "HCS05FRF1", + "connected": true, + "type": "FridgeFreezer", + "enumber": "HCS05FRF1/03", + "haId": "SIEMENS-HCS05FRF1-304F4F9E541D" + }, + { + "name": "Dishwasher", + "brand": "SIEMENS", + "vib": "HCS02DWH1", + "connected": true, + "type": "Dishwasher", + "enumber": "HCS02DWH1/03", + "haId": "SIEMENS-HCS02DWH1-6BE58C26DCC1" + }, + { + "name": "Oven", + "brand": "BOSCH", + "vib": "HCS01OVN1", + "connected": true, + "type": "Oven", + "enumber": "HCS01OVN1/03", + "haId": "BOSCH-HCS01OVN1-43E0065FE245" + }, + { + "name": "Washer", + "brand": "SIEMENS", + "vib": "HCS03WCH1", + "connected": true, + "type": "Washer", + "enumber": "HCS03WCH1/03", + "haId": "SIEMENS-HCS03WCH1-7BC6383CF794" + }, + { + "name": "Dryer", + "brand": "BOSCH", + "vib": "HCS04DYR1", + "connected": true, + "type": "Dryer", + "enumber": "HCS04DYR1/03", + "haId": "BOSCH-HCS04DYR1-831694AE3C5A" + }, + { + "name": "CoffeeMaker", + "brand": "BOSCH", + "vib": "HCS06COM1", + "connected": true, + "type": "CoffeeMaker", + "enumber": "HCS06COM1/03", + "haId": "BOSCH-HCS06COM1-D70390681C2C" + }, + { + "name": "WasherDryer", + "brand": "BOSCH", + "vib": "HCS000001", + "connected": true, + "type": "WasherDryer", + "enumber": "HCS000000/01", + "haId": "BOSCH-HCS000000-D00000000001" + }, + { + "name": "Refrigerator", + "brand": "BOSCH", + "vib": "HCS000002", + "connected": true, + "type": "Refrigerator", + "enumber": "HCS000000/02", + "haId": "BOSCH-HCS000000-D00000000002" + }, + { + "name": "Freezer", + "brand": "BOSCH", + "vib": "HCS000003", + "connected": true, + "type": "Freezer", + "enumber": "HCS000000/03", + "haId": "BOSCH-HCS000000-D00000000003" + }, + { + "name": "Hood", + "brand": "BOSCH", + "vib": "HCS000004", + "connected": true, + "type": "Hood", + "enumber": "HCS000000/04", + "haId": "BOSCH-HCS000000-D00000000004" + }, + { + "name": "Hob", + "brand": "BOSCH", + "vib": "HCS000005", + "connected": true, + "type": "Hob", + "enumber": "HCS000000/05", + "haId": "BOSCH-HCS000000-D00000000005" + }, + { + "name": "CookProcessor", + "brand": "BOSCH", + "vib": "HCS000006", + "connected": true, + "type": "CookProcessor", + "enumber": "HCS000000/06", + "haId": "BOSCH-HCS000000-D00000000006" + }, + { + "name": "DNE", + "brand": "BOSCH", + "vib": "HCS000000", + "connected": true, + "type": "DNE", + "enumber": "HCS000000/00", + "haId": "BOSCH-000000000-000000000000" + } + ] } diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 1a49d2bb2a0..1e584335fcd 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,19 +1,20 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable -import copy from datetime import timedelta -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.model import ( ArrayOfEvents, + ArrayOfHomeAppliances, ArrayOfSettings, ArrayOfStatus, Event, EventKey, EventMessage, EventType, + HomeAppliance, ) from aiohomeconnect.model.error import ( EventStreamInterruptedError, @@ -41,8 +42,6 @@ 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 @@ -81,16 +80,21 @@ async def test_coordinator_update_failing_get_appliances( @pytest.mark.usefixtures("setup_credentials") @pytest.mark.parametrize("platforms", [("binary_sensor",)]) -@pytest.mark.parametrize("appliance_ha_id", ["Washer"], indirect=True) +@pytest.mark.parametrize("appliance", ["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, + appliance: HomeAppliance, ) -> None: """Test entity available state via coordinator refresh and event stream.""" + appliance_data = ( + cast(str, appliance.to_json()) + .replace("ha_id", "haId") + .replace("e_number", "enumber") + ) entity_id_1 = "binary_sensor.washer_remote_control" entity_id_2 = "binary_sensor.washer_remote_start" await async_setup_component(hass, "homeassistant", {}) @@ -121,7 +125,9 @@ async def test_coordinator_failure_refresh_and_stream( # 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) + client.get_home_appliances.return_value = ArrayOfHomeAppliances( + [HomeAppliance.from_json(appliance_data)] + ) # Move time forward to pass the debounce time. freezer.tick(timedelta(hours=1)) @@ -166,11 +172,13 @@ async def test_coordinator_failure_refresh_and_stream( # Now make the entity available again. client.get_home_appliances.side_effect = None - client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + client.get_home_appliances.return_value = ArrayOfHomeAppliances( + [HomeAppliance.from_json(appliance_data)] + ) # One event should make all entities for this appliance available again. event_message = EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -399,6 +407,9 @@ async def test_event_listener_error( assert not config_entry._background_tasks +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize("platforms", [("sensor",)]) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "exception", [HomeConnectRequestError(), EventStreamInterruptedError()], @@ -429,11 +440,10 @@ async def test_event_listener_resilience( after_event_expected_state: str, exception: HomeConnectError, hass: HomeAssistant, + appliance: HomeAppliance, + client: MagicMock, 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() @@ -467,7 +477,7 @@ async def test_event_listener_resilience( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ From 3ee5262a8d5f6601d862a1a1aa3731d6c2c40b30 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Tue, 4 Mar 2025 23:48:13 +0000 Subject: [PATCH 1337/1941] Clean up squeezebox build_item_response part 2 (#139595) --- .../components/squeezebox/browse_media.py | 103 +++++++----------- 1 file changed, 38 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 82fa55c7b2f..633f004993f 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -62,7 +62,7 @@ SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = { MediaType.APPS: "item_id", } -CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = { +CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = { "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, "Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, "Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP}, @@ -76,7 +76,7 @@ CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = "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}, + MediaType.TRACK: {"item": MediaClass.TRACK, "children": ""}, MediaType.GENRE: {"item": MediaClass.GENRE, "children": MediaClass.ARTIST}, MediaType.PLAYLIST: {"item": MediaClass.PLAYLIST, "children": MediaClass.TRACK}, MediaType.APP: {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, @@ -115,7 +115,7 @@ class BrowseData: str | MediaType, str | MediaType | None, ] = field(default_factory=dict) - content_type_media_class: dict[str | MediaType, dict[str, MediaClass | None]] = ( + content_type_media_class: dict[str | MediaType, dict[str, MediaClass | str]] = ( field(default_factory=dict) ) squeezebox_id_by_type: dict[str | MediaType, str] = field(default_factory=dict) @@ -130,18 +130,6 @@ class BrowseData: 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 - title: str - id: str - - def _add_new_command_to_browse_data( browse_data: BrowseData, cmd: str | MediaType, type: str ) -> None: @@ -157,13 +145,13 @@ def _add_new_command_to_browse_data( def _build_response_apps_radios_category( browse_data: BrowseData, cmd: str | MediaType, item: dict[str, Any] -) -> BrowseItemResponse: +) -> BrowseMedia: """Build item for App or radio category.""" - return BrowseItemResponse( - id=item.get("id", ""), + return BrowseMedia( + media_content_id=item.get("id", ""), title=item["title"], - child_item_type=cmd, - child_media_class=browse_data.content_type_media_class[cmd], + media_content_type=cmd, + media_class=browse_data.content_type_media_class[cmd]["item"], can_expand=True, can_play=False, ) @@ -171,44 +159,44 @@ def _build_response_apps_radios_category( def _build_response_known_app( browse_data: BrowseData, search_type: str, item: dict[str, Any] -) -> BrowseItemResponse: +) -> BrowseMedia: """Build item for app or radio.""" - return BrowseItemResponse( - id=item.get("id", ""), + return BrowseMedia( + media_content_id=item.get("id", ""), title=item["title"], - child_item_type=search_type, - child_media_class=browse_data.content_type_media_class[search_type], + media_content_type=search_type, + media_class=browse_data.content_type_media_class[search_type]["item"], can_play=bool(item["isaudio"] and item.get("url")), can_expand=item["hasitems"], ) -def _build_response_favorites(item: dict[str, Any]) -> BrowseItemResponse: +def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia: """Build item for Favorites.""" if "album_id" in item: - return BrowseItemResponse( - id=str(item["album_id"]), + return BrowseMedia( + media_content_id=str(item["album_id"]), title=item["title"], - child_item_type=MediaType.ALBUM, - child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM], + media_content_type=MediaType.ALBUM, + media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM]["item"], can_expand=True, can_play=True, ) if item["hasitems"] and not item["isaudio"]: - return BrowseItemResponse( - id=item.get("id", ""), + return BrowseMedia( + media_content_id=item.get("id", ""), title=item["title"], - child_item_type="Favorites", - child_media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"], + media_content_type="Favorites", + media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"]["item"], can_expand=True, can_play=False, ) - return BrowseItemResponse( - id=item.get("id", ""), + return BrowseMedia( + media_content_id=item.get("id", ""), title=item["title"], - child_item_type="Favorites", - child_media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK], + media_content_type="Favorites", + media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"], can_expand=item["hasitems"], can_play=bool(item["isaudio"] and item.get("url")), ) @@ -274,12 +262,9 @@ async def build_item_response( item_type = browse_data.content_type_to_child_type[search_type] children = [] - list_playable = [] for item in result["items"]: - item_thumbnail: str | None = None - if search_type == "Favorites": - browse_item_response = _build_response_favorites(item) + child_media = _build_response_favorites(item) elif search_type in ["Apps", "Radios"]: # item["cmd"] contains the name of the command to use with the cli for the app @@ -293,7 +278,7 @@ async def build_item_response( 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( + child_media = _build_response_apps_radios_category( browse_data=browse_data, cmd=app_cmd, item=item ) @@ -305,22 +290,22 @@ async def build_item_response( # Skip searches in apps as they'd need UI continue - browse_item_response = _build_response_known_app( - browse_data, search_type, item - ) + child_media = _build_response_known_app(browse_data, search_type, item) elif item_type: - browse_item_response = BrowseItemResponse( - id=str(item.get("id", "")), + child_media = BrowseMedia( + media_content_id=str(item.get("id", "")), title=item["title"], - child_item_type=item_type, - child_media_class=CONTENT_TYPE_MEDIA_CLASS[item_type], + media_content_type=item_type, + media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] is not None, can_play=True, ) - item_thumbnail = _get_item_thumbnail( + assert child_media.media_class is not None + + child_media.thumbnail = _get_item_thumbnail( item=item, player=player, entity=entity, @@ -329,19 +314,7 @@ async def build_item_response( internal_request=internal_request, ) - assert browse_item_response.child_media_class["item"] is not None - children.append( - BrowseMedia( - title=browse_item_response.title, - media_class=browse_item_response.child_media_class["item"], - media_content_id=browse_item_response.id, - media_content_type=browse_item_response.child_item_type, - can_play=browse_item_response.can_play, - can_expand=browse_item_response.can_expand, - thumbnail=item_thumbnail, - ) - ) - list_playable.append(browse_item_response.can_play) + children.append(child_media) if children is None: raise BrowseError(f"Media not found: {search_type} / {search_id}") @@ -356,7 +329,7 @@ async def build_item_response( children_media_class=media_class["children"], media_content_id=search_id, media_content_type=search_type, - can_play=any(list_playable), + can_play=any(child.can_play for child in children), children=children, can_expand=True, ) From 366c5c3f108fca380da3734e3ede7bd7d343d0d7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 5 Mar 2025 01:03:38 +0100 Subject: [PATCH 1338/1941] Improve unique_id tests for Shelly block devices (#139778) * Improve unique_id tests for Shelly block devices * type test --------- Co-authored-by: J. Nick Koston --- tests/components/shelly/test_switch.py | 32 ++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 1e5ae9dd88c..0425f883ad6 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -3,7 +3,7 @@ from copy import deepcopy from unittest.mock import AsyncMock, Mock -from aioshelly.const import MODEL_GAS +from aioshelly.const import MODEL_1PM, MODEL_GAS, MODEL_MOTION from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest @@ -177,15 +177,37 @@ async def test_block_restored_motion_switch_no_last_state( assert get_entity_state(hass, entity_id) == STATE_ON +@pytest.mark.parametrize( + ("model", "sleep", "entity", "unique_id"), + [ + (MODEL_1PM, 0, "switch.test_name_channel_1", "123456789ABC-relay_0"), + ( + MODEL_MOTION, + 1000, + "switch.test_name_motion_detection", + "123456789ABC-sensor_0-motionActive", + ), + ], +) async def test_block_device_unique_ids( - hass: HomeAssistant, entity_registry: EntityRegistry, mock_block_device: Mock + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_block_device: Mock, + model: str, + sleep: int, + entity: str, + unique_id: str, ) -> None: """Test block device unique_ids.""" - await init_integration(hass, 1) + await init_integration(hass, 1, model=model, sleep_period=sleep) - entry = entity_registry.async_get("switch.test_name_channel_1") + if sleep: + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + entry = entity_registry.async_get(entity) assert entry - assert entry.unique_id == "123456789ABC-relay_0" + assert entry.unique_id == unique_id async def test_block_set_state_connection_error( From 9bc806ab21078e0decd5ba316ca25305b63c3d62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 14:57:03 -1000 Subject: [PATCH 1339/1941] 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 1664f8c5299..ab01706915e 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 299dfbb107e..910c14c7b6a 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 0143a71e9705cba6f845f12458668a5ab8cc48bc Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 5 Mar 2025 04:45:23 +0200 Subject: [PATCH 1340/1941] 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 ab01706915e..cca1a8f3f37 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 910c14c7b6a..5fd2b2d3da7 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 49b2f8fd7ff351cdb797c05ec9563fc124a48b45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 16:57:27 -1000 Subject: [PATCH 1341/1941] Bump bluetooth-data-tools to 1.25.0 (#139802) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.23.4...v1.25.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 6c851e603d9..d293d450e25 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", - "bluetooth-data-tools==1.23.4", + "bluetooth-data-tools==1.25.0", "dbus-fast==2.33.0", "habluetooth==3.24.1" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 36d0150642e..c92bcb3294f 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.23.4", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.25.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 309399e6958..8f624a3c225 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.23.4", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.25.0", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 445affbcd57..98a9f757585 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.23.4"] + "requirements": ["bluetooth-data-tools==1.25.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f6181d214e2..8956f565993 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.4 -bluetooth-data-tools==1.23.4 +bluetooth-data-tools==1.25.0 cached-ipaddress==0.9.2 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index cca1a8f3f37..9562378660a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -640,7 +640,7 @@ bluetooth-auto-recovery==1.4.4 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.4 +bluetooth-data-tools==1.25.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5fd2b2d3da7..5066c08cd6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.4 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.23.4 +bluetooth-data-tools==1.25.0 # homeassistant.components.bond bond-async==0.2.1 From 3eb7302fde659ce10acfe29706789cbe7119d855 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 16:57:43 -1000 Subject: [PATCH 1342/1941] Bump fnv-hash-fast to 1.4.0 (#139801) changelog: https://github.com/Bluetooth-Devices/fnv-hash-fast/compare/v1.2.6...v1.4.0 --- 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 f9a31489ca4..4ae2e43dfb2 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.6", + "fnv-hash-fast==1.4.0", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 40513c8ea24..3ba36ab86c0 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.6", + "fnv-hash-fast==1.4.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8956f565993..d3f49baff73 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.6 +fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.24.1 diff --git a/pyproject.toml b/pyproject.toml index 7c60a931c91..55577b7769c 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.6", + "fnv-hash-fast==1.4.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.94.0", diff --git a/requirements.txt b/requirements.txt index aef3fdb0f09..ed794e79fe9 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.6 +fnv-hash-fast==1.4.0 hass-nabucasa==0.94.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9562378660a..592c53c8ad1 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.6 +fnv-hash-fast==1.4.0 # homeassistant.components.foobot foobot_async==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5066c08cd6e..ce1bfb91cf0 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.6 +fnv-hash-fast==1.4.0 # homeassistant.components.foobot foobot_async==1.0.0 From e51d9bd6f45014265080fd9d296d1b9dba4e6768 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 4 Mar 2025 21:58:41 -0500 Subject: [PATCH 1343/1941] Remove redundant is not None checks in Template integration (#139790) Remove redundant is not None checks --- homeassistant/components/template/cover.py | 10 ++-- homeassistant/components/template/entity.py | 2 - homeassistant/components/template/fan.py | 6 +-- homeassistant/components/template/light.py | 51 ++++++++------------- homeassistant/components/template/number.py | 2 +- homeassistant/components/template/select.py | 8 ++-- homeassistant/components/template/switch.py | 4 +- homeassistant/components/template/vacuum.py | 12 ++--- 8 files changed, 37 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index ef5e6bc5758..7a8e347ee8f 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -325,9 +325,9 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" - if (open_script := self._action_scripts.get(OPEN_ACTION)) is not None: + if open_script := self._action_scripts.get(OPEN_ACTION): await self.async_run_script(open_script, context=self._context) - elif (position_script := self._action_scripts.get(POSITION_ACTION)) is not None: + elif position_script := self._action_scripts.get(POSITION_ACTION): await self.async_run_script( position_script, run_variables={"position": 100}, @@ -339,9 +339,9 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" - if (close_script := self._action_scripts.get(CLOSE_ACTION)) is not None: + if close_script := self._action_scripts.get(CLOSE_ACTION): await self.async_run_script(close_script, context=self._context) - elif (position_script := self._action_scripts.get(POSITION_ACTION)) is not None: + elif position_script := self._action_scripts.get(POSITION_ACTION): await self.async_run_script( position_script, run_variables={"position": 0}, @@ -353,7 +353,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" - if (stop_script := self._action_scripts.get(STOP_ACTION)) is not None: + if stop_script := self._action_scripts.get(STOP_ACTION): await self.async_run_script(stop_script, context=self._context) async def async_set_cover_position(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index dd8623060be..3617d9acdee 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -37,8 +37,6 @@ class AbstractTemplateEntity(Entity): ): """Add an action script.""" - # Cannot use self.hass because it may be None in child class - # at instantiation. self._action_scripts[script_id] = Script( self.hass, config, diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 2ca05681f7f..6e0f9fe5e0c 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -260,7 +260,7 @@ class TemplateFan(TemplateEntity, FanEntity): """Set the percentage speed of the fan.""" self._percentage = percentage - if (script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION)) is not None: + if script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION): await self.async_run_script( script, run_variables={ATTR_PERCENTAGE: self._percentage}, @@ -275,9 +275,7 @@ class TemplateFan(TemplateEntity, FanEntity): """Set the preset_mode of the fan.""" self._preset_mode = preset_mode - if ( - script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION) - ) is not None: + if script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION): await self.async_run_script( script, run_variables={ATTR_PRESET_MODE: self._preset_mode}, diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index c7188f380bc..352f571078a 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -222,7 +222,7 @@ class LightTemplate(TemplateEntity, LightEntity): (CONF_RGBW_ACTION, ColorMode.RGBW), (CONF_RGBWW_ACTION, ColorMode.RGBWW), ): - if (action_config := config.get(action_id)) is not None: + if action_config := config.get(action_id): self.add_script(action_id, action_config, name, DOMAIN) color_modes.add(color_mode) self._supported_color_modes = filter_supported_color_modes(color_modes) @@ -232,7 +232,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._color_mode = next(iter(self._supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) - if self._action_scripts.get(CONF_EFFECT_ACTION) is not None: + if self._action_scripts.get(CONF_EFFECT_ACTION): self._attr_supported_features |= LightEntityFeature.EFFECT if self._supports_transition is True: self._attr_supported_features |= LightEntityFeature.TRANSITION @@ -530,12 +530,8 @@ class LightTemplate(TemplateEntity, LightEntity): if ATTR_TRANSITION in kwargs and self._supports_transition is True: common_params["transition"] = kwargs[ATTR_TRANSITION] - if ( - ATTR_COLOR_TEMP_KELVIN in kwargs - and ( - temperature_script := self._action_scripts.get(CONF_TEMPERATURE_ACTION) - ) - is not None + if ATTR_COLOR_TEMP_KELVIN in kwargs and ( + temperature_script := self._action_scripts.get(CONF_TEMPERATURE_ACTION) ): common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] @@ -546,10 +542,8 @@ class LightTemplate(TemplateEntity, LightEntity): run_variables=common_params, context=self._context, ) - elif ( - ATTR_EFFECT in kwargs - and (effect_script := self._action_scripts.get(CONF_EFFECT_ACTION)) - is not None + elif ATTR_EFFECT in kwargs and ( + effect_script := self._action_scripts.get(CONF_EFFECT_ACTION) ): assert self._effect_list is not None effect = kwargs[ATTR_EFFECT] @@ -567,10 +561,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( effect_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_HS_COLOR in kwargs - and (color_script := self._action_scripts.get(CONF_COLOR_ACTION)) - is not None + elif ATTR_HS_COLOR in kwargs and ( + color_script := self._action_scripts.get(CONF_COLOR_ACTION) ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value @@ -580,9 +572,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( color_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_HS_COLOR in kwargs - and (hs_script := self._action_scripts.get(CONF_HS_ACTION)) is not None + elif ATTR_HS_COLOR in kwargs and ( + hs_script := self._action_scripts.get(CONF_HS_ACTION) ): hs_value = kwargs[ATTR_HS_COLOR] common_params["hs"] = hs_value @@ -592,10 +583,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( hs_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_RGBWW_COLOR in kwargs - and (rgbww_script := self._action_scripts.get(CONF_RGBWW_ACTION)) - is not None + elif ATTR_RGBWW_COLOR in kwargs and ( + rgbww_script := self._action_scripts.get(CONF_RGBWW_ACTION) ): rgbww_value = kwargs[ATTR_RGBWW_COLOR] common_params["rgbww"] = rgbww_value @@ -613,9 +602,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( rgbww_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_RGBW_COLOR in kwargs - and (rgbw_script := self._action_scripts.get(CONF_RGBW_ACTION)) is not None + elif ATTR_RGBW_COLOR in kwargs and ( + rgbw_script := self._action_scripts.get(CONF_RGBW_ACTION) ): rgbw_value = kwargs[ATTR_RGBW_COLOR] common_params["rgbw"] = rgbw_value @@ -632,9 +620,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( rgbw_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_RGB_COLOR in kwargs - and (rgb_script := self._action_scripts.get(CONF_RGB_ACTION)) is not None + elif ATTR_RGB_COLOR in kwargs and ( + rgb_script := self._action_scripts.get(CONF_RGB_ACTION) ): rgb_value = kwargs[ATTR_RGB_COLOR] common_params["rgb"] = rgb_value @@ -645,10 +632,8 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( rgb_script, run_variables=common_params, context=self._context ) - elif ( - ATTR_BRIGHTNESS in kwargs - and (level_script := self._action_scripts.get(CONF_LEVEL_ACTION)) - is not None + elif ATTR_BRIGHTNESS in kwargs and ( + level_script := self._action_scripts.get(CONF_LEVEL_ACTION) ): await self.async_run_script( level_script, run_variables=common_params, context=self._context diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 6661afc619c..e3654661158 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -208,7 +208,7 @@ class TemplateNumber(TemplateEntity, NumberEntity): if self._optimistic: self._attr_native_value = value self.async_write_ha_state() - if (set_value := self._action_scripts.get(CONF_SET_VALUE)) is not None: + if set_value := self._action_scripts.get(CONF_SET_VALUE): await self.async_run_script( set_value, run_variables={ATTR_VALUE: value}, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index d3b879a695d..1e7cb781eb0 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -142,10 +142,8 @@ class TemplateSelect(TemplateEntity, SelectEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - if (selection_option := config.get(CONF_SELECT_OPTION)) is not None: - self.add_script( - CONF_SELECT_OPTION, selection_option, self._attr_name, DOMAIN - ) + if select_option := config.get(CONF_SELECT_OPTION): + self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN) self._options_template = config[ATTR_OPTIONS] self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) self._attr_options = [] @@ -177,7 +175,7 @@ class TemplateSelect(TemplateEntity, SelectEntity): if self._optimistic: self._attr_current_option = option self.async_write_ha_state() - if (select_option := self._action_scripts.get(CONF_SELECT_OPTION)) is not None: + if select_option := self._action_scripts.get(CONF_SELECT_OPTION): await self.async_run_script( select_option, run_variables={ATTR_OPTION: option}, diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 148648a7a3c..feaabc3b17c 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -206,7 +206,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Fire the on action.""" - if (on_script := self._action_scripts.get(CONF_TURN_ON)) is not None: + if on_script := self._action_scripts.get(CONF_TURN_ON): await self.async_run_script(on_script, context=self._context) if self._template is None: self._state = True @@ -214,7 +214,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Fire the off action.""" - if (off_script := self._action_scripts.get(CONF_TURN_OFF)) is not None: + if off_script := self._action_scripts.get(CONF_TURN_OFF): await self.async_run_script(off_script, context=self._context) if self._template is None: self._state = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index ba7c330dad2..c4d41b52f31 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -185,27 +185,27 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): async def async_pause(self) -> None: """Pause the cleaning task.""" - if (script := self._action_scripts.get(SERVICE_PAUSE)) is not None: + if script := self._action_scripts.get(SERVICE_PAUSE): await self.async_run_script(script, context=self._context) async def async_stop(self, **kwargs: Any) -> None: """Stop the cleaning task.""" - if (script := self._action_scripts.get(SERVICE_STOP)) is not None: + if script := self._action_scripts.get(SERVICE_STOP): await self.async_run_script(script, context=self._context) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" - if (script := self._action_scripts.get(SERVICE_RETURN_TO_BASE)) is not None: + if script := self._action_scripts.get(SERVICE_RETURN_TO_BASE): await self.async_run_script(script, context=self._context) async def async_clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" - if (script := self._action_scripts.get(SERVICE_CLEAN_SPOT)) is not None: + if script := self._action_scripts.get(SERVICE_CLEAN_SPOT): await self.async_run_script(script, context=self._context) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" - if (script := self._action_scripts.get(SERVICE_LOCATE)) is not None: + if script := self._action_scripts.get(SERVICE_LOCATE): await self.async_run_script(script, context=self._context) async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: @@ -219,7 +219,7 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): ) return - if (script := self._action_scripts.get(SERVICE_SET_FAN_SPEED)) is not None: + if script := self._action_scripts.get(SERVICE_SET_FAN_SPEED): await self.async_run_script( script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context ) From 24188ffb3186d353ebbbf2e6854901e5a7d469d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:10:07 -1000 Subject: [PATCH 1344/1941] Bump zeroconf to 0.146.0 (#139804) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.145.1...0.146.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 8abaa4a838e..a7fbfdfeada 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.145.1"] + "requirements": ["zeroconf==0.146.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d3f49baff73..1dd33524110 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.145.1 +zeroconf==0.146.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 55577b7769c..2c61c000d4a 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.145.1" + "zeroconf==0.146.0" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index ed794e79fe9..aa47afe95ce 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.145.1 +zeroconf==0.146.0 diff --git a/requirements_all.txt b/requirements_all.txt index 592c53c8ad1..5bb49c10204 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3143,7 +3143,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.145.1 +zeroconf==0.146.0 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce1bfb91cf0..74c8ad94875 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2532,7 +2532,7 @@ yt-dlp[default]==2025.02.19 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.145.1 +zeroconf==0.146.0 # homeassistant.components.zeversolar zeversolar==0.3.2 From 27fd0a88f4b419bdc7c4574ae5189a3d94da0401 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:12:45 -1000 Subject: [PATCH 1345/1941] Bump bleak-esphome to 2.11.0 (#139803) changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.10.2...v2.11.0 --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index f106868679b..4b65852d205 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.10.2"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.11.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d9ac746924f..a159c5a2a53 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.3.2", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.10.2" + "bleak-esphome==2.11.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 5bb49c10204..98088180cba 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.10.2 +bleak-esphome==2.11.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74c8ad94875..7e762e7413d 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.10.2 +bleak-esphome==2.11.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From d5d9bc1df66063df826b17a458bc3223326ab1f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:25:11 -1000 Subject: [PATCH 1346/1941] Bump ulid-transform to 1.3.0 (#139808) changelog: https://github.com/Bluetooth-Devices/ulid-transform/compare/v1.2.1...v1.3.0 --- 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 1dd33524110..d7997e9e54d 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.1 +ulid-transform==1.3.0 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous-openapi==0.0.6 diff --git a/pyproject.toml b/pyproject.toml index 2c61c000d4a..b11c2403d69 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.1", + "ulid-transform==1.3.0", # 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 aa47afe95ce..6d138a6060d 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.1 +ulid-transform==1.3.0 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous==0.15.2 From e60a284354b376cedbc4cc5abb005fa20de7c0a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:25:43 -1000 Subject: [PATCH 1347/1941] Bump aioesphomeapi to 29.4.0 (#139806) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.3.2...v29.4.0 --- 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 a159c5a2a53..aa0f6f3752b 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.3.2", + "aioesphomeapi==29.4.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.11.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 98088180cba..c3a085d2a7a 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.3.2 +aioesphomeapi==29.4.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e762e7413d..680005447ad 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.3.2 +aioesphomeapi==29.4.0 # homeassistant.components.flo aioflo==2021.11.0 From 782f50452299bd2a125e6074c5876cce0b104896 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Tue, 4 Mar 2025 19:26:43 -0800 Subject: [PATCH 1348/1941] Add common PDU sensors to NUT (#139669) * Add common PDU sensors and alphabetize sensors list * Back out code quality improvements * Change voltage and current status to diagnostic and disabled by default --- homeassistant/components/nut/icons.json | 15 +++++++++++ homeassistant/components/nut/sensor.py | 33 +++++++++++++++++++++++ homeassistant/components/nut/strings.json | 5 ++++ 3 files changed, 53 insertions(+) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index 91df9d10553..261d28d712f 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -43,18 +43,33 @@ "input_bypass_phases": { "default": "mdi:information-outline" }, + "input_current_status": { + "default": "mdi:information-outline" + }, "input_frequency_status": { "default": "mdi:information-outline" }, + "input_load": { + "default": "mdi:gauge" + }, "input_phases": { "default": "mdi:information-outline" }, + "input_power": { + "default": "mdi:gauge" + }, "input_sensitivity": { "default": "mdi:information-outline" }, "input_transfer_reason": { "default": "mdi:information-outline" }, + "input_voltage_status": { + "default": "mdi:information-outline" + }, + "outlet_voltage": { + "default": "mdi:gauge" + }, "output_l1_power_percent": { "default": "mdi:gauge" }, diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 2f574ec4842..bb74ea617f5 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -463,6 +463,12 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.voltage.status": SensorEntityDescription( + key="input.voltage.status", + translation_key="input_voltage_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.L1-N.voltage": SensorEntityDescription( key="input.L1-N.voltage", translation_key="input_l1_n_voltage", @@ -671,6 +677,12 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "input.current.status": SensorEntityDescription( + key="input.current.status", + translation_key="input_current_status", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.L1.current": SensorEntityDescription( key="input.L1.current", translation_key="input_l1_current", @@ -698,12 +710,26 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.load": SensorEntityDescription( + key="input.load", + translation_key="input_load", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), "input.phases": SensorEntityDescription( key="input.phases", translation_key="input_phases", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.power": SensorEntityDescription( + key="input.power", + translation_key="input_power", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.realpower": SensorEntityDescription( key="input.realpower", translation_key="input_realpower", @@ -740,6 +766,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "outlet.voltage": SensorEntityDescription( + key="outlet.voltage", + translation_key="outlet_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), "output.power.nominal": SensorEntityDescription( key="output.power.nominal", translation_key="output_power_nominal", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 1cd5415b0d6..08971732bc6 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -130,6 +130,7 @@ "name": "Input bypass L3 real power" }, "input_current": { "name": "Input current" }, + "input_current_status": { "name": "Input current status" }, "input_l1_current": { "name": "Input L1 current" }, "input_l2_current": { "name": "Input L2 current" }, "input_l3_current": { "name": "Input L3 current" }, @@ -140,19 +141,23 @@ "input_l2_frequency": { "name": "Input L2 line frequency" }, "input_l3_frequency": { "name": "Input L3 line frequency" }, "input_phases": { "name": "Input phases" }, + "input_power": { "name": "Input power" }, "input_realpower": { "name": "Input real power" }, "input_l1_realpower": { "name": "Input L1 real power" }, "input_l2_realpower": { "name": "Input L2 real power" }, "input_l3_realpower": { "name": "Input L3 real power" }, + "input_load": { "name": "Input load" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, "input_transfer_reason": { "name": "Voltage transfer reason" }, "input_voltage": { "name": "Input voltage" }, "input_voltage_nominal": { "name": "Nominal input voltage" }, + "input_voltage_status": { "name": "Input voltage status" }, "input_l1_n_voltage": { "name": "Input L1 voltage" }, "input_l2_n_voltage": { "name": "Input L2 voltage" }, "input_l3_n_voltage": { "name": "Input L3 voltage" }, + "outlet_voltage": { "name": "Outlet voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, "output_l1_current": { "name": "Output L1 current" }, From 457a7216ff877f185faee5175d350fe5a2fddb73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:51:31 -1000 Subject: [PATCH 1349/1941] Bump dbus-fast to 2.35.1 (#139809) --- 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 d293d450e25..177f0d67a03 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.25.0", - "dbus-fast==2.33.0", + "dbus-fast==2.35.1", "habluetooth==3.24.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d7997e9e54d..59b9b6f14af 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.33.0 +dbus-fast==2.35.1 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index c3a085d2a7a..ae055360fff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.33.0 +dbus-fast==2.35.1 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 680005447ad..17dd1c7009d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.33.0 +dbus-fast==2.35.1 # homeassistant.components.debugpy debugpy==1.8.11 From f0ad0e6eae2b66f667724e114a0d312cc5a32691 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 17:51:46 -1000 Subject: [PATCH 1350/1941] Bump cached-ipaddress to 0.10.0 (#139807) --- 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 5b3a5abd26f..64fd2ff38c6 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.9.2" + "cached-ipaddress==0.10.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 59b9b6f14af..c00117efc66 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.25.0 -cached-ipaddress==0.9.2 +cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 diff --git a/requirements_all.txt b/requirements_all.txt index ae055360fff..b43ccf31d37 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.9.2 +cached-ipaddress==0.10.0 # homeassistant.components.caldav caldav==1.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17dd1c7009d..dfd485ef5af 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.9.2 +cached-ipaddress==0.10.0 # homeassistant.components.caldav caldav==1.3.9 From d1995086ccce3e2c62ce5bc2d9727036f99dd78e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 19:15:00 -1000 Subject: [PATCH 1351/1941] Bump habluetooth to 3.25.0 (#139811) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.24.1...v3.25.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 177f0d67a03..81a2aae990a 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.25.0", "dbus-fast==2.35.1", - "habluetooth==3.24.1" + "habluetooth==3.25.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c00117efc66..b399a1a24ba 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.35.1 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.24.1 +habluetooth==3.25.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index b43ccf31d37..ad6799e066c 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.1 +habluetooth==3.25.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfd485ef5af..0082cc31539 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.1 +habluetooth==3.25.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 From 0d329bd83df47c3a2ce57688ef419c373ed8499b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Mar 2025 07:49:18 +0100 Subject: [PATCH 1352/1941] Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#139813) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.0 to 4.6.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.6.0...v4.6.1) --- updated-dependencies: - dependency-name: actions/upload-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/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cf7b80540a1..4172d796da0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1002,7 +1002,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1136,7 +1136,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1272,7 +1272,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: test-results-postgres-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1418,7 +1418,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml From 1c045ab2228b5ba75b5faad75fcbc7b99bc7d2bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Mar 2025 08:16:42 +0100 Subject: [PATCH 1353/1941] Bump actions/download-artifact from 4.1.8 to 4.1.9 (#139814) 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/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4172d796da0..07cbc13594c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1463,7 +1463,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.8 + uses: actions/download-artifact@v4.1.9 with: pattern: test-results-* - name: Upload test results to Codecov From 1fb02944b740d241ea2ae49211a5e9ae47401527 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 5 Mar 2025 09:04:55 +0100 Subject: [PATCH 1354/1941] 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 916087075cc..eee1d22dcb0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3650,7 +3650,7 @@ "iot_class": "cloud_push" }, "matter": { - "name": "Matter (BETA)", + "name": "Matter", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" From 13dfd27b7ee581dbe24294eec02486268b2de435 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 5 Mar 2025 09:07:45 +0100 Subject: [PATCH 1355/1941] Clean Home Connect error handling (#139817) --- .../components/home_connect/coordinator.py | 18 +++++------------- homeassistant/components/home_connect/utils.py | 8 ++------ 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 7898fb7be12..dfac68084d1 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -266,7 +266,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Non-breaking error (%s) while listening for events," " continuing in %s seconds", - type(error).__name__, + error, retry_time, ) await asyncio.sleep(retry_time) @@ -343,9 +343,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Error fetching settings for %s: %s", appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) settings = {} try: @@ -357,9 +355,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Error fetching status for %s: %s", appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) status = {} @@ -373,9 +369,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Error fetching programs for %s: %s", appliance.ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) else: programs.extend(all_programs.programs) @@ -465,9 +459,7 @@ class HomeConnectCoordinator( _LOGGER.debug( "Error fetching options for %s: %s", ha_id, - error - if isinstance(error, HomeConnectApiError) - else type(error).__name__, + error, ) return {} diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py index 108465072e1..ee5febb3cf7 100644 --- a/homeassistant/components/home_connect/utils.py +++ b/homeassistant/components/home_connect/utils.py @@ -2,7 +2,7 @@ import re -from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError +from aiohomeconnect.model.error import 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__ - } + return {"error": str(err)} def bsh_key_to_translation_key(bsh_key: str) -> str: From 36412a034d10ce53f8a2986ffd41c1854dfb1e3c Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Wed, 5 Mar 2025 08:27:10 +0000 Subject: [PATCH 1356/1941] Bump ohmepy to 1.4.0 (#139791) --- 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 fb11fa0dd06..f31af213387 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.2"] + "requirements": ["ohme==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ad6799e066c..e50533e7c0a 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.2 +ohme==1.4.0 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0082cc31539..d89ce212743 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.2 +ohme==1.4.0 # homeassistant.components.ollama ollama==0.4.7 From bba889975ab7e2c116922edc3b77fddb64d87b80 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 5 Mar 2025 04:45:23 +0200 Subject: [PATCH 1357/1941] 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 1358/1941] 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 2a11c413c7678a8e4e6c8c7abe3f8deb14727e78 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 1359/1941] 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 e50533e7c0a..a12305a317b 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 d89ce212743..da65fc8ec24 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 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 1360/1941] 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 1361/1941] 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 09561aeb397042b1c48aa7b3a3a2633afe2b4591 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 10:43:29 +0100 Subject: [PATCH 1362/1941] Improve frame helper tests (#139821) --- tests/helpers/test_frame.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index fb98111fd42..d86693dcf9b 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -449,11 +449,18 @@ async def test_report( @pytest.mark.parametrize( - ("behavior", "integration_domain", "source", "logs_again"), + ( + "behavior", + "integration_domain", + "integration_frame_path", + "source", + "logs_again", + ), [ pytest.param( "core_behavior", None, + "homeassistant", "code that", True, id="core", @@ -461,6 +468,7 @@ async def test_report( pytest.param( "core_behavior", "unknown_integration", + "homeassistant", "code that", True, id="unknown integration", @@ -468,6 +476,7 @@ async def test_report( pytest.param( "core_integration_behavior", "sensor", + "homeassistant", "that integration 'sensor'", False, id="core integration", @@ -475,13 +484,32 @@ async def test_report( pytest.param( "custom_integration_behavior", "test_package", + "homeassistant", "that custom integration 'test_package'", False, id="custom integration", ), + # Assert integration found in stack frame has priority over integration_domain + pytest.param( + "core_integration_behavior", + "sensor", + "homeassistant/components/hue", + "that integration 'hue'", + False, + id="core integration", + ), + # Assert integration found in stack frame has priority over integration_domain + pytest.param( + "custom_integration_behavior", + "test_package", + "custom_components/hue", + "that custom integration 'hue'", + False, + id="custom integration", + ), ], ) -@pytest.mark.usefixtures("enable_custom_integrations") +@pytest.mark.usefixtures("enable_custom_integrations", "mock_integration_frame") async def test_report_integration_domain( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, From 5f88354cb329836c03ba6ed0460462e1ce47d04b Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Wed, 5 Mar 2025 09:59:47 +0000 Subject: [PATCH 1363/1941] Add vehicle select to Ohme (#139795) * Add vehicle select to Ohme * mypy fixes * Update homeassistant/components/ohme/select.py Co-authored-by: Josef Zweck --------- Co-authored-by: Josef Zweck --- homeassistant/components/ohme/icons.json | 3 + homeassistant/components/ohme/select.py | 30 +++++++++- homeassistant/components/ohme/strings.json | 3 + tests/components/ohme/conftest.py | 2 + .../ohme/snapshots/test_select.ambr | 56 +++++++++++++++++++ 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 9771b0bf5c2..0e4d58a5294 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -16,6 +16,9 @@ "select": { "charge_mode": { "default": "mdi:play-box" + }, + "vehicle": { + "default": "mdi:car" } }, "sensor": { diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py index 17cc7c67e9a..f065afeb176 100644 --- a/homeassistant/components/ohme/select.py +++ b/homeassistant/components/ohme/select.py @@ -25,10 +25,12 @@ class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription): """Class to describe an Ohme select entity.""" select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]] + options: list[str] | None = None + options_fn: Callable[[OhmeApiClient], list[str]] | None = None current_option_fn: Callable[[OhmeApiClient], str | None] -SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( +MODE_SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( key="charge_mode", translation_key="charge_mode", select_fn=lambda client, mode: client.async_set_mode(mode), @@ -37,6 +39,14 @@ SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( available_fn=lambda client: client.mode is not None, ) +VEHICLE_SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription( + key="vehicle", + translation_key="vehicle", + select_fn=lambda client, selection: client.async_set_vehicle(selection), + options_fn=lambda client: client.vehicles, + current_option_fn=lambda client: client.current_vehicle or None, +) + async def async_setup_entry( hass: HomeAssistant, @@ -44,9 +54,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Ohme selects.""" - coordinator = config_entry.runtime_data.charge_session_coordinator + charge_sessions_coordinator = config_entry.runtime_data.charge_session_coordinator + device_info_coordinator = config_entry.runtime_data.device_info_coordinator - async_add_entities([OhmeSelect(coordinator, SELECT_DESCRIPTION)]) + async_add_entities( + [ + OhmeSelect(charge_sessions_coordinator, MODE_SELECT_DESCRIPTION), + OhmeSelect(device_info_coordinator, VEHICLE_SELECT_DESCRIPTION), + ] + ) class OhmeSelect(OhmeEntity, SelectEntity): @@ -64,6 +80,14 @@ class OhmeSelect(OhmeEntity, SelectEntity): ) from e await self.coordinator.async_request_refresh() + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + if self.entity_description.options_fn: + return self.entity_description.options_fn(self.coordinator.client) + assert self.entity_description.options + return self.entity_description.options + @property def current_option(self) -> str | None: """Return the current selected option.""" diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 4c845daa8f0..187e825c159 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -66,6 +66,9 @@ "max_charge": "Max charge", "paused": "Paused" } + }, + "vehicle": { + "name": "Vehicle" } }, "sensor": { diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index 01cc668ae32..d05e34d1ed2 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -66,4 +66,6 @@ def mock_client(): "model": "Home Pro", "sw_version": "v2.65", } + client.vehicles = ["Nissan Leaf", "Tesla Model 3"] + client.current_vehicle = "Nissan Leaf" yield client diff --git a/tests/components/ohme/snapshots/test_select.ambr b/tests/components/ohme/snapshots/test_select.ambr index 8eec0556889..063a9616588 100644 --- a/tests/components/ohme/snapshots/test_select.ambr +++ b/tests/components/ohme/snapshots/test_select.ambr @@ -57,3 +57,59 @@ 'state': 'unknown', }) # --- +# name: test_selects[select.ohme_home_pro_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Nissan Leaf', + 'Tesla Model 3', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ohme_home_pro_vehicle', + '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': 'Vehicle', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle', + 'unique_id': 'chargerid_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[select.ohme_home_pro_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Vehicle', + 'options': list([ + 'Nissan Leaf', + 'Tesla Model 3', + ]), + }), + 'context': , + 'entity_id': 'select.ohme_home_pro_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Nissan Leaf', + }) +# --- From 7fe75a959fbe0aa4a053d5c9c888ceb9221f8c0d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Mar 2025 11:54:58 +0100 Subject: [PATCH 1364/1941] 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 b399a1a24ba..1df15df867f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.25.0 hass-nabucasa==0.94.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 a12305a317b..535f3eace46 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 da65fc8ec24..e27a33ce02f 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 2c2fd76270e353cc14c4eed0062c49d8f7afc3b1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Mar 2025 11:54:58 +0100 Subject: [PATCH 1365/1941] 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 1366/1941] 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 df2248bb8286bd29face578a00ac31ffafae18ef Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 5 Mar 2025 20:13:11 +0900 Subject: [PATCH 1367/1941] 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 1c1a950c05db4b2b506cff6b00b7cefce2c2e8df Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Wed, 5 Mar 2025 04:12:56 -0800 Subject: [PATCH 1368/1941] Add conditional support for ambient sensors in NUT (#139675) * Conditionally remove ambient sensors if not present * Create ambient sensors list and use list comprehension * Update homeassistant/components/nut/sensor.py Co-authored-by: J. Nick Koston --------- Co-authored-by: J. Nick Koston --- homeassistant/components/nut/sensor.py | 11 + .../EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json | 539 ++++++++++++++++++ tests/components/nut/test_sensor.py | 32 ++ 3 files changed, 582 insertions(+) create mode 100644 tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index bb74ea617f5..1484f11dac7 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -46,6 +46,13 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { "serial": ATTR_SERIAL_NUMBER, } +AMBIENT_PRESENT = "ambient.present" +AMBIENT_SENSORS = { + "ambient.humidity", + "ambient.humidity.status", + "ambient.temperature", + "ambient.temperature.status", +} AMBIENT_THRESHOLD_STATUS_OPTIONS = [ "good", "warning-low", @@ -1035,6 +1042,10 @@ async def async_setup_entry( if KEY_STATUS in resources: resources.append(KEY_STATUS_DISPLAY) + # If device reports ambient sensors are not present, then remove + if status.get(AMBIENT_PRESENT) == "no": + resources = [item for item in resources if item not in AMBIENT_SENSORS] + async_add_entities( NUTSensor( coordinator, diff --git a/tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json b/tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.json new file mode 100644 index 00000000000..96394e618c9 --- /dev/null +++ b/tests/components/nut/fixtures/EATON-EPDU-G3-AMBIENT-NOT-PRESENT.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": "no", + "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_sensor.py b/tests/components/nut/test_sensor.py index eb171c39011..6483d581070 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -241,3 +241,35 @@ async def test_stale_options( state = hass.states.get("sensor.ups1_battery_charge") assert state.state == "10" + + +@pytest.mark.parametrize( + ("model", "unique_id_base"), + [ + ( + "EATON-EPDU-G3-AMBIENT-NOT-PRESENT", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + ), + ], +) +async def test_pdu_devices_ambient_not_present( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test that ambient sensors not created.""" + + await async_init_integration(hass, model) + + entry = entity_registry.async_get("sensor.ups1_ambient_humidity") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_ambient_humidity_status") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_ambient_temperature") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_ambient_temperature_status") + assert not entry From f0bba1d6d4d29ea34d86a5f86d46adfb75645c2f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 5 Mar 2025 13:52:29 +0100 Subject: [PATCH 1369/1941] Fix disable test results uploads properly (#139827) * Fix disable test results uploads properly * use dedicated variable * fix pushes --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 07cbc13594c..f8f14f2a126 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1452,7 +1452,7 @@ jobs: name: Upload test results to Codecov # codecov/test-results-action currently doesn't support tokenless uploads # therefore we can't run it on forks - if: github.repository_owner == 'home-assistant' && needs.info.outputs.skip_coverage != 'true' && !cancelled() + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.info.outputs.skip_coverage != 'true' && !cancelled() }} runs-on: ubuntu-24.04 needs: - info From c0e5a549b6b83d6aa1ae17cd3feefce06596e54f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 5 Mar 2025 05:36:20 -0800 Subject: [PATCH 1370/1941] 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 61e0b938aeb4d3c252c7940a55b94786a7a275b7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 5 Mar 2025 14:56:30 +0100 Subject: [PATCH 1371/1941] Convert Shelly block switches to EntityDescription (#106985) --- homeassistant/components/shelly/switch.py | 91 ++++++++++------------- homeassistant/components/shelly/utils.py | 14 +++- 2 files changed, 52 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 68708a2cc2b..ce9e4f065fb 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, RPC_GENERATIONS +from aioshelly.const import RPC_GENERATIONS from homeassistant.components.climate import DOMAIN as CLIMATE_PLATFORM from homeassistant.components.switch import ( @@ -21,12 +21,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD, MOTION_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, RpcEntityDescription, - ShellyBlockEntity, + ShellyBlockAttributeEntity, ShellyRpcAttributeEntity, ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, @@ -34,10 +33,9 @@ from .entity import ( ) from .utils import ( async_remove_orphaned_entities, - async_remove_shelly_entity, get_device_entry_gen, get_virtual_component_ids, - is_block_channel_type_light, + is_block_exclude_from_relay, is_rpc_exclude_from_relay, ) @@ -47,11 +45,20 @@ class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): """Class to describe a BLOCK switch.""" -MOTION_SWITCH = BlockSwitchDescription( - key="sensor|motionActive", - name="Motion detection", - entity_category=EntityCategory.CONFIG, -) +BLOCK_RELAY_SWITCHES = { + ("relay", "output"): BlockSwitchDescription( + key="relay|output", + removal_condition=is_block_exclude_from_relay, + ) +} + +BLOCK_SLEEPING_MOTION_SWITCH = { + ("sensor", "motionActive"): BlockSwitchDescription( + key="sensor|motionActive", + name="Motion detection", + entity_category=EntityCategory.CONFIG, + ) +} @dataclass(frozen=True, kw_only=True) @@ -120,46 +127,17 @@ def async_setup_block_entry( coordinator = config_entry.runtime_data.block assert coordinator - # Add Shelly Motion as a switch - if coordinator.model in MOTION_MODELS: - async_setup_entry_attribute_entities( - hass, - config_entry, - async_add_entities, - {("sensor", "motionActive"): MOTION_SWITCH}, - BlockSleepingMotionSwitch, - ) - return + async_setup_entry_attribute_entities( + hass, config_entry, async_add_entities, BLOCK_RELAY_SWITCHES, BlockRelaySwitch + ) - if config_entry.data[CONF_SLEEP_PERIOD]: - return - - # In roller mode the relay blocks exist but do not contain required info - if ( - coordinator.model in [MODEL_2, MODEL_25] - and coordinator.device.settings["mode"] != "relay" - ): - return - - relay_blocks = [] - assert coordinator.device.blocks - for block in coordinator.device.blocks: - if block.type != "relay" or ( - block.channel is not None - and is_block_channel_type_light( - coordinator.device.settings, int(block.channel) - ) - ): - continue - - relay_blocks.append(block) - unique_id = f"{coordinator.mac}-{block.type}_{block.channel}" - async_remove_shelly_entity(hass, "light", unique_id) - - if not relay_blocks: - return - - async_add_entities(BlockRelaySwitch(coordinator, block) for block in relay_blocks) + async_setup_entry_attribute_entities( + hass, + config_entry, + async_add_entities, + BLOCK_SLEEPING_MOTION_SWITCH, + BlockSleepingMotionSwitch, + ) @callback @@ -265,13 +243,22 @@ class BlockSleepingMotionSwitch( self.last_state = last_state -class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): +class BlockRelaySwitch(ShellyBlockAttributeEntity, SwitchEntity): """Entity that controls a relay on Block based Shelly devices.""" - def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: + entity_description: BlockSwitchDescription + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockSwitchDescription, + ) -> None: """Initialize relay switch.""" - super().__init__(coordinator, block) + super().__init__(coordinator, block, attribute, description) self.control_result: dict[str, Any] | None = None + self._attr_unique_id: str = f"{coordinator.mac}-{block.description}" @property def is_on(self) -> bool: diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d9e86427d0b..b478e416c50 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address, ip_address from types import MappingProxyType -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import COAP, Block, BlockDevice @@ -175,6 +175,18 @@ def is_block_momentary_input( return button_type in momentary_types +def is_block_exclude_from_relay(settings: dict[str, Any], block: Block) -> bool: + """Return true if block should be excluded from switch platform.""" + + if settings.get("mode") == "roller": + return True + + if TYPE_CHECKING: + assert block.channel is not None + + return is_block_channel_type_light(settings, int(block.channel)) + + def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime: """Return device uptime string, tolerate up to 5 seconds deviation.""" delta_uptime = utcnow() - timedelta(seconds=uptime) From c69cec28fe6a5c56358b4252c8e19d816b334cb3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 5 Mar 2025 15:04:56 +0100 Subject: [PATCH 1372/1941] Bump `gios` to version 6.0.0 (#139832) * Fix the code * Fix tests * Bump version * Use https for configuration URL --- homeassistant/components/gios/__init__.py | 11 ++++++++++- homeassistant/components/gios/config_flow.py | 2 +- homeassistant/components/gios/const.py | 2 +- homeassistant/components/gios/coordinator.py | 6 ++---- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index c76efbcf361..f756980f5d0 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -4,9 +4,14 @@ from __future__ import annotations import logging +from aiohttp.client_exceptions import ClientConnectorError +from gios import Gios +from gios.exceptions import GiosError + from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -36,8 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool device_registry.async_update_device(device_entry.id, new_identifiers={new_ids}) websession = async_get_clientsession(hass) + try: + gios = await Gios.create(websession, station_id) + except (GiosError, ConnectionError, ClientConnectorError) as err: + raise ConfigEntryNotReady from err - coordinator = GiosDataUpdateCoordinator(hass, entry, websession, station_id) + coordinator = GiosDataUpdateCoordinator(hass, entry, gios) await coordinator.async_config_entry_first_refresh() entry.runtime_data = GiosData(coordinator) diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index a089aeab820..ecd0baee6f9 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -37,7 +37,7 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN): websession = async_get_clientsession(self.hass) async with asyncio.timeout(API_TIMEOUT): - gios = Gios(user_input[CONF_STATION_ID], websession) + gios = await Gios.create(websession, user_input[CONF_STATION_ID]) await gios.async_update() assert gios.station_name is not None diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index a8490511ab8..2294e89c961 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -13,7 +13,7 @@ SCAN_INTERVAL: Final = timedelta(minutes=30) DOMAIN: Final = "gios" MANUFACTURER: Final = "Główny Inspektorat Ochrony Środowiska" -URL = "http://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id}" +URL = "https://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id}" API_TIMEOUT: Final = 30 diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index be4b41ca6ee..95f3b8af797 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -6,7 +6,6 @@ import asyncio from dataclasses import dataclass import logging -from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from gios import Gios from gios.exceptions import GiosError @@ -39,11 +38,10 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): self, hass: HomeAssistant, config_entry: GiosConfigEntry, - session: ClientSession, - station_id: int, + gios: Gios, ) -> None: """Class to manage fetching GIOS data API.""" - self.gios = Gios(station_id, session) + self.gios = gios super().__init__( hass, diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 3d2e719fab6..8deb2eee414 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==5.0.0"] + "requirements": ["gios==6.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 535f3eace46..7a600a83c07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1008,7 +1008,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==5.0.0 +gios==6.0.0 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e27a33ce02f..fa66d8b0552 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -861,7 +861,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==5.0.0 +gios==6.0.0 # homeassistant.components.glances glances-api==0.8.0 From 1552aec416d3b3d72b77664932249db3b88aff2f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 16:13:09 +0100 Subject: [PATCH 1373/1941] Improve frame helper tests (#139843) --- tests/helpers/snapshots/test_frame.ambr | 120 ++++++++++++++ tests/helpers/test_frame.py | 209 +++++++++++++++++++----- 2 files changed, 286 insertions(+), 43 deletions(-) create mode 100644 tests/helpers/snapshots/test_frame.ambr diff --git a/tests/helpers/snapshots/test_frame.ambr b/tests/helpers/snapshots/test_frame.ambr new file mode 100644 index 00000000000..f3fbd54cf45 --- /dev/null +++ b/tests/helpers/snapshots/test_frame.ambr @@ -0,0 +1,120 @@ +# serializer version: 1 +# name: test_report[core default] + list([ + ]) +# --- +# name: test_report[core integration default] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report[custom integration default] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + ]) +# --- +# name: test_report[disable error_if_core] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report[error_if_integration with core integration] + list([ + "Detected that integration 'test_integration_frame' test_report_string at homeassistant/components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + ]) +# --- +# name: test_report[error_if_integration with custom integration] + list([ + "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + ]) +# --- +# name: test_report[log_custom_component_only with core integration] + list([ + ]) +# --- +# name: test_report[log_custom_component_only with custom integration] + list([ + "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + ]) +# --- +# name: test_report_usage[core default] + list([ + ]) +# --- +# name: test_report_usage[core integration default] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report_usage[core_behavior ignore] + list([ + ]) +# --- +# name: test_report_usage[core_behavior log] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report_usage[core_integration_behavior error] + list([ + "Detected that integration 'test_integration_frame' test_report_string at homeassistant/components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + ]) +# --- +# name: test_report_usage[core_integration_behavior ignore] + list([ + ]) +# --- +# name: test_report_usage[custom integration default] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + ]) +# --- +# name: test_report_usage[custom integration error] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + ]) +# --- +# name: test_report_usage[custom integration ignore] + list([ + ]) +# --- +# name: test_report_usage_find_issue_tracker[core integration] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker[core] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report_usage_find_issue_tracker[custom integration] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://blablabla.com", + ]) +# --- +# name: test_report_usage_find_issue_tracker[unknown custom integration] + list([ + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+unknown_custom_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[core integration] + list([ + "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[core] + list([ + 'Detected code that test_report_string. Please report this issue', + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[custom integration] + list([ + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + ]) +# --- +# name: test_report_usage_find_issue_tracker_other_thread[unknown custom integration] + list([ + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+unknown_custom_integration%22", + ]) +# --- diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index d86693dcf9b..22209380dfe 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,15 +1,17 @@ """Test the frame helper.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from typing import Any from unittest.mock import ANY, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.helpers import frame from homeassistant.loader import async_get_integration -from tests.common import extract_stack_to_frame +from tests.common import MockModule, extract_stack_to_frame, mock_integration async def test_extract_frame_integration( @@ -159,68 +161,68 @@ async def test_get_integration_logger_no_integration( @pytest.mark.parametrize( - ("integration_frame_path", "keywords", "expected_error", "expected_log"), + ("integration_frame_path", "keywords", "expected_result", "expected_log"), [ pytest.param( "homeassistant/test_core", {}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 0, id="core default", ), pytest.param( "homeassistant/components/test_core_integration", {}, - False, + does_not_raise(), 1, id="core integration default", ), pytest.param( "custom_components/test_custom_integration", {}, - False, + does_not_raise(), 1, id="custom integration default", ), pytest.param( "custom_components/test_custom_integration", {"custom_integration_behavior": frame.ReportBehavior.IGNORE}, - False, + does_not_raise(), 0, id="custom integration ignore", ), pytest.param( "custom_components/test_custom_integration", {"custom_integration_behavior": frame.ReportBehavior.ERROR}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="custom integration error", ), pytest.param( "homeassistant/components/test_integration_frame", {"core_integration_behavior": frame.ReportBehavior.IGNORE}, - False, + does_not_raise(), 0, id="core_integration_behavior ignore", ), pytest.param( "homeassistant/components/test_integration_frame", {"core_integration_behavior": frame.ReportBehavior.ERROR}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="core_integration_behavior error", ), pytest.param( "homeassistant/test_integration_frame", {"core_behavior": frame.ReportBehavior.IGNORE}, - False, + does_not_raise(), 0, id="core_behavior ignore", ), pytest.param( "homeassistant/test_integration_frame", {"core_behavior": frame.ReportBehavior.LOG}, - False, + does_not_raise(), 1, id="core_behavior log", ), @@ -229,24 +231,142 @@ async def test_get_integration_logger_no_integration( @pytest.mark.usefixtures("mock_integration_frame") async def test_report_usage( caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, keywords: dict[str, Any], - expected_error: bool, + expected_result: AbstractContextManager, expected_log: int, ) -> None: - """Test report.""" + """Test report_usage. + + Note: This test doesn't set up mock integrations, so it will not + find the correct issue tracker URL, and we don't check for that. + """ what = "test_report_string" - errored = False - try: - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - frame.report_usage(what, **keywords) - except RuntimeError: - errored = True - - assert errored == expected_error + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expected_result: + frame.report_usage(what, **keywords) assert caplog.text.count(what) == expected_log + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot + + +@pytest.mark.parametrize( + "integration_frame_path", + [ + pytest.param( + "homeassistant/test_core", + id="core", + ), + pytest.param( + "homeassistant/components/test_core_integration", + id="core integration", + ), + pytest.param( + "custom_components/test_custom_integration", + id="custom integration", + ), + pytest.param( + "custom_components/unknown_custom_integration", + id="unknown custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report_usage_find_issue_tracker( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test report_usage finds the correct issue tracker. + + Note: The issue tracker is found by loader.async_suggest_report_issue, this + test is a sanity check to ensure async_suggest_report_issue is given the + right parameters. + """ + + what = "test_report_string" + mock_integration(hass, MockModule("test_core_integration")) + mock_integration( + hass, + MockModule( + "test_custom_integration", + partial_manifest={"issue_tracker": "https://blablabla.com"}, + ), + built_in=False, + ) + + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) + + assert caplog.text.count(what) == 1 + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot + + +@pytest.mark.parametrize( + "integration_frame_path", + [ + pytest.param( + "homeassistant/test_core", + id="core", + ), + pytest.param( + "homeassistant/components/test_core_integration", + id="core integration", + ), + pytest.param( + "custom_components/test_custom_integration", + id="custom integration", + ), + pytest.param( + "custom_components/unknown_custom_integration", + id="unknown custom integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report_usage_find_issue_tracker_other_thread( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test report_usage finds the correct issue tracker. + + In this test, we run the report_usage in a separate thread. + + Note: The issue tracker is found by loader.async_suggest_report_issue, this + test is a sanity check to ensure async_suggest_report_issue is given the + right parameters. + """ + + what = "test_report_string" + mock_integration(hass, MockModule("test_core_integration")) + mock_integration( + hass, + MockModule( + "test_custom_integration", + partial_manifest={"issue_tracker": "https://blablabla.com"}, + ), + built_in=False, + ) + + def sync_job() -> None: + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) + + await hass.async_add_executor_job(sync_job) + + assert caplog.text.count(what) == 1 + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @@ -365,61 +485,61 @@ async def test_report_error_if_integration( @pytest.mark.parametrize( - ("integration_frame_path", "keywords", "expected_error", "expected_log"), + ("integration_frame_path", "keywords", "expected_result", "expected_log"), [ pytest.param( "homeassistant/test_core", {}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 0, id="core default", ), pytest.param( "homeassistant/components/test_core_integration", {}, - False, + does_not_raise(), 1, id="core integration default", ), pytest.param( "custom_components/test_custom_integration", {}, - False, + does_not_raise(), 1, id="custom integration default", ), pytest.param( "custom_components/test_integration_frame", {"log_custom_component_only": True}, - False, + does_not_raise(), 1, id="log_custom_component_only with custom integration", ), pytest.param( "homeassistant/components/test_integration_frame", {"log_custom_component_only": True}, - False, + does_not_raise(), 0, id="log_custom_component_only with core integration", ), pytest.param( "homeassistant/test_integration_frame", {"error_if_core": False}, - False, + does_not_raise(), 1, id="disable error_if_core", ), pytest.param( "custom_components/test_integration_frame", {"error_if_integration": True}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="error_if_integration with custom integration", ), pytest.param( "homeassistant/components/test_integration_frame", {"error_if_integration": True}, - True, + pytest.raises(RuntimeError, match="test_report_string"), 1, id="error_if_integration with core integration", ), @@ -428,24 +548,27 @@ async def test_report_error_if_integration( @pytest.mark.usefixtures("mock_integration_frame") async def test_report( caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, keywords: dict[str, Any], - expected_error: bool, + expected_result: AbstractContextManager, expected_log: int, ) -> None: - """Test report.""" + """Test report. + + Note: This test doesn't set up mock integrations, so it will not + find the correct issue tracker URL, and we don't check for that. + """ what = "test_report_string" - errored = False - try: - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - frame.report(what, **keywords) - except RuntimeError: - errored = True - - assert errored == expected_error + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expected_result: + frame.report(what, **keywords) assert caplog.text.count(what) == expected_log + reports = [ + rec.message for rec in caplog.records if rec.message.startswith("Detected") + ] + assert reports == snapshot @pytest.mark.parametrize( @@ -496,7 +619,7 @@ async def test_report( "homeassistant/components/hue", "that integration 'hue'", False, - id="core integration", + id="core integration stack mismatch", ), # Assert integration found in stack frame has priority over integration_domain pytest.param( @@ -505,7 +628,7 @@ async def test_report( "custom_components/hue", "that custom integration 'hue'", False, - id="custom integration", + id="custom integration stack mismatch", ), ], ) @@ -518,7 +641,7 @@ async def test_report_integration_domain( source: str, logs_again: bool, ) -> None: - """Test report.""" + """Test report_usage when integration_domain is specified.""" await async_get_integration(hass, "sensor") await async_get_integration(hass, "test_package") From fffb414ba920b3cf18408646e4f99bfc86d2f752 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 5 Mar 2025 16:19:15 +0100 Subject: [PATCH 1374/1941] 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 7a600a83c07..5d8a5b79acc 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 fa66d8b0552..cc8c6b2208a 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 e7d371cddc398f392bbd7a54df3d3249c06a54df Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 5 Mar 2025 17:04:41 +0100 Subject: [PATCH 1375/1941] 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 5d8a5b79acc..efd9ab91eff 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 cc8c6b2208a..a92e0b8134e 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 0f3409bd094f09ab441472d2acd5b3e2e2dae885 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 17:07:43 +0100 Subject: [PATCH 1376/1941] Fix stale test name in vacuum (#139853) --- tests/components/vacuum/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 8ae054b5646..5735d557288 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -428,7 +428,7 @@ async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( @pytest.mark.usefixtures("mock_as_custom_component") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_alarm_control_panel_deprecated_state_does_not_break_state( +async def test_vacuum_deprecated_state_does_not_break_state( hass: HomeAssistant, config_flow_fixture: None, caplog: pytest.LogCaptureFixture, From b225a7f37012e5507189f11d2438441e1abd51a3 Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Wed, 5 Mar 2025 18:12:34 +0100 Subject: [PATCH 1377/1941] Bump pysuezV2 to 2.0.4 (#139824) --- homeassistant/components/suez_water/coordinator.py | 4 ++-- homeassistant/components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 38f94b8937e..10d4d3cdbcb 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -20,8 +20,8 @@ class SuezWaterAggregatedAttributes: this_month_consumption: dict[str, float] previous_month_consumption: dict[str, float] - last_year_overall: dict[str, float] - this_year_overall: dict[str, float] + last_year_overall: int + this_year_overall: int history: dict[str, float] highest_monthly_consumption: float diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 5d317ea5ba3..f09d2e22633 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], "quality_scale": "bronze", - "requirements": ["pysuezV2==2.0.3"] + "requirements": ["pysuezV2==2.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index efd9ab91eff..19a74b01d90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2346,7 +2346,7 @@ pysqueezebox==0.12.0 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==2.0.3 +pysuezV2==2.0.4 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a92e0b8134e..60d767c2199 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1915,7 +1915,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.12.0 # homeassistant.components.suez_water -pysuezV2==2.0.3 +pysuezV2==2.0.4 # homeassistant.components.switchbee pyswitchbee==1.8.3 From cfe102f274404045da4bdff26ce2308e97a5793a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Mar 2025 11:20:48 -0600 Subject: [PATCH 1378/1941] 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 1df15df867f..4a2d4219b50 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ hass-nabucasa==0.94.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 19a74b01d90..1d8947d861b 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 60d767c2199..b47e238e1f2 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 c09d547ba79..9d0bbeefd74 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.8 \ - 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 2812c8a9930309ab2b957f6e8047cb7ef229c117 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 5 Mar 2025 20:13:11 +0900 Subject: [PATCH 1379/1941] 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 1380/1941] 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 1381/1941] 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 1382/1941] 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 1383/1941] 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 1384/1941] 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 1385/1941] 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 1386/1941] 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 1f24e5aec4821da7aac9cd276fb0d0c41371f01e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 5 Mar 2025 18:41:21 +0100 Subject: [PATCH 1387/1941] 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 969df42bed9..9e2178196d5 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -199,11 +199,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 From 98e317dd5560e1168f0db5b0b1ec6331ef903bad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 5 Mar 2025 18:41:21 +0100 Subject: [PATCH 1388/1941] 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 From cfaf18f942191745c7ef731c5e57aad07f322df8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 18:42:34 +0100 Subject: [PATCH 1389/1941] Improve the mock_integration_frame test fixture (#139850) * Improve the mock_integration_frame test fixture * Update test --- tests/conftest.py | 4 ++++ tests/helpers/snapshots/test_frame.ambr | 16 ++++++++-------- tests/helpers/test_frame.py | 2 +- tests/test_config_entries.py | 3 ++- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2f7330ebf22..dc834633774 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ import reprlib from shutil import rmtree import sqlite3 import ssl +import sys import threading from typing import TYPE_CHECKING, Any, cast from unittest.mock import AsyncMock, MagicMock, Mock, _patch, patch @@ -1889,12 +1890,15 @@ def mock_integration_frame(integration_frame_path: str) -> Generator[Mock]: Defaults to calling from `hue` core integration, and can be parametrized with `integration_frame_path`. """ + correct_filename = f"/home/paulus/{integration_frame_path}/light.py" + correct_module_name = f"{integration_frame_path.replace('/', '.')}.light" correct_frame = Mock( filename=f"/home/paulus/{integration_frame_path}/light.py", lineno="23", line="self.light.is_on", ) with ( + patch.dict(sys.modules, {correct_module_name: Mock(__file__=correct_filename)}), patch( "homeassistant.helpers.frame.linecache.getline", return_value=correct_frame.line, diff --git a/tests/helpers/snapshots/test_frame.ambr b/tests/helpers/snapshots/test_frame.ambr index f3fbd54cf45..996fd33ada4 100644 --- a/tests/helpers/snapshots/test_frame.ambr +++ b/tests/helpers/snapshots/test_frame.ambr @@ -10,7 +10,7 @@ # --- # name: test_report[custom integration default] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", ]) # --- # name: test_report[disable error_if_core] @@ -25,7 +25,7 @@ # --- # name: test_report[error_if_integration with custom integration] list([ - "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_integration_frame' custom integration", ]) # --- # name: test_report[log_custom_component_only with core integration] @@ -34,7 +34,7 @@ # --- # name: test_report[log_custom_component_only with custom integration] list([ - "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", + "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_integration_frame' custom integration", ]) # --- # name: test_report_usage[core default] @@ -66,12 +66,12 @@ # --- # name: test_report_usage[custom integration default] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", ]) # --- # name: test_report_usage[custom integration error] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", ]) # --- # name: test_report_usage[custom integration ignore] @@ -95,7 +95,7 @@ # --- # name: test_report_usage_find_issue_tracker[unknown custom integration] list([ - "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+unknown_custom_integration%22", + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'unknown_custom_integration' custom integration", ]) # --- # name: test_report_usage_find_issue_tracker_other_thread[core integration] @@ -110,11 +110,11 @@ # --- # name: test_report_usage_find_issue_tracker_other_thread[custom integration] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_custom_integration%22", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", ]) # --- # name: test_report_usage_find_issue_tracker_other_thread[unknown custom integration] list([ - "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+unknown_custom_integration%22", + "Detected that custom integration 'unknown_custom_integration' test_report_string at custom_components/unknown_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'unknown_custom_integration' custom integration", ]) # --- diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 22209380dfe..6d53088d9df 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -23,7 +23,7 @@ async def test_extract_frame_integration( custom_integration=False, frame=mock_integration_frame, integration="hue", - module=None, + module="homeassistant.components.hue.light", relative_filename="homeassistant/components/hue/light.py", ) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 66aa29d95d1..857c5952df9 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8849,7 +8849,8 @@ async def test_options_flow_deprecated_config_entry_setter( "config_entry explicitly, which is deprecated at " "custom_components/my_integration/light.py, line 23: " "self.light.is_on. This will stop working in Home Assistant 2025.12, please " - "create a bug report at " in caplog.text + "report it to the author of the 'my_integration' custom integration" + in caplog.text ) From cc5c8bf5e3bc5d6fca9598a7324d003e0c49c74e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Mar 2025 19:37:34 +0100 Subject: [PATCH 1390/1941] Make helpers.frame.report_usage work when called from any thread (#139836) * Make helpers.frame.report_usage work when called from any thread * Address review comments, update tests * Add test * Update test * Update recorder test * Update tests --- homeassistant/bootstrap.py | 4 +- homeassistant/helpers/frame.py | 64 +++++++++++++++++-- tests/components/history_stats/test_sensor.py | 1 + tests/components/recorder/test_pool.py | 17 ++++- tests/conftest.py | 3 + tests/helpers/snapshots/test_frame.ambr | 2 +- tests/helpers/test_condition.py | 2 + tests/helpers/test_config_validation.py | 4 ++ tests/helpers/test_frame.py | 27 +++++--- tests/helpers/test_selector.py | 1 + tests/helpers/test_template.py | 2 + tests/test_config_entries.py | 4 +- 12 files changed, 113 insertions(+), 18 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index cf8e5e1ea09..734439842b2 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -81,6 +81,7 @@ from .helpers import ( entity, entity_registry, floor_registry, + frame, issue_registry, label_registry, recorder, @@ -441,9 +442,10 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None: if DATA_REGISTRIES_LOADED in hass.data: return hass.data[DATA_REGISTRIES_LOADED] = None - translation.async_setup(hass) entity.async_setup(hass) + frame.async_setup(hass) template.async_setup(hass) + translation.async_setup(hass) await asyncio.gather( create_eager_task(get_internal_store_manager(hass).async_initialize()), create_eager_task(area_registry.async_load(hass)), diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index f33f8407e47..3416c8d49f6 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -10,18 +10,20 @@ import functools import linecache import logging import sys +import threading from types import FrameType from typing import Any, cast from propcache.api import cached_property -from homeassistant.core import HomeAssistant, async_get_hass_or_none +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import ( Integration, async_get_issue_integration, async_suggest_report_issue, ) +from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -29,6 +31,21 @@ _LOGGER = logging.getLogger(__name__) _REPORTED_INTEGRATIONS: set[str] = set() +class _Hass: + """Container which makes a HomeAssistant instance available to frame helper.""" + + hass: HomeAssistant | None = None + + +_hass = _Hass() + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the frame helper.""" + _hass.hass = hass + + @dataclass(kw_only=True) class IntegrationFrame: """Integration frame container.""" @@ -204,14 +221,49 @@ def report_usage( :param integration_domain: fallback for identifying the integration if the frame is not found """ + if (hass := _hass.hass) is None: + raise RuntimeError("Frame helper not set up") + _report_usage_partial = functools.partial( + _report_usage, + hass, + what, + breaks_in_ha_version=breaks_in_ha_version, + core_behavior=core_behavior, + core_integration_behavior=core_integration_behavior, + custom_integration_behavior=custom_integration_behavior, + exclude_integrations=exclude_integrations, + integration_domain=integration_domain, + level=level, + ) + if hass.loop_thread_id != threading.get_ident(): + future = run_callback_threadsafe(hass.loop, _report_usage_partial) + future.result() + return + _report_usage_partial() + + +def _report_usage( + hass: HomeAssistant, + what: str, + *, + breaks_in_ha_version: str | None, + core_behavior: ReportBehavior, + core_integration_behavior: ReportBehavior, + custom_integration_behavior: ReportBehavior, + exclude_integrations: set[str] | None, + integration_domain: str | None, + level: int, +) -> None: + """Report incorrect code usage. + + Must be called from the event loop. + """ try: integration_frame = get_integration_frame( exclude_integrations=exclude_integrations ) except MissingIntegrationFrame as err: - if integration := async_get_issue_integration( - hass := async_get_hass_or_none(), integration_domain - ): + if integration := async_get_issue_integration(hass, integration_domain): _report_integration_domain( hass, what, @@ -240,6 +292,7 @@ def report_usage( if integration_behavior is not ReportBehavior.IGNORE: _report_integration_frame( + hass, what, breaks_in_ha_version, integration_frame, @@ -299,6 +352,7 @@ def _report_integration_domain( def _report_integration_frame( + hass: HomeAssistant, what: str, breaks_in_ha_version: str | None, integration_frame: IntegrationFrame, @@ -316,7 +370,7 @@ def _report_integration_frame( _REPORTED_INTEGRATIONS.add(key) report_issue = async_suggest_report_issue( - async_get_hass_or_none(), + hass, integration_domain=integration_frame.integration, module=integration_frame.module, ) diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 721e540b04d..e2dba1b9355 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -122,6 +122,7 @@ async def test_setup_multiple_states( }, ], ) +@pytest.mark.usefixtures("hass") def test_setup_invalid_config(config) -> None: """Test the history statistics sensor setup with invalid config.""" diff --git a/tests/components/recorder/test_pool.py b/tests/components/recorder/test_pool.py index 3cca095399b..e391161c1ec 100644 --- a/tests/components/recorder/test_pool.py +++ b/tests/components/recorder/test_pool.py @@ -1,5 +1,6 @@ """Test pool.""" +import asyncio import threading import pytest @@ -8,6 +9,7 @@ from sqlalchemy.orm import sessionmaker from homeassistant.components.recorder.const import DB_WORKER_PREFIX from homeassistant.components.recorder.pool import RecorderPool +from homeassistant.core import HomeAssistant async def test_recorder_pool_called_from_event_loop() -> None: @@ -22,7 +24,9 @@ async def test_recorder_pool_called_from_event_loop() -> None: sessionmaker(bind=engine)().connection() -def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: +async def test_recorder_pool( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: """Test RecorderPool gives the same connection in the creating thread.""" recorder_and_worker_thread_ids: set[int] = set() engine = create_engine( @@ -35,6 +39,8 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: connections = [] add_thread = False + event = asyncio.Event() + def _get_connection_twice(): if add_thread: recorder_and_worker_thread_ids.add(threading.get_ident()) @@ -48,33 +54,42 @@ def test_recorder_pool(caplog: pytest.LogCaptureFixture) -> None: session = get_session() connections.append(session.connection().connection.driver_connection) session.close() + hass.loop.call_soon_threadsafe(event.set) caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice) new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" in caplog.text assert connections[0] != connections[1] add_thread = True caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX) new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" not in caplog.text assert connections[2] == connections[3] caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice, name="Recorder") new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" not in caplog.text assert connections[4] == connections[5] shutdown = True caplog.clear() + event.clear() new_thread = threading.Thread(target=_get_connection_twice, name=DB_WORKER_PREFIX) new_thread.start() + await event.wait() new_thread.join() assert "accesses the database without the database executor" not in caplog.text assert connections[6] != connections[7] diff --git a/tests/conftest.py b/tests/conftest.py index dc834633774..e3313813112 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,6 +84,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, floor_registry as fr, + frame, issue_registry as ir, label_registry as lr, recorder as recorder_helper, @@ -433,6 +434,7 @@ def reset_hass_threading_local_object() -> Generator[None]: """Reset the _Hass threading.local object for every test case.""" yield ha._hass.__dict__.clear() + frame.async_setup(None) @pytest.fixture(autouse=True, scope="session") @@ -599,6 +601,7 @@ async def hass( async with async_test_home_assistant(loop, load_registries) as hass: orig_exception_handler = loop.get_exception_handler() loop.set_exception_handler(exc_handle) + frame.async_setup(hass) yield hass diff --git a/tests/helpers/snapshots/test_frame.ambr b/tests/helpers/snapshots/test_frame.ambr index 996fd33ada4..abdaff6c1b7 100644 --- a/tests/helpers/snapshots/test_frame.ambr +++ b/tests/helpers/snapshots/test_frame.ambr @@ -110,7 +110,7 @@ # --- # name: test_report_usage_find_issue_tracker_other_thread[custom integration] list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", + "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://blablabla.com", ]) # --- # name: test_report_usage_find_issue_tracker_other_thread[unknown custom integration] diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index b8c8c8a18c8..aac64f6139a 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2080,6 +2080,7 @@ async def test_multiple_zones(hass: HomeAssistant) -> None: assert not test(hass) +@pytest.mark.usefixtures("hass") async def test_extract_entities() -> None: """Test extracting entities.""" assert condition.async_extract_entities( @@ -2153,6 +2154,7 @@ async def test_extract_entities() -> None: } +@pytest.mark.usefixtures("hass") async def test_extract_devices() -> None: """Test extracting devices.""" assert condition.async_extract_devices( diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 7202cef6f5f..c72295493e8 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -773,6 +773,7 @@ async def test_dynamic_template_no_hass(hass: HomeAssistant) -> None: await hass.async_add_executor_job(schema, value) +@pytest.mark.usefixtures("hass") def test_template_complex() -> None: """Test template_complex validator.""" schema = vol.Schema(cv.template_complex) @@ -1414,6 +1415,7 @@ def test_key_value_schemas() -> None: schema({"mode": mode, "data": data}) +@pytest.mark.usefixtures("hass") def test_key_value_schemas_with_default() -> None: """Test key value schemas.""" schema = vol.Schema( @@ -1492,6 +1494,7 @@ def test_key_value_schemas_with_default() -> None: ), ], ) +@pytest.mark.usefixtures("hass") def test_script(caplog: pytest.LogCaptureFixture, config: dict, error: str) -> None: """Test script validation is user friendly.""" with pytest.raises(vol.Invalid, match=error): @@ -1570,6 +1573,7 @@ def test_language() -> None: assert schema(value) +@pytest.mark.usefixtures("hass") def test_positive_time_period_template() -> None: """Test positive time period template validation.""" schema = vol.Schema(cv.positive_time_period_template) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 6d53088d9df..9bec7cce996 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -36,8 +36,8 @@ async def test_get_integration_logger( assert logger.name == "homeassistant.components.hue" -@pytest.mark.usefixtures("enable_custom_integrations") -async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_custom_integrations", "hass") +async def test_extract_frame_resolve_module() -> None: """Test extracting the current frame from integration context.""" # pylint: disable-next=import-outside-toplevel from custom_components.test_integration_frame import call_get_integration_frame @@ -53,8 +53,8 @@ async def test_extract_frame_resolve_module(hass: HomeAssistant) -> None: ) -@pytest.mark.usefixtures("enable_custom_integrations") -async def test_get_integration_logger_resolve_module(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("enable_custom_integrations", "hass") +async def test_get_integration_logger_resolve_module() -> None: """Test getting the logger from integration context.""" # pylint: disable-next=import-outside-toplevel from custom_components.test_integration_frame import call_get_integration_logger @@ -228,7 +228,7 @@ async def test_get_integration_logger_no_integration( ), ], ) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_report_usage( caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, @@ -254,6 +254,13 @@ async def test_report_usage( assert reports == snapshot +async def test_report_usage_no_hass() -> None: + """Test report_usage when frame helper is not set up.""" + + with pytest.raises(RuntimeError, match="Frame helper not set up"): + frame.report_usage("blablabla") + + @pytest.mark.parametrize( "integration_frame_path", [ @@ -370,8 +377,9 @@ async def test_report_usage_find_issue_tracker_other_thread( @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_prevent_flooding( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock + caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test to ensure a report is only written once to the log.""" @@ -401,8 +409,9 @@ async def test_prevent_flooding( @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_breaks_in_ha_version( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock + caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test to ensure a report is only written once to the log.""" @@ -422,6 +431,7 @@ async def test_breaks_in_ha_version( assert expected_message in caplog.text +@pytest.mark.usefixtures("hass") async def test_report_missing_integration_frame( caplog: pytest.LogCaptureFixture, ) -> None: @@ -445,6 +455,7 @@ async def test_report_missing_integration_frame( @pytest.mark.parametrize("run_count", [1, 2]) # Run this twice to make sure the flood check does not # kick in when error_if_integration=True +@pytest.mark.usefixtures("hass") async def test_report_error_if_integration( caplog: pytest.LogCaptureFixture, run_count: int ) -> None: @@ -545,7 +556,7 @@ async def test_report_error_if_integration( ), ], ) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_report( caplog: pytest.LogCaptureFixture, snapshot: SnapshotAssertion, diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index a977a70973d..3ddbecaf48d 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -980,6 +980,7 @@ def test_datetime_selector_schema(schema, valid_selections, invalid_selections) ("schema", "valid_selections", "invalid_selections"), [({}, ("abc123", "{{ now() }}"), (None, "{{ incomplete }", "{% if True %}Hi!"))], ) +@pytest.mark.usefixtures("hass") def test_template_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test template selector.""" _test_selector("template", schema, valid_selections, invalid_selections) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 016aedb2f99..8c890bfd53d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -149,6 +149,7 @@ async def test_template_render_info_collision(hass: HomeAssistant) -> None: template_obj.async_render_to_info() +@pytest.mark.usefixtures("hass") def test_template_equality() -> None: """Test template comparison and hashing.""" template_one = template.Template("{{ template_one }}") @@ -5166,6 +5167,7 @@ def test_iif(hass: HomeAssistant) -> None: assert tpl.async_render() == "no" +@pytest.mark.usefixtures("hass") async def test_cache_garbage_collection() -> None: """Test caching a template.""" template_string = ( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 857c5952df9..190453afe06 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5995,7 +5995,7 @@ async def test_async_wait_component_startup(hass: HomeAssistant) -> None: "integration_frame_path", ["homeassistant/components/my_integration", "homeassistant.core"], ) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_options_flow_with_config_entry_core() -> None: """Test that OptionsFlowWithConfigEntry cannot be used in core.""" entry = MockConfigEntry( @@ -6009,7 +6009,7 @@ async def test_options_flow_with_config_entry_core() -> None: @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) -@pytest.mark.usefixtures("mock_integration_frame") +@pytest.mark.usefixtures("hass", "mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None: """Test that OptionsFlowWithConfigEntry doesn't mutate entry options.""" From cc308237260b52fee4d1bd870c1d4e90d2ac49f4 Mon Sep 17 00:00:00 2001 From: pglab-electronics <89299919+pglab-electronics@users.noreply.github.com> Date: Wed, 5 Mar 2025 20:33:59 +0100 Subject: [PATCH 1391/1941] Reimplement PGLab sensor to use a coordinator (#139789) * Reimplement PGLab sensor to use a coordinator * fix spelling mistake on coordinator name * rename createDiscoverDeviceInfo function in snake_case * adding suffix pglab_ to PGLabBaseEntity/PGLabEntity constructor parameters * Fix docs of PGLabEntity::async_added_to_hass * make coordinator able to return the sensor native value * renaming PGLABConfigEntry in PGLabConfigEntry to be consistent with the integration naming * renamed entry function arguments to config_entry to be less confusing * pass config_entry to constructor of base class of PGLabSensorsCoordinator * set the return value type of get_sensor_value * store coordinator as regular instance attribute * Avoid to access directly entity from discovery module * Rearrange get_sensor_value return types --- homeassistant/components/pglab/__init__.py | 18 ++-- homeassistant/components/pglab/coordinator.py | 78 ++++++++++++++ .../components/pglab/device_sensor.py | 56 ---------- homeassistant/components/pglab/discovery.py | 58 +++++----- homeassistant/components/pglab/entity.py | 100 ++++++++++++------ homeassistant/components/pglab/sensor.py | 70 ++++++------ homeassistant/components/pglab/switch.py | 10 +- .../pglab/snapshots/test_sensor.ambr | 6 +- 8 files changed, 228 insertions(+), 168 deletions(-) create mode 100644 homeassistant/components/pglab/coordinator.py delete mode 100644 homeassistant/components/pglab/device_sensor.py diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py index 7307ac2f801..8bce7be26e8 100644 --- a/homeassistant/components/pglab/__init__.py +++ b/homeassistant/components/pglab/__init__.py @@ -23,12 +23,14 @@ from homeassistant.helpers import config_validation as cv from .const import DOMAIN, LOGGER from .discovery import PGLabDiscovery -type PGLABConfigEntry = ConfigEntry[PGLabDiscovery] +type PGLabConfigEntry = ConfigEntry[PGLabDiscovery] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: PGLabConfigEntry +) -> bool: """Set up PG LAB Electronics integration from a config entry.""" async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None: @@ -67,19 +69,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> boo pglab_mqtt = PyPGLabMqttClient(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe) # Setup PGLab device discovery. - entry.runtime_data = PGLabDiscovery() + config_entry.runtime_data = PGLabDiscovery() # Start to discovery PG Lab devices. - await entry.runtime_data.start(hass, pglab_mqtt, entry) + await config_entry.runtime_data.start(hass, pglab_mqtt, config_entry) return True -async def async_unload_entry(hass: HomeAssistant, entry: PGLABConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: PGLabConfigEntry +) -> bool: """Unload a config entry.""" # Stop PGLab device discovery. - pglab_discovery = entry.runtime_data - await pglab_discovery.stop(hass, entry) + pglab_discovery = config_entry.runtime_data + await pglab_discovery.stop(hass, config_entry) return True diff --git a/homeassistant/components/pglab/coordinator.py b/homeassistant/components/pglab/coordinator.py new file mode 100644 index 00000000000..53c5dbc3b58 --- /dev/null +++ b/homeassistant/components/pglab/coordinator.py @@ -0,0 +1,78 @@ +"""Coordinator for PG LAB Electronics.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE +from pypglab.device import Device as PyPGLabDevice +from pypglab.sensor import Sensor as PyPGLabSensors + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.dt import utcnow + +from .const import DOMAIN, LOGGER + +if TYPE_CHECKING: + from . import PGLabConfigEntry + + +class PGLabSensorsCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to update Sensor Entities when receiving new data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: PGLabConfigEntry, + pglab_device: PyPGLabDevice, + ) -> None: + """Initialize.""" + + # get a reference of PG Lab device internal sensors state + self._sensors: PyPGLabSensors = pglab_device.sensors + + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + ) + + @callback + def _new_sensors_data(self, payload: str) -> None: + """Handle new sensor data.""" + + # notify all listeners that new sensor values are available + self.async_set_updated_data(self._sensors.state) + + async def subscribe_topics(self) -> None: + """Subscribe the sensors state to be notifty from MQTT update messages.""" + + # subscribe to the pypglab sensors to receive updates from the mqtt broker + # when a new sensor values are available + await self._sensors.subscribe_topics() + + # set the callback to be called when a new sensor values are available + self._sensors.set_on_state_callback(self._new_sensors_data) + + def get_sensor_value(self, sensor_key: str) -> float | datetime | None: + """Return the value of a sensor.""" + + if self.data: + value = self.data[sensor_key] + + if (sensor_key == SENSOR_REBOOT_TIME) and value: + # convert the reboot time to a datetime object + return utcnow() - timedelta(seconds=value) + + if (sensor_key == SENSOR_TEMPERATURE) and value: + # convert the temperature value to a float + return float(value) + + if (sensor_key == SENSOR_VOLTAGE) and value: + # convert the voltage value to a float + return float(value) + + return None diff --git a/homeassistant/components/pglab/device_sensor.py b/homeassistant/components/pglab/device_sensor.py deleted file mode 100644 index d202d11d6e7..00000000000 --- a/homeassistant/components/pglab/device_sensor.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Device Sensor for PG LAB Electronics.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pypglab.device import Device as PyPGLabDevice -from pypglab.sensor import Sensor as PyPGLabSensors - -from homeassistant.core import callback - -if TYPE_CHECKING: - from .entity import PGLabEntity - - -class PGLabDeviceSensor: - """Keeps PGLab device sensor update.""" - - def __init__(self, pglab_device: PyPGLabDevice) -> None: - """Initialize the device sensor.""" - - # get a reference of PG Lab device internal sensors state - self._sensors: PyPGLabSensors = pglab_device.sensors - - self._ha_sensors: list[PGLabEntity] = [] # list of HA entity sensors - - async def subscribe_topics(self): - """Subscribe to the device sensors topics.""" - self._sensors.set_on_state_callback(self.state_updated) - await self._sensors.subscribe_topics() - - def add_ha_sensor(self, entity: PGLabEntity) -> None: - """Add a new HA sensor to the list.""" - self._ha_sensors.append(entity) - - def remove_ha_sensor(self, entity: PGLabEntity) -> None: - """Remove a HA sensor from the list.""" - self._ha_sensors.remove(entity) - - @callback - def state_updated(self, payload: str) -> None: - """Handle state updates.""" - - # notify all HA sensors that PG LAB device sensor fields have been updated - for s in self._ha_sensors: - s.state_updated(payload) - - @property - def state(self) -> dict: - """Return the device sensors state.""" - return self._sensors.state - - @property - def sensors(self) -> PyPGLabSensors: - """Return the pypglab device sensors.""" - return self._sensors diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index fec6f5ce40d..e34f80a2e2d 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -25,13 +25,12 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER -from .device_sensor import PGLabDeviceSensor +from .coordinator import PGLabSensorsCoordinator if TYPE_CHECKING: - from . import PGLABConfigEntry + from . import PGLabConfigEntry # Supported platforms. PLATFORMS = [ @@ -69,7 +68,12 @@ def get_device_id_from_discovery_topic(topic: str) -> str | None: class DiscoverDeviceInfo: """Keeps information of the PGLab discovered device.""" - def __init__(self, pglab_device: PyPGLabDevice) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: PGLabConfigEntry, + pglab_device: PyPGLabDevice, + ) -> None: """Initialize the device discovery info.""" # Hash string represents the devices actual configuration, @@ -77,15 +81,15 @@ class DiscoverDeviceInfo: # When the hash string changes the devices entities must be rebuilt. self._hash = pglab_device.hash self._entities: list[tuple[str, str]] = [] - self._sensors = PGLabDeviceSensor(pglab_device) + self.coordinator = PGLabSensorsCoordinator(hass, config_entry, pglab_device) - def add_entity(self, entity: Entity) -> None: + def add_entity(self, platform_domain: str, entity_unique_id: str | None) -> None: """Add an entity.""" # PGLabEntity always have unique IDs if TYPE_CHECKING: - assert entity.unique_id is not None - self._entities.append((entity.platform.domain, entity.unique_id)) + assert entity_unique_id is not None + self._entities.append((platform_domain, entity_unique_id)) @property def hash(self) -> int: @@ -97,18 +101,15 @@ class DiscoverDeviceInfo: """Return array of entities available.""" return self._entities - @property - def sensors(self) -> PGLabDeviceSensor: - """Return the PGLab device sensor.""" - return self._sensors - -async def createDiscoverDeviceInfo(pglab_device: PyPGLabDevice) -> DiscoverDeviceInfo: +async def create_discover_device_info( + hass: HomeAssistant, config_entry: PGLabConfigEntry, pglab_device: PyPGLabDevice +) -> DiscoverDeviceInfo: """Create a new DiscoverDeviceInfo instance.""" - discovery_info = DiscoverDeviceInfo(pglab_device) + discovery_info = DiscoverDeviceInfo(hass, config_entry, pglab_device) # Subscribe to sensor state changes. - await discovery_info.sensors.subscribe_topics() + await discovery_info.coordinator.subscribe_topics() return discovery_info @@ -184,7 +185,10 @@ class PGLabDiscovery: del self._discovered[device_id] async def start( - self, hass: HomeAssistant, mqtt: PyPGLabMqttClient, entry: PGLABConfigEntry + self, + hass: HomeAssistant, + mqtt: PyPGLabMqttClient, + config_entry: PGLabConfigEntry, ) -> None: """Start discovering a PGLab devices.""" @@ -210,7 +214,7 @@ class PGLabDiscovery: # Create a new device. device_registry = dr.async_get(hass) device_registry.async_get_or_create( - config_entry_id=entry.entry_id, + config_entry_id=config_entry.entry_id, configuration_url=f"http://{pglab_device.ip}/", connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, identifiers={(DOMAIN, pglab_device.id)}, @@ -241,7 +245,9 @@ class PGLabDiscovery: self.__clean_discovered_device(hass, pglab_device.id) # Add a new device. - discovery_info = await createDiscoverDeviceInfo(pglab_device) + discovery_info = await create_discover_device_info( + hass, config_entry, pglab_device + ) self._discovered[pglab_device.id] = discovery_info # Create all new relay entities. @@ -256,7 +262,7 @@ class PGLabDiscovery: hass, CREATE_NEW_ENTITY[Platform.SENSOR], pglab_device, - discovery_info.sensors, + discovery_info.coordinator, ) topics = { @@ -267,7 +273,7 @@ class PGLabDiscovery: } # Forward setup all HA supported platforms. - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) self._mqtt_client = mqtt self._substate = async_prepare_subscribe_topics(hass, self._substate, topics) @@ -282,9 +288,9 @@ class PGLabDiscovery: ) self._disconnect_platform.append(disconnect_callback) - async def stop(self, hass: HomeAssistant, entry: PGLABConfigEntry) -> None: + async def stop(self, hass: HomeAssistant, config_entry: PGLabConfigEntry) -> None: """Stop to discovery PG LAB devices.""" - await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) # Disconnect all registered platforms. for disconnect_callback in self._disconnect_platform: @@ -292,7 +298,9 @@ class PGLabDiscovery: async_unsubscribe_topics(hass, self._substate) - async def add_entity(self, entity: Entity, device_id: str): + async def add_entity( + self, platform_domain: str, entity_unique_id: str | None, device_id: str + ): """Save a new PG LAB device entity.""" # Be sure that the device is been discovered. @@ -300,4 +308,4 @@ class PGLabDiscovery: raise PGLabDiscoveryError("Unknown device, device_id not discovered") discovery_info = self._discovered[device_id] - discovery_info.add_entity(entity) + discovery_info.add_entity(platform_domain, entity_unique_id) diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 175b4c1eb0f..59a4e28de89 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -8,69 +8,105 @@ from pypglab.entity import Entity as PyPGLabEntity from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import PGLabSensorsCoordinator from .discovery import PGLabDiscovery -class PGLabEntity(Entity): - """Representation of a PGLab entity in Home Assistant.""" +class PGLabBaseEntity(Entity): + """Base class of a PGLab entity in Home Assistant.""" _attr_has_entity_name = True def __init__( self, - discovery: PGLabDiscovery, - device: PyPGLabDevice, - entity: PyPGLabEntity, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, ) -> None: """Initialize the class.""" - self._id = entity.id - self._device_id = device.id - self._entity = entity - self._discovery = discovery + self._device_id = pglab_device.id + self._discovery = pglab_discovery # Information about the device that is partially visible in the UI. self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.id)}, - name=device.name, - sw_version=device.firmware_version, - hw_version=device.hardware_version, - model=device.type, - manufacturer=device.manufactor, - configuration_url=f"http://{device.ip}/", - connections={(CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, pglab_device.id)}, + name=pglab_device.name, + sw_version=pglab_device.firmware_version, + hw_version=pglab_device.hardware_version, + model=pglab_device.type, + manufacturer=pglab_device.manufactor, + configuration_url=f"http://{pglab_device.ip}/", + connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)}, ) - async def subscribe_to_update(self): - """Subscribe to the entity updates.""" - self._entity.set_on_state_callback(self.state_updated) - await self._entity.subscribe_topics() - - async def unsubscribe_to_update(self): - """Unsubscribe to the entity updates.""" - await self._entity.unsubscribe_topics() - self._entity.set_on_state_callback(None) - async def async_added_to_hass(self) -> None: """Update the device discovery info.""" - await self.subscribe_to_update() - await super().async_added_to_hass() - # Inform PGLab discovery instance that a new entity is available. # This is important to know in case the device needs to be reconfigured # and the entity can be potentially destroyed. - await self._discovery.add_entity(self, self._device_id) + await self._discovery.add_entity( + self.platform.domain, + self.unique_id, + self._device_id, + ) + + # propagate the async_added_to_hass to the super class + await super().async_added_to_hass() + + +class PGLabEntity(PGLabBaseEntity): + """Representation of a PGLab entity in Home Assistant.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_entity: PyPGLabEntity, + ) -> None: + """Initialize the class.""" + + super().__init__(pglab_discovery, pglab_device) + + self._id = pglab_entity.id + self._entity: PyPGLabEntity = pglab_entity + + async def async_added_to_hass(self) -> None: + """Subscribe pypglab entity to be updated from mqtt when pypglab entity internal state change.""" + + # set the callback to be called when pypglab entity state is changed + self._entity.set_on_state_callback(self.state_updated) + + # subscribe to the pypglab entity to receive updates from the mqtt broker + await self._entity.subscribe_topics() + await super().async_added_to_hass() async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" await super().async_will_remove_from_hass() - await self.unsubscribe_to_update() + await self._entity.unsubscribe_topics() + self._entity.set_on_state_callback(None) @callback def state_updated(self, payload: str) -> None: """Handle state updates.""" self.async_write_ha_state() + + +class PGLabSensorEntity(PGLabBaseEntity, CoordinatorEntity[PGLabSensorsCoordinator]): + """Representation of a PGLab sensor entity in Home Assistant.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_coordinator: PGLabSensorsCoordinator, + ) -> None: + """Initialize the class.""" + + PGLabBaseEntity.__init__(self, pglab_discovery, pglab_device) + CoordinatorEntity.__init__(self, pglab_coordinator) diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py index f868e7ae101..ce19ec3a21a 100644 --- a/homeassistant/components/pglab/sensor.py +++ b/homeassistant/components/pglab/sensor.py @@ -2,8 +2,6 @@ from __future__ import annotations -from datetime import timedelta - from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE from pypglab.device import Device as PyPGLabDevice @@ -16,12 +14,11 @@ from homeassistant.components.sensor import ( from homeassistant.const import Platform, UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.dt import utcnow -from . import PGLABConfigEntry -from .device_sensor import PGLabDeviceSensor +from . import PGLabConfigEntry +from .coordinator import PGLabSensorsCoordinator from .discovery import PGLabDiscovery -from .entity import PGLabEntity +from .entity import PGLabSensorEntity PARALLEL_UPDATES = 0 @@ -50,7 +47,7 @@ SENSOR_INFO: list[SensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: PGLABConfigEntry, + config_entry: PGLabConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensor for device.""" @@ -58,62 +55,55 @@ async def async_setup_entry( @callback def async_discover( pglab_device: PyPGLabDevice, - pglab_device_sensor: PGLabDeviceSensor, + pglab_coordinator: PGLabSensorsCoordinator, ) -> None: """Discover and add a PG LAB Sensor.""" pglab_discovery = config_entry.runtime_data - for description in SENSOR_INFO: - pglab_sensor = PGLabSensor( - pglab_discovery, pglab_device, pglab_device_sensor, description + + sensors: list[PGLabSensor] = [ + PGLabSensor( + description, + pglab_discovery, + pglab_device, + pglab_coordinator, ) - async_add_entities([pglab_sensor]) + for description in SENSOR_INFO + ] + + async_add_entities(sensors) # Register the callback to create the sensor entity when discovered. pglab_discovery = config_entry.runtime_data await pglab_discovery.register_platform(hass, Platform.SENSOR, async_discover) -class PGLabSensor(PGLabEntity, SensorEntity): +class PGLabSensor(PGLabSensorEntity, SensorEntity): """A PGLab sensor.""" def __init__( self, + description: SensorEntityDescription, pglab_discovery: PGLabDiscovery, pglab_device: PyPGLabDevice, - pglab_device_sensor: PGLabDeviceSensor, - description: SensorEntityDescription, + pglab_coordinator: PGLabSensorsCoordinator, ) -> None: """Initialize the Sensor class.""" - super().__init__( - discovery=pglab_discovery, - device=pglab_device, - entity=pglab_device_sensor.sensors, - ) + super().__init__(pglab_discovery, pglab_device, pglab_coordinator) - self._type = description.key - self._pglab_device_sensor = pglab_device_sensor self._attr_unique_id = f"{pglab_device.id}_{description.key}" self.entity_description = description @callback - def state_updated(self, payload: str) -> None: - """Handle state updates.""" + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" - # get the sensor value from pglab multi fields sensor - value = self._pglab_device_sensor.state[self._type] + self._attr_native_value = self.coordinator.get_sensor_value( + self.entity_description.key + ) + super()._handle_coordinator_update() - if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: - self._attr_native_value = utcnow() - timedelta(seconds=value) - else: - self._attr_native_value = value - - super().state_updated(payload) - - async def subscribe_to_update(self): - """Register the HA sensor to be notify when the sensor status is changed.""" - self._pglab_device_sensor.add_ha_sensor(self) - - async def unsubscribe_to_update(self): - """Unregister the HA sensor from sensor tatus updates.""" - self._pglab_device_sensor.remove_ha_sensor(self) + @property + def available(self) -> bool: + """Return PG LAB sensor availability.""" + return super().available and self.native_value is not None diff --git a/homeassistant/components/pglab/switch.py b/homeassistant/components/pglab/switch.py index 554b5cf80ca..76b177e84c4 100644 --- a/homeassistant/components/pglab/switch.py +++ b/homeassistant/components/pglab/switch.py @@ -12,7 +12,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import PGLABConfigEntry +from . import PGLabConfigEntry from .discovery import PGLabDiscovery from .entity import PGLabEntity @@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: PGLABConfigEntry, + config_entry: PGLabConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches for device.""" @@ -52,9 +52,9 @@ class PGLabSwitch(PGLabEntity, SwitchEntity): """Initialize the Switch class.""" super().__init__( - discovery=pglab_discovery, - device=pglab_device, - entity=pglab_relay, + pglab_discovery, + pglab_device, + pglab_relay, ) self._attr_unique_id = f"{pglab_device.id}_relay{pglab_relay.id}" diff --git a/tests/components/pglab/snapshots/test_sensor.ambr b/tests/components/pglab/snapshots/test_sensor.ambr index f25f459bb70..71889b65183 100644 --- a/tests/components/pglab/snapshots/test_sensor.ambr +++ b/tests/components/pglab/snapshots/test_sensor.ambr @@ -12,7 +12,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[mpu_voltage][updated_sensor_mpu_voltage] @@ -43,7 +43,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[run_time][updated_sensor_run_time] @@ -74,7 +74,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[temperature][updated_sensor_temperature] From f8e3f2a94fcff3e27350909649997fc40ea5d230 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Mar 2025 00:00:12 +0100 Subject: [PATCH 1392/1941] Improve descriptions in overseerr.get_requests action (#139781) Make the action description consistent with HA style adding a bit more info from the online docs. Fix spelling of "ID" --- homeassistant/components/overseerr/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json index 14650fd5c25..ce8b9fe9fec 100644 --- a/homeassistant/components/overseerr/strings.json +++ b/homeassistant/components/overseerr/strings.json @@ -90,7 +90,7 @@ "services": { "get_requests": { "name": "Get requests", - "description": "Get media requests from Overseerr.", + "description": "Retrieves a list of media requests from Overseerr.", "fields": { "config_entry_id": { "name": "Overseerr instance", @@ -106,7 +106,7 @@ }, "requested_by": { "name": "Requested by", - "description": "Filter the requests by the user id that requested them." + "description": "Filter the requests by the user ID that requested them." } } } From 8e357831644ca31080a12f82c48aba1fde8264f6 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 5 Mar 2025 18:34:11 -0800 Subject: [PATCH 1393/1941] Trim the Schema allowed keys to match the Public Gemini API docs. (#139876) * Trim the Schema allowed types to match the Public API docs, not the SDK types as those do not match * Testing --- .../conversation.py | 30 +++------ .../snapshots/test_conversation.ambr | 2 +- .../test_conversation.py | 64 ++++++++++++++----- 3 files changed, 58 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 2c84249dcb3..168e867d857 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -64,28 +64,18 @@ async def async_setup_entry( SUPPORTED_SCHEMA_KEYS = { - "min_items", - "example", - "property_ordering", - "pattern", - "minimum", - "default", - "any_of", - "max_length", - "title", - "min_properties", - "min_length", - "max_items", - "maximum", - "nullable", - "max_properties", + # Gemini API does not support all of the OpenAPI schema + # SoT: https://ai.google.dev/api/caching#Schema "type", - "description", - "enum", "format", - "items", + "description", + "nullable", + "enum", + "max_items", + "min_items", "properties", "required", + "items", } @@ -109,9 +99,7 @@ def _format_schema(schema: dict[str, Any]) -> Schema: key = _camel_to_snake(key) if key not in SUPPORTED_SCHEMA_KEYS: continue - if key == "any_of": - val = [_format_schema(subschema) for subschema in val] - elif key == "type": + if key == "type": val = val.upper() elif key == "format": # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema 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 106366fd240..c840f7da324 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=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), + '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=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 5e887d3cab7..64f71c18bf2 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -493,6 +493,26 @@ async def test_escape_decode() -> None: {"type": "string", "enum": ["a", "b", "c"]}, {"type": "STRING", "enum": ["a", "b", "c"]}, ), + ( + {"type": "string", "default": "default"}, + {"type": "STRING"}, + ), + ( + {"type": "string", "pattern": "default"}, + {"type": "STRING"}, + ), + ( + {"type": "string", "maxLength": 10}, + {"type": "STRING"}, + ), + ( + {"type": "string", "minLength": 10}, + {"type": "STRING"}, + ), + ( + {"type": "string", "title": "title"}, + {"type": "STRING"}, + ), ( {"type": "string", "format": "enum", "enum": ["a", "b", "c"]}, {"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]}, @@ -517,6 +537,10 @@ async def test_escape_decode() -> None: {"type": "number", "format": "hex"}, {"type": "NUMBER"}, ), + ( + {"type": "number", "minimum": 1}, + {"type": "NUMBER"}, + ), ( {"type": "integer", "format": "int32"}, {"type": "INTEGER", "format": "int32"}, @@ -535,21 +559,7 @@ async def test_escape_decode() -> None: ), ( {"anyOf": [{"type": "integer"}, {"type": "number"}]}, - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - ), - ( - { - "any_of": [ - {"any_of": [{"type": "integer"}, {"type": "number"}]}, - {"any_of": [{"type": "integer"}, {"type": "number"}]}, - ] - }, - { - "any_of": [ - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - {"any_of": [{"type": "INTEGER"}, {"type": "NUMBER"}]}, - ] - }, + {}, ), ({"type": "string", "format": "lower"}, {"type": "STRING"}), ({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}), @@ -570,7 +580,15 @@ async def test_escape_decode() -> None: }, ), ( - {"type": "object", "additionalProperties": True}, + {"type": "object", "additionalProperties": True, "minProperties": 1}, + { + "type": "OBJECT", + "properties": {"json": {"type": "STRING"}}, + "required": [], + }, + ), + ( + {"type": "object", "additionalProperties": True, "maxProperties": 1}, { "type": "OBJECT", "properties": {"json": {"type": "STRING"}}, @@ -581,6 +599,20 @@ async def test_escape_decode() -> None: {"type": "array", "items": {"type": "string"}}, {"type": "ARRAY", "items": {"type": "STRING"}}, ), + ( + { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 2, + }, + { + "type": "ARRAY", + "items": {"type": "STRING"}, + "min_items": 1, + "max_items": 2, + }, + ), ], ) async def test_format_schema(openapi, genai_schema) -> None: From a5002018e0b9ec148649af5d0f1092b5c972de7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Mar 2025 20:38:23 -1000 Subject: [PATCH 1394/1941] Bump dbus-fast to 2.37.0 (#139877) --- 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 81a2aae990a..f097eb3a3cf 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.25.0", - "dbus-fast==2.35.1", + "dbus-fast==2.37.0", "habluetooth==3.25.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4a2d4219b50..e7f1cf096a4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.35.1 +dbus-fast==2.37.0 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 1d8947d861b..b94467cbcae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.35.1 +dbus-fast==2.37.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b47e238e1f2..9170291121f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.35.1 +dbus-fast==2.37.0 # homeassistant.components.debugpy debugpy==1.8.11 From 48865e00b6c35b4fa985322521e38665cfbea1fe Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Mar 2025 07:42:09 +0100 Subject: [PATCH 1395/1941] Bump pynecil to v4.1.0 (#139881) --- homeassistant/components/iron_os/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index c9868791668..58cbdaa3bc6 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pynecil"], "quality_scale": "platinum", - "requirements": ["pynecil==4.0.1"] + "requirements": ["pynecil==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b94467cbcae..40f8f53a20a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ pymsteams==0.1.12 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==4.0.1 +pynecil==4.1.0 # homeassistant.components.netgear pynetgear==0.10.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9170291121f..a0134a18edd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ pymonoprice==0.4 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==4.0.1 +pynecil==4.1.0 # homeassistant.components.netgear pynetgear==0.10.10 From aec6868af174c142fe95d95a3316a5abf6d9c5b3 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 6 Mar 2025 02:00:11 -0500 Subject: [PATCH 1396/1941] Add abstract class to trigger based template entities (#139650) * add abstract class to trigger based template entities * updates after merge of parent PR * add comments * add tests --- homeassistant/components/template/config.py | 8 ++- .../components/template/coordinator.py | 27 +++++++- homeassistant/components/template/helpers.py | 7 ++- homeassistant/components/template/number.py | 18 +++--- homeassistant/components/template/select.py | 23 ++++--- .../components/template/trigger_entity.py | 16 ++++- tests/components/template/test_blueprint.py | 62 ++++++++++++++++++- tests/components/template/test_number.py | 8 ++- tests/components/template/test_select.py | 26 +++++++- .../template/test_trigger_entity.py | 13 ++++ .../template/test_event_sensor.yaml | 27 ++++++++ 11 files changed, 202 insertions(+), 33 deletions(-) create mode 100644 tests/components/template/test_trigger_entity.py create mode 100644 tests/testing_config/blueprints/template/test_event_sensor.yaml diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index e0c5514def9..9c92ed2b334 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -122,9 +122,15 @@ async def _async_resolve_blueprints( raise vol.Invalid("more than one platform defined per blueprint") if len(platforms) == 1: platform = platforms.pop() - for prop in (CONF_NAME, CONF_UNIQUE_ID, CONF_VARIABLES): + for prop in (CONF_NAME, CONF_UNIQUE_ID): if prop in config: config[platform][prop] = config.pop(prop) + # For regular template entities, CONF_VARIABLES should be removed because they just + # house input results for template entities. For Trigger based template entities + # CONF_VARIABLES should not be removed because the variables are always + # executed between the trigger and action. + if CONF_TRIGGER not in config and CONF_VARIABLES in config: + config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES) raw_config = dict(config) template_config = TemplateConfig(CONFIG_SECTION_SCHEMA(config)) diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index 4d8fe78f2b5..c11e9b6101b 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -2,12 +2,14 @@ from collections.abc import Callable, Mapping import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.components.blueprint import CONF_USE_BLUEPRINT +from homeassistant.const import CONF_PATH, CONF_VARIABLES, EVENT_HOMEASSISTANT_START from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback from homeassistant.helpers import condition, discovery, trigger as trigger_helper from homeassistant.helpers.script import Script +from homeassistant.helpers.script_variables import ScriptVariables from homeassistant.helpers.trace import trace_get from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -22,7 +24,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): REMOVE_TRIGGER = object() - def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Instantiate trigger data.""" super().__init__( hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator" @@ -32,6 +34,18 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): self._unsub_start: Callable[[], None] | None = None self._unsub_trigger: Callable[[], None] | None = None self._script: Script | None = None + self._run_variables: ScriptVariables | None = None + self._blueprint_inputs: dict | None = None + if config is not None: + self._run_variables = config.get(CONF_VARIABLES) + self._blueprint_inputs = getattr(config, "raw_blueprint_inputs", None) + + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + if self._blueprint_inputs is None: + return None + return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH]) @property def unique_id(self) -> str | None: @@ -104,6 +118,10 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _handle_triggered_with_script( self, run_variables: TemplateVarsType, context: Context | None = None ) -> None: + # Render run variables after the trigger, before checking conditions. + if self._run_variables: + run_variables = self._run_variables.async_render(self.hass, run_variables) + if not self._check_condition(run_variables): return # Create a context referring to the trigger context. @@ -119,6 +137,9 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): async def _handle_triggered( self, run_variables: TemplateVarsType, context: Context | None = None ) -> None: + if self._run_variables: + run_variables = self._run_variables.async_render(self.hass, run_variables) + if not self._check_condition(run_variables): return self._execute_update(run_variables, context) diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index b320f2128cd..d74a4a4ed00 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.singleton import singleton from .const import DOMAIN, TEMPLATE_BLUEPRINT_SCHEMA -from .template_entity import TemplateEntity +from .entity import AbstractTemplateEntity DATA_BLUEPRINTS = "template_blueprints" @@ -23,7 +23,7 @@ def templates_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[s entity_id for platform in async_get_platforms(hass, DOMAIN) for entity_id, template_entity in platform.entities.items() - if isinstance(template_entity, TemplateEntity) + if isinstance(template_entity, AbstractTemplateEntity) and template_entity.referenced_blueprint == blueprint_path ] @@ -33,7 +33,8 @@ def blueprint_in_template(hass: HomeAssistant, entity_id: str) -> str | None: """Return the blueprint the template entity is based on or None.""" for platform in async_get_platforms(hass, DOMAIN): if isinstance( - (template_entity := platform.entities.get(entity_id)), TemplateEntity + (template_entity := platform.entities.get(entity_id)), + AbstractTemplateEntity, ): return template_entity.referenced_blueprint return None diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index e3654661158..3ecf1db565a 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -31,7 +31,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator @@ -236,12 +235,8 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): """Initialize the entity.""" super().__init__(hass, coordinator, config) - self._command_set_value = Script( - hass, - config[CONF_SET_VALUE], - self._rendered.get(CONF_NAME, DEFAULT_NAME), - DOMAIN, - ) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -276,6 +271,9 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): if self._config[CONF_OPTIMISTIC]: self._attr_native_value = value self.async_write_ha_state() - await self._command_set_value.async_run( - {ATTR_VALUE: value}, context=self._context - ) + if set_value := self._action_scripts.get(CONF_SET_VALUE): + await self.async_run_script( + set_value, + run_variables={ATTR_VALUE: value}, + context=self._context, + ) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 1e7cb781eb0..eb60a3dbfe4 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -28,7 +28,6 @@ from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator @@ -198,12 +197,13 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) - self._command_select_option = Script( - hass, - config[CONF_SELECT_OPTION], - self._rendered.get(CONF_NAME, DEFAULT_NAME), - DOMAIN, - ) + if select_option := config.get(CONF_SELECT_OPTION): + self.add_script( + CONF_SELECT_OPTION, + select_option, + self._rendered.get(CONF_NAME, DEFAULT_NAME), + DOMAIN, + ) @property def current_option(self) -> str | None: @@ -220,6 +220,9 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): if self._config[CONF_OPTIMISTIC]: self._attr_current_option = option self.async_write_ha_state() - await self._command_select_option.async_run( - {ATTR_OPTION: option}, context=self._context - ) + if select_option := self._action_scripts.get(CONF_SELECT_OPTION): + await self.async_run_script( + select_option, + run_variables={ATTR_OPTION: option}, + context=self._context, + ) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 5130f332d5b..87c93b6143b 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -8,10 +8,13 @@ from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator +from .entity import AbstractTemplateEntity class TriggerEntity( # pylint: disable=hass-enforce-class-module - TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator] + TriggerBaseEntity, + CoordinatorEntity[TriggerUpdateCoordinator], + AbstractTemplateEntity, ): """Template entity based on trigger data.""" @@ -24,6 +27,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Initialize the entity.""" CoordinatorEntity.__init__(self, coordinator) TriggerBaseEntity.__init__(self, hass, config) + AbstractTemplateEntity.__init__(self, hass) async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" @@ -38,6 +42,16 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module else: self._unique_id = unique_id + @property + def referenced_blueprint(self) -> str | None: + """Return referenced blueprint or None.""" + return self.coordinator.referenced_blueprint + + @callback + def _render_script_variables(self) -> dict: + """Render configured variables.""" + return self.coordinator.data["run_variables"] + @callback def _process_data(self) -> None: """Process new data.""" diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index dd008a27822..66630ecf739 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -16,10 +16,10 @@ from homeassistant.components.blueprint import ( DomainBlueprints, ) from homeassistant.components.template import DOMAIN, SERVICE_RELOAD -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import yaml as yaml_util +from homeassistant.util import dt as dt_util, yaml as yaml_util from tests.common import async_mock_service @@ -212,6 +212,61 @@ async def test_reload_template_when_blueprint_changes(hass: HomeAssistant) -> No assert not_inverted.state == "on" +async def test_trigger_event_sensor( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test event sensor blueprint.""" + blueprint = "test_event_sensor.yaml" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": blueprint, + "input": { + "event_type": "my_custom_event", + "event_data": {"foo": "bar"}, + }, + }, + "name": "My Custom Event", + }, + ] + }, + ) + + context = Context() + now = dt_util.utcnow() + with patch("homeassistant.util.dt.now", return_value=now): + hass.bus.async_fire( + "my_custom_event", {"foo": "bar", "beer": 2}, context=context + ) + await hass.async_block_till_done() + + date_state = hass.states.get("sensor.my_custom_event") + assert date_state is not None + assert date_state.state == now.isoformat(timespec="seconds") + data = date_state.attributes.get("data") + assert data is not None + assert data != "" + assert data.get("foo") == "bar" + assert data.get("beer") == 2 + + inverted_foo_template = template.helpers.blueprint_in_template( + hass, "sensor.my_custom_event" + ) + assert inverted_foo_template == blueprint + + inverted_binary_sensor_blueprint_entity_ids = ( + template.helpers.templates_with_blueprint(hass, blueprint) + ) + assert len(inverted_binary_sensor_blueprint_entity_ids) == 1 + + with pytest.raises(BlueprintInUse): + await template.async_get_blueprints(hass).async_remove_blueprint(blueprint) + + async def test_domain_blueprint(hass: HomeAssistant) -> None: """Test DomainBlueprint services.""" reload_handler_calls = async_mock_service(hass, DOMAIN, SERVICE_RELOAD) @@ -262,7 +317,8 @@ async def test_invalid_blueprint( ) assert "more than one platform defined per blueprint" in caplog.text - assert await template.async_get_blueprints(hass).async_get_blueprints() == {} + blueprints = await template.async_get_blueprints(hass).async_get_blueprints() + assert "invalid.yaml" not in blueprints async def test_no_blueprint(hass: HomeAssistant) -> None: diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index ec96245b4d0..f73a943e752 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -330,7 +330,10 @@ async def test_trigger_number(hass: HomeAssistant) -> None: "max": "{{ trigger.event.data.max_beers }}", "step": "{{ trigger.event.data.step }}", "unit_of_measurement": "beer", - "set_value": {"event": "test_number_event"}, + "set_value": { + "event": "test_number_event", + "event_data": {"entity_id": "{{ this.entity_id }}"}, + }, "optimistic": True, }, ], @@ -379,6 +382,9 @@ async def test_trigger_number(hass: HomeAssistant) -> None: ) assert len(events) == 1 assert events[0].event_type == "test_number_event" + entity_id = events[0].data.get("entity_id") + assert entity_id is not None + assert entity_id == "number.hello_name" def _verify( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 5b4723a3034..59ab45aeb36 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -264,6 +264,7 @@ async def test_templates_with_entities( async def test_trigger_select(hass: HomeAssistant) -> None: """Test trigger based template select.""" events = async_capture_events(hass, "test_number_event") + action_events = async_capture_events(hass, "action_event") assert await setup.async_setup_component( hass, "template", @@ -274,13 +275,23 @@ async def test_trigger_select(hass: HomeAssistant) -> None: { "unique_id": "listening-test-event", "trigger": {"platform": "event", "event_type": "test_event"}, + "variables": {"beer": "{{ trigger.event.data.beer }}"}, + "action": [ + {"event": "action_event", "event_data": {"beer": "{{ beer }}"}} + ], "select": [ { "name": "Hello Name", "unique_id": "hello_name-id", "state": "{{ trigger.event.data.beer }}", "options": "{{ trigger.event.data.beers }}", - "select_option": {"event": "test_number_event"}, + "select_option": { + "event": "test_number_event", + "event_data": { + "entity_id": "{{ this.entity_id }}", + "beer": "{{ beer }}", + }, + }, "optimistic": True, }, ], @@ -308,6 +319,12 @@ async def test_trigger_select(hass: HomeAssistant) -> None: assert state.state == "duff" assert state.attributes["options"] == ["duff", "alamo"] + assert len(action_events) == 1 + assert action_events[0].event_type == "action_event" + beer = action_events[0].data.get("beer") + assert beer is not None + assert beer == "duff" + await hass.services.async_call( SELECT_DOMAIN, SELECT_SERVICE_SELECT_OPTION, @@ -316,6 +333,13 @@ async def test_trigger_select(hass: HomeAssistant) -> None: ) assert len(events) == 1 assert events[0].event_type == "test_number_event" + entity_id = events[0].data.get("entity_id") + assert entity_id is not None + assert entity_id == "select.hello_name" + + beer = events[0].data.get("beer") + assert beer is not None + assert beer == "duff" def _verify( diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py new file mode 100644 index 00000000000..99aa2d65df9 --- /dev/null +++ b/tests/components/template/test_trigger_entity.py @@ -0,0 +1,13 @@ +"""Test trigger template entity.""" + +from homeassistant.components.template import trigger_entity +from homeassistant.components.template.coordinator import TriggerUpdateCoordinator +from homeassistant.core import HomeAssistant + + +async def test_reference_blueprints_is_none(hass: HomeAssistant) -> None: + """Test template entity requires hass to be set before accepting templates.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = trigger_entity.TriggerEntity(hass, coordinator, {}) + + assert entity.referenced_blueprint is None diff --git a/tests/testing_config/blueprints/template/test_event_sensor.yaml b/tests/testing_config/blueprints/template/test_event_sensor.yaml new file mode 100644 index 00000000000..8b615eb90ba --- /dev/null +++ b/tests/testing_config/blueprints/template/test_event_sensor.yaml @@ -0,0 +1,27 @@ +blueprint: + name: Create Sensor from Event + description: Creates a timestamp sensor from an event + domain: template + source_url: https://github.com/home-assistant/core/blob/dev/homeassistant/components/template/blueprints/event_sensor.yaml + input: + event_type: + name: Name of the event_type + description: The event_type for the event trigger + selector: + text: + event_data: + name: The data for the event + description: The event_data for the event trigger + selector: + object: +trigger: + - trigger: event + event_type: !input event_type + event_data: !input event_data +variables: + event_data: "{{ trigger.event.data }}" +sensor: + state: "{{ now() }}" + device_class: timestamp + attributes: + data: "{{ event_data }}" From b280874dc022af653bf96f6e9678fe445506e234 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Mar 2025 22:54:48 -1000 Subject: [PATCH 1397/1941] Small cleanups for HomeKit (#139889) * Small cleanups for HomeKit - Add some missing typing - Break out some duplicate code * Small cleanups for HomeKit - Add some missing typing - Break out some duplicate code --- homeassistant/components/homekit/__init__.py | 97 ++++++++++---------- 1 file changed, 46 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 97fb17d7db5..9bd5711832c 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -221,6 +221,34 @@ UNPAIR_SERVICE_SCHEMA = vol.All( ) +@callback +def _async_update_entries_from_yaml( + hass: HomeAssistant, config: ConfigType, start_import_flow: bool +) -> None: + current_entries = hass.config_entries.async_entries(DOMAIN) + entries_by_name, entries_by_port = _async_get_imported_entries_indices( + current_entries + ) + hk_config: list[dict[str, Any]] = config[DOMAIN] + + for index, conf in enumerate(hk_config): + if _async_update_config_entry_from_yaml( + hass, entries_by_name, entries_by_port, conf + ): + continue + + if start_import_flow: + conf[CONF_ENTRY_INDEX] = index + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=conf, + ), + eager_start=True, + ) + + def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]: """All active HomeKit instances.""" hk_data: HomeKitEntryData | None @@ -258,31 +286,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job(get_loader) _async_register_events_and_services(hass) - if DOMAIN not in config: return True - current_entries = hass.config_entries.async_entries(DOMAIN) - entries_by_name, entries_by_port = _async_get_imported_entries_indices( - current_entries - ) - - for index, conf in enumerate(config[DOMAIN]): - if _async_update_config_entry_from_yaml( - hass, entries_by_name, entries_by_port, conf - ): - continue - - conf[CONF_ENTRY_INDEX] = index - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=conf, - ), - eager_start=True, - ) - + _async_update_entries_from_yaml(hass, config, start_import_flow=True) return True @@ -326,13 +333,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeKitConfigEntry) -> b conf = entry.data options = entry.options - name = conf[CONF_NAME] - port = conf[CONF_PORT] - _LOGGER.debug("Begin setup HomeKit for %s", name) - + name: str = conf[CONF_NAME] + port: int = conf[CONF_PORT] # ip_address and advertise_ip are yaml only - ip_address = conf.get(CONF_IP_ADDRESS, _DEFAULT_BIND) - advertise_ips: list[str] = conf.get( + ip_address: str | list[str] | None = conf.get(CONF_IP_ADDRESS, _DEFAULT_BIND) + advertise_ips: list[str] + advertise_ips = conf.get( CONF_ADVERTISE_IP ) or await network.async_get_announce_addresses(hass) @@ -344,13 +350,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeKitConfigEntry) -> b # with users who have not migrated yet we do not do exclude # these entities by default as we cannot migrate automatically # since it requires a re-pairing. - exclude_accessory_mode = conf.get( + exclude_accessory_mode: bool = conf.get( CONF_EXCLUDE_ACCESSORY_MODE, DEFAULT_EXCLUDE_ACCESSORY_MODE ) - homekit_mode = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) - entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy() - entity_filter = FILTER_SCHEMA(options.get(CONF_FILTER, {})) - devices = options.get(CONF_DEVICES, []) + homekit_mode: str = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) + entity_config: dict[str, Any] = options.get(CONF_ENTITY_CONFIG, {}).copy() + entity_filter: EntityFilter = FILTER_SCHEMA(options.get(CONF_FILTER, {})) + devices: list[str] = options.get(CONF_DEVICES, []) homekit = HomeKit( hass, @@ -500,26 +506,15 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: async def _handle_homekit_reload(service: ServiceCall) -> None: """Handle start HomeKit service call.""" config = await async_integration_yaml_config(hass, DOMAIN) - if not config or DOMAIN not in config: return - - current_entries = hass.config_entries.async_entries(DOMAIN) - entries_by_name, entries_by_port = _async_get_imported_entries_indices( - current_entries - ) - - for conf in config[DOMAIN]: - _async_update_config_entry_from_yaml( - hass, entries_by_name, entries_by_port, conf + _async_update_entries_from_yaml(hass, config, start_import_flow=False) + await asyncio.gather( + *( + create_eager_task(hass.config_entries.async_reload(entry.entry_id)) + for entry in hass.config_entries.async_entries(DOMAIN) ) - - reload_tasks = [ - create_eager_task(hass.config_entries.async_reload(entry.entry_id)) - for entry in current_entries - ] - - await asyncio.gather(*reload_tasks) + ) async_register_admin_service( hass, @@ -537,7 +532,7 @@ class HomeKit: hass: HomeAssistant, name: str, port: int, - ip_address: str | None, + ip_address: list[str] | str | None, entity_filter: EntityFilter, exclude_accessory_mode: bool, entity_config: dict[str, Any], From 46f4bc34342ebcd1294a1a7ecde3b9367ba95966 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Mar 2025 10:02:16 +0100 Subject: [PATCH 1398/1941] Bump actions/attest-build-provenance from 2.2.2 to 2.2.3 (#139896) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.2.2 to 2.2.3. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/bd77c077858b8d561b7a36cbe48ef4cc642ca39d...c074443f1aee8d4aeeae555aebba3282517141b2) --- updated-dependencies: - dependency-name: actions/attest-build-provenance 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index f3bdd0084af..346f90fbe4f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2 + uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From b44c26d324c334e24336caa08884cbd11be7ee40 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Thu, 6 Mar 2025 21:10:49 +1100 Subject: [PATCH 1399/1941] Bump aiolifx to 1.1.4 to enable new LIFX product support (#139897) Signed-off-by: Avi Miller --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 8d460c25322..18b9457ebf4 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -51,7 +51,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.1.2", + "aiolifx==1.1.4", "aiolifx-effects==0.3.2", "aiolifx-themes==0.6.4" ] diff --git a/requirements_all.txt b/requirements_all.txt index 40f8f53a20a..3d2c38c5756 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,7 +291,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.2 +aiolifx==1.1.4 # homeassistant.components.lookin aiolookin==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0134a18edd..8c5c3f4885d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -273,7 +273,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.2 +aiolifx==1.1.4 # homeassistant.components.lookin aiolookin==1.0.0 From 4f255439ebce57e1d430b7898a2c2bb3c625e900 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Mar 2025 11:11:22 +0100 Subject: [PATCH 1400/1941] Fix sentence-casing in `music_assistant.get_library` action (#139901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - make the casing of several words consistent - make the action's description consistent with HA style using "Retrieves …" instead of "Get …" --- homeassistant/components/music_assistant/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 7338af7cb65..371ecdc3a86 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -139,8 +139,8 @@ } }, "get_library": { - "name": "Get Library items", - "description": "Get items from a Music Assistant library.", + "name": "Get library items", + "description": "Retrieves items from a Music Assistant library.", "fields": { "config_entry_id": { "name": "[%key:component::music_assistant::services::search::fields::config_entry_id::name%]", @@ -167,7 +167,7 @@ "description": "Offset to start the list from." }, "order_by": { - "name": "Order By", + "name": "Order by", "description": "Sort the list by this field." }, "album_type": { @@ -176,7 +176,7 @@ }, "album_artists_only": { "name": "Enable album artists filter (only for artist library)", - "description": "Only return Album Artists when listing the Artists library items." + "description": "Only return album artists when listing the artists library items." } } } From f2b07ea886cdde3fd8879d510f86967fb9c9a953 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 6 Mar 2025 11:23:10 +0100 Subject: [PATCH 1401/1941] Add support for IronOS v2.23 (#139903) Add support for IronOS 2.23 --- .../components/iron_os/coordinator.py | 6 ++ homeassistant/components/iron_os/icons.json | 17 ++++- homeassistant/components/iron_os/number.py | 25 +++++++- homeassistant/components/iron_os/select.py | 37 ++++++++++- homeassistant/components/iron_os/strings.json | 23 ++++++- tests/components/iron_os/conftest.py | 5 +- .../iron_os/snapshots/test_diagnostics.ambr | 2 +- .../iron_os/snapshots/test_number.ambr | 57 +++++++++++++++++ .../iron_os/snapshots/test_select.ambr | 62 +++++++++++++++++++ .../iron_os/snapshots/test_update.ambr | 2 +- tests/components/iron_os/test_init.py | 36 ++++++++++- tests/components/iron_os/test_number.py | 6 ++ tests/components/iron_os/test_select.py | 6 ++ 13 files changed, 273 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index fc89ecea43c..84c9b895766 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -8,6 +8,7 @@ from enum import Enum import logging from typing import cast +from awesomeversion import AwesomeVersion from pynecil import ( CharSetting, CommunicationError, @@ -34,6 +35,8 @@ SCAN_INTERVAL = timedelta(seconds=5) SCAN_INTERVAL_GITHUB = timedelta(hours=3) SCAN_INTERVAL_SETTINGS = timedelta(seconds=60) +V223 = AwesomeVersion("v2.23") + @dataclass class IronOSCoordinators: @@ -72,6 +75,7 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): ), ) self.device = device + self.v223_features = False async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -81,6 +85,8 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): except CommunicationError as e: raise UpdateFailed("Cannot connect to device") from e + self.v223_features = AwesomeVersion(self.device_info.build) >= V223 + class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]): """IronOS coordinator.""" diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 6410c561b9d..695b9d16849 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -73,6 +73,9 @@ }, "power_limit": { "default": "mdi:flash-alert" + }, + "hall_effect_sleep_time": { + "default": "mdi:timer-sand" } }, "select": { @@ -105,6 +108,9 @@ }, "usb_pd_mode": { "default": "mdi:meter-electric-outline" + }, + "tip_type": { + "default": "mdi:pencil-outline" } }, "sensor": { @@ -154,7 +160,16 @@ "soldering": "mdi:soldering-iron", "sleeping": "mdi:sleep", "settings": "mdi:menu-open", - "debug": "mdi:bug-play" + "debug": "mdi:bug-play", + "soldering_profile": "mdi:chart-box-outline", + "temperature_adjust": "mdi:thermostat-box", + "usb_pd_debug": "mdi:bug-play", + "thermal_runaway": "mdi:fire-alert", + "startup_logo": "mdi:dots-circle", + "cjc_calibration": "mdi:tune-vertical", + "startup_warnings": "mdi:alert", + "initialisation_done": "mdi:check-circle", + "hibernating": "mdi:sleep" } }, "estimated_power": { diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index b8bb3c7d999..6ad5947cb6f 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -65,6 +65,7 @@ class PinecilNumber(StrEnum): VOLTAGE_DIV = "voltage_div" TEMP_INCREMENT_SHORT = "temp_increment_short" TEMP_INCREMENT_LONG = "temp_increment_long" + HALL_EFFECT_SLEEP_TIME = "hall_effect_sleep_time" def multiply(value: float | None, multiplier: float) -> float | None: @@ -323,6 +324,23 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( ), ) +PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( + IronOSNumberEntityDescription( + key=PinecilNumber.HALL_EFFECT_SLEEP_TIME, + translation_key=PinecilNumber.HALL_EFFECT_SLEEP_TIME, + value_fn=(lambda _, settings: settings.get("hall_sleep_time")), + characteristic=CharSetting.HALL_SLEEP_TIME, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=60, + native_step=5, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -331,10 +349,13 @@ async def async_setup_entry( ) -> None: """Set up number entities from a config entry.""" coordinators = entry.runtime_data + descriptions = PINECIL_NUMBER_DESCRIPTIONS + + if coordinators.live_data.v223_features: + descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223 async_add_entities( - IronOSNumberEntity(coordinators, description) - for description in PINECIL_NUMBER_DESCRIPTIONS + IronOSNumberEntity(coordinators, description) for description in descriptions ) diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py index a005bf29af2..32652829531 100644 --- a/homeassistant/components/iron_os/select.py +++ b/homeassistant/components/iron_os/select.py @@ -17,6 +17,7 @@ from pynecil import ( ScrollSpeed, SettingsDataResponse, TempUnit, + TipType, USBPDMode, ) @@ -53,6 +54,7 @@ class PinecilSelect(StrEnum): LOCKING_MODE = "locking_mode" LOGO_DURATION = "logo_duration" USB_PD_MODE = "usb_pd_mode" + TIP_TYPE = "tip_type" def enum_to_str(enum: Enum | None) -> str | None: @@ -138,6 +140,8 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), +) +PINECIL_SELECT_DESCRIPTIONS_V222: tuple[IronOSSelectEntityDescription, ...] = ( IronOSSelectEntityDescription( key=PinecilSelect.USB_PD_MODE, translation_key=PinecilSelect.USB_PD_MODE, @@ -149,6 +153,27 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = ( entity_registry_enabled_default=False, ), ) +PINECIL_SELECT_DESCRIPTIONS_V223: tuple[IronOSSelectEntityDescription, ...] = ( + IronOSSelectEntityDescription( + key=PinecilSelect.USB_PD_MODE, + translation_key=PinecilSelect.USB_PD_MODE, + characteristic=CharSetting.USB_PD_MODE, + value_fn=lambda x: enum_to_str(x.get("usb_pd_mode")), + raw_value_fn=lambda value: USBPDMode[value.upper()], + options=[x.name.lower() for x in USBPDMode], + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + ), + IronOSSelectEntityDescription( + key=PinecilSelect.TIP_TYPE, + translation_key=PinecilSelect.TIP_TYPE, + characteristic=CharSetting.TIP_TYPE, + value_fn=lambda x: enum_to_str(x.get("tip_type")), + raw_value_fn=lambda value: TipType[value.upper()], + options=[x.name.lower() for x in TipType], + entity_category=EntityCategory.CONFIG, + ), +) async def async_setup_entry( @@ -157,11 +182,17 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up select entities from a config entry.""" - coordinator = entry.runtime_data + coordinators = entry.runtime_data + descriptions = PINECIL_SELECT_DESCRIPTIONS + + descriptions += ( + PINECIL_SELECT_DESCRIPTIONS_V223 + if coordinators.live_data.v223_features + else PINECIL_SELECT_DESCRIPTIONS_V222 + ) async_add_entities( - IronOSSelectEntity(coordinator, description) - for description in PINECIL_SELECT_DESCRIPTIONS + IronOSSelectEntity(coordinators, description) for description in descriptions ) diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 60168699427..ddae9a3020f 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -94,6 +94,9 @@ }, "temp_increment_long": { "name": "Long-press temperature step" + }, + "hall_effect_sleep_time": { + "name": "Hall sensor sleep timeout" } }, "select": { @@ -173,6 +176,15 @@ "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "tip_type": { + "name": "Soldering tip type", + "state": { + "auto": "Auto sense", + "ts100_long": "TS100 long/Hakko T12 tip", + "pine_short": "Pinecil short tip", + "pts200": "PTS200 short tip" + } } }, "sensor": { @@ -223,7 +235,16 @@ "sleeping": "Sleeping", "settings": "Settings", "debug": "Debug", - "boost": "Boost" + "boost": "Boost", + "soldering_profile": "Soldering profile", + "temperature_adjust": "Temperature adjust", + "usb_pd_debug": "USB PD debug", + "thermal_runaway": "Thermal runaway", + "startup_logo": "Booting", + "cjc_calibration": "CJC calibration", + "startup_warnings": "Startup warnings", + "initialisation_done": "Initialisation done", + "hibernating": "Hibernating" } }, "estimated_power": { diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index 63c7d129987..bf8c756ebee 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -20,6 +20,7 @@ from pynecil import ( ScrollSpeed, SettingsDataResponse, TempUnit, + TipType, ) import pytest @@ -164,7 +165,7 @@ def mock_pynecil() -> Generator[AsyncMock]: client = mock_client.return_value client.get_device_info.return_value = DeviceInfoResponse( - build="v2.22", + build="v2.23", device_id="c0ffeeC0", address="c0:ff:ee:c0:ff:ee", device_sn="0000c0ffeec0ffee", @@ -205,6 +206,8 @@ def mock_pynecil() -> Generator[AsyncMock]: display_invert=True, calibrate_cjc=True, usb_pd_mode=True, + hall_sleep_time=5, + tip_type=TipType.PINE_SHORT, ) client.get_live_data.return_value = LiveDataResponse( live_temp=298, diff --git a/tests/components/iron_os/snapshots/test_diagnostics.ambr b/tests/components/iron_os/snapshots/test_diagnostics.ambr index f8db1262254..49cb3878b87 100644 --- a/tests/components/iron_os/snapshots/test_diagnostics.ambr +++ b/tests/components/iron_os/snapshots/test_diagnostics.ambr @@ -6,7 +6,7 @@ }), 'device_info': dict({ '__type': "", - 'repr': "DeviceInfoResponse(build='v2.22', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", + 'repr': "DeviceInfoResponse(build='v2.23', device_id='c0ffeeC0', address='c0:ff:ee:c0:ff:ee', device_sn='0000c0ffeec0ffee', name='Pinecil-C0FFEEE', is_synced=False)", }), 'live_data': dict({ '__type': "", diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index 62fcd120201..b2ec7a70a92 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -226,6 +226,63 @@ 'state': '7', }) # --- +# name: test_state[number.pinecil_hall_sensor_sleep_timeout-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pinecil_hall_sensor_sleep_timeout', + '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': 'Hall sensor sleep timeout', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_hall_effect_sleep_time', + 'unit_of_measurement': , + }) +# --- +# name: test_state[number.pinecil_hall_sensor_sleep_timeout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Hall sensor sleep timeout', + 'max': 60, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pinecil_hall_sensor_sleep_timeout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_state[number.pinecil_keep_awake_pulse_delay-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/snapshots/test_select.ambr b/tests/components/iron_os/snapshots/test_select.ambr index 10aacc838df..540cab234a5 100644 --- a/tests/components/iron_os/snapshots/test_select.ambr +++ b/tests/components/iron_os/snapshots/test_select.ambr @@ -250,6 +250,7 @@ 'options': list([ 'off', 'on', + 'safe', ]), }), 'config_entry_id': , @@ -287,6 +288,7 @@ 'options': list([ 'off', 'on', + 'safe', ]), }), 'context': , @@ -415,6 +417,66 @@ 'state': 'fast', }) # --- +# name: test_state[select.pinecil_soldering_tip_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'ts100_long', + 'pine_short', + 'pts200', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pinecil_soldering_tip_type', + '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': 'Soldering tip type', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_tip_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[select.pinecil_soldering_tip_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Soldering tip type', + 'options': list([ + 'auto', + 'ts100_long', + 'pine_short', + 'pts200', + ]), + }), + 'context': , + 'entity_id': 'select.pinecil_soldering_tip_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pine_short', + }) +# --- # name: test_state[select.pinecil_start_up_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/snapshots/test_update.ambr b/tests/components/iron_os/snapshots/test_update.ambr index f2db3246158..fcd7196a70c 100644 --- a/tests/components/iron_os/snapshots/test_update.ambr +++ b/tests/components/iron_os/snapshots/test_update.ambr @@ -45,7 +45,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/iron_os/icon.png', 'friendly_name': 'Pinecil Firmware', 'in_progress': False, - 'installed_version': 'v2.22', + 'installed_version': 'v2.23', 'latest_version': 'v2.22', 'release_summary': None, 'release_url': 'https://github.com/Ralim/IronOS/releases/tag/v2.22', diff --git a/tests/components/iron_os/test_init.py b/tests/components/iron_os/test_init.py index 4749e1b6199..d1c596f4de5 100644 --- a/tests/components/iron_os/test_init.py +++ b/tests/components/iron_os/test_init.py @@ -4,13 +4,15 @@ from datetime import timedelta from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from pynecil import CommunicationError +from pynecil import CommunicationError, DeviceInfoResponse import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import DEFAULT_NAME + from tests.common import MockConfigEntry, async_fire_time_changed @@ -89,3 +91,35 @@ async def test_settings_exception( assert (state := hass.states.get("number.pinecil_boost_temperature")) assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures( + "entity_registry_enabled_by_default", "mock_pynecil", "ble_device" +) +async def test_v223_entities_not_loaded( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, +) -> None: + """Test the new entities in IronOS v2.23 are not loaded on smaller versions.""" + + mock_pynecil.get_device_info.return_value = DeviceInfoResponse( + build="v2.22", + device_id="c0ffeeC0", + address="c0:ff:ee:c0:ff:ee", + device_sn="0000c0ffeec0ffee", + name=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get("number.pinecil_hall_sensor_sleep_timeout") is None + assert hass.states.get("select.pinecil_soldering_tip_type") is None + assert ( + state := hass.states.get("select.pinecil_power_delivery_3_1_epr") + ) is not None + + assert len(state.attributes["options"]) == 2 diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index bdec922a88c..9a4ba53f338 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -138,6 +138,12 @@ async def test_state( ("number.pinecil_sleep_temperature", CharSetting.SLEEP_TEMP, 150, 150), ("number.pinecil_sleep_timeout", CharSetting.SLEEP_TIMEOUT, 5, 5), ("number.pinecil_voltage_divider", CharSetting.VOLTAGE_DIV, 600, 600), + ( + "number.pinecil_hall_sensor_sleep_timeout", + CharSetting.HALL_SLEEP_TIME, + 60, + 60, + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") diff --git a/tests/components/iron_os/test_select.py b/tests/components/iron_os/test_select.py index 8cc848dd4cb..5590bfc2ba6 100644 --- a/tests/components/iron_os/test_select.py +++ b/tests/components/iron_os/test_select.py @@ -16,6 +16,7 @@ from pynecil import ( ScreenOrientationMode, ScrollSpeed, TempUnit, + TipType, USBPDMode, ) import pytest @@ -111,6 +112,11 @@ async def test_state( "on", (CharSetting.USB_PD_MODE, USBPDMode.ON), ), + ( + "select.pinecil_soldering_tip_type", + "auto", + (CharSetting.TIP_TYPE, TipType.AUTO), + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") From 8bfffcbd296bfac2545884da7c312e6d563fdae0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Mar 2025 00:24:56 -1000 Subject: [PATCH 1402/1941] Bump thermobeacon-ble to 0.8.1 (#139919) changelog: https://github.com/Bluetooth-Devices/thermobeacon-ble/compare/v0.8.0...v0.8.1 fixes #139917 --- homeassistant/components/thermobeacon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index e060cbd91bf..b231137d335 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -54,5 +54,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.8.0"] + "requirements": ["thermobeacon-ble==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3d2c38c5756..cd2f8700256 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2890,7 +2890,7 @@ tessie-api==0.1.1 # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.0 +thermobeacon-ble==0.8.1 # homeassistant.components.thermopro thermopro-ble==0.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c5c3f4885d..6c522060143 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2327,7 +2327,7 @@ teslemetry-stream==0.6.10 tessie-api==0.1.1 # homeassistant.components.thermobeacon -thermobeacon-ble==0.8.0 +thermobeacon-ble==0.8.1 # homeassistant.components.thermopro thermopro-ble==0.11.0 From 83dd1af6d2bfc5d662c4ede364e328fd26c485be Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 6 Mar 2025 11:25:22 +0100 Subject: [PATCH 1403/1941] Drop report method from frame helper (#139920) * Drop report method from frame helper * Adjust test_prevent_flooding * Adjust test_report_missing_integration_frame * Adjust test_report_error_if_integration * Remove test_report --- homeassistant/helpers/frame.py | 49 ++---------- tests/helpers/snapshots/test_frame.ambr | 38 ---------- tests/helpers/test_frame.py | 99 ++----------------------- 3 files changed, 13 insertions(+), 173 deletions(-) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 3416c8d49f6..acdadb95788 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -150,44 +150,6 @@ class MissingIntegrationFrame(HomeAssistantError): """Raised when no integration is found in the frame.""" -def report( - what: str, - *, - exclude_integrations: set[str] | None = None, - error_if_core: bool = True, - error_if_integration: bool = False, - level: int = logging.WARNING, - log_custom_component_only: bool = False, -) -> None: - """Report incorrect usage. - - If error_if_core is True, raise instead of log if an integration is not found - when unwinding the stack frame. - If error_if_integration is True, raise instead of log if an integration is found - when unwinding the stack frame. - """ - core_behavior = ReportBehavior.ERROR if error_if_core else ReportBehavior.LOG - core_integration_behavior = ( - ReportBehavior.ERROR if error_if_integration else ReportBehavior.LOG - ) - custom_integration_behavior = core_integration_behavior - - if log_custom_component_only: - if core_behavior is ReportBehavior.LOG: - core_behavior = ReportBehavior.IGNORE - if core_integration_behavior is ReportBehavior.LOG: - core_integration_behavior = ReportBehavior.IGNORE - - report_usage( - what, - core_behavior=core_behavior, - core_integration_behavior=core_integration_behavior, - custom_integration_behavior=custom_integration_behavior, - exclude_integrations=exclude_integrations, - level=level, - ) - - class ReportBehavior(enum.Enum): """Enum for behavior on code usage.""" @@ -406,25 +368,26 @@ def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: @functools.wraps(func) async def report_use(*args: Any, **kwargs: Any) -> None: - report(what) + report_usage(what) else: @functools.wraps(func) def report_use(*args: Any, **kwargs: Any) -> None: - report(what) + report_usage(what) return cast(_CallableT, report_use) def report_non_thread_safe_operation(what: str) -> None: """Report a non-thread safe operation.""" - report( + report_usage( f"calls {what} from a thread other than the event loop, " "which may cause Home Assistant to crash or data to corrupt. " "For more information, see " "https://developers.home-assistant.io/docs/asyncio_thread_safety/" f"#{what.replace('.', '')}", - error_if_core=True, - error_if_integration=True, + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.ERROR, ) diff --git a/tests/helpers/snapshots/test_frame.ambr b/tests/helpers/snapshots/test_frame.ambr index abdaff6c1b7..e74a4b2947a 100644 --- a/tests/helpers/snapshots/test_frame.ambr +++ b/tests/helpers/snapshots/test_frame.ambr @@ -1,42 +1,4 @@ # serializer version: 1 -# name: test_report[core default] - list([ - ]) -# --- -# name: test_report[core integration default] - list([ - "Detected that integration 'test_core_integration' test_report_string at homeassistant/components/test_core_integration/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_core_integration%22", - ]) -# --- -# name: test_report[custom integration default] - list([ - "Detected that custom integration 'test_custom_integration' test_report_string at custom_components/test_custom_integration/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_custom_integration' custom integration", - ]) -# --- -# name: test_report[disable error_if_core] - list([ - 'Detected code that test_report_string. Please report this issue', - ]) -# --- -# name: test_report[error_if_integration with core integration] - list([ - "Detected that integration 'test_integration_frame' test_report_string at homeassistant/components/test_integration_frame/light.py, line 23: self.light.is_on. Please create a bug report at https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_integration_frame%22", - ]) -# --- -# name: test_report[error_if_integration with custom integration] - list([ - "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_integration_frame' custom integration", - ]) -# --- -# name: test_report[log_custom_component_only with core integration] - list([ - ]) -# --- -# name: test_report[log_custom_component_only with custom integration] - list([ - "Detected that custom integration 'test_integration_frame' test_report_string at custom_components/test_integration_frame/light.py, line 23: self.light.is_on. Please report it to the author of the 'test_integration_frame' custom integration", - ]) -# --- # name: test_report_usage[core default] list([ ]) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 9bec7cce996..6127761d69b 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -395,14 +395,14 @@ async def test_prevent_flooding( f"q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+{integration}%22" ) - frame.report(what, error_if_core=False) + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) assert expected_message in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 caplog.clear() - frame.report(what, error_if_core=False) + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) assert expected_message not in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 @@ -442,13 +442,13 @@ async def test_report_missing_integration_frame( "homeassistant.helpers.frame.get_integration_frame", side_effect=frame.MissingIntegrationFrame, ): - frame.report(what, error_if_core=False) + frame.report_usage(what, core_behavior=frame.ReportBehavior.LOG) assert what in caplog.text assert caplog.text.count(what) == 1 caplog.clear() - frame.report(what, error_if_core=False, log_custom_component_only=True) + frame.report_usage(what, core_behavior=frame.ReportBehavior.IGNORE) assert caplog.text == "" @@ -492,94 +492,9 @@ async def test_report_error_if_integration( ), ), ): - frame.report("did a bad thing", error_if_integration=True) - - -@pytest.mark.parametrize( - ("integration_frame_path", "keywords", "expected_result", "expected_log"), - [ - pytest.param( - "homeassistant/test_core", - {}, - pytest.raises(RuntimeError, match="test_report_string"), - 0, - id="core default", - ), - pytest.param( - "homeassistant/components/test_core_integration", - {}, - does_not_raise(), - 1, - id="core integration default", - ), - pytest.param( - "custom_components/test_custom_integration", - {}, - does_not_raise(), - 1, - id="custom integration default", - ), - pytest.param( - "custom_components/test_integration_frame", - {"log_custom_component_only": True}, - does_not_raise(), - 1, - id="log_custom_component_only with custom integration", - ), - pytest.param( - "homeassistant/components/test_integration_frame", - {"log_custom_component_only": True}, - does_not_raise(), - 0, - id="log_custom_component_only with core integration", - ), - pytest.param( - "homeassistant/test_integration_frame", - {"error_if_core": False}, - does_not_raise(), - 1, - id="disable error_if_core", - ), - pytest.param( - "custom_components/test_integration_frame", - {"error_if_integration": True}, - pytest.raises(RuntimeError, match="test_report_string"), - 1, - id="error_if_integration with custom integration", - ), - pytest.param( - "homeassistant/components/test_integration_frame", - {"error_if_integration": True}, - pytest.raises(RuntimeError, match="test_report_string"), - 1, - id="error_if_integration with core integration", - ), - ], -) -@pytest.mark.usefixtures("hass", "mock_integration_frame") -async def test_report( - caplog: pytest.LogCaptureFixture, - snapshot: SnapshotAssertion, - keywords: dict[str, Any], - expected_result: AbstractContextManager, - expected_log: int, -) -> None: - """Test report. - - Note: This test doesn't set up mock integrations, so it will not - find the correct issue tracker URL, and we don't check for that. - """ - - what = "test_report_string" - - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()), expected_result: - frame.report(what, **keywords) - - assert caplog.text.count(what) == expected_log - reports = [ - rec.message for rec in caplog.records if rec.message.startswith("Detected") - ] - assert reports == snapshot + frame.report_usage( + "did a bad thing", core_integration_behavior=frame.ReportBehavior.ERROR + ) @pytest.mark.parametrize( From 095b04caf9f12e6aab8e30764dd692cf8e36f97b Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Thu, 6 Mar 2025 12:20:22 +0100 Subject: [PATCH 1404/1941] Homee parallel updates (#139926) * set parallel updates to 0 * add platforms --- homeassistant/components/homee/button.py | 2 ++ homeassistant/components/homee/cover.py | 2 ++ homeassistant/components/homee/light.py | 2 ++ homeassistant/components/homee/number.py | 2 ++ homeassistant/components/homee/quality_scale.yaml | 2 +- homeassistant/components/homee/sensor.py | 2 ++ homeassistant/components/homee/switch.py | 2 ++ homeassistant/components/homee/valve.py | 2 ++ 8 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py index af6d769c1dc..33a8b5f23c8 100644 --- a/homeassistant/components/homee/button.py +++ b/homeassistant/components/homee/button.py @@ -15,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeeConfigEntry from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = { AttributeType.AUTOMATIC_MODE_IMPULSE: ButtonEntityDescription(key="automatic_mode"), AttributeType.BRIEFLY_OPEN_IMPULSE: ButtonEntityDescription(key="briefly_open"), diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py index 6e7e4fd5c55..79a9b00ffba 100644 --- a/homeassistant/components/homee/cover.py +++ b/homeassistant/components/homee/cover.py @@ -21,6 +21,8 @@ from .entity import HomeeNodeEntity _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + OPEN_CLOSE_ATTRIBUTES = [ AttributeType.OPEN_CLOSE, AttributeType.SLAT_ROTATION_IMPULSE, diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py index b9c4460075a..9c66764760e 100644 --- a/homeassistant/components/homee/light.py +++ b/homeassistant/components/homee/light.py @@ -32,6 +32,8 @@ LIGHT_ATTRIBUTES = [ AttributeType.DIMMING_LEVEL, ] +PARALLEL_UPDATES = 0 + def is_light_node(node: HomeeNode) -> bool: """Determine if a node is controllable as a homee light based on its profile and attributes.""" diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py index 3f1f08a6618..5f76b826fcf 100644 --- a/homeassistant/components/homee/number.py +++ b/homeassistant/components/homee/number.py @@ -16,6 +16,8 @@ from . import HomeeConfigEntry from .const import HOMEE_UNIT_TO_HA_UNIT from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + NUMBER_DESCRIPTIONS = { AttributeType.DOWN_POSITION: NumberEntityDescription( key="down_position", diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml index ff99d177018..906218cf823 100644 --- a/homeassistant/components/homee/quality_scale.yaml +++ b/homeassistant/components/homee/quality_scale.yaml @@ -35,7 +35,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: todo diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py index 410f87f2168..e65b73b4a67 100644 --- a/homeassistant/components/homee/sensor.py +++ b/homeassistant/components/homee/sensor.py @@ -27,6 +27,8 @@ from .const import ( from .entity import HomeeEntity, HomeeNodeEntity from .helpers import get_name_for_enum +PARALLEL_UPDATES = 0 + def get_open_close_value(attribute: HomeeAttribute) -> str | None: """Return the open/close value.""" diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index 86c7acdbf11..041b96963f1 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -20,6 +20,8 @@ from . import HomeeConfigEntry from .const import CLIMATE_PROFILES, LIGHT_PROFILES from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + def get_device_class( attribute: HomeeAttribute, config_entry: HomeeConfigEntry diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py index 9a4ff446a10..995716d7ef8 100644 --- a/homeassistant/components/homee/valve.py +++ b/homeassistant/components/homee/valve.py @@ -15,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import HomeeConfigEntry from .entity import HomeeEntity +PARALLEL_UPDATES = 0 + VALVE_DESCRIPTIONS = { AttributeType.CURRENT_VALVE_POSITION: ValveEntityDescription( key="valve_position", From 052eed6bb361fd97d300e4093210dd4986fa8921 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 12:20:53 +0100 Subject: [PATCH 1405/1941] Deduplicate climate modes in SmartThings (#139930) * Deduplicate climate modes in SmartThings * Deduplicate climate modes in SmartThings --- homeassistant/components/smartthings/climate.py | 1 + .../smartthings/fixtures/device_status/da_ac_rac_01001.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index b2f8819601c..c2b44fc41f9 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -566,5 +566,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES ) if (state := AC_MODE_TO_STATE.get(mode)) is not None + if state not in modes ) return modes 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 index 257d553cb9f..e8e71c53ace 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_01001.json @@ -32,7 +32,7 @@ "timestamp": "2025-02-09T14:35:56.800Z" }, "supportedAcModes": { - "value": ["auto", "cool", "dry", "wind", "heat"], + "value": ["auto", "cool", "dry", "wind", "heat", "dryClean"], "timestamp": "2025-02-09T15:42:13.444Z" }, "airConditionerMode": { From e06af94a1a4ac2a5bfa91be7d2faaa44d6b23a99 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 6 Mar 2025 12:22:36 +0100 Subject: [PATCH 1406/1941] Improve description of `tibber.get_prices` action (#139863) Replace with the description from the online docs which add the information that a price level is included. This also makes it consistent with the standard descriptive style in Home Assistant. --- homeassistant/components/tibber/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 05b98b97995..ec2c005d4e3 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -87,7 +87,7 @@ "services": { "get_prices": { "name": "Get energy prices", - "description": "Get hourly energy prices from Tibber", + "description": "Fetches hourly energy prices including price level.", "fields": { "start": { "name": "Start", From 6455daf092e178f038c94434ef55354c0e4d81a1 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 6 Mar 2025 12:30:42 +0100 Subject: [PATCH 1407/1941] Set Ondilo ICO diagnostic sensors (#139934) --- homeassistant/components/ondilo_ico/sensor.py | 3 +++ tests/components/ondilo_ico/snapshots/test_sensor.ambr | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index ddc4a94853f..da5ccae11a5 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + EntityCategory, UnitOfElectricPotential, UnitOfTemperature, ) @@ -56,12 +57,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="rssi", translation_key="rssi", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/tests/components/ondilo_ico/snapshots/test_sensor.ambr b/tests/components/ondilo_ico/snapshots/test_sensor.ambr index 84a2d3da4cb..7df2bfc22ce 100644 --- a/tests/components/ondilo_ico/snapshots/test_sensor.ambr +++ b/tests/components/ondilo_ico/snapshots/test_sensor.ambr @@ -13,7 +13,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_1_battery', 'has_entity_name': True, 'hidden_by': None, @@ -167,7 +167,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_1_rssi', 'has_entity_name': True, 'hidden_by': None, @@ -372,7 +372,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_2_battery', 'has_entity_name': True, 'hidden_by': None, @@ -526,7 +526,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.pool_2_rssi', 'has_entity_name': True, 'hidden_by': None, From 47919fe7e97a8bca005277941967102ee9664e88 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 6 Mar 2025 12:56:46 +0100 Subject: [PATCH 1408/1941] Simplify lint-only config (2) [ci] (#139933) --- .github/workflows/ci.yaml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f8f14f2a126..9ef851009f6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -197,7 +197,9 @@ jobs: if [[ "${{ github.event.inputs.lint-only }}" == "true" ]] \ || [[ "${{ github.event.inputs.pylint-only }}" == "true" ]] \ || [[ "${{ github.event.inputs.mypy-only }}" == "true" ]] \ - || [[ "${{ github.event.inputs.audit-licenses-only }}" == "true" ]]; + || [[ "${{ github.event.inputs.audit-licenses-only }}" == "true" ]] \ + || [[ "${{ github.event_name }}" == "push" \ + && "${{ github.event.repository.full_name }}" != "home-assistant/core" ]]; then lint_only="true" skip_coverage="true" @@ -842,8 +844,7 @@ jobs: prepare-pytest-full: runs-on: ubuntu-24.04 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -896,8 +897,7 @@ jobs: pytest-full: runs-on: ubuntu-24.04 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.test_full_suite == 'true' needs: - info @@ -1023,8 +1023,7 @@ jobs: MYSQL_ROOT_PASSWORD: password options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.mariadb_groups != '[]' needs: - info @@ -1156,8 +1155,7 @@ jobs: POSTGRES_PASSWORD: password options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.postgresql_groups != '[]' needs: - info @@ -1309,8 +1307,7 @@ jobs: pytest-partial: runs-on: ubuntu-24.04 if: | - (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core') - && needs.info.outputs.lint_only != 'true' + needs.info.outputs.lint_only != 'true' && needs.info.outputs.tests_glob && needs.info.outputs.test_full_suite == 'false' needs: From c51e644203a0c93a791bc0d342d14a56728078fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 6 Mar 2025 13:16:50 +0100 Subject: [PATCH 1409/1941] Prioritize integration_domain passed to helper.frame.report_usage (#139819) * Prioritize integration_domain passed to helper.frame.report_usage * Update tests * Update tests * Improve docstring * Rename according to suggestion --- homeassistant/helpers/frame.py | 60 ++++++++++++------- .../alarm_control_panel/test_init.py | 22 +++---- tests/components/vacuum/test_init.py | 3 + tests/helpers/test_frame.py | 8 +-- 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index acdadb95788..ca7b097d90d 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -180,8 +180,8 @@ def report_usage( breaking version :param exclude_integrations: skip specified integration when reviewing the stack. If no integration is found, the core behavior will be applied - :param integration_domain: fallback for identifying the integration if the - frame is not found + :param integration_domain: domain of the integration causing the issue. If None, the + stack frame will be searched to identify the integration causing the issue. """ if (hass := _hass.hass) is None: raise RuntimeError("Frame helper not set up") @@ -220,13 +220,9 @@ def _report_usage( Must be called from the event loop. """ - try: - integration_frame = get_integration_frame( - exclude_integrations=exclude_integrations - ) - except MissingIntegrationFrame as err: + if integration_domain: if integration := async_get_issue_integration(hass, integration_domain): - _report_integration_domain( + _report_usage_integration_domain( hass, what, breaks_in_ha_version, @@ -236,16 +232,15 @@ def _report_usage( level, ) return - msg = f"Detected code that {what}. Please report this issue" - if core_behavior is ReportBehavior.ERROR: - raise RuntimeError(msg) from err - if core_behavior is ReportBehavior.LOG: - if breaks_in_ha_version: - msg = ( - f"Detected code that {what}. This will stop working in Home " - f"Assistant {breaks_in_ha_version}, please report this issue" - ) - _LOGGER.warning(msg, stack_info=True) + _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, None) + return + + try: + integration_frame = get_integration_frame( + exclude_integrations=exclude_integrations + ) + except MissingIntegrationFrame as err: + _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, err) return integration_behavior = core_integration_behavior @@ -253,7 +248,7 @@ def _report_usage( integration_behavior = custom_integration_behavior if integration_behavior is not ReportBehavior.IGNORE: - _report_integration_frame( + _report_usage_integration_frame( hass, what, breaks_in_ha_version, @@ -263,7 +258,7 @@ def _report_usage( ) -def _report_integration_domain( +def _report_usage_integration_domain( hass: HomeAssistant | None, what: str, breaks_in_ha_version: str | None, @@ -313,7 +308,7 @@ def _report_integration_domain( ) -def _report_integration_frame( +def _report_usage_integration_frame( hass: HomeAssistant, what: str, breaks_in_ha_version: str | None, @@ -362,6 +357,29 @@ def _report_integration_frame( ) +def _report_usage_no_integration( + what: str, + core_behavior: ReportBehavior, + breaks_in_ha_version: str | None, + err: MissingIntegrationFrame | None, +) -> None: + """Report incorrect usage without an integration. + + This could happen because the offending call happened outside of an integration, + or because the integration could not be identified. + """ + msg = f"Detected code that {what}. Please report this issue" + if core_behavior is ReportBehavior.ERROR: + raise RuntimeError(msg) from err + if core_behavior is ReportBehavior.LOG: + if breaks_in_ha_version: + msg = ( + f"Detected code that {what}. This will stop working in Home " + f"Assistant {breaks_in_ha_version}, please report this issue" + ) + _LOGGER.warning(msg, stack_info=True) + + def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: """Mock a function to warn when it was about to be used.""" if asyncio.iscoroutinefunction(func): diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 168d7ecc269..747a9d1a358 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -292,13 +292,13 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop assert state is not None assert ( - "Detected that custom integration 'alarm_control_panel' is setting state" - " directly. Entity None (.MockLegacyAlarmControlPanel'>) should implement" " the 'alarm_state' property and return its state using the AlarmControlPanelState" - " enum at test_init.py, line 123: yield. This will stop working in Home Assistant" - " 2025.11, please create a bug report at" in caplog.text + " enum. This will stop working in Home Assistant 2025.11, please report it to" + " the author of the 'test' custom integration" in caplog.text ) @@ -345,6 +345,7 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform( hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True @@ -355,7 +356,7 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state assert state is not None assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." + "Detected that custom integration 'test' is setting state directly." not in caplog.text ) @@ -364,14 +365,14 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." + "Detected that custom integration 'test' is setting state directly." " Entity alarm_control_panel.test_alarm_control_panel" " (.MockLegacyAlarmControlPanel'>) should implement the 'alarm_state' property" - " and return its state using the AlarmControlPanelState enum at test_init.py, line 123:" - " yield. This will stop working in Home Assistant 2025.11," - " please create a bug report at" in caplog.text + " and return its state using the AlarmControlPanelState enum. " + "This will stop working in Home Assistant 2025.11, please report " + "it to the author of the 'test' custom integration" in caplog.text ) caplog.clear() await help_test_async_alarm_control_panel_service( @@ -379,7 +380,7 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) # Test we only log once assert ( - "Detected that custom integration 'alarm_control_panel' is setting state directly." + "Detected that custom integration 'test' is setting state directly." not in caplog.text ) @@ -428,6 +429,7 @@ async def test_alarm_control_panel_deprecated_state_does_not_break_state( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform( hass, ALARM_CONTROL_PANEL_DOMAIN, [entity], from_config_entry=True diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 5735d557288..717a69470b3 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -356,6 +356,7 @@ async def test_vacuum_log_deprecated_state_warning_using_state_prop( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -399,6 +400,7 @@ async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -463,6 +465,7 @@ async def test_vacuum_deprecated_state_does_not_break_state( async_setup_entry=help_async_setup_entry_init, async_unload_entry=help_async_unload_entry, ), + built_in=False, ) setup_test_component_platform(hass, VACUUM_DOMAIN, [entity], from_config_entry=True) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 6127761d69b..6a509ffae5c 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -538,21 +538,21 @@ async def test_report_error_if_integration( False, id="custom integration", ), - # Assert integration found in stack frame has priority over integration_domain + # Assert integration_domain has priority over integration found in stack frame pytest.param( "core_integration_behavior", "sensor", "homeassistant/components/hue", - "that integration 'hue'", + "that integration 'sensor'", False, id="core integration stack mismatch", ), - # Assert integration found in stack frame has priority over integration_domain + # Assert integration_domain has priority over integration found in stack frame pytest.param( "custom_integration_behavior", "test_package", "custom_components/hue", - "that custom integration 'hue'", + "that custom integration 'test_package'", False, id="custom integration stack mismatch", ), From edc763b7d29d7d84796fd0811ecc8addbabddf65 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 13:22:49 +0100 Subject: [PATCH 1410/1941] Bump pysmartthings to 2.6.1 (#139936) * Bump pysmartthings to 2.6.1 * Bump pysmartthings to 2.6.1 --- homeassistant/components/smartthings/entity.py | 4 +++- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/devices/da_ac_rac_000001.json | 14 +++----------- .../smartthings/snapshots/test_init.ambr | 6 +++--- 6 files changed, 12 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 542401109ad..c2637174a5c 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -60,7 +60,9 @@ class SmartThingsEntity(Entity): self._attr_device_info.update( { "manufacturer": ocf.manufacturer_name, - "model": ocf.model_number.split("|")[0], + "model": ( + (ocf.model_number.split("|")[0]) if ocf.model_number else None + ), "hw_version": ocf.hardware_version, "sw_version": ocf.firmware_version, } diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 22926e70ba0..9efa8b81186 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.5.0"] + "requirements": ["pysmartthings==2.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd2f8700256..7f8bede248b 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.5.0 +pysmartthings==2.6.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c522060143..6a04b6d4337 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.5.0 +pysmartthings==2.6.1 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json index d831e15a86b..cc4e13784bf 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000001.json @@ -286,18 +286,10 @@ "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", + "ocfDeviceType": "x.com.st.d.sensor.light", "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", + "vendorId": "VD-Sensor.Light-2023", + "lastSignupTime": "2025-01-08T02:32:04.631093137Z", "transferCandidate": false, "additionalAuthCodeRequired": false }, diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index fb856ae32d6..12745ea8f2c 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -207,7 +207,7 @@ }), 'disabled_by': None, 'entry_type': None, - 'hw_version': '1.0', + 'hw_version': None, 'id': , 'identifiers': set({ tuple( @@ -219,14 +219,14 @@ 'labels': set({ }), 'manufacturer': 'Samsung Electronics', - 'model': 'ARTIK051_KRAC_18K', + 'model': None, 'model_id': None, 'name': 'AC Office Granit', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': 'Theater', - 'sw_version': '0.1.0', + 'sw_version': None, 'via_device_id': None, }) # --- From 5d8e03c1242750387a500110f507c999443dd633 Mon Sep 17 00:00:00 2001 From: marc7s <34547876+marc7s@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:29:30 +0100 Subject: [PATCH 1411/1941] Update geocachingapi to v0.3.0 (#139878) Bump Geocaching API version Co-authored-by: Franck Nijhof --- homeassistant/components/geocaching/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geocaching/manifest.json b/homeassistant/components/geocaching/manifest.json index 127519ca5d0..4617bd1c57b 100644 --- a/homeassistant/components/geocaching/manifest.json +++ b/homeassistant/components/geocaching/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/geocaching", "iot_class": "cloud_polling", - "requirements": ["geocachingapi==0.2.1"] + "requirements": ["geocachingapi==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7f8bede248b..443a40c64b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -986,7 +986,7 @@ gcal-sync==7.0.0 geniushub-client==0.7.1 # homeassistant.components.geocaching -geocachingapi==0.2.1 +geocachingapi==0.3.0 # homeassistant.components.aprs geopy==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a04b6d4337..770e19df3f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -839,7 +839,7 @@ gcal-sync==7.0.0 geniushub-client==0.7.1 # homeassistant.components.geocaching -geocachingapi==0.2.1 +geocachingapi==0.3.0 # homeassistant.components.aprs geopy==2.3.0 From 5d7b60e4c82d23abf00fde81cbf32d6589f6f068 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 6 Mar 2025 13:30:02 +0100 Subject: [PATCH 1412/1941] Bump aiowebdav2 to 0.4.0 (#139938) --- 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 b4950bc23f3..3f465ceed4a 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.1"] + "requirements": ["aiowebdav2==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 443a40c64b3..e9fcd5f577b 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.1 +aiowebdav2==0.4.0 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 770e19df3f1..b0ab760f963 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.1 +aiowebdav2==0.4.0 # homeassistant.components.webostv aiowebostv==0.7.3 From 485da61d3c5f46d143e94610dbcf96484fe9c4fc Mon Sep 17 00:00:00 2001 From: Ishima Date: Thu, 6 Mar 2025 13:42:23 +0100 Subject: [PATCH 1413/1941] Check support for demand load control in SmartThings AC (#139616) * Check support for demand load control in SmartThings AC * Fix --------- Co-authored-by: Joostlek --- .../components/smartthings/climate.py | 5 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ac_rac_100001.json | 167 ++++++++++++++ .../fixtures/devices/da_ac_rac_100001.json | 112 ++++++++++ .../smartthings/snapshots/test_climate.ambr | 84 +++++++ .../smartthings/snapshots/test_init.ambr | 33 +++ .../smartthings/snapshots/test_sensor.ambr | 207 ++++++++++++++++++ 7 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index c2b44fc41f9..b19d65db867 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -451,12 +451,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): ) @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return device specific state attributes. Include attributes from the Demand Response Load Control (drlc) and Power Consumption capabilities. """ + if not self.supports_capability(Capability.DEMAND_RESPONSE_LOAD_CONTROL): + return None + drlc_status = self.get_attribute_value( Capability.DEMAND_RESPONSE_LOAD_CONTROL, Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index a47f32d3a8b..c9a74862187 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -88,6 +88,7 @@ def mock_smartthings() -> Generator[AsyncMock]: @pytest.fixture( params=[ "da_ac_rac_000001", + "da_ac_rac_100001", "da_ac_rac_01001", "multipurpose_sensor", "contact_sensor", diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json new file mode 100644 index 00000000000..305624e5b3b --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json @@ -0,0 +1,167 @@ +{ + "components": { + "main": { + "refresh": {}, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 16, + "timestamp": "2024-11-25T22:17:38.251Z" + }, + "maximumSetpoint": { + "value": 30, + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["cool", "dry", "wind", "auto"], + "timestamp": "2025-03-02T10:16:19.519Z" + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2025-03-02T10:16:19.519Z" + } + }, + "execute": { + "data": { + "value": null + } + }, + "airQualitySensor": { + "airQuality": { + "value": null + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-02T06:54:52.852Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": null + }, + "mnhw": { + "value": null + }, + "di": { + "value": "F8042E25-0E53-0000-0000-000000000000", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnsl": { + "value": null + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "n": { + "value": "Room A/C", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnmo": { + "value": "TP6X_RAC_15K", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "vid": { + "value": "DA-AC-RAC-100001", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "mnml": { + "value": null + }, + "mnpv": { + "value": null + }, + "mnos": { + "value": null + }, + "pi": { + "value": "shp", + "timestamp": "2025-02-28T21:15:28.920Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-28T21:15:28.920Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": null + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "auto", + "timestamp": "2025-02-28T21:15:28.941Z" + }, + "supportedAcFanModes": { + "value": ["auto", "low", "medium", "high", "turbo"], + "timestamp": "2025-02-28T21:15:28.941Z" + }, + "availableAcFanModes": { + "value": null + } + }, + "samsungce.driverState": { + "driverState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["odorSensor"], + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22090101, + "timestamp": "2024-11-25T22:17:38.251Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 27, + "unit": "C", + "timestamp": "2025-03-02T08:28:39.409Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": null + }, + "fineDustLevel": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 18, + "unit": "C", + "timestamp": "2025-03-02T06:54:23.887Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json new file mode 100644 index 00000000000..3938ffc9d9b --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_100001.json @@ -0,0 +1,112 @@ +{ + "items": [ + { + "deviceId": "F8042E25-0E53-0000-0000-000000000000", + "name": "Room A/C", + "label": "Corridor A/C", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-RAC-100001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "5df0730b-38ed-43e4-b291-ec14feb3224c", + "ownerId": "63b9c79b-90fe-5262-9a6a-5e24db90915e", + "roomId": "7715151d-0314-457a-a82c-5ce48900e065", + "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": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.driverState", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-25T22:17:38.129Z", + "profile": { + "id": "9e3e03b1-7f8c-3ea2-8568-6902b79b99dd" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Room A/C", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP6X_RAC_15K", + "vendorId": "DA-AC-RAC-100001", + "lastSignupTime": "2024-11-25T22:17:37.928118320Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index ba32776011a..08ddacf45c6 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -209,6 +209,90 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ac_rac_100001][climate.corridor_a_c-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, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.corridor_a_c', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'F8042E25-0E53-0000-0000-000000000000', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_rac_100001][climate.corridor_a_c-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + 'friendly_name': 'Corridor A/C', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 18, + }), + 'context': , + 'entity_id': 'climate.corridor_a_c', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[ecobee_thermostat][climate.main_floor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 12745ea8f2c..ad7764848b4 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -263,6 +263,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_rac_100001] + DeviceRegistryEntrySnapshot({ + 'area_id': 'theater', + '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', + 'F8042E25-0E53-0000-0000-000000000000', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP6X_RAC_15K', + 'model_id': None, + 'name': 'Corridor A/C', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Theater', + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[da_ks_microwave_0101x] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 78aa4db62f8..ba2a21fe86b 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1379,6 +1379,213 @@ 'state': '0', }) # --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_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.corridor_a_c_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': 'F8042E25-0E53-0000-0000-000000000000.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Corridor A/C Air quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_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.corridor_a_c_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': 'F8042E25-0E53-0000-0000-000000000000.dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Corridor A/C PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_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.corridor_a_c_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': 'F8042E25-0E53-0000-0000-000000000000.fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Corridor A/C PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_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.corridor_a_c_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': 'F8042E25-0E53-0000-0000-000000000000.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Corridor A/C Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.corridor_a_c_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1a4a3a0f08f915593e511ce4c0c28f04e33b9174 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:42:35 +0100 Subject: [PATCH 1414/1941] Use runtime_data in forked_daapd (#138284) * Use runtime_data in forked_daapd * Adjust --- .../components/forked_daapd/__init__.py | 25 ++++++------------- .../components/forked_daapd/config_flow.py | 10 +++----- .../components/forked_daapd/const.py | 3 +-- .../components/forked_daapd/coordinator.py | 3 +++ .../components/forked_daapd/media_player.py | 13 +++------- 5 files changed, 19 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/forked_daapd/__init__.py b/homeassistant/components/forked_daapd/__init__.py index 16fd96ee365..844a6a3eff9 100644 --- a/homeassistant/components/forked_daapd/__init__.py +++ b/homeassistant/components/forked_daapd/__init__.py @@ -2,18 +2,16 @@ from pyforked_daapd import ForkedDaapdAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, HASS_DATA_UPDATER_KEY -from .coordinator import ForkedDaapdUpdater +from .coordinator import ForkedDaapdConfigEntry, ForkedDaapdUpdater PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ForkedDaapdConfigEntry) -> bool: """Set up forked-daapd from a config entry by forwarding to platform.""" host: str = entry.data[CONF_HOST] port: int = entry.data[CONF_PORT] @@ -22,24 +20,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_get_clientsession(hass), host, port, password ) forked_daapd_updater = ForkedDaapdUpdater(hass, forked_daapd_api, entry.entry_id) - if not hass.data.get(DOMAIN): - hass.data[DOMAIN] = {entry.entry_id: {}} - hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})[ - HASS_DATA_UPDATER_KEY - ] = forked_daapd_updater + entry.runtime_data = forked_daapd_updater 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: ForkedDaapdConfigEntry +) -> bool: """Remove forked-daapd component.""" status = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if status and hass.data.get(DOMAIN) and hass.data[DOMAIN].get(entry.entry_id): - if websocket_handler := hass.data[DOMAIN][entry.entry_id][ - HASS_DATA_UPDATER_KEY - ].websocket_handler: + if status: + if websocket_handler := entry.runtime_data.websocket_handler: websocket_handler.cancel() - del hass.data[DOMAIN][entry.entry_id] - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] return status diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index b2b2d498f60..890976c7503 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -7,12 +7,7 @@ from typing import Any from pyforked_daapd import ForkedDaapdAPI import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -28,6 +23,7 @@ from .const import ( DEFAULT_TTS_VOLUME, DOMAIN, ) +from .coordinator import ForkedDaapdConfigEntry _LOGGER = logging.getLogger(__name__) @@ -115,7 +111,7 @@ class ForkedDaapdFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: ForkedDaapdConfigEntry, ) -> ForkedDaapdOptionsFlowHandler: """Return options flow handler.""" return ForkedDaapdOptionsFlowHandler() diff --git a/homeassistant/components/forked_daapd/const.py b/homeassistant/components/forked_daapd/const.py index dd7ed1bdf16..effd4c9454c 100644 --- a/homeassistant/components/forked_daapd/const.py +++ b/homeassistant/components/forked_daapd/const.py @@ -30,9 +30,8 @@ DEFAULT_SERVER_NAME = "My Server" DEFAULT_TTS_PAUSE_TIME = 1.2 DEFAULT_TTS_VOLUME = 0.8 DEFAULT_UNMUTE_VOLUME = 0.6 -DOMAIN = "forked_daapd" # key for hass.data +DOMAIN = "forked_daapd" FD_NAME = "OwnTone" -HASS_DATA_UPDATER_KEY = "UPDATER" KNOWN_PIPES = {"librespot-java"} PIPE_FUNCTION_MAP = { "librespot-java": { diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py index 246ad1caa7d..0ba339be505 100644 --- a/homeassistant/components/forked_daapd/coordinator.py +++ b/homeassistant/components/forked_daapd/coordinator.py @@ -9,6 +9,7 @@ from typing import Any from pyforked_daapd import ForkedDaapdAPI +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -22,6 +23,8 @@ from .const import ( SIGNAL_UPDATE_QUEUE, ) +type ForkedDaapdConfigEntry = ConfigEntry[ForkedDaapdUpdater] + _LOGGER = logging.getLogger(__name__) WS_NOTIFY_EVENT_TYPES = ["player", "outputs", "volume", "options", "queue", "database"] diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 90a04dbc177..fd5390195a6 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -27,7 +27,6 @@ from homeassistant.components.spotify import ( resolve_spotify_media_type, spotify_uri_from_media_browser_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -54,9 +53,7 @@ from .const import ( DEFAULT_TTS_PAUSE_TIME, DEFAULT_TTS_VOLUME, DEFAULT_UNMUTE_VOLUME, - DOMAIN, FD_NAME, - HASS_DATA_UPDATER_KEY, KNOWN_PIPES, PIPE_FUNCTION_MAP, SIGNAL_ADD_ZONES, @@ -73,20 +70,18 @@ from .const import ( SUPPORTED_FEATURES_ZONE, TTS_TIMEOUT, ) -from .coordinator import ForkedDaapdUpdater +from .coordinator import ForkedDaapdConfigEntry _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ForkedDaapdConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up forked-daapd from a config entry.""" - forked_daapd_updater: ForkedDaapdUpdater = hass.data[DOMAIN][config_entry.entry_id][ - HASS_DATA_UPDATER_KEY - ] + forked_daapd_updater = config_entry.runtime_data host: str = config_entry.data[CONF_HOST] forked_daapd_api = forked_daapd_updater.api @@ -115,7 +110,7 @@ async def async_setup_entry( await forked_daapd_updater.async_init() -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: ForkedDaapdConfigEntry) -> None: """Handle options update.""" async_dispatcher_send( hass, SIGNAL_CONFIG_OPTIONS_UPDATE.format(entry.entry_id), entry.options From 377e0a64d16ca7c3c8efe1b376a94290f2d5745d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:57:13 +0100 Subject: [PATCH 1415/1941] Reset helpers.frame._REPORTED_INTEGRATIONS in between tests (#139924) * Reset helpers.frame._REPORTED_INTEGRATIONS in between tests * Rename * Apply suggestions from code review Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- tests/components/alarm_control_panel/test_init.py | 7 +------ tests/components/light/test_init.py | 2 -- tests/components/media_source/test_init.py | 1 - tests/components/vacuum/test_init.py | 5 ----- tests/conftest.py | 9 +++++++-- tests/helpers/test_aiohttp_client.py | 2 -- tests/helpers/test_frame.py | 2 -- tests/helpers/test_httpx_client.py | 2 -- tests/helpers/test_update_coordinator.py | 3 +-- tests/test_config_entries.py | 3 --- tests/test_loader.py | 3 --- 11 files changed, 9 insertions(+), 30 deletions(-) diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 747a9d1a358..01d103d01aa 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -1,7 +1,6 @@ """Test for the alarm control panel const module.""" from typing import Any -from unittest.mock import patch import pytest @@ -23,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import entity_registry as er, frame +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import UNDEFINED, UndefinedType from . import help_async_setup_entry_init, help_async_unload_entry @@ -222,7 +221,6 @@ async def test_alarm_control_panel_with_default_code( mock_alarm_control_panel_entity.calls_disarm.assert_called_with("1234") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_not_log_deprecated_state_warning( hass: HomeAssistant, mock_alarm_control_panel_entity: MockAlarmControlPanel, @@ -238,7 +236,6 @@ async def test_alarm_control_panel_not_log_deprecated_state_warning( @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop( hass: HomeAssistant, code_format: CodeFormat | None, @@ -303,7 +300,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_state_prop @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state_attr( hass: HomeAssistant, code_format: CodeFormat | None, @@ -386,7 +382,6 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_alarm_control_panel_deprecated_state_does_not_break_state( hass: HomeAssistant, code_format: CodeFormat | None, diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 5bc17ea3e24..29604ce7595 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -21,7 +21,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import frame from homeassistant.setup import async_setup_component from homeassistant.util import color as color_util @@ -2846,7 +2845,6 @@ def test_report_invalid_color_modes( ], ids=["with_kelvin", "with_mired_values", "with_mired_defaults"], ) -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) def test_missing_kelvin_property_warnings( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index c37e418020b..2c2952068ee 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -114,7 +114,6 @@ async def test_async_resolve_media(hass: HomeAssistant) -> None: assert media.mime_type == "audio/mpeg" -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_async_resolve_media_no_entity( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 717a69470b3..967b9672805 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -5,7 +5,6 @@ from __future__ import annotations from enum import Enum from types import ModuleType from typing import Any -from unittest.mock import patch import pytest @@ -25,7 +24,6 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import frame from . import MockVacuum, help_async_setup_entry_init, help_async_unload_entry from .common import async_start @@ -326,7 +324,6 @@ async def test_vacuum_not_log_deprecated_state_warning( @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_vacuum_log_deprecated_state_warning_using_state_prop( hass: HomeAssistant, config_flow_fixture: None, @@ -371,7 +368,6 @@ async def test_vacuum_log_deprecated_state_warning_using_state_prop( @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( hass: HomeAssistant, config_flow_fixture: None, @@ -429,7 +425,6 @@ async def test_vacuum_log_deprecated_state_warning_using_attr_state_attr( @pytest.mark.usefixtures("mock_as_custom_component") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_vacuum_deprecated_state_does_not_break_state( hass: HomeAssistant, config_flow_fixture: None, diff --git a/tests/conftest.py b/tests/conftest.py index e3313813112..7725189aa53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -430,11 +430,16 @@ def verify_cleanup( @pytest.fixture(autouse=True) -def reset_hass_threading_local_object() -> Generator[None]: - """Reset the _Hass threading.local object for every test case.""" +def reset_globals() -> Generator[None]: + """Reset global objects for every test case.""" yield + + # Reset the _Hass threading.local object ha._hass.__dict__.clear() + + # Reset the frame helper globals frame.async_setup(None) + frame._REPORTED_INTEGRATIONS.clear() @pytest.fixture(autouse=True, scope="session") diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 13cb25bc516..6d2a7e7a8bb 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -249,7 +249,6 @@ async def test_get_clientsession_patched_close(hass: HomeAssistant) -> None: assert mock_close.call_count == 0 -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -292,7 +291,6 @@ async def test_warning_close_session_integration( ) in caplog.text -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 6a509ffae5c..e99db76dcbc 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -376,7 +376,6 @@ async def test_report_usage_find_issue_tracker_other_thread( assert reports == snapshot -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_prevent_flooding( caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock @@ -408,7 +407,6 @@ async def test_prevent_flooding( assert len(frame._REPORTED_INTEGRATIONS) == 1 -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @pytest.mark.usefixtures("hass", "mock_integration_frame") async def test_breaks_in_ha_version( caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index 4b9f2fa2bf6..c3b9c1f9de8 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -100,7 +100,6 @@ async def test_get_async_client_context_manager(hass: HomeAssistant) -> None: assert mock_aclose.call_count == 0 -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -144,7 +143,6 @@ async def test_warning_close_session_integration( ) in caplog.text -@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 3ad5754dada..5fd9f9e39fd 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -19,7 +19,7 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import frame, update_coordinator +from homeassistant.helpers import update_coordinator from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -638,7 +638,6 @@ async def test_async_config_entry_first_refresh_invalid_state( @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_async_config_entry_first_refresh_invalid_state_in_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 190453afe06..d19c3b38650 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5763,7 +5763,6 @@ async def test_reauth_reconfigure_missing_entry( @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) @pytest.mark.parametrize( "source", [config_entries.SOURCE_REAUTH, config_entries.SOURCE_RECONFIGURE] ) @@ -6010,7 +6009,6 @@ async def test_options_flow_with_config_entry_core() -> None: @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("hass", "mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None: """Test that OptionsFlowWithConfigEntry doesn't mutate entry options.""" entry = MockConfigEntry( @@ -8789,7 +8787,6 @@ async def test_options_flow_config_entry( @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_deprecated_config_entry_setter( hass: HomeAssistant, manager: config_entries.ConfigEntries, diff --git a/tests/test_loader.py b/tests/test_loader.py index 8afe800144c..e4c1982781c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -15,7 +15,6 @@ from homeassistant import loader from homeassistant.components import http, hue from homeassistant.components.hue import light as hue_light from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import frame from homeassistant.helpers.json import json_dumps from homeassistant.util.json import json_loads @@ -1314,7 +1313,6 @@ async def test_config_folder_not_in_path() -> None: ], ) @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_hass_components_use_reported( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -2010,7 +2008,6 @@ async def test_has_services(hass: HomeAssistant) -> None: ], ) @pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_hass_helpers_use_reported( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, From 88f18fdfdc1de40fd8f81eda1952cd974432e511 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 6 Mar 2025 15:20:08 +0100 Subject: [PATCH 1416/1941] Improve loader dependency tests (#139916) --- tests/test_loader.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_loader.py b/tests/test_loader.py index e4c1982781c..548091a3503 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -55,12 +55,35 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: with pytest.raises(loader.CircularDependency): await loader._async_component_dependencies(hass, mod_4) + # Create a circular after_dependency without a hard dependency + mock_integration( + hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) + ) + mod_4 = mock_integration( + hass, MockModule("mod4", partial_manifest={"after_dependencies": ["mod2"]}) + ) + # this currently doesn't raise, but it should. Will be improved in a follow-up. + await loader._async_component_dependencies(hass, mod_4) + async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: """Test if we can detect nonexistent dependencies of components.""" mod_1 = mock_integration(hass, MockModule("mod1", dependencies=["nonexistent"])) with pytest.raises(loader.IntegrationNotFound): await loader._async_component_dependencies(hass, mod_1) + mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) + + assert not await mod_2.resolve_dependencies() + assert mod_2.all_dependencies_resolved + with pytest.raises(RuntimeError): + mod_2.all_dependencies # noqa: B018 + + # this currently is not resolved, because intermediate results are not cached + # this will be improved in a follow-up + assert not mod_1.all_dependencies_resolved + assert not await mod_1.resolve_dependencies() + with pytest.raises(RuntimeError): + mod_1.all_dependencies # noqa: B018 def test_component_loader(hass: HomeAssistant) -> None: From 6ba45a32c08a12e6063aaa1840ec3c3d0f174d97 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 6 Mar 2025 17:25:34 +0100 Subject: [PATCH 1417/1941] Update typing of `BackupAgent.async_get_backup` (#139923) * Update typing of BackupAgent.async_get_backup * Remove manual reset of frame helper --- homeassistant/components/backup/agent.py | 2 +- homeassistant/components/backup/http.py | 8 +- homeassistant/components/backup/manager.py | 16 ++++ .../backup/snapshots/test_websocket.ambr | 34 ++++++++ tests/components/backup/test_http.py | 20 +++++ tests/components/backup/test_websocket.py | 84 +++++++++++++++++++ 6 files changed, 162 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index 0a2531900ae..8093ac88338 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -83,7 +83,7 @@ class BackupAgent(abc.ABC): self, backup_id: str, **kwargs: Any, - ) -> AgentBackup | None: + ) -> AgentBackup: """Return a backup. Raises BackupNotFound if the backup does not exist. diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 20ad613933b..8f241e6363d 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -15,6 +15,7 @@ from multidict import istr from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import frame from homeassistant.util import slugify from . import util @@ -66,7 +67,12 @@ class DownloadBackupView(HomeAssistantView): # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 - if backup is None: + if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) return Response(status=HTTPStatus.NOT_FOUND) headers = { diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4f3ea8b296c..bfaa5c5a48e 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -30,6 +30,7 @@ from homeassistant.backup_restore import ( from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( + frame, instance_id, integration_platform, issue_registry as ir, @@ -665,6 +666,11 @@ class BackupManager: # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 if not result: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) continue if backup is None: if known_backup := self.known_backups.get(backup_id): @@ -1280,6 +1286,11 @@ class BackupManager: # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) @@ -1376,6 +1387,11 @@ class BackupManager: # Check for None to be backwards compatible with the old BackupAgent API, # this can be removed in HA Core 2025.10 if not backup: + frame.report_usage( + "returns None from BackupAgent.async_get_backup", + breaks_in_ha_version="2025.10", + integration_domain=agent_id.partition(".")[0], + ) raise BackupManagerError( f"Backup {backup_id} not found in agent {agent_id}" ) diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 17e3ca8b176..6ecb508d9e9 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -229,6 +229,17 @@ 'type': 'result', }) # --- +# name: test_can_decrypt_on_download_get_backup_returns_none + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_can_decrypt_on_download_with_agent_error[BackupAgentError] dict({ 'error': dict({ @@ -4930,6 +4941,18 @@ 'type': 'result', }) # --- +# name: test_details_get_backup_returns_none + dict({ + 'id': 1, + 'result': dict({ + 'agent_errors': dict({ + }), + 'backup': None, + }), + 'success': True, + 'type': 'result', + }) +# --- # name: test_details_with_errors[BackupAgentUnreachableError] dict({ 'id': 1, @@ -5728,6 +5751,17 @@ # name: test_restore_remote_agent[remote_agents1-backups1].1 1 # --- +# name: test_restore_remote_agent_get_backup_returns_none + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup abc123 not found in agent test.remote', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_restore_wrong_password dict({ 'error': dict({ diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index a03217beac2..92bf454095e 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -234,6 +234,26 @@ async def test_downloading_backup_not_found( assert resp.status == 404 +async def test_downloading_backup_not_found_get_backup_returns_none( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test downloading a backup file that does not exist.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.test"]) + mock_agents["test.test"].async_get_backup.return_value = None + mock_agents["test.test"].async_get_backup.side_effect = None + + client = await hass_client() + + resp = await client.get("/api/backup/download/abc123?agent_id=test.test") + assert resp.status == 404 + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + async def test_downloading_as_non_admin( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 404ba52de4b..d89e68f4ed8 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -234,6 +234,31 @@ async def test_details_with_errors( assert await client.receive_json() == snapshot +async def test_details_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test getting backup info when the agent returns None from get_backup.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + with patch("pathlib.Path.exists", return_value=True): + await client.send_json_auto_id( + {"type": "backup/details", "backup_id": "abc123"} + ) + assert await client.receive_json() == snapshot + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + @pytest.mark.parametrize( ("remote_agents", "backups"), [ @@ -724,6 +749,36 @@ async def test_restore_remote_agent( assert len(restart_calls) == snapshot +async def test_restore_remote_agent_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test calling the restore command when the agent returns None from get_backup.""" + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + restart_calls = async_mock_service(hass, "homeassistant", "restart") + + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json_auto_id( + { + "type": "backup/restore", + "backup_id": "abc123", + "agent_id": "test.remote", + } + ) + assert await client.receive_json() == snapshot + assert len(restart_calls) == 0 + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) + + async def test_restore_wrong_password( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -3543,3 +3598,32 @@ async def test_can_decrypt_on_download_with_agent_error( } ) assert await client.receive_json() == snapshot + + +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download_get_backup_returns_none( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test can decrypt on download when the agent returns None from get_backup.""" + + mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"]) + mock_agents["test.remote"].async_get_backup.return_value = None + mock_agents["test.remote"].async_get_backup.side_effect = None + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": TEST_BACKUP_ABC123.backup_id, + "agent_id": "test.remote", + "password": "hunter2", + } + ) + assert await client.receive_json() == snapshot + assert ( + "Detected that integration 'test' returns None from BackupAgent.async_get_backup." + in caplog.text + ) From 9549b1488ef494d4dcc3d0aa721bb22197ed1497 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 17:52:05 +0100 Subject: [PATCH 1418/1941] Fix SmartThings dust sensor UoM (#139977) --- homeassistant/components/smartthings/sensor.py | 1 + .../fixtures/device_status/da_ac_rac_100001.json | 8 ++++++-- tests/components/smartthings/snapshots/test_sensor.ambr | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ff6e7f252b0..22fdf3084c8 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -951,6 +951,7 @@ UNITS = { "F": UnitOfTemperature.FAHRENHEIT, "lux": LIGHT_LUX, "mG": None, + "μg/m^3": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, } diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json index 305624e5b3b..5c062d904bb 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_100001.json @@ -146,10 +146,14 @@ }, "dustSensor": { "dustLevel": { - "value": null + "value": 46, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-06T16:01:49.656000+00:00" }, "fineDustLevel": { - "value": null + "value": 10, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-06T16:01:49.656000+00:00" } }, "thermostatCoolingSetpoint": { diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index ba2a21fe86b..fa9af0f2812 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1479,7 +1479,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '46', }) # --- # name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_pm2_5-entry] @@ -1531,7 +1531,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '10', }) # --- # name: test_all_entities[da_ac_rac_100001][sensor.corridor_a_c_temperature-entry] From 93dfbb41664613592886ec2a4ee68e34c5a79059 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 6 Mar 2025 17:52:45 +0100 Subject: [PATCH 1419/1941] Update frontend to 20250306.0 (#139965) --- 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 e661439cff2..b210fdb6661 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==20250305.0"] + "requirements": ["home-assistant-frontend==20250306.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e7f1cf096a4..3513ddfdb82 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.25.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250305.0 +home-assistant-frontend==20250306.0 home-assistant-intents==2025.3.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e9fcd5f577b..50fecf0e76b 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==20250305.0 +home-assistant-frontend==20250306.0 # homeassistant.components.conversation home-assistant-intents==2025.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0ab760f963..2920503ecbe 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==20250305.0 +home-assistant-frontend==20250306.0 # homeassistant.components.conversation home-assistant-intents==2025.3.5 From df1563daaf6d0e5d95b88f693076bbcbdc2f57a0 Mon Sep 17 00:00:00 2001 From: Regev Brody Date: Thu, 6 Mar 2025 19:18:37 +0200 Subject: [PATCH 1420/1941] Add Roborock buttons for starting routines (#139845) --- homeassistant/components/roborock/__init__.py | 24 ++++- homeassistant/components/roborock/button.py | 62 ++++++++++-- .../components/roborock/coordinator.py | 49 +++++++++- tests/components/roborock/conftest.py | 23 ++++- tests/components/roborock/mock_data.py | 17 ++++ tests/components/roborock/test_button.py | 97 ++++++++++++++++++- 6 files changed, 252 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index c382a56cde7..955e50cd15b 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -83,7 +83,13 @@ 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 + hass, + entry, + device_map, + user_data, + product_info, + home_data.rooms, + api_client, ), return_exceptions=True, ) @@ -135,6 +141,7 @@ def build_setup_functions( user_data: UserData, product_info: dict[str, HomeDataProduct], home_data_rooms: list[HomeDataRoom], + api_client: RoborockApiClient, ) -> list[ Coroutine[ Any, @@ -151,6 +158,7 @@ def build_setup_functions( device, product_info[device.product_id], home_data_rooms, + api_client, ) for device in device_map.values() ] @@ -163,11 +171,12 @@ 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 + hass, entry, user_data, device, product_info, home_data_rooms, api_client ) if device.pv == "A01": return await setup_device_a01(hass, entry, user_data, device, product_info) @@ -187,6 +196,7 @@ 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( @@ -208,7 +218,15 @@ async def setup_device_v1( await mqtt_client.async_release() raise coordinator = RoborockDataUpdateCoordinator( - hass, entry, device, networking, product_info, mqtt_client, home_data_rooms + hass, + entry, + device, + networking, + product_info, + mqtt_client, + home_data_rooms, + api_client, + user_data, ) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 33e9502aca1..f0f0d7beea2 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -2,7 +2,10 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass +import itertools +from typing import Any from roborock.roborock_typing import RoborockCommand @@ -12,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator -from .entity import RoborockEntityV1 +from .entity import RoborockEntity, RoborockEntityV1 @dataclass(frozen=True, kw_only=True) @@ -65,14 +68,34 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Roborock button platform.""" + routines_lists = await asyncio.gather( + *[coordinator.get_routines() for coordinator in config_entry.runtime_data.v1], + ) async_add_entities( - RoborockButtonEntity( - coordinator, - description, + itertools.chain( + ( + RoborockButtonEntity( + coordinator, + description, + ) + for coordinator in config_entry.runtime_data.v1 + for description in CONSUMABLE_BUTTON_DESCRIPTIONS + if isinstance(coordinator, RoborockDataUpdateCoordinator) + ), + ( + RoborockRoutineButtonEntity( + coordinator, + ButtonEntityDescription( + key=str(routine.id), + name=routine.name, + ), + ) + for coordinator, routines in zip( + config_entry.runtime_data.v1, routines_lists, strict=True + ) + for routine in routines + ), ) - for coordinator in config_entry.runtime_data.v1 - for description in CONSUMABLE_BUTTON_DESCRIPTIONS - if isinstance(coordinator, RoborockDataUpdateCoordinator) ) @@ -97,3 +120,28 @@ class RoborockButtonEntity(RoborockEntityV1, ButtonEntity): async def async_press(self) -> None: """Press the button.""" await self.send(self.entity_description.command, self.entity_description.param) + + +class RoborockRoutineButtonEntity(RoborockEntity, ButtonEntity): + """A class to define Roborock routines button entities.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinator, + entity_description: ButtonEntityDescription, + ) -> None: + """Create a button entity.""" + super().__init__( + f"{entity_description.key}_{coordinator.duid_slug}", + coordinator.device_info, + coordinator.api, + ) + self._routine_id = int(entity_description.key) + self._coordinator = coordinator + self.entity_description = entity_description + + async def async_press(self, **kwargs: Any) -> None: + """Press the button.""" + await self._coordinator.execute_routines(self._routine_id) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 806651c9ac5..1ab23fc927a 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -10,17 +10,26 @@ 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, NetworkInfo +from roborock.containers import ( + DeviceData, + HomeDataDevice, + HomeDataProduct, + HomeDataScene, + NetworkInfo, + UserData, +) 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 @@ -67,6 +76,8 @@ 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__( @@ -89,7 +100,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, - identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, + identifiers={(DOMAIN, self.duid)}, manufacturer="Roborock", model=self.roborock_device_info.product.model, model_id=self.roborock_device_info.product.model, @@ -103,8 +114,10 @@ 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, slugify(self.duid) + hass, self.config_entry.entry_id, self.duid_slug ) + self._user_data = user_data + self._api_client = api_client async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -134,7 +147,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.roborock_device_info.device.duid, + self.duid, ) await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. @@ -194,6 +207,34 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): for room in room_mapping or () } + async def get_routines(self) -> list[HomeDataScene]: + """Get routines.""" + try: + return await self._api_client.get_scenes(self._user_data, self.duid) + except RoborockException as err: + _LOGGER.error("Failed to get routines %s", err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "command": "get_scenes", + }, + ) from err + + async def execute_routines(self, routine_id: int) -> None: + """Execute routines.""" + try: + await self._api_client.execute_scene(self._user_data, routine_id) + except RoborockException as err: + _LOGGER.error("Failed to execute routines %s %s", routine_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/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 43e5148c9a8..9b3a6633c62 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -30,6 +30,7 @@ from .mock_data import ( MULTI_MAP_LIST, NETWORK_INFO, PROP, + SCENES, USER_DATA, USER_EMAIL, ) @@ -67,8 +68,24 @@ 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() -> None: +def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: """Skip calls to the API.""" with ( patch("homeassistant.components.roborock.RoborockMqttClientV1.async_connect"), @@ -76,10 +93,6 @@ def bypass_api_fixture() -> 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 6e3fb229aa9..59c54892687 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -9,6 +9,7 @@ from roborock.containers import ( Consumable, DnDTimer, HomeData, + HomeDataScene, MultiMapsList, NetworkInfo, S7Status, @@ -1150,3 +1151,19 @@ 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_button.py b/tests/components/roborock/test_button.py index 0a7efe83513..77c5d4d7cb0 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -1,9 +1,10 @@ """Test Roborock Button platform.""" -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest import roborock +from roborock import RoborockException from homeassistant.components.button import SERVICE_PRESS from homeassistant.const import Platform @@ -13,6 +14,18 @@ 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.fixture def platforms() -> list[Platform]: """Fixture to set platforms used in the test.""" @@ -84,3 +97,85 @@ async def test_update_failure( ) assert mock_send_message.assert_called_once assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("button.roborock_s7_maxv_sc1"), + ("button.roborock_s7_maxv_sc2"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_get_button_routines_failure( + hass: HomeAssistant, + bypass_api_client_get_scenes_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that if routine retrieval fails, no entity is being created.""" + # Ensure that the entity does not exist + assert hass.states.get(entity_id) is None + + +@pytest.mark.parametrize( + ("entity_id", "routine_id"), + [ + ("button.roborock_s7_maxv_sc1", 12), + ("button.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_press_routine_button_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + routine_id: int, +) -> None: + """Test pressing the button entities.""" + with patch( + "homeassistant.components.roborock.RoborockApiClient.execute_scene" + ) as mock_execute_scene: + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + mock_execute_scene.assert_called_once_with(ANY, routine_id) + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id", "routine_id"), + [ + ("button.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_press_routine_button_failure( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + routine_id: int, +) -> None: + """Test failure while pressing the button 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( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + mock_execute_scene.assert_called_once_with(ANY, routine_id) + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" From 59073d47a1b241c63a3ff5f9d30a1655220d7cb5 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 6 Mar 2025 12:44:13 -0500 Subject: [PATCH 1421/1941] Bump to python-snoo 0.6.1 (#139954) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 3dca8cfe7dd..c9306e58413 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.0"] + "requirements": ["python-snoo==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 50fecf0e76b..3e280a4b560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2467,7 +2467,7 @@ python-roborock==2.11.1 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.0 +python-snoo==0.6.1 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2920503ecbe..c7c83a3eb43 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-roborock==2.11.1 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.0 +python-snoo==0.6.1 # homeassistant.components.songpal python-songpal==0.16.2 From f38a32477ec4424a71be85c6c838d22e35dafd50 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 18:47:37 +0100 Subject: [PATCH 1422/1941] Fix SmartThings fan (#139962) --- homeassistant/components/smartthings/fan.py | 6 +- tests/components/smartthings/conftest.py | 1 + .../device_status/generic_fan_3_speed.json | 19 ++++++ .../fixtures/devices/generic_fan_3_speed.json | 63 +++++++++++++++++++ .../smartthings/snapshots/test_fan.ambr | 56 ++++++++++++++++- .../smartthings/snapshots/test_init.ambr | 33 ++++++++++ 6 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json create mode 100644 tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 9aa467cbfa8..ef3d9702ce2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -119,7 +119,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): @property def is_on(self) -> bool: """Return true if fan is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" @property def percentage(self) -> int | None: @@ -135,6 +135,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ + if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): + return None return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE ) @@ -145,6 +147,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): Requires FanEntityFeature.PRESET_MODE. """ + if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE): + return None return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES ) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index c9a74862187..c78b4bc05de 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -119,6 +119,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ecobee_sensor", "ecobee_thermostat", "fake_fan", + "generic_fan_3_speed", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json b/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json new file mode 100644 index 00000000000..9335bd8e042 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/generic_fan_3_speed.json @@ -0,0 +1,19 @@ +{ + "components": { + "main": { + "refresh": {}, + "fanSpeed": { + "fanSpeed": { + "value": 0, + "timestamp": "2025-03-06T11:47:32.683Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-06T11:47:32.697Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json b/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json new file mode 100644 index 00000000000..db218189c68 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/generic_fan_3_speed.json @@ -0,0 +1,63 @@ +{ + "items": [ + { + "deviceId": "6d95a8b7-4ee3-429a-a13a-00ec9354170c", + "name": "GE In-Wall Smart Dimmer", + "label": "Bedroom Fan", + "manufacturerName": "SmartThingsEdge", + "presentationId": "generic-fan-3-speed", + "deviceManufacturerCode": "0063-4944-3131", + "locationId": "f1313f27-6732-481d-a2a9-c7bbf900f867", + "ownerId": "e5216062-ac82-79b8-20db-ea65fa3d3fdd", + "roomId": "5f77f7cf-ece8-485e-a409-98f7b128a41a", + "components": [ + { + "id": "main", + "label": "Bedroom Fan", + "capabilities": [ + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Fan", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2018-01-12T22:12:15Z", + "parentDeviceId": "4ceb9b86-2f0d-4e98-ba4e-3fbe705f7805", + "profile": { + "id": "9bd81754-fc81-3ed1-86c2-d1094d6cbf6d" + }, + "zwave": { + "networkId": "02", + "driverId": "e7947a05-947d-4bb5-92c4-2aafaff6d69c", + "executingLocally": true, + "hubId": "4ceb9b86-2f0d-4e98-ba4e-3fbe705f7805", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "PROVISIONED", + "manufacturerId": 99, + "productType": 18756, + "productId": 12593 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 33caffcacc6..40ab7b12267 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -62,6 +62,60 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', + }) +# --- +# name: test_all_entities[generic_fan_3_speed][fan.bedroom_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.bedroom_fan', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '6d95a8b7-4ee3-429a-a13a-00ec9354170c', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_fan_3_speed][fan.bedroom_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Fan', + 'percentage': 0, + 'percentage_step': 33.333333333333336, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.bedroom_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', }) # --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index ad7764848b4..2c09d0addaf 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -626,6 +626,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[generic_fan_3_speed] + 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', + '6d95a8b7-4ee3-429a-a13a-00ec9354170c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Bedroom Fan', + '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, From 4bafdf5e4b4c9e7226420b2c8e021dfa2ebe7cb8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 6 Mar 2025 18:48:39 +0100 Subject: [PATCH 1423/1941] Add config entry level diagnostics to SmartThings (#139939) * Add config entry level diagnostics to SmartThings * Add config entry level diagnostics to SmartThings * Add config entry level diagnostics to SmartThings --- .../components/smartthings/diagnostics.py | 25 +- .../snapshots/test_diagnostics.ambr | 2561 ++++++++++------- .../smartthings/test_diagnostics.py | 39 +- 3 files changed, 1513 insertions(+), 1112 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index fc34415e419..dbc5d4e8224 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -17,6 +17,15 @@ from .const import DOMAIN EVENT_WAIT_TIME = 5 +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + client = entry.runtime_data.client + return await client.get_raw_devices() + + async def async_get_device_diagnostics( hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry ) -> dict[str, Any]: @@ -26,7 +35,8 @@ async def async_get_device_diagnostics( identifier for identifier in device.identifiers if identifier[0] == DOMAIN )[1] - device_status = await client.get_device_status(device_id) + device_status = await client.get_raw_device_status(device_id) + device_info = await client.get_raw_device(device_id) events: list[DeviceEvent] = [] @@ -39,11 +49,8 @@ async def async_get_device_diagnostics( listener() - 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} + return { + "events": [asdict(event) for event in events], + "status": device_status, + "info": device_info, + } diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index 50f568df5d1..7610c8839ba 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -1,1160 +1,1525 @@ # serializer version: 1 -# name: test_device[da_ac_rac_000001] +# name: test_config_entry_diagnostics[da_ac_rac_000001] + dict({ + '_links': dict({ + }), + 'items': list([ + dict({ + 'allowed': list([ + ]), + 'components': list([ + dict({ + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', + }), + dict({ + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', + }), + ]), + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', + }), + ]), + }) +# --- +# name: test_device_diagnostics[da_ac_rac_000001] dict({ 'events': list([ ]), + 'info': dict({ + 'allowed': list([ + ]), + 'components': list([ + dict({ + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', + }), + dict({ + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', + }), + ]), + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', + }), 'status': dict({ - '1': dict({ - 'airConditionerFanMode': dict({ - 'availableAcFanModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'components': dict({ + '1': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'value': None, + }), + 'fanMode': dict({ + 'timestamp': '2021-04-06T16:44:10.381Z', + 'value': None, + }), + 'supportedAcFanModes': dict({ + 'timestamp': '2024-09-10T10:26:28.605Z', + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), }), - '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, + 'airConditionerMode': dict({ + 'timestamp': '2021-04-08T03:50:50.930Z', + 'value': None, + }), + 'availableAcModes': dict({ + 'value': None, + }), + 'supportedAcModes': dict({ + 'timestamp': '2021-04-08T03:50:50.930Z', + 'value': None, + }), }), - 'availableAcModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'timestamp': '2021-04-06T16:57:57.602Z', + 'unit': 'CAQI', + 'value': None, + }), }), - 'supportedAcModes': dict({ - 'data': None, - 'timestamp': '2021-04-08T03:50:50.930000+00:00', - 'unit': None, - 'value': None, + 'audioVolume': dict({ + 'volume': dict({ + 'timestamp': '2021-04-06T16:43:53.541Z', + 'unit': '%', + 'value': None, + }), }), - }), - 'airQualitySensor': dict({ - 'airQuality': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.602000+00:00', - 'unit': 'CAQI', - 'value': None, + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'timestamp': '2021-04-08T04:11:38.269Z', + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'timestamp': '2021-04-08T04:11:38.269Z', + 'value': None, + }), }), - }), - 'audioVolume': dict({ - 'volume': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:53.541000+00:00', - 'unit': '%', - 'value': None, + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'timestamp': '2021-04-06T16:57:57.659Z', + 'value': None, + }), + 'supportedAcOptionalMode': dict({ + 'timestamp': '2021-04-06T16:57:57.659Z', + 'value': None, + }), }), - }), - 'custom.airConditionerOdorController': dict({ - 'airConditionerOdorControllerProgress': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:11:38.269000+00:00', - 'unit': None, - 'value': None, + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.498Z', + 'value': None, + }), }), - 'airConditionerOdorControllerState': dict({ - 'data': None, - 'timestamp': '2021-04-08T04:11:38.269000+00:00', - 'unit': None, - 'value': None, + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'timestamp': '2021-04-06T16:43:53.344Z', + 'value': None, + }), + 'operatingState': dict({ + 'value': None, + }), + 'progress': dict({ + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'value': None, + }), + 'timedCleanDuration': dict({ + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'value': None, + }), }), - }), - 'custom.airConditionerOptionalMode': dict({ - 'acOptionalMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.659000+00:00', - 'unit': None, - 'value': None, + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:39.118Z', + 'value': None, + }), }), - 'supportedAcOptionalMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:57:57.659000+00:00', - 'unit': None, - 'value': None, + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), + 'reportStateRealtime': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'timestamp': '2021-04-06T16:44:09.800Z', + 'value': None, + }), }), - }), - 'custom.airConditionerTropicalNightMode': dict({ - 'acTropicalNightModeLevel': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.498000+00:00', - 'unit': None, - 'value': None, + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'timestamp': '2024-09-10T10:26:28.605Z', + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'odorSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'audioVolume', + 'custom.autoCleaningMode', + 'custom.airConditionerTropicalNightMode', + 'custom.airConditionerOdorController', + 'demandResponseLoadControl', + 'relativeHumidityMeasurement', + ]), + }), }), - }), - 'custom.autoCleaningMode': dict({ - 'autoCleaningMode': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:43:53.344000+00:00', - 'unit': None, - 'value': None, + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:39.145Z', + 'value': None, + }), }), - 'operatingState': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'value': None, + }), + 'energySavingInfo': dict({ + 'value': None, + }), + 'energySavingLevel': dict({ + 'value': None, + }), + 'energySavingOperation': dict({ + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'value': None, + }), + 'energySavingSupport': dict({ + 'value': None, + }), + 'energyType': dict({ + 'timestamp': '2021-04-06T16:43:38.843Z', + 'value': None, + }), + 'notificationTemplateID': dict({ + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'value': None, + }), }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'timestamp': '2021-04-06T16:57:57.686Z', + 'value': None, + }), }), - 'supportedAutoCleaningModes': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'timestamp': '2021-04-08T04:04:19.901Z', + 'value': None, + }), + 'minimumSetpoint': dict({ + 'timestamp': '2021-04-08T04:04:19.901Z', + 'value': None, + }), }), - 'supportedOperatingStates': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'timestamp': '2021-04-06T16:43:54.748Z', + '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, + 'dustSensor': dict({ + 'dustLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.122Z', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'timestamp': '2021-04-06T16:44:10.122Z', + 'unit': 'μg/m^3', + 'value': None, + }), }), 'fanOscillationMode': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.247000+00:00', - 'unit': None, - 'value': 'fixed', + 'availableFanOscillationModes': dict({ + 'value': None, + }), + 'fanOscillationMode': dict({ + 'timestamp': '2025-02-08T00:44:53.247Z', + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'timestamp': '2021-04-06T16:44:10.325Z', + 'value': None, + }), }), - 'supportedFanOscillationModes': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.325000+00:00', - 'unit': None, - 'value': None, + 'ocf': dict({ + 'di': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'dmv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'icv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mndt': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnfv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnhw': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnml': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnmn': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnmo': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnos': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnpv': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'mnsl': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'n': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'pi': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'st': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), + 'vid': dict({ + 'timestamp': '2021-04-06T16:44:10.472Z', + 'value': None, + }), }), - }), - 'ocf': dict({ - 'di': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'odorSensor': dict({ + 'odorLevel': dict({ + 'timestamp': '2021-04-06T16:43:38.992Z', + 'value': None, + }), }), - 'dmv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'timestamp': '2021-04-06T16:43:53.364Z', + 'value': None, + }), }), - 'icv': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'timestamp': '2021-04-06T16:43:35.291Z', + 'unit': '%', + 'value': 0, + }), }), - 'mndt': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.472000+00:00', - 'unit': None, - 'value': None, + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'timestamp': '2021-04-06T16:43:39.097Z', + '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, + 'switch': dict({ + 'timestamp': '2021-04-06T16:44:10.518Z', + 'value': None, + }), + }), + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'timestamp': '2021-04-06T16:44:10.373Z', + 'value': None, + }), + 'temperatureRange': dict({ + 'value': None, + }), + }), + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'timestamp': '2021-04-06T16:43:59.136Z', + 'value': None, + }), + 'coolingSetpointRange': dict({ + 'value': None, + }), + }), + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:38.529Z', + 'unit': 'μg/m^3', + 'value': None, + }), }), }), - 'temperatureMeasurement': dict({ - 'temperature': dict({ - 'data': None, - 'timestamp': '2021-04-06T16:44:10.373000+00:00', - 'unit': None, - 'value': None, + 'main': dict({ + 'airConditionerFanMode': dict({ + 'availableAcFanModes': dict({ + 'value': None, + }), + 'fanMode': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': 'low', + }), + 'supportedAcFanModes': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': list([ + 'auto', + 'low', + 'medium', + 'high', + 'turbo', + ]), + }), }), - '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', + 'airConditionerMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'heat', + }), + 'availableAcModes': dict({ + 'value': None, + }), + 'supportedAcModes': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': list([ + 'cool', + 'dry', + 'wind', + 'auto', + 'heat', + ]), }), }), - }), - 'execute': dict({ - 'data': dict({ - 'data': dict({ - 'href': '/temperature/desired/0', + 'airQualitySensor': dict({ + 'airQuality': dict({ + 'timestamp': '2021-04-06T16:43:37.208Z', + 'unit': 'CAQI', + 'value': None, }), - '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', + }), + 'audioVolume': dict({ + 'volume': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'unit': '%', + 'value': 100, + }), + }), + 'custom.airConditionerOdorController': dict({ + 'airConditionerOdorControllerProgress': dict({ + 'timestamp': '2021-04-06T16:43:37.555Z', + 'value': None, + }), + 'airConditionerOdorControllerState': dict({ + 'timestamp': '2021-04-06T16:43:37.555Z', + 'value': None, + }), + }), + 'custom.airConditionerOptionalMode': dict({ + 'acOptionalMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + 'supportedAcOptionalMode': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': list([ + 'off', + 'windFree', + ]), + }), + }), + 'custom.airConditionerTropicalNightMode': dict({ + 'acTropicalNightModeLevel': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 0, + }), + }), + 'custom.autoCleaningMode': dict({ + 'autoCleaningMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + 'operatingState': dict({ + 'value': None, + }), + 'progress': dict({ + 'value': None, + }), + 'supportedAutoCleaningModes': dict({ + 'value': None, + }), + 'supportedOperatingStates': dict({ + 'value': None, + }), + 'timedCleanDuration': dict({ + 'value': None, + }), + 'timedCleanDurationRange': dict({ + 'value': None, + }), + }), + 'custom.deodorFilter': dict({ + 'deodorFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + 'deodorFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:35.502Z', + 'value': None, + }), + }), + 'custom.deviceReportStateConfiguration': dict({ + 'reportStatePeriod': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + 'reportStateRealtime': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + 'reportStateRealtimePeriod': dict({ + 'timestamp': '2021-04-06T16:43:35.643Z', + 'value': None, + }), + }), + 'custom.disabledCapabilities': dict({ + 'disabledCapabilities': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': list([ + 'remoteControlStatus', + 'airQualitySensor', + 'dustSensor', + 'veryFineDustSensor', + 'custom.dustFilter', + 'custom.deodorFilter', + 'custom.deviceReportStateConfiguration', + 'samsungce.dongleSoftwareInstallation', + 'demandResponseLoadControl', + 'custom.airConditionerOdorController', + ]), + }), + }), + 'custom.disabledComponents': dict({ + 'disabledComponents': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': list([ + '1', + ]), + }), + }), + 'custom.dustFilter': dict({ + 'dustFilterCapacity': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterLastResetDate': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterResetType': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterStatus': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterUsage': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + 'dustFilterUsageStep': dict({ + 'timestamp': '2021-04-06T16:43:35.527Z', + 'value': None, + }), + }), + 'custom.energyType': dict({ + 'drMaxDuration': dict({ + 'value': None, + }), + 'energySavingInfo': dict({ + 'value': None, + }), + 'energySavingLevel': dict({ + 'value': None, + }), + 'energySavingOperation': dict({ + 'value': None, + }), + 'energySavingOperationSupport': dict({ + 'value': None, + }), + 'energySavingSupport': dict({ + 'timestamp': '2021-12-29T07:29:17.526Z', + 'value': False, + }), + 'energyType': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': '1.0', + }), + 'notificationTemplateID': dict({ + 'value': None, + }), + 'supportedEnergySavingLevels': dict({ + 'value': None, + }), + }), + 'custom.spiMode': dict({ + 'spiMode': dict({ + 'timestamp': '2025-02-09T09:14:39.642Z', + 'value': 'off', + }), + }), + 'custom.thermostatSetpointControl': dict({ + 'maximumSetpoint': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'unit': 'C', + 'value': 30, + }), + 'minimumSetpoint': dict({ + 'timestamp': '2025-01-08T06:30:58.307Z', + 'unit': 'C', + 'value': 16, + }), + }), + 'demandResponseLoadControl': dict({ + 'drlcStatus': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': dict({ + 'drlcLevel': -1, + 'drlcType': 1, + 'duration': 0, + 'override': False, + 'start': '1970-01-01T00:00:00Z', }), }), }), - }), - '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', + 'dustSensor': dict({ + 'dustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.665Z', + 'unit': 'μg/m^3', + 'value': None, + }), + 'fineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.665Z', + 'unit': 'μg/m^3', + 'value': None, }), }), - }), - 'refresh': dict({ - }), - 'relativeHumidityMeasurement': dict({ - 'humidity': dict({ - 'data': None, - 'timestamp': '2024-12-30T13:10:23.759000+00:00', - 'unit': '%', - 'value': 60, + 'execute': dict({ + 'data': dict({ + 'data': dict({ + 'href': '/temperature/desired/0', + }), + 'timestamp': '2023-07-19T03:07:43.270Z', + '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', + }), + }), + }), }), - }), - 'samsungce.deviceIdentification': dict({ - 'binaryId': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.855000+00:00', - 'unit': None, - 'value': 'ARTIK051_KRAC_18K', + 'fanOscillationMode': dict({ + 'availableFanOscillationModes': dict({ + 'value': None, + }), + 'fanOscillationMode': dict({ + 'timestamp': '2025-02-09T09:14:39.249Z', + 'value': 'fixed', + }), + 'supportedFanOscillationModes': dict({ + 'timestamp': '2021-04-06T16:43:35.782Z', + 'value': None, + }), }), - 'description': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'ocf': dict({ + 'di': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'dmv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'res.1.1.0,sh.1.1.0', + }), + 'icv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'core.1.1.0', + }), + 'mndt': dict({ + 'timestamp': '2021-04-06T16:43:35.912Z', + 'value': None, + }), + 'mnfv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '0.1.0', + }), + 'mnhw': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '1.0', + }), + 'mnml': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'http://www.samsung.com', + }), + 'mnmn': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'Samsung Electronics', + }), + 'mnmo': dict({ + 'timestamp': '2024-09-10T10:26:28.781Z', + 'value': 'ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000', + }), + 'mnos': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'TizenRT2.0', + }), + 'mnpv': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '0G3MPDCKA00010E', + }), + 'mnsl': dict({ + 'timestamp': '2021-04-06T16:43:35.803Z', + 'value': None, + }), + 'n': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '[room a/c] Samsung', + }), + 'pi': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': '96a5ef74-5832-a84b-f1f7-ca799957065d', + }), + 'st': dict({ + 'timestamp': '2021-04-06T16:43:35.933Z', + 'value': None, + }), + 'vid': dict({ + 'timestamp': '2024-09-10T10:26:28.552Z', + 'value': 'DA-AC-RAC-000001', + }), }), - 'micomAssayCode': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'powerConsumptionReport': dict({ + 'powerConsumption': dict({ + 'timestamp': '2025-02-09T16:15:33.639Z', + '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', + }), + }), }), - 'modelClassificationCode': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'refresh': dict({ }), - 'modelName': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'relativeHumidityMeasurement': dict({ + 'humidity': dict({ + 'timestamp': '2024-12-30T13:10:23.759Z', + 'unit': '%', + 'value': 60, + }), }), - 'releaseYear': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'remoteControlStatus': dict({ + 'remoteControlEnabled': dict({ + 'timestamp': '2021-04-06T16:43:35.379Z', + 'value': None, + }), }), - 'serialNumber': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'samsungce.deviceIdentification': dict({ + 'binaryId': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': 'ARTIK051_KRAC_18K', + }), + 'description': dict({ + 'value': None, + }), + 'micomAssayCode': dict({ + 'value': None, + }), + 'modelClassificationCode': dict({ + 'value': None, + }), + 'modelName': dict({ + 'value': None, + }), + 'releaseYear': dict({ + 'value': None, + }), + 'serialNumber': dict({ + 'value': None, + }), + 'serialNumberExtra': dict({ + 'value': None, + }), }), - 'serialNumberExtra': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'samsungce.dongleSoftwareInstallation': dict({ + 'status': dict({ + 'timestamp': '2021-12-29T01:36:51.289Z', + 'value': 'completed', + }), }), - }), - 'samsungce.driverVersion': dict({ - 'versionNumber': dict({ - 'data': None, - 'timestamp': '2024-09-04T06:35:09.557000+00:00', - 'unit': None, - 'value': 24070101, + 'samsungce.driverVersion': dict({ + 'versionNumber': dict({ + 'timestamp': '2024-09-04T06:35:09.557Z', + 'value': 24070101, + }), }), - }), - 'samsungce.selfCheck': dict({ - 'errors': dict({ - 'data': None, - 'timestamp': '2025-02-08T00:44:53.349000+00:00', - 'unit': None, - 'value': list([ - ]), + 'samsungce.selfCheck': dict({ + 'errors': dict({ + 'timestamp': '2025-02-08T00:44:53.349Z', + 'value': list([ + ]), + }), + 'progress': dict({ + 'value': None, + }), + 'result': dict({ + 'value': None, + }), + 'status': dict({ + 'timestamp': '2025-02-08T00:44:53.549Z', + 'value': 'ready', + }), + 'supportedActions': dict({ + 'timestamp': '2024-09-04T06:35:09.557Z', + 'value': list([ + 'start', + ]), + }), }), - 'progress': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'samsungce.softwareUpdate': dict({ + 'availableModules': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': list([ + ]), + }), + 'lastUpdatedDate': dict({ + 'value': None, + }), + 'newVersionAvailable': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': False, + }), + 'operatingState': dict({ + 'value': None, + }), + 'otnDUID': dict({ + 'timestamp': '2025-02-08T00:44:53.855Z', + 'value': '43CEZFTFFL7Z2', + }), + 'progress': dict({ + 'value': None, + }), + 'targetModule': dict({ + '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', + 'switch': dict({ + 'timestamp': '2025-02-09T16:37:54.072Z', + 'value': 'off', + }), }), - }), - 'temperatureMeasurement': dict({ - 'temperature': dict({ - 'data': None, - 'timestamp': '2025-02-09T16:33:29.164000+00:00', - 'unit': 'C', - 'value': 25, + 'temperatureMeasurement': dict({ + 'temperature': dict({ + 'timestamp': '2025-02-09T16:33:29.164Z', + 'unit': 'C', + 'value': 25, + }), + 'temperatureRange': dict({ + 'value': None, + }), }), - 'temperatureRange': dict({ - 'data': None, - 'timestamp': None, - 'unit': None, - 'value': None, + 'thermostatCoolingSetpoint': dict({ + 'coolingSetpoint': dict({ + 'timestamp': '2025-02-09T09:15:11.608Z', + 'unit': 'C', + 'value': 25, + }), + 'coolingSetpointRange': dict({ + '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, + 'veryFineDustSensor': dict({ + 'veryFineDustLevel': dict({ + 'timestamp': '2021-04-06T16:43:35.363Z', + 'unit': 'μg/m^3', + 'value': None, + }), }), }), }), diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index 768be155c86..f486c19de14 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -12,13 +12,36 @@ 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.common import MockConfigEntry, load_json_object_fixture +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) from tests.typing import ClientSessionGenerator @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) -async def test_device( +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + devices: AsyncMock, + mock_smartthings: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a device entry.""" + mock_smartthings.get_raw_devices.return_value = load_json_object_fixture( + "devices/da_ac_rac_000001.json", DOMAIN + ) + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, @@ -28,13 +51,19 @@ async def test_device( snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a device entry.""" + mock_smartthings.get_raw_device_status.return_value = load_json_object_fixture( + "device_status/da_ac_rac_000001.json", DOMAIN + ) + mock_smartthings.get_raw_device.return_value = load_json_object_fixture( + "devices/da_ac_rac_000001.json", DOMAIN + )["items"][0] await setup_integration(hass, mock_config_entry) device = device_registry.async_get_device( identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")} ) - mock_smartthings.get_device_status.reset_mock() + mock_smartthings.get_raw_device_status.reset_mock() with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1): diag = await get_diagnostics_for_device( @@ -44,6 +73,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( + mock_smartthings.get_raw_device_status.assert_called_once_with( "96a5ef74-5832-a84b-f1f7-ca799957065d" ) From 4ff2309a90b9936b8c7f75ceb1259dfac6e32fda Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 6 Mar 2025 18:50:47 +0100 Subject: [PATCH 1424/1941] Use mysensors config entry async_on_unload (#139978) * Use config entry on unload in mysensors * Test mysensors config entry load and unload * Fix docstring --- .../components/mysensors/__init__.py | 8 --- .../components/mysensors/binary_sensor.py | 5 +- homeassistant/components/mysensors/climate.py | 5 +- homeassistant/components/mysensors/const.py | 1 - homeassistant/components/mysensors/cover.py | 5 +- .../components/mysensors/device_tracker.py | 5 +- homeassistant/components/mysensors/gateway.py | 5 +- homeassistant/components/mysensors/helpers.py | 13 ----- homeassistant/components/mysensors/light.py | 5 +- homeassistant/components/mysensors/remote.py | 5 +- homeassistant/components/mysensors/sensor.py | 9 +--- homeassistant/components/mysensors/switch.py | 5 +- homeassistant/components/mysensors/text.py | 5 +- tests/components/mysensors/conftest.py | 2 +- tests/components/mysensors/test_init.py | 49 +++++++++++++++++++ 15 files changed, 61 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 19dcce78446..e2aca8b9f01 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -17,7 +17,6 @@ from .const import ( DOMAIN, MYSENSORS_DISCOVERED_NODES, MYSENSORS_GATEWAYS, - MYSENSORS_ON_UNLOAD, PLATFORMS, DevId, DiscoveryInfo, @@ -62,13 +61,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not unload_ok: return False - key = MYSENSORS_ON_UNLOAD.format(entry.entry_id) - if key in hass.data[DOMAIN]: - for fnct in hass.data[DOMAIN][key]: - fnct() - - hass.data[DOMAIN].pop(key) - del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] hass.data[DOMAIN].pop(MYSENSORS_DISCOVERED_NODES.format(entry.entry_id), None) diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index d42b2194315..e950f083b5b 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload @dataclass(frozen=True) @@ -86,9 +85,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.BINARY_SENSOR), diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d1504f3afab..d1b697a3458 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -21,7 +21,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload DICT_HA_TO_MYS = { HVACMode.AUTO: "AutoChangeOver", @@ -57,9 +56,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.CLIMATE), diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index a65b46616d3..a87b78b549e 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -34,7 +34,6 @@ CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" NODE_CALLBACK: str = "mysensors_node_callback_{}_{}" MYSENSORS_DISCOVERY: str = "mysensors_discovery_{}_{}" MYSENSORS_NODE_DISCOVERY: str = "mysensors_node_discovery" -MYSENSORS_ON_UNLOAD: str = "mysensors_on_unload_{}" TYPE: Final = "type" UPDATE_DELAY: float = 0.1 diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 14e6ff6dc15..2ac0367d1fc 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -15,7 +15,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload @unique @@ -45,9 +44,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.COVER), diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 56d8b2f5923..e6368b0b81d 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -33,9 +32,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.DEVICE_TRACKER), diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index bdc83f30b21..91453ea3306 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -47,7 +47,6 @@ from .handler import HANDLERS from .helpers import ( discover_mysensors_node, discover_mysensors_platform, - on_unload, validate_child, validate_node, ) @@ -293,9 +292,7 @@ async def _gw_start( """Stop the gateway.""" await gw_stop(hass, entry, gateway) - on_unload( - hass, - entry.entry_id, + entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw), ) diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index c96ad6cea8e..9ed41dfe4e9 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -27,7 +27,6 @@ from .const import ( MYSENSORS_DISCOVERED_NODES, MYSENSORS_DISCOVERY, MYSENSORS_NODE_DISCOVERY, - MYSENSORS_ON_UNLOAD, TYPE_TO_PLATFORMS, DevId, GatewayId, @@ -41,18 +40,6 @@ SCHEMAS: Registry[ ] = Registry() -@callback -def on_unload(hass: HomeAssistant, gateway_id: GatewayId, fnct: Callable) -> None: - """Register a callback to be called when entry is unloaded. - - This function is used by platforms to cleanup after themselves. - """ - key = MYSENSORS_ON_UNLOAD.format(gateway_id) - if key not in hass.data[DOMAIN]: - hass.data[DOMAIN][key] = [] - hass.data[DOMAIN][key].append(fnct) - - @callback def discover_mysensors_platform( hass: HomeAssistant, gateway_id: GatewayId, platform: str, new_devices: list[DevId] diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 9e4054ca3d0..4fa9eaa8ea6 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -21,7 +21,6 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -46,9 +45,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.LIGHT), diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index ada801f92ab..ccb67f78eba 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -19,7 +19,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -40,9 +39,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.REMOTE), diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 759cf7b010f..3a7101e6b39 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -50,7 +50,6 @@ from .const import ( NodeDiscoveryInfo, ) from .entity import MySensorNodeEntity, MySensorsChildEntity -from .helpers import on_unload SENSORS: dict[str, SensorEntityDescription] = { "V_TEMP": SensorEntityDescription( @@ -233,9 +232,7 @@ async def async_setup_entry( gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id] async_add_entities([MyBatterySensor(gateway_id, gateway, node_id)]) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.SENSOR), @@ -243,9 +240,7 @@ async def async_setup_entry( ), ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_NODE_DISCOVERY, diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 52207c21f77..499124919b5 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -14,7 +14,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -48,9 +47,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.SWITCH), diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index 8eff7a255e7..9fdd9da5345 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -12,7 +12,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .entity import MySensorsChildEntity -from .helpers import on_unload async def async_setup_entry( @@ -33,9 +32,7 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - on_unload( - hass, - config_entry.entry_id, + config_entry.async_on_unload( async_dispatcher_connect( hass, MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.TEXT), diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 1d407815db0..b14a3f9c529 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -53,7 +53,7 @@ def gateway_nodes_fixture() -> dict[int, Sensor]: async def serial_transport_fixture( gateway_nodes: dict[int, Sensor], is_serial_port: MagicMock, -) -> AsyncGenerator[dict[int, Sensor]]: +) -> AsyncGenerator[MagicMock]: """Mock a serial transport.""" with ( patch( diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 7f6ea76d3e1..108f2d7e592 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -2,10 +2,15 @@ from __future__ import annotations +from collections.abc import Callable +from unittest.mock import MagicMock + from mysensors import BaseSyncGateway from mysensors.sensor import Sensor from homeassistant.components.mysensors import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -14,6 +19,50 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +async def test_load_unload( + hass: HomeAssistant, + door_sensor: Sensor, + transport: MagicMock, + integration: MockConfigEntry, + receive_message: Callable[[str], None], +) -> None: + """Test loading and unloading the MySensors config entry.""" + config_entry = integration + + assert config_entry.state == ConfigEntryState.LOADED + + entity_id = "binary_sensor.door_sensor_1_1" + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + + receive_message("1;1;1;0;16;1\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state != STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert transport.return_value.disconnect.call_count == 1 + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNAVAILABLE + + receive_message("1;1;1;0;16;1\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_UNAVAILABLE + + async def test_remove_config_entry_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 99e1a7a676b2fc14f9f8a8db64bee2840fae4646 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 6 Mar 2025 18:52:46 +0100 Subject: [PATCH 1425/1941] Check if the unit of measurement is valid before creating the entity (#139932) --- homeassistant/components/mqtt/sensor.py | 15 ++++++++++++++ tests/components/mqtt/test_sensor.py | 26 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3e8a4fef0fa..432431c96d9 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, @@ -107,6 +108,20 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) + if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is None: + return config + + if ( + device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + raise vol.Invalid( + f"The unit of measurement `{unit_of_measurement}` is not valid " + f"together with device class `{device_class}`" + ) + return config diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 9226b03a7d2..f40082d84be 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -870,6 +870,32 @@ async def test_invalid_device_class( assert "expected SensorDeviceClass or one of" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "energy", + "unit_of_measurement": "ppm", + } + } + } + ], +) +async def test_invalid_unit_of_measurement( + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture +) -> None: + """Test device_class with invalid unit of measurement.""" + assert await mqtt_mock_entry() + assert ( + "The unit of measurement `ppm` is not valid together with device class `energy`" + in caplog.text + ) + + @pytest.mark.parametrize( "hass_config", [ From eaad8ec49d034b1cd5a89b6fd41b179b70c33bbb Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Thu, 6 Mar 2025 19:56:17 +0100 Subject: [PATCH 1426/1941] Add Homee select platform (#139534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * homee select initial * Finish select tests * Add motor rotation * fix snapshot after translation compilation * string improvement * last fixes * fix review comments * remove restore last known state * readd wind monitoring state * fix strings * remove problematic selects * remove motor rotation from strings * fix review comments * Update tests/components/homee/test_select.py Co-authored-by: Abílio Costa * add PARALLEL_UPDATES * parallel updates for select, not light. --------- Co-authored-by: Robert Resch Co-authored-by: Abílio Costa --- homeassistant/components/homee/__init__.py | 1 + homeassistant/components/homee/select.py | 63 +++++++++++ homeassistant/components/homee/strings.json | 10 ++ tests/components/homee/fixtures/selects.json | 43 +++++++ .../homee/snapshots/test_select.ambr | 59 ++++++++++ tests/components/homee/test_select.py | 106 ++++++++++++++++++ 6 files changed, 282 insertions(+) create mode 100644 homeassistant/components/homee/select.py create mode 100644 tests/components/homee/fixtures/selects.json create mode 100644 tests/components/homee/snapshots/test_select.ambr create mode 100644 tests/components/homee/test_select.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index d7785ad9104..92773dae656 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -19,6 +19,7 @@ PLATFORMS = [ Platform.COVER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.VALVE, diff --git a/homeassistant/components/homee/select.py b/homeassistant/components/homee/select.py new file mode 100644 index 00000000000..70c7972bbda --- /dev/null +++ b/homeassistant/components/homee/select.py @@ -0,0 +1,63 @@ +"""The Homee select platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +PARALLEL_UPDATES = 0 + +SELECT_DESCRIPTIONS: dict[AttributeType, SelectEntityDescription] = { + AttributeType.REPEATER_MODE: SelectEntityDescription( + key="repeater_mode", + options=["off", "level1", "level2"], + 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 select component.""" + + async_add_entities( + HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type]) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in SELECT_DESCRIPTIONS and attribute.editable + ) + + +class HomeeSelect(HomeeEntity, SelectEntity): + """Representation of a Homee select entity.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: SelectEntityDescription, + ) -> None: + """Initialize a Homee select entity.""" + super().__init__(attribute, entry) + self.entity_description = description + assert description.options is not None + self._attr_options = description.options + self._attr_translation_key = description.key + + @property + def current_option(self) -> str: + """Return the current selected option.""" + return self.options[int(self._attribute.current_value)] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.async_set_homee_value(self.options.index(option)) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 94f85824280..8b61cc6d28c 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -110,6 +110,16 @@ "name": "Wake-up interval" } }, + "select": { + "repeater_mode": { + "name": "Repeater mode", + "state": { + "off": "[%key:common::state::off%]", + "level1": "Level 1", + "level2": "Level 2" + } + } + }, "sensor": { "brightness": { "name": "Illuminance" diff --git a/tests/components/homee/fixtures/selects.json b/tests/components/homee/fixtures/selects.json new file mode 100644 index 00000000000..27adcf07298 --- /dev/null +++ b/tests/components/homee/fixtures/selects.json @@ -0,0 +1,43 @@ +{ + "id": 1, + "name": "Test Select", + "profile": 33, + "image": "nodeicon_dimmablebulb", + "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": 2, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 226, + "state": 1, + "last_changed": 1680027880, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + } + ] +} diff --git a/tests/components/homee/snapshots/test_select.ambr b/tests/components/homee/snapshots/test_select.ambr new file mode 100644 index 00000000000..9fa831230c2 --- /dev/null +++ b/tests/components/homee/snapshots/test_select.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_select_snapshot[select.test_select_repeater_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'level1', + 'level2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.test_select_repeater_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Repeater mode', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'repeater_mode', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_snapshot[select.test_select_repeater_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Select Repeater mode', + 'options': list([ + 'off', + 'level1', + 'level2', + ]), + }), + 'context': , + 'entity_id': 'select.test_select_repeater_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level1', + }) +# --- diff --git a/tests/components/homee/test_select.py b/tests/components/homee/test_select.py new file mode 100644 index 00000000000..c0dec2234d6 --- /dev/null +++ b/tests/components/homee/test_select.py @@ -0,0 +1,106 @@ +"""Test homee selects.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_FIRST, + SERVICE_SELECT_LAST, + SERVICE_SELECT_NEXT, + SERVICE_SELECT_OPTION, + SERVICE_SELECT_PREVIOUS, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def setup_select( + hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Setups the integration for select tests.""" + mock_homee.nodes = [build_mock_node("selects.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("service", "extra_options", "expected"), + [ + (SERVICE_SELECT_FIRST, {}, 0), + (SERVICE_SELECT_LAST, {}, 2), + (SERVICE_SELECT_NEXT, {}, 2), + (SERVICE_SELECT_PREVIOUS, {}, 0), + ( + SERVICE_SELECT_OPTION, + { + "option": "level2", + }, + 2, + ), + ], +) +async def test_select_services( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + service: str, + extra_options: dict[str, str], + expected: int, +) -> None: + """Test the select services.""" + await setup_select(hass, mock_homee, mock_config_entry) + + OPTIONS = {ATTR_ENTITY_ID: "select.test_select_repeater_mode"} + OPTIONS.update(extra_options) + + await hass.services.async_call( + SELECT_DOMAIN, + service, + OPTIONS, + blocking=True, + ) + + mock_homee.set_value.assert_called_once_with(1, 1, expected) + + +async def test_select_option_service_error( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the select_option service called with invalid option.""" + await setup_select(hass, mock_homee, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_select_repeater_mode", + "option": "invalid", + }, + blocking=True, + ) + + +async def test_select_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the select entity snapshot.""" + with patch("homeassistant.components.homee.PLATFORMS", [Platform.SELECT]): + await setup_select(hass, mock_homee, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From eb49e596f9da5dea739ed9ea9425afd5f6b759a8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 6 Mar 2025 11:00:40 -0800 Subject: [PATCH 1427/1941] Add a roborock quality_scale.yaml (#139849) * Add a roborock quality_scale.yaml * Update wording in polling * Update event listening * Update quality scale based on feedback --- .../components/roborock/quality_scale.yaml | 102 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/roborock/quality_scale.yaml diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml new file mode 100644 index 00000000000..845d77d0fbe --- /dev/null +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -0,0 +1,102 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: todo + comment: | + The device currently polls every 30 seconds, which is a bit high when idle. + We should consider dynamic polling intervals (e.g. when cleaning) and + separate cloud vs local intervals. + brands: done + common-modules: done + config-flow: + status: todo + comment: Not all fields have a data_description. + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: todo + comment: | + The documentation for `roborock.get_maps` should be updated so it is next + to the other actions rather than only an example. All actions should be + updated to use the simple table format. + docs-high-level-description: done + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: done + comment: The config flow verifies credentials and the cloud APIs. + 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: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + # Gold + devices: done + diagnostics: done + discovery: + status: todo + comment: Determine if these devices can support discovery + discovery-update-info: + status: exempt + comment: Devices do not support discovery. + docs-data-update: + status: todo + comment: | + The docs talk about device communication works (cloud vs local), but does + not yet describe data flow (e.g. polling). We should move into a separate + section. + docs-examples: todo + docs-known-limitations: + status: todo + comment: Documentation does not describe known limitations like rate limiting + docs-supported-devices: todo + docs-supported-functions: + status: todo + comment: Mostly complete, but some documentation is outdated (e.g. maps/images) + docs-troubleshooting: + status: todo + comment: | + There are good troubleshooting steps, however we should update the "cloud vs local" + and rate limiting documentation with more information. + docs-use-cases: + status: todo + comment: | + The docs describe controlling the vacuum, though does not describe more + interesting potential integrations with the homoe assistant ecosystem. + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: There are no noisy entities. + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: todo + comment: The Cloud vs Local API warning should probably be a repair issue. + stale-devices: + status: todo + comment: | + The integration does not yet handle stale devices. The roborock app does + support deleting devices and this is a gap #132590 + # Platinum + async-dependency: todo + inject-websession: + status: todo + comment: Web API uses aiohttp but does not yet inject web session. + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 9ddce29a4f3..65e9d4ed9cc 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -855,7 +855,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "risco", "rituals_perfume_genie", "rmvtransport", - "roborock", "rocketchat", "roku", "romy", From e78139edf13aee5a1c4cd4ec957da4d0d3cc5a7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Mar 2025 10:10:07 -1000 Subject: [PATCH 1428/1941] Bump nexia to 2.2.2 (#139986) changelog: https://github.com/bdraco/nexia/compare/2.2.1...2.2.2 --- 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 337378a283c..09b79d37c55 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.2.1"] + "requirements": ["nexia==2.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3e280a4b560..49a3982c1b6 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.2.1 +nexia==2.2.2 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7c83a3eb43..e146810b836 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.2.1 +nexia==2.2.2 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From 2aa584ce39901e68339da802c631d1a89532a0c1 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Thu, 6 Mar 2025 13:17:33 -0800 Subject: [PATCH 1429/1941] Correctly retrieve only loaded Google Generative AI config_entries (#139999) * Correctly retrieve only loaded config_entries * Ruff --- .../__init__.py | 6 +-- .../snapshots/test_init.ambr | 15 ++++++ .../test_init.py | 49 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 33e361d1433..6b10565e0b5 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -65,9 +65,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: prompt_parts = [call.data[CONF_PROMPT]] - config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries( - DOMAIN - )[0] + config_entry: GoogleGenerativeAIConfigEntry = ( + hass.config_entries.async_loaded_entries(DOMAIN)[0] + ) client = config_entry.runtime_data 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 8e6231cbffd..ce882adf6e6 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -31,3 +31,18 @@ ), ]) # --- +# name: test_load_entry_with_unloaded_entries + list([ + tuple( + '', + tuple( + ), + dict({ + 'contents': list([ + 'Write an opening speech for a Home Assistant release party', + ]), + '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 0dad485812e..25533ffd46e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -224,3 +224,52 @@ async def test_config_entry_error( await hass.async_block_till_done() assert mock_config_entry.state == state assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) == reauth + + +@pytest.mark.usefixtures("mock_init_component") +async def test_load_entry_with_unloaded_entries( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test loading an entry with unloaded entries.""" + config_entries = hass.config_entries.async_entries( + "google_generative_ai_conversation" + ) + runtime_data = config_entries[0].runtime_data + await hass.config_entries.async_unload(config_entries[0].entry_id) + + entry = MockConfigEntry( + domain="google_generative_ai_conversation", + title="Google Generative AI Conversation", + data={ + "api_key": "bla", + }, + state=ConfigEntryState.LOADED, + ) + entry.runtime_data = runtime_data + entry.add_to_hass(hass) + + stubbed_generated_content = ( + "I'm thrilled to welcome you all to the release " + "party for the latest version of Home Assistant!" + ) + + with patch( + "google.genai.models.AsyncModels.generate_content", + return_value=Mock( + text=stubbed_generated_content, + prompt_feedback=None, + candidates=[Mock()], + ), + ) as mock_generate: + response = await hass.services.async_call( + "google_generative_ai_conversation", + "generate_content", + {"prompt": "Write an opening speech for a Home Assistant release party"}, + blocking=True, + return_response=True, + ) + + assert response == { + "text": stubbed_generated_content, + } + assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot From fd1044dcba44c4f35762ec66ba6e1db2b94853d1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 6 Mar 2025 23:06:47 +0100 Subject: [PATCH 1430/1941] Bump aiowebdav2 to 0.4.1 (#139988) --- 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 3f465ceed4a..fd3c749781e 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.4.0"] + "requirements": ["aiowebdav2==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 49a3982c1b6..a36eeed26cc 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.4.0 +aiowebdav2==0.4.1 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e146810b836..ef235d89ecc 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.4.0 +aiowebdav2==0.4.1 # homeassistant.components.webostv aiowebostv==0.7.3 From 3dd1fadc7ddff4d5f3723ebbc8507fbf11d375a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 7 Mar 2025 01:50:06 +0100 Subject: [PATCH 1431/1941] Check operation state on Home Connect program sensor update (#140011) Check operation state on program sensor update --- .../components/home_connect/sensor.py | 7 ++ tests/components/home_connect/test_sensor.py | 82 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 924744ded56..c12e1b7b6e4 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -386,6 +386,13 @@ class HomeConnectProgramSensor(HomeConnectSensor): def update_native_value(self) -> None: """Update the program sensor's status.""" + 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, + ] event = self.appliance.events.get(cast(EventKey, self.bsh_key)) if event: self._update_native_value(event.value) diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 31fc9ea6d3f..04f5e056aa5 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -27,7 +27,7 @@ from homeassistant.components.home_connect.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -302,7 +302,7 @@ ENTITY_ID_STATES = { ) ), ) -async def test_event_sensors( +async def test_program_sensors( client: MagicMock, appliance_ha_id: str, states: tuple, @@ -313,7 +313,7 @@ async def test_event_sensors( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, ) -> None: - """Test sequence for sensors that are only available after an event happens.""" + """Test sequence for sensors that expose information about a program.""" entity_ids = ENTITY_ID_STATES.keys() time_to_freeze = "2021-01-09 12:00:00+00:00" @@ -358,6 +358,82 @@ async def test_event_sensors( assert hass.states.is_state(entity_id, state) +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize( + ("initial_operation_state", "initial_state", "event_order", "entity_states"), + [ + ( + "BSH.Common.EnumType.OperationState.Ready", + STATE_UNAVAILABLE, + (EventType.STATUS, EventType.EVENT), + (STATE_UNKNOWN, "60"), + ), + ( + "BSH.Common.EnumType.OperationState.Run", + STATE_UNKNOWN, + (EventType.EVENT, EventType.STATUS), + ("60", "60"), + ), + ], +) +async def test_program_sensor_edge_case( + initial_operation_state: str, + initial_state: str, + event_order: tuple[EventType, EventType], + entity_states: tuple[str, str], + appliance_ha_id: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test edge case for the program related entities.""" + entity_id = "sensor.dishwasher_program_progress" + client.get_status = AsyncMock( + return_value=ArrayOfStatus( + [ + Status( + StatusKey.BSH_COMMON_OPERATION_STATE, + StatusKey.BSH_COMMON_OPERATION_STATE.value, + initial_operation_state, + ) + ] + ) + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.is_state(entity_id, initial_state) + + for event_type, state in zip(event_order, entity_states, strict=True): + 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_key, value in EVENT_PROG_RUN[event_type].items() + ] + ) + 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_DELAYED_START, From d47481a30eadde901a90c196cd9b60c09a187541 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Mar 2025 22:52:29 -0500 Subject: [PATCH 1432/1941] Track when an LLM expects to continue a conversation (#139810) * Track when an LLM expects to continue a conversation * Strip content * Address comments --- .../components/anthropic/conversation.py | 4 ++- .../components/conversation/chat_log.py | 19 +++++++++++++ .../conversation.py | 4 ++- .../components/ollama/conversation.py | 4 ++- .../openai_conversation/conversation.py | 4 ++- .../components/conversation/test_chat_log.py | 28 +++++++++++++++++++ 6 files changed, 59 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 5511119d377..8d3ba5085ee 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -305,7 +305,9 @@ class AnthropicConversationEntity( intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(response_content.content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=chat_log.conversation_id + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) async def _async_entry_update_listener( diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 19482af1983..355f423dbb6 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -183,6 +183,25 @@ class ChatLog: llm_api: llm.APIInstance | None = None delta_listener: Callable[[ChatLog, dict], None] | None = None + @property + def continue_conversation(self) -> bool: + """Return whether the conversation should continue.""" + if not self.content: + return False + + last_msg = self.content[-1] + + return ( + last_msg.role == "assistant" + and last_msg.content is not None # type: ignore[union-attr] + and last_msg.content.strip().endswith( # type: ignore[union-attr] + ( + "?", + ";", # Greek question mark + ) + ) + ) + @property def unresponded_tool_results(self) -> bool: """Return if there are unresponded tool results.""" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 168e867d857..b43558c6768 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -459,7 +459,9 @@ class GoogleGenerativeAIConversationEntity( " ".join([part.text.strip() for part in response_parts if part.text]) ) return conversation.ConversationResult( - response=response, conversation_id=chat_log.conversation_id + response=response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) async def _async_entry_update_listener( diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 90e81544f66..85daf742035 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -292,7 +292,9 @@ class OllamaConversationEntity( ) intent_response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=chat_log.conversation_id + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index cc09ec77c0e..37be41947f7 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -310,7 +310,9 @@ class OpenAIConversationEntity( assert type(chat_log.content[-1]) is conversation.AssistantContent intent_response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=chat_log.conversation_id + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, ) async def _async_entry_update_listener( diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index c0687ebecfb..97094740af0 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -14,6 +14,7 @@ from homeassistant.components.conversation import ( ConversationInput, ConverseError, ToolResultContent, + UserContent, async_get_chat_log, ) from homeassistant.components.conversation.chat_log import DATA_CHAT_LOGS @@ -643,3 +644,30 @@ async def test_chat_log_reuse( assert len(chat_log.content) == 2 assert chat_log.content[1].role == "user" assert chat_log.content[1].content == mock_conversation_input.text + + +async def test_chat_log_continue_conversation( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test continue conversation.""" + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session) as chat_log, + ): + assert chat_log.continue_conversation is False + chat_log.async_add_user_content(UserContent(mock_conversation_input.text)) + assert chat_log.continue_conversation is False + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Hey? ", + ) + ) + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id="mock-agent-id", + content="Ποιο είναι το αγαπημένο σου χρώμα στα ελληνικά;", + ) + ) + assert chat_log.continue_conversation is True From 9682d3b313996f648293905ea09d6456edb8420d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 7 Mar 2025 07:50:34 +0100 Subject: [PATCH 1433/1941] Bump aiohomeconnect to 0.16.3 (#140014) --- 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 5293e8bf468..62892e7c85b 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.16.2"], + "requirements": ["aiohomeconnect==0.16.3"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index a36eeed26cc..bee5afef4cb 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.16.2 +aiohomeconnect==0.16.3 # homeassistant.components.homekit_controller aiohomekit==3.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef235d89ecc..bcfc539e41b 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.16.2 +aiohomeconnect==0.16.3 # homeassistant.components.homekit_controller aiohomekit==3.2.8 From 6be8370eb372ef0fdb9152fa39044fec5657da95 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 7 Mar 2025 17:45:25 +1000 Subject: [PATCH 1434/1941] Fix powerwall 0% in Tessie and Tesla Fleet (#140017) Fix powerwall zero --- homeassistant/components/tesla_fleet/sensor.py | 1 + homeassistant/components/tessie/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py index 64ecc35469b..bdd5ce2c001 100644 --- a/homeassistant/components/tesla_fleet/sensor.py +++ b/homeassistant/components/tesla_fleet/sensor.py @@ -466,6 +466,7 @@ async def async_setup_entry( for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ), ( # Add energy site history TeslaFleetEnergyHistorySensorEntity(energysite, description) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 4f62e1b1855..1c26ad633f3 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -397,6 +397,7 @@ async def async_setup_entry( for energysite in entry.runtime_data.energysites for description in ENERGY_LIVE_DESCRIPTIONS if description.key in energysite.live_coordinator.data + or description.key == "percentage_charged" ), ( # Add wall connectors TessieWallConnectorSensorEntity(energysite, din, description) @@ -449,7 +450,6 @@ class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_available = self._value is not None self._attr_native_value = self.entity_description.value_fn(self._value) From 452fbbe61cd5542361eaa2463f2e2624352eb23b Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 7 Mar 2025 09:12:21 +0000 Subject: [PATCH 1435/1941] Fix regression to evohome debug logging (#140000) * fix regression in debug logging * lint --- homeassistant/components/evohome/coordinator.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 7b197f1b643..3264af6b2fd 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -11,6 +11,7 @@ from typing import Any import evohomeasync as ec1 import evohomeasync2 as ec2 from evohomeasync2.const import ( + SZ_DHW, SZ_GATEWAY_ID, SZ_GATEWAY_INFO, SZ_GATEWAYS, @@ -19,8 +20,9 @@ from evohomeasync2.const import ( SZ_TEMPERATURE_CONTROL_SYSTEMS, SZ_TIME_ZONE, SZ_USE_DAYLIGHT_SAVE_SWITCHING, + SZ_ZONES, ) -from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT +from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant @@ -113,17 +115,19 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): SZ_USE_DAYLIGHT_SAVE_SWITCHING ], } + tcs_info: EvoTcsConfigResponseT = self.tcs.config # type: ignore[assignment] + tcs_info[SZ_ZONES] = [zone.config for zone in self.tcs.zones] + if self.tcs.hotwater: + tcs_info[SZ_DHW] = self.tcs.hotwater.config gwy_info = { SZ_GATEWAY_ID: self.loc.gateways[0].id, - SZ_TEMPERATURE_CONTROL_SYSTEMS: [ - self.loc.gateways[0].systems[0].config - ], + SZ_TEMPERATURE_CONTROL_SYSTEMS: [tcs_info], } config = { SZ_LOCATION_INFO: loc_info, SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}], } - self.logger.debug("Config = %s", config) + self.logger.debug("Config = %s", [config]) async def call_client_api( self, From 8780bc99eb06c34db9d6bad0ad0c36a91a2f9f4f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 7 Mar 2025 10:44:17 +0100 Subject: [PATCH 1436/1941] Set content length when uploading files to WebDAV (#139950) --- homeassistant/components/webdav/backup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index a5cf2c56182..321ed98bfa8 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -169,6 +169,7 @@ class WebDavBackupAgent(BackupAgent): await open_stream(), f"{self._backup_path}/{filename_tar}", timeout=BACKUP_TIMEOUT, + content_length=backup.size, ) _LOGGER.debug( From 2985f08054815df125c0405028eb0ba095d69eff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Mar 2025 00:56:00 -1000 Subject: [PATCH 1437/1941] Bump dbus-fast to 2.39.3 (#140015) * Bump dbus-fast to 2.39.2 changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.37.0...v2.39.2 * bump again for more fixes --- 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 f097eb3a3cf..ec617b82a04 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", "bluetooth-data-tools==1.25.0", - "dbus-fast==2.37.0", + "dbus-fast==2.39.3", "habluetooth==3.25.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3513ddfdb82..988c2934cd8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.37.0 +dbus-fast==2.39.3 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index bee5afef4cb..a4d5f6c6914 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.37.0 +dbus-fast==2.39.3 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bcfc539e41b..2d34851cecc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.37.0 +dbus-fast==2.39.3 # homeassistant.components.debugpy debugpy==1.8.11 From 73ef2409216fdc7d0bb6f5e786bf281485a580d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 12:55:32 +0100 Subject: [PATCH 1438/1941] Fix SmartThings disabling working capabilities (#140039) --- .../components/smartthings/__init__.py | 18 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_wm_wm_000001_1.json | 1416 +++++++++++++++++ .../fixtures/devices/da_wm_wm_000001_1.json | 261 +++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 469 ++++++ .../smartthings/snapshots/test_switch.ambr | 47 + 7 files changed, 2241 insertions(+), 4 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 9e2178196d5..cf17e6a110b 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging from typing import TYPE_CHECKING, cast @@ -185,6 +186,16 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +DATA_VALIDATION: dict[ + Capability | str, Callable[[dict[Attribute | str, Status]], bool] +] = { + Capability.WASHER_OPERATING_STATE: ( + lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None + ), + Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, +} + + def process_status( status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], ) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]: @@ -201,10 +212,9 @@ def process_status( ) 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 + if capability in main_component and ( + capability not in DATA_VALIDATION + or not DATA_VALIDATION[capability](main_component[capability]) ): del main_component[capability] return status diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index c78b4bc05de..b6e6339af97 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -105,6 +105,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_dw_000001", "da_wm_wd_000001", "da_wm_wm_000001", + "da_wm_wm_000001_1", "da_rvc_normal_000001", "da_ks_microwave_0101x", "hue_color_temperature_bulb", diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json new file mode 100644 index 00000000000..157e5496625 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wm_000001_1.json @@ -0,0 +1,1416 @@ +{ + "components": { + "hca.main": { + "hca.washerMode": { + "mode": { + "value": "mix", + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "supportedModes": { + "value": ["normal", "quickWash", "mix", "eco"], + "timestamp": "2025-03-07T06:06:08.613Z" + } + } + }, + "main": { + "samsungce.washerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-07T06:06:08.806Z" + }, + "minimumReservableTime": { + "value": 49, + "unit": "min", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerWaterLevel": { + "supportedWaterLevel": { + "value": null, + "timestamp": "2021-03-31T22:35:35.010Z" + }, + "waterLevel": { + "value": null, + "timestamp": "2021-04-17T09:56:20.618Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null, + "timestamp": "2021-04-01T23:43:08.541Z" + } + }, + "custom.washerWaterTemperature": { + "supportedWasherWaterTemperature": { + "value": ["none", "cold", "20", "30", "40", "60", "90"], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerWaterTemperature": { + "value": "40", + "timestamp": "2025-03-07T06:06:08.901Z" + } + }, + "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, + "timestamp": "2021-01-29T10:38:25.844Z" + }, + "amount": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "supportedDensity": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "density": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + }, + "supportedAmount": { + "value": null, + "timestamp": "2020-12-28T07:28:49.408Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-14T22:23:10.096Z" + } + }, + "samsungce.autoDispenseDetergent": { + "remainingAmount": { + "value": null, + "timestamp": "2021-01-26T01:49:50.635Z" + }, + "amount": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "supportedDensity": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "density": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "supportedAmount": { + "value": null, + "timestamp": "2020-12-28T07:15:24.539Z" + }, + "availableTypes": { + "value": null + }, + "type": { + "value": null + }, + "recommendedAmount": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20224941", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "modelName": { + "value": null, + "timestamp": "2021-04-01T23:32:40.512Z" + }, + "serialNumber": { + "value": null, + "timestamp": "2021-04-01T23:32:38.884Z" + }, + "serialNumberExtra": { + "value": null, + "timestamp": "2021-04-01T23:32:36.541Z" + }, + "modelClassificationCode": { + "value": "20010102011211030203000000000000", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_\u0018WD7800N/DC92-02249A_08CC", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-03-07T06:06:08.719Z" + } + }, + "samsungce.washerWaterValve": { + "waterValve": { + "value": null, + "timestamp": "2021-04-01T23:43:07.144Z" + }, + "supportedWaterValve": { + "value": null, + "timestamp": "2021-03-31T22:35:34.371Z" + } + }, + "washerOperatingState": { + "completionTime": { + "value": "2025-03-07T07:01:12Z", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "machineState": { + "value": "run", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "washerJobState": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-03-07T06:06:08.806Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-07T06:06:08.856Z" + } + }, + "custom.washerAutoSoftener": { + "washerAutoSoftener": { + "value": null, + "timestamp": "2020-08-07T21:22:34.172Z" + } + }, + "samsungce.washerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.washerCycle": { + "supportedCycles": { + "value": [ + { + "cycle": "D0", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "847E", + "default": "40", + "options": ["cold", "20", "30", "40", "60", "90"] + } + } + }, + { + "cycle": "DC", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A33F", + "default": "800", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "933F", + "default": "3", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "811E", + "default": "cold", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "E3", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "E4", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "943F", + "default": "4", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8560", + "default": "60", + "options": ["60", "90"] + } + } + }, + { + "cycle": "50", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B11E", + "default": "cupboard", + "options": ["cupboard", "30", "60", "90", "210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "51", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B11E", + "default": "cupboard", + "options": ["cupboard", "30", "60", "90", "210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "CA", + "cycleType": "dryingOnly", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A000", + "default": "rinseHold", + "options": [] + }, + "rinseCycle": { + "raw": "9000", + "default": "0", + "options": [] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "E7", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B100", + "default": "cupboard", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A640", + "default": "1400", + "options": ["1400"] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "C7", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A520", + "default": "1200", + "options": ["1200"] + }, + "rinseCycle": { + "raw": "9204", + "default": "2", + "options": ["2"] + }, + "waterTemperature": { + "raw": "8520", + "default": "60", + "options": ["60"] + } + } + }, + { + "cycle": "D8", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B000", + "default": "none", + "options": ["210", "240", "270"] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "841E", + "default": "40", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "D4", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "913F", + "default": "1", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "D3", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A30F", + "default": "800", + "options": ["rinseHold", "noSpin", "400", "800"] + }, + "rinseCycle": { + "raw": "920F", + "default": "2", + "options": ["0", "1", "2", "3"] + }, + "waterTemperature": { + "raw": "831E", + "default": "30", + "options": ["cold", "20", "30", "40"] + } + } + }, + { + "cycle": "DA", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "3500", + "default": "off", + "options": [] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A53F", + "default": "1200", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "8102", + "default": "cold", + "options": ["cold"] + } + } + }, + { + "cycle": "D2", + "cycleType": "allInOne", + "supportedOptions": { + "bubbleSoak": { + "raw": "35F0", + "default": "off", + "options": ["on", "off"] + }, + "dryingLevel": { + "raw": "B01F", + "default": "none", + "options": [ + "none", + "cupboard", + "30", + "60", + "90", + "210", + "240", + "270" + ] + }, + "spinLevel": { + "raw": "A67F", + "default": "1400", + "options": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ] + }, + "rinseCycle": { + "raw": "923F", + "default": "2", + "options": ["0", "1", "2", "3", "4", "5"] + }, + "waterTemperature": { + "raw": "843E", + "default": "40", + "options": ["cold", "20", "30", "40", "60"] + } + } + } + ], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerCycle": { + "value": "Table_00_Course_E3", + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2021-12-01T23:55:08.740Z" + }, + "specializedFunctionClassification": { + "value": 7, + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.waterConsumptionReport": { + "waterConsumption": { + "value": null, + "timestamp": "2021-03-31T22:35:33.802Z" + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-08-11T22:47:36.523Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-08-11T22:47:41.693Z" + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "di": { + "value": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2024-12-27T04:48:02.896Z" + }, + "n": { + "value": "[washer] Samsung", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "vid": { + "value": "DA-WM-WM-000001", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "pi": { + "value": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "timestamp": "2024-12-09T22:01:37.735Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-27T04:48:02.896Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "none", + "timestamp": "2025-03-07T06:06:08.901Z" + }, + "supportedDryerDryLevel": { + "value": [ + "none", + "cupboard", + "30", + "60", + "90", + "120", + "150", + "180", + "210", + "240", + "270" + ], + "timestamp": "2024-12-09T22:01:38.311Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.washerDelayEnd", + "washerOperatingState", + "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.washerFreezePrevent", + "custom.washerSoilLevel", + "samsungce.washerWaterLevel", + "samsungce.washerWaterValve", + "samsungce.washerWashingTime", + "custom.washerAutoDetergent", + "custom.washerAutoSoftener", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-03T08:44:32.524Z" + } + }, + "custom.washerRinseCycles": { + "supportedWasherRinseCycles": { + "value": ["0", "1", "2", "3", "4", "5"], + "timestamp": "2024-12-09T22:01:38.311Z" + }, + "washerRinseCycles": { + "value": "2", + "timestamp": "2025-03-07T06:06:08.901Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-03T02:08:44.235Z" + } + }, + "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.washerOperatingState": { + "washerJobState": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-07T06:12:12.191Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-02T21:35:52.935Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "wash", + "timeInMin": 21 + }, + { + "jobName": "rinse", + "timeInMin": 16 + }, + { + "jobName": "spin", + "timeInMin": 11 + } + ], + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "scheduledPhases": { + "value": [ + { + "phaseName": "wash", + "timeInMin": 21 + }, + { + "phaseName": "rinse", + "timeInMin": 16 + }, + { + "phaseName": "spin", + "timeInMin": 11 + } + ], + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "progress": { + "value": 36, + "unit": "%", + "timestamp": "2025-03-07T06:30:10.639Z" + }, + "remainingTimeStr": { + "value": "00:31", + "timestamp": "2025-03-07T06:30:10.639Z" + }, + "washerJobPhase": { + "value": "wash", + "timestamp": "2025-03-07T06:12:37.974Z" + }, + "operationTime": { + "value": 49, + "unit": "min", + "timestamp": "2025-03-06T02:24:50.104Z" + }, + "remainingTime": { + "value": 31, + "unit": "min", + "timestamp": "2025-03-07T06:30:10.639Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-07T06:06:08.688Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 1323600, + "deltaEnergy": 100, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-03-07T06:21:09Z", + "end": "2025-03-07T06:23:21Z" + }, + "timestamp": "2025-03-07T06:23:21.062Z" + } + }, + "samsungce.detergentAutoReplenishment": { + "neutralDetergentType": { + "value": null + }, + "regularDetergentRemainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "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-03-07T06:06:08.613Z" + }, + "babyDetergentDosage": { + "value": null + }, + "regularDetergentOrderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentType": { + "value": "none", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentInitialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "regularDetergentAlarmEnabled": { + "value": false, + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "neutralDetergentDosage": { + "value": null + }, + "babyDetergentOrderThreshold": { + "value": null + }, + "babyDetergentAlarmEnabled": { + "value": null + } + }, + "samsungce.softenerOrder": { + "alarmEnabled": { + "value": null, + "timestamp": "2020-12-28T11:12:47.109Z" + }, + "orderThreshold": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T11:12:47.109Z" + } + }, + "custom.washerSoilLevel": { + "supportedWasherSoilLevel": { + "value": null, + "timestamp": "2020-08-11T22:49:08.023Z" + }, + "washerSoilLevel": { + "value": null, + "timestamp": "2020-08-11T22:49:08.023Z" + } + }, + "samsungce.washerBubbleSoak": { + "status": { + "value": "off", + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-03-07T06:06:08.957Z" + }, + "presets": { + "value": null, + "timestamp": "2021-03-31T08:11:41.657Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-07T06:06:08.613Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-03-31T22:35:33.949Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null, + "timestamp": "2020-08-11T22:48:26.262Z" + } + }, + "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|20224941|20010102011211030203000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_\u0018WD7800N/DC92-02249A_08CC", + "x.com.samsung.da.serialNum": "0TE65ADMC00093F", + "x.com.samsung.da.otnDUID": "EXCEZFTFQ53G2", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "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": "18072525,18090310", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-06T02:14:23.034Z" + } + }, + "samsungce.softenerState": { + "remainingAmount": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T07:11:13.285Z" + }, + "dosage": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T01:14:27.011Z" + }, + "softenerType": { + "value": null, + "timestamp": "2020-11-19T21:57:19.712Z" + }, + "initialAmount": { + "value": null, + "unit": "cc", + "timestamp": "2020-12-28T00:45:40.863Z" + } + }, + "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": "true", + "timestamp": "2025-03-07T06:06:08.819Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-07T06:06:08.905Z" + }, + "supportedCourses": { + "value": [ + "D0", + "DC", + "E3", + "E4", + "50", + "51", + "CA", + "E7", + "C7", + "D8", + "D4", + "D3", + "DA", + "D2" + ], + "timestamp": "2025-03-07T06:06:08.613Z" + } + }, + "samsungce.washerWashingTime": { + "supportedWashingTimes": { + "value": null, + "timestamp": "2021-03-31T08:10:28.542Z" + }, + "washingTime": { + "value": null, + "unit": "min", + "timestamp": "2021-03-31T08:10:28.542Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-14T22:23:10.096Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-14T22:38:10.576Z" + }, + "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": "EXCEZFTFQ53G2", + "timestamp": "2025-03-07T06:06:08.719Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T10:37:29.975Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T10:37:29.975Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "custom.washerAutoDetergent": { + "washerAutoDetergent": { + "value": null, + "timestamp": "2020-08-11T22:47:34.372Z" + } + }, + "custom.washerSpinLevel": { + "washerSpinLevel": { + "value": "1400", + "timestamp": "2025-03-07T06:06:08.901Z" + }, + "supportedWasherSpinLevel": { + "value": [ + "rinseHold", + "noSpin", + "400", + "800", + "1000", + "1200", + "1400" + ], + "timestamp": "2024-12-09T22:01:38.311Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json new file mode 100644 index 00000000000..bb1831d6f03 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wm_000001_1.json @@ -0,0 +1,261 @@ +{ + "items": [ + { + "deviceId": "63803fae-cbed-f356-a063-2cf148ae3ca7", + "name": "[washer] Samsung", + "label": "Washing Machine", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WM-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "ca23214d-d9ae-41e5-9d26-f1a604c864d8", + "ownerId": "9b53a4ba-4422-b04d-f436-33c0490e7c37", + "roomId": "e226f1ae-1112-4794-bd3a-0beddf811645", + "deviceTypeName": "Samsung OCF Washer", + "components": [ + { + "id": "main", + "label": "Washing Machine", + "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": "2020-03-04T03:03:19Z", + "profile": { + "id": "3f221c79-d81c-315f-8e8b-b5742802a1e3" + }, + "ocf": { + "ocfDeviceType": "oic.d.washer", + "name": "[washer] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "DA_WM_A51_20_COMMON|20224941|20010102011211030203000000000000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 1.0 + IPv6", + "hwVersion": "ARTIK051", + "firmwareVersion": "DA_WM_A51_20_COMMON_30230708", + "vendorId": "DA-WM-WM-000001", + "vendorResourceClientServerVersion": "ARTIK051 Release 2.210224.1", + "lastSignupTime": "2024-12-27T04:47:59.763899737Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 2c09d0addaf..7ec2ce79c5b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -494,6 +494,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_wm_wm_000001_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': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '63803fae-cbed-f356-a063-2cf148ae3ca7', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON', + 'model_id': None, + 'name': 'Washing Machine', + '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[ecobee_sensor] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index fa9af0f2812..72364d59277 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3899,6 +3899,475 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_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.washing_machine_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Washing Machine Completion time', + }), + 'context': , + 'entity_id': 'sensor.washing_machine_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-07T07:01:12+00:00', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_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.washing_machine_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1323.6', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_difference-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.washing_machine_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_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.washing_machine_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + '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': '63803fae-cbed-f356-a063-2cf148ae3ca7.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_job_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.washerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing Machine 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.washing_machine_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'wash', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.washing_machine_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_machine_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Washing Machine Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.washing_machine_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_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.washing_machine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washing Machine Power', + 'power_consumption_end': '2025-03-07T06:23:21Z', + 'power_consumption_start': '2025-03-07T06:21:09Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power_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.washing_machine_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][sensor.washing_machine_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Washing Machine Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[ecobee_sensor][sensor.child_bedroom_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index d12bd4ea5b6..00177b3b603 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -281,6 +281,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-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.washing_machine', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][switch.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine', + }), + 'context': , + 'entity_id': 'switch.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 9a90e1e410f83733d0f9ea79d61ff314741ca16a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Mar 2025 02:01:31 -1000 Subject: [PATCH 1439/1941] Bump ulid-transform to 1.4.0 (#140037) changelog: https://github.com/Bluetooth-Devices/ulid-transform/compare/v1.3.0...v1.4.0 --- 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 988c2934cd8..0727beae8ed 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.3.0 +ulid-transform==1.4.0 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous-openapi==0.0.6 diff --git a/pyproject.toml b/pyproject.toml index b11c2403d69..3affa95a082 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.3.0", + "ulid-transform==1.4.0", # 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 6d138a6060d..9bf94749ac9 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.3.0 +ulid-transform==1.4.0 urllib3>=1.26.5,<2 uv==0.6.1 voluptuous==0.15.2 From c834944ee7f1b3c566c1d82a78111f8ced512c71 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 7 Mar 2025 12:04:04 +0000 Subject: [PATCH 1440/1941] Fix evohome to gracefully handle null schedules (#140036) * extend tests to catch null schedules * add fixture with null schedule * remove null schedules for now * fic the typing for _schedule attr (is list, not dict) * add valid schedule to fixture * update ssetpoints only if there is a schedule * snapshot to match last change * refactor: dont update switchpoints if no schedule * add in warnings for null schedules * add fixture for DHW without schedule --- .../components/evohome/coordinator.py | 12 +- homeassistant/components/evohome/entity.py | 10 +- tests/components/evohome/conftest.py | 12 +- tests/components/evohome/const.py | 3 +- .../fixtures/botched/schedule_3933910.json | 3 + .../fixtures/h139906/schedule_3454854.json | 3 + .../fixtures/h139906/schedule_3454855.json | 143 +++++++++++++ .../fixtures/h139906/status_2727366.json | 52 +++++ .../fixtures/h139906/user_locations.json | 125 ++++++++++++ .../evohome/snapshots/test_climate.ambr | 188 ++++++++++++++++++ .../evohome/snapshots/test_init.ambr | 3 + .../evohome/snapshots/test_water_heater.ambr | 10 + tests/components/evohome/test_water_heater.py | 2 +- 13 files changed, 553 insertions(+), 13 deletions(-) create mode 100644 tests/components/evohome/fixtures/botched/schedule_3933910.json create mode 100644 tests/components/evohome/fixtures/h139906/schedule_3454854.json create mode 100644 tests/components/evohome/fixtures/h139906/schedule_3454855.json create mode 100644 tests/components/evohome/fixtures/h139906/status_2727366.json create mode 100644 tests/components/evohome/fixtures/h139906/user_locations.json diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 3264af6b2fd..33af90089a4 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -207,10 +207,18 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): async def _update_v2_schedules(self) -> None: for zone in self.tcs.zones: - await zone.get_schedule() + try: + await zone.get_schedule() + except ec2.InvalidScheduleError as err: + self.logger.warning( + "Zone '%s' has an invalid/missing schedule: %r", zone.name, err + ) if dhw := self.tcs.hotwater: - await dhw.get_schedule() + try: + await dhw.get_schedule() + except ec2.InvalidScheduleError as err: + self.logger.warning("DHW has an invalid/missing schedule: %r", err) async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override] """Fetch the latest state of an entire TCC Location. diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 11215dd47b6..2f93f0fb143 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -6,6 +6,7 @@ import logging from typing import Any import evohomeasync2 as evo +from evohomeasync2.schemas.typedefs import DayOfWeekDhwT from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -102,7 +103,7 @@ class EvoChild(EvoEntity): self._evo_tcs = evo_device.tcs - self._schedule: dict[str, Any] | None = None + self._schedule: list[DayOfWeekDhwT] | None = None self._setpoints: dict[str, Any] = {} @property @@ -123,6 +124,9 @@ class EvoChild(EvoEntity): Only Zones & DHW controllers (but not the TCS) can have schedules. """ + if not self._schedule: + return self._setpoints + this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint @@ -152,10 +156,10 @@ class EvoChild(EvoEntity): self._evo_device, err, ) - self._schedule = {} + self._schedule = [] return else: - self._schedule = schedule or {} # mypy hint + self._schedule = schedule # type: ignore[assignment] _LOGGER.debug("Schedule['%s'] = %s", self.name, schedule) diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 5f60bc418e3..313982e3f97 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -48,18 +48,18 @@ def location_status_fixture(install: str, loc_id: str | None = None) -> JsonObje return load_json_object_fixture(f"{install}/status_{loc_id}.json", DOMAIN) -def dhw_schedule_fixture(install: str) -> JsonObjectType: +def dhw_schedule_fixture(install: str, dhw_id: str | None = None) -> JsonObjectType: """Load JSON for the schedule of a domesticHotWater zone.""" try: - return load_json_object_fixture(f"{install}/schedule_dhw.json", DOMAIN) + return load_json_object_fixture(f"{install}/schedule_{dhw_id}.json", DOMAIN) except FileNotFoundError: return load_json_object_fixture("default/schedule_dhw.json", DOMAIN) -def zone_schedule_fixture(install: str) -> JsonObjectType: +def zone_schedule_fixture(install: str, zon_id: str | None = None) -> JsonObjectType: """Load JSON for the schedule of a temperatureZone zone.""" try: - return load_json_object_fixture(f"{install}/schedule_zone.json", DOMAIN) + return load_json_object_fixture(f"{install}/schedule_{zon_id}.json", DOMAIN) except FileNotFoundError: return load_json_object_fixture("default/schedule_zone.json", DOMAIN) @@ -120,9 +120,9 @@ def mock_make_request(install: str) -> Callable: elif "schedule" in url: if url.startswith("domesticHotWater"): # /v2/domesticHotWater/{id}/schedule - return dhw_schedule_fixture(install) + return dhw_schedule_fixture(install, url[16:23]) if url.startswith("temperatureZone"): # /v2/temperatureZone/{id}/schedule - return zone_schedule_fixture(install) + return zone_schedule_fixture(install, url[16:23]) pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") diff --git a/tests/components/evohome/const.py b/tests/components/evohome/const.py index c3dc92c3fbc..dceb2f60a06 100644 --- a/tests/components/evohome/const.py +++ b/tests/components/evohome/const.py @@ -15,8 +15,9 @@ TEST_INSTALLS: Final = ( "default", # evohome: multi-zone, with DHW "h032585", # VisionProWifi: no preset modes for TCS, zoneId=systemId "h099625", # RoundThermostat + "h139906", # zone with null schedule "sys_004", # RoundModulation ) # "botched", # as default: but with activeFaults, ghost zones & unknown types -TEST_INSTALLS_WITH_DHW: Final = ("default",) +TEST_INSTALLS_WITH_DHW: Final = ("default", "botched") diff --git a/tests/components/evohome/fixtures/botched/schedule_3933910.json b/tests/components/evohome/fixtures/botched/schedule_3933910.json new file mode 100644 index 00000000000..0e5a9308d5b --- /dev/null +++ b/tests/components/evohome/fixtures/botched/schedule_3933910.json @@ -0,0 +1,3 @@ +{ + "dailySchedules": [] +} diff --git a/tests/components/evohome/fixtures/h139906/schedule_3454854.json b/tests/components/evohome/fixtures/h139906/schedule_3454854.json new file mode 100644 index 00000000000..0e5a9308d5b --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/schedule_3454854.json @@ -0,0 +1,3 @@ +{ + "dailySchedules": [] +} diff --git a/tests/components/evohome/fixtures/h139906/schedule_3454855.json b/tests/components/evohome/fixtures/h139906/schedule_3454855.json new file mode 100644 index 00000000000..12f8a6cb390 --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/schedule_3454855.json @@ -0,0 +1,143 @@ +{ + "dailySchedules": [ + { + "dayOfWeek": "Monday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Tuesday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Wednesday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "12:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Thursday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Friday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "05:30:00" + }, + { + "heatSetpoint": 20.0, + "timeOfDay": "08:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Saturday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "07:00:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + }, + { + "dayOfWeek": "Sunday", + "switchpoints": [ + { + "heatSetpoint": 22.0, + "timeOfDay": "07:30:00" + }, + { + "heatSetpoint": 22.5, + "timeOfDay": "16:00:00" + }, + { + "heatSetpoint": 15.0, + "timeOfDay": "23:00:00" + } + ] + } + ] +} diff --git a/tests/components/evohome/fixtures/h139906/status_2727366.json b/tests/components/evohome/fixtures/h139906/status_2727366.json new file mode 100644 index 00000000000..2c123b796bd --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/status_2727366.json @@ -0,0 +1,52 @@ +{ + "locationId": "2727366", + "gateways": [ + { + "gatewayId": "2513794", + "temperatureControlSystems": [ + { + "systemId": "3454856", + "zones": [ + { + "zoneId": "3454854", + "temperatureStatus": { + "temperature": 22.0, + "isAvailable": true + }, + "activeFaults": [ + { + "faultType": "TempZoneSensorCommunicationLost", + "since": "2025-02-06T11:20:29" + } + ], + "setpointStatus": { + "targetHeatTemperature": 5.0, + "setpointMode": "FollowSchedule" + }, + "name": "Thermostat" + }, + { + "zoneId": "3454855", + "temperatureStatus": { + "temperature": 22.0, + "isAvailable": true + }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 20.0, + "setpointMode": "FollowSchedule" + }, + "name": "Thermostat 2" + } + ], + "activeFaults": [], + "systemModeStatus": { + "mode": "Auto", + "isPermanent": true + } + } + ], + "activeFaults": [] + } + ] +} diff --git a/tests/components/evohome/fixtures/h139906/user_locations.json b/tests/components/evohome/fixtures/h139906/user_locations.json new file mode 100644 index 00000000000..14db65a5e0d --- /dev/null +++ b/tests/components/evohome/fixtures/h139906/user_locations.json @@ -0,0 +1,125 @@ +[ + { + "locationInfo": { + "locationId": "2727366", + "name": "Vr**********", + "streetAddress": "********** *", + "city": "*********", + "country": "Netherlands", + "postcode": "******", + "locationType": "Residential", + "useDaylightSaveSwitching": true, + "timeZone": { + "timeZoneId": "WEuropeStandardTime", + "displayName": "(UTC+01:00) Amsterdam, Berlijn, Bern, Rome, Stockholm, Wenen", + "offsetMinutes": 60, + "currentOffsetMinutes": 60, + "supportsDaylightSaving": true + }, + "locationOwner": { + "userId": "2276512", + "username": "nobody@nowhere.com", + "firstname": "Gl***", + "lastname": "de*****" + } + }, + "gateways": [ + { + "gatewayInfo": { + "gatewayId": "2513794", + "mac": "************", + "crc": "****", + "isWiFi": false + }, + "temperatureControlSystems": [ + { + "systemId": "3454856", + "modelType": "EvoTouch", + "zones": [ + { + "zoneId": "3454854", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat", + "zoneType": "ZoneTemperatureControl" + }, + { + "zoneId": "3454855", + "modelType": "RoundWireless", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 0, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat 2", + "zoneType": "Thermostat" + } + ], + "allowedSystemModes": [ + { + "systemMode": "Auto", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithEco", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "1.00:00:00", + "timingResolution": "01:00:00", + "timingMode": "Duration" + }, + { + "systemMode": "Away", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "HeatingOff", + "canBePermanent": true, + "canBeTemporary": false + } + ] + } + ] + } + ] + } +] diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index 23a15e3f64f..5a6a6bff863 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -29,6 +29,16 @@ ), ]) # --- +# name: test_ctl_set_hvac_mode[h139906] + list([ + tuple( + , + ), + tuple( + , + ), + ]) +# --- # name: test_ctl_set_hvac_mode[minimal] list([ tuple( @@ -70,6 +80,13 @@ ), ]) # --- +# name: test_ctl_turn_off[h139906] + list([ + tuple( + , + ), + ]) +# --- # name: test_ctl_turn_off[minimal] list([ tuple( @@ -105,6 +122,13 @@ ), ]) # --- +# name: test_ctl_turn_on[h139906] + list([ + tuple( + , + ), + ]) +# --- # name: test_ctl_turn_on[minimal] list([ tuple( @@ -1118,6 +1142,136 @@ 'state': 'heat', }) # --- +# name: test_setup_platform[h139906][climate.thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Thermostat', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'activeFaults': tuple( + dict({ + 'fault_type': 'TempZoneSensorCommunicationLost', + 'since': '2025-02-06T11:20:29+01:00', + }), + ), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 5.0, + }), + 'setpoints': dict({ + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 22.0, + }), + 'zone_id': '3454854', + }), + 'supported_features': , + 'temperature': 5.0, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_platform[h139906][climate.thermostat_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Thermostat 2', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'activeFaults': tuple( + ), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 20.0, + }), + 'setpoints': dict({ + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), + 'next_sp_temp': 15.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')), + 'this_sp_temp': 22.5, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 22.0, + }), + 'zone_id': '3454855', + }), + 'supported_features': , + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[h139906][climate.vr-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'friendly_name': 'Vr**********', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'away', + ]), + 'status': dict({ + 'activeSystemFaults': tuple( + ), + 'system_id': '3454856', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'Auto', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.vr', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_setup_platform[minimal][climate.main_room-state] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1312,6 +1466,13 @@ ), ]) # --- +# name: test_zone_set_hvac_mode[h139906] + list([ + tuple( + 5.0, + ), + ]) +# --- # name: test_zone_set_hvac_mode[minimal] list([ tuple( @@ -1365,6 +1526,19 @@ }), ]) # --- +# name: test_zone_set_preset_mode[h139906] + list([ + tuple( + 5.0, + ), + tuple( + 5.0, + ), + dict({ + 'until': None, + }), + ]) +# --- # name: test_zone_set_preset_mode[minimal] list([ tuple( @@ -1412,6 +1586,13 @@ }), ]) # --- +# name: test_zone_set_temperature[h139906] + list([ + dict({ + 'until': None, + }), + ]) +# --- # name: test_zone_set_temperature[minimal] list([ dict({ @@ -1447,6 +1628,13 @@ ), ]) # --- +# name: test_zone_turn_off[h139906] + list([ + tuple( + 5.0, + ), + ]) +# --- # name: test_zone_turn_off[minimal] list([ tuple( diff --git a/tests/components/evohome/snapshots/test_init.ambr b/tests/components/evohome/snapshots/test_init.ambr index d2e91e3c43d..d6174a53356 100644 --- a/tests/components/evohome/snapshots/test_init.ambr +++ b/tests/components/evohome/snapshots/test_init.ambr @@ -11,6 +11,9 @@ # name: test_setup[h099625] dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) # --- +# name: test_setup[h139906] + dict_keys(['refresh_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) +# --- # name: test_setup[minimal] dict_keys(['refresh_system', 'reset_system', 'set_system_mode', 'clear_zone_override', 'set_zone_override']) # --- diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 771e2c20cba..7b1bc44550a 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -1,4 +1,14 @@ # serializer version: 1 +# name: test_set_operation_mode[botched] + list([ + dict({ + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + }), + dict({ + 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + }), + ]) +# --- # name: test_set_operation_mode[default] list([ dict({ diff --git a/tests/components/evohome/test_water_heater.py b/tests/components/evohome/test_water_heater.py index a201ff63d1e..ca9a5ba6af8 100644 --- a/tests/components/evohome/test_water_heater.py +++ b/tests/components/evohome/test_water_heater.py @@ -33,7 +33,7 @@ from .const import TEST_INSTALLS_WITH_DHW DHW_ENTITY_ID = "water_heater.domestic_hot_water" -@pytest.mark.parametrize("install", [*TEST_INSTALLS_WITH_DHW, "botched"]) +@pytest.mark.parametrize("install", TEST_INSTALLS_WITH_DHW) async def test_setup_platform( hass: HomeAssistant, config: dict[str, str], From 2401d8900aaab1611ce6bb3af9b3ea43c8c81468 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Fri, 7 Mar 2025 13:11:45 +0100 Subject: [PATCH 1441/1941] Add description for HomematicIP HCU1 in homematicip_cloud setup config flow (#140025) add description for hcu1 --- homeassistant/components/homematicip_cloud/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 37deace7ebf..228ebc7500e 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -3,6 +3,7 @@ "step": { "init": { "title": "Pick Homematic IP access point", + "description": "If you are about to register a **Homematic IP HCU1**, please press the button on top of the device before you continue.\n\nThe registration process must be completed within 5 minutes.", "data": { "hapid": "Access point ID (SGTIN)", "pin": "[%key:common::config_flow::data::pin%]", From 82d5304b457d5c32d89ad0648b7c394c87bfc641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 7 Mar 2025 12:13:35 +0000 Subject: [PATCH 1442/1941] Update whirlpool-sixth-sense to 0.19.1 (#139987) --- .../components/whirlpool/__init__.py | 16 +--- homeassistant/components/whirlpool/climate.py | 45 +++------- .../components/whirlpool/diagnostics.py | 23 +++-- .../components/whirlpool/manifest.json | 2 +- homeassistant/components/whirlpool/sensor.py | 69 +++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/whirlpool/__init__.py | 1 - tests/components/whirlpool/conftest.py | 58 ++++++------- tests/components/whirlpool/const.py | 6 ++ .../whirlpool/snapshots/test_diagnostics.ambr | 34 +++++--- tests/components/whirlpool/test_climate.py | 12 ++- .../components/whirlpool/test_diagnostics.py | 5 -- tests/components/whirlpool/test_init.py | 13 +-- tests/components/whirlpool/test_sensor.py | 84 +++++++------------ 15 files changed, 137 insertions(+), 235 deletions(-) create mode 100644 tests/components/whirlpool/const.py diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 6231324bb0d..cb073779379 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -1,6 +1,5 @@ """The Whirlpool Appliances integration.""" -from dataclasses import dataclass import logging from aiohttp import ClientError @@ -20,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -type WhirlpoolConfigEntry = ConfigEntry[WhirlpoolData] +type WhirlpoolConfigEntry = ConfigEntry[AppliancesManager] async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: @@ -52,8 +51,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> if not await appliances_manager.fetch_appliances(): _LOGGER.error("Cannot fetch appliances") return False + await appliances_manager.connect() - entry.runtime_data = WhirlpoolData(appliances_manager, auth, backend_selector) + entry.runtime_data = appliances_manager await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -61,13 +61,5 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool: """Unload a config entry.""" + await entry.runtime_data.disconnect() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -@dataclass -class WhirlpoolData: - """Whirlpool integaration shared data.""" - - appliances_manager: AppliancesManager - auth: Auth - backend_selector: BackendSelector diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 6baf738e54e..84a2c0d52ca 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -5,10 +5,7 @@ from __future__ import annotations import logging from typing import Any -from aiohttp import ClientSession from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode -from whirlpool.auth import Auth -from whirlpool.backendselector import BackendSelector from homeassistant.components.climate import ( ENTITY_ID_FORMAT, @@ -25,7 +22,6 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -73,19 +69,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - whirlpool_data = config_entry.runtime_data - - aircons = [ - AirConEntity( - hass, - ac_data["SAID"], - ac_data["NAME"], - whirlpool_data.backend_selector, - whirlpool_data.auth, - async_get_clientsession(hass), - ) - for ac_data in whirlpool_data.appliances_manager.aircons - ] + appliances_manager = config_entry.runtime_data + aircons = [AirConEntity(hass, aircon) for aircon in appliances_manager.aircons] async_add_entities(aircons, True) @@ -110,36 +95,26 @@ class AirConEntity(ClimateEntity): _attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__( - self, - hass: HomeAssistant, - said: str, - name: str | None, - backend_selector: BackendSelector, - auth: Auth, - session: ClientSession, - ) -> None: + def __init__(self, hass: HomeAssistant, aircon: Aircon) -> None: """Initialize the entity.""" - self._aircon = Aircon(backend_selector, auth, said, session) - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, said, hass=hass) - self._attr_unique_id = said + self._aircon = aircon + self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, aircon.said, hass=hass) + self._attr_unique_id = aircon.said self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, said)}, - name=name if name is not None else said, + identifiers={(DOMAIN, aircon.said)}, + name=aircon.name if aircon.name is not None else aircon.said, manufacturer="Whirlpool", model="Sixth Sense", ) async def async_added_to_hass(self) -> None: - """Connect aircon to the cloud.""" + """Register updates callback.""" self._aircon.register_attr_callback(self.async_write_ha_state) - await self._aircon.connect() async def async_will_remove_from_hass(self) -> None: - """Close Whrilpool Appliance sockets before removing.""" + """Unregister updates callback.""" self._aircon.unregister_attr_callback(self.async_write_ha_state) - await self._aircon.disconnect() @property def available(self) -> bool: diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index 87d6ea827e2..09338396de4 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from whirlpool.appliance import Appliance + from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant @@ -26,18 +28,25 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - whirlpool = config_entry.runtime_data + def get_appliance_diagnostics(appliance: Appliance) -> dict[str, Any]: + return { + "data_model": appliance.appliance_info.data_model, + "category": appliance.appliance_info.category, + "model_number": appliance.appliance_info.model_number, + } + + appliances_manager = config_entry.runtime_data diagnostics_data = { - "Washer_dryers": { - wd["NAME"]: dict(wd.items()) - for wd in whirlpool.appliances_manager.washer_dryers + "washer_dryers": { + wd.name: get_appliance_diagnostics(wd) + for wd in appliances_manager.washer_dryers }, "aircons": { - ac["NAME"]: dict(ac.items()) for ac in whirlpool.appliances_manager.aircons + ac.name: get_appliance_diagnostics(ac) for ac in appliances_manager.aircons }, "ovens": { - oven["NAME"]: dict(oven.items()) - for oven in whirlpool.appliances_manager.ovens + oven.name: get_appliance_diagnostics(oven) + for oven in appliances_manager.ovens }, } diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 67901eea482..ace2e31791d 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.12"] + "requirements": ["whirlpool-sixth-sense==0.19.1"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index f4811feb2c9..d0d13a128e2 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -134,37 +133,16 @@ async def async_setup_entry( config_entry: WhirlpoolConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Config flow entry for Whrilpool Laundry.""" + """Config flow entry for Whirlpool sensors.""" entities: list = [] - whirlpool_data = config_entry.runtime_data - for appliance in whirlpool_data.appliances_manager.washer_dryers: - _wd = WasherDryer( - whirlpool_data.backend_selector, - whirlpool_data.auth, - appliance["SAID"], - async_get_clientsession(hass), - ) - await _wd.connect() - + appliances_manager = config_entry.runtime_data + for washer_dryer in appliances_manager.washer_dryers: entities.extend( - [ - WasherDryerClass( - appliance["SAID"], - appliance["NAME"], - description, - _wd, - ) - for description in SENSORS - ] + [WasherDryerClass(washer_dryer, description) for description in SENSORS] ) entities.extend( [ - WasherDryerTimeClass( - appliance["SAID"], - appliance["NAME"], - description, - _wd, - ) + WasherDryerTimeClass(washer_dryer, description) for description in SENSOR_TIMER ] ) @@ -178,34 +156,30 @@ class WasherDryerClass(SensorEntity): _attr_has_entity_name = True def __init__( - self, - said: str, - name: str, - description: WhirlpoolSensorEntityDescription, - washdry: WasherDryer, + self, washer_dryer: WasherDryer, description: WhirlpoolSensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washdry + self._wd: WasherDryer = washer_dryer - if name == "dryer": + if washer_dryer.name == "dryer": self._attr_icon = ICON_D else: self._attr_icon = ICON_W self.entity_description: WhirlpoolSensorEntityDescription = description self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, said)}, - name=name.capitalize(), + identifiers={(DOMAIN, washer_dryer.said)}, + name=washer_dryer.name.capitalize(), manufacturer="Whirlpool", ) - self._attr_unique_id = f"{said}-{description.key}" + self._attr_unique_id = f"{washer_dryer.said}-{description.key}" async def async_added_to_hass(self) -> None: - """Connect washer/dryer to the cloud.""" + """Register updates callback.""" self._wd.register_attr_callback(self.async_write_ha_state) async def async_will_remove_from_hass(self) -> None: - """Close Whirlpool Appliance sockets before removing.""" + """Unregister updates callback.""" self._wd.unregister_attr_callback(self.async_write_ha_state) @property @@ -226,16 +200,12 @@ class WasherDryerTimeClass(RestoreSensor): _attr_has_entity_name = True def __init__( - self, - said: str, - name: str, - description: SensorEntityDescription, - washdry: WasherDryer, + self, washer_dryer: WasherDryer, description: SensorEntityDescription ) -> None: """Initialize the washer sensor.""" - self._wd: WasherDryer = washdry + self._wd: WasherDryer = washer_dryer - if name == "dryer": + if washer_dryer.name == "dryer": self._attr_icon = ICON_D else: self._attr_icon = ICON_W @@ -243,11 +213,11 @@ class WasherDryerTimeClass(RestoreSensor): self.entity_description: SensorEntityDescription = description self._running: bool | None = None self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, said)}, - name=name.capitalize(), + identifiers={(DOMAIN, washer_dryer.said)}, + name=washer_dryer.name.capitalize(), manufacturer="Whirlpool", ) - self._attr_unique_id = f"{said}-{description.key}" + self._attr_unique_id = f"{washer_dryer.said}-{description.key}" async def async_added_to_hass(self) -> None: """Connect washer/dryer to the cloud.""" @@ -259,7 +229,6 @@ class WasherDryerTimeClass(RestoreSensor): async def async_will_remove_from_hass(self) -> None: """Close Whrilpool Appliance sockets before removing.""" self._wd.unregister_attr_callback(self.update_from_latest_data) - await self._wd.disconnect() @property def available(self) -> bool: diff --git a/requirements_all.txt b/requirements_all.txt index a4d5f6c6914..22cbf6bf7cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3061,7 +3061,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.12 +whirlpool-sixth-sense==0.19.1 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d34851cecc..0e5dd85decd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2465,7 +2465,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.2.26 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.12 +whirlpool-sixth-sense==0.19.1 # homeassistant.components.whois whois==0.9.27 diff --git a/tests/components/whirlpool/__init__.py b/tests/components/whirlpool/__init__.py index ca00975941a..97d9b4d61d5 100644 --- a/tests/components/whirlpool/__init__.py +++ b/tests/components/whirlpool/__init__.py @@ -31,5 +31,4 @@ async def init_integration_with_entry( entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - return entry diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index c302922fe25..93881d3735a 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -8,10 +8,7 @@ import whirlpool import whirlpool.aircon from whirlpool.backendselector import Brand, Region -MOCK_SAID1 = "said1" -MOCK_SAID2 = "said2" -MOCK_SAID3 = "said3" -MOCK_SAID4 = "said4" +from .const import MOCK_SAID1, MOCK_SAID2, MOCK_SAID3, MOCK_SAID4 @pytest.fixture( @@ -36,7 +33,7 @@ def fixture_brand(request: pytest.FixtureRequest) -> tuple[str, Brand]: return request.param -@pytest.fixture(name="mock_auth_api") +@pytest.fixture(name="mock_auth_api", autouse=True) def fixture_mock_auth_api(): """Set up Auth fixture.""" with ( @@ -50,8 +47,10 @@ def fixture_mock_auth_api(): yield mock_auth -@pytest.fixture(name="mock_appliances_manager_api") -def fixture_mock_appliances_manager_api(): +@pytest.fixture(name="mock_appliances_manager_api", autouse=True) +def fixture_mock_appliances_manager_api( + mock_aircon1_api, mock_aircon2_api, mock_sensor1_api, mock_sensor2_api +): """Set up AppliancesManager fixture.""" with ( mock.patch( @@ -63,28 +62,15 @@ def fixture_mock_appliances_manager_api(): ), ): mock_appliances_manager.return_value.fetch_appliances = AsyncMock() + mock_appliances_manager.return_value.connect = AsyncMock() + mock_appliances_manager.return_value.disconnect = AsyncMock() mock_appliances_manager.return_value.aircons = [ - {"SAID": MOCK_SAID1, "NAME": "TestZone"}, - {"SAID": MOCK_SAID2, "NAME": "TestZone"}, + mock_aircon1_api, + mock_aircon2_api, ] mock_appliances_manager.return_value.washer_dryers = [ - {"SAID": MOCK_SAID3, "NAME": "washer"}, - {"SAID": MOCK_SAID4, "NAME": "dryer"}, - ] - yield mock_appliances_manager - - -@pytest.fixture(name="mock_appliances_manager_laundry_api") -def fixture_mock_appliances_manager_laundry_api(): - """Set up AppliancesManager fixture.""" - with mock.patch( - "homeassistant.components.whirlpool.AppliancesManager" - ) as mock_appliances_manager: - mock_appliances_manager.return_value.fetch_appliances = AsyncMock() - mock_appliances_manager.return_value.aircons = None - mock_appliances_manager.return_value.washer_dryers = [ - {"SAID": MOCK_SAID3, "NAME": "washer"}, - {"SAID": MOCK_SAID4, "NAME": "dryer"}, + mock_sensor1_api, + mock_sensor2_api, ] yield mock_appliances_manager @@ -107,9 +93,11 @@ def fixture_mock_backend_selector_api(): def get_aircon_mock(said): """Get a mock of an air conditioner.""" mock_aircon = mock.Mock(said=said) - mock_aircon.connect = AsyncMock() - mock_aircon.disconnect = AsyncMock() + mock_aircon.name = f"Aircon {said}" mock_aircon.register_attr_callback = MagicMock() + mock_aircon.appliance_info.data_model = "aircon_model" + mock_aircon.appliance_info.category = "aircon" + mock_aircon.appliance_info.model_number = "12345" mock_aircon.get_online.return_value = True mock_aircon.get_power_on.return_value = True mock_aircon.get_mode.return_value = whirlpool.aircon.Mode.Cool @@ -132,13 +120,13 @@ def get_aircon_mock(said): @pytest.fixture(name="mock_aircon1_api", autouse=False) -def fixture_mock_aircon1_api(mock_auth_api, mock_appliances_manager_api): +def fixture_mock_aircon1_api(): """Set up air conditioner API fixture.""" return get_aircon_mock(MOCK_SAID1) @pytest.fixture(name="mock_aircon2_api", autouse=False) -def fixture_mock_aircon2_api(mock_auth_api, mock_appliances_manager_api): +def fixture_mock_aircon2_api(): """Set up air conditioner API fixture.""" return get_aircon_mock(MOCK_SAID2) @@ -168,9 +156,11 @@ def side_effect_function(*args, **kwargs): def get_sensor_mock(said): """Get a mock of a sensor.""" mock_sensor = mock.Mock(said=said) - mock_sensor.connect = AsyncMock() - mock_sensor.disconnect = AsyncMock() + mock_sensor.name = f"WasherDryer {said}" mock_sensor.register_attr_callback = MagicMock() + mock_sensor.appliance_info.data_model = "washer_dryer_model" + mock_sensor.appliance_info.category = "washer_dryer" + mock_sensor.appliance_info.model_number = "12345" mock_sensor.get_online.return_value = True mock_sensor.get_machine_state.return_value = ( whirlpool.washerdryer.MachineState.Standby @@ -187,13 +177,13 @@ def get_sensor_mock(said): @pytest.fixture(name="mock_sensor1_api", autouse=False) -def fixture_mock_sensor1_api(mock_auth_api, mock_appliances_manager_laundry_api): +def fixture_mock_sensor1_api(): """Set up sensor API fixture.""" return get_sensor_mock(MOCK_SAID3) @pytest.fixture(name="mock_sensor2_api", autouse=False) -def fixture_mock_sensor2_api(mock_auth_api, mock_appliances_manager_laundry_api): +def fixture_mock_sensor2_api(): """Set up sensor API fixture.""" return get_sensor_mock(MOCK_SAID4) diff --git a/tests/components/whirlpool/const.py b/tests/components/whirlpool/const.py new file mode 100644 index 00000000000..04ea5c0645c --- /dev/null +++ b/tests/components/whirlpool/const.py @@ -0,0 +1,6 @@ +"""Constants for the Whirlpool Sixth Sense integration tests.""" + +MOCK_SAID1 = "said1" +MOCK_SAID2 = "said2" +MOCK_SAID3 = "said3" +MOCK_SAID4 = "said4" diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index ee8abe04bf1..7ffae8bc808 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -2,24 +2,32 @@ # name: test_entry_diagnostics dict({ 'appliances': dict({ - 'Washer_dryers': dict({ - 'dryer': dict({ - 'NAME': 'dryer', - 'SAID': '**REDACTED**', - }), - 'washer': dict({ - 'NAME': 'washer', - 'SAID': '**REDACTED**', - }), - }), 'aircons': dict({ - 'TestZone': dict({ - 'NAME': 'TestZone', - 'SAID': '**REDACTED**', + 'Aircon said1': dict({ + 'category': 'aircon', + 'data_model': 'aircon_model', + 'model_number': '12345', + }), + 'Aircon said2': dict({ + 'category': 'aircon', + 'data_model': 'aircon_model', + 'model_number': '12345', }), }), 'ovens': dict({ }), + 'washer_dryers': dict({ + 'WasherDryer said3': dict({ + 'category': 'washer_dryer', + 'data_model': 'washer_dryer_model', + 'model_number': '12345', + }), + 'WasherDryer said4': dict({ + 'category': 'washer_dryer', + 'data_model': 'washer_dryer_model', + 'model_number': '12345', + }), + }), }), 'config_entry': dict({ 'data': dict({ diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index cdae28f4432..0586d654f7f 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -68,6 +68,7 @@ async def test_no_appliances( ) -> None: """Test the setup of the climate entities when there are no appliances available.""" mock_appliances_manager_api.return_value.aircons = [] + mock_appliances_manager_api.return_value.washer_dryers = [] await init_integration(hass) assert len(hass.states.async_all()) == 0 @@ -75,16 +76,15 @@ async def test_no_appliances( async def test_static_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, - mock_aircon1_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test static climate attributes.""" await init_integration(hass) - for entity_id in ("climate.said1", "climate.said2"): + for said in ("said1", "said2"): + entity_id = f"climate.{said}" entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == entity_id.split(".")[1] + assert entry.unique_id == said state = hass.states.get(entity_id) assert state is not None @@ -92,7 +92,7 @@ async def test_static_attributes( assert state.state == HVACMode.COOL attributes = state.attributes - assert attributes[ATTR_FRIENDLY_NAME] == "TestZone" + assert attributes[ATTR_FRIENDLY_NAME] == f"Aircon {said}" assert ( attributes[ATTR_SUPPORTED_FEATURES] @@ -123,7 +123,6 @@ async def test_static_attributes( async def test_dynamic_attributes( hass: HomeAssistant, - mock_aircon_api_instances: MagicMock, mock_aircon1_api: MagicMock, mock_aircon2_api: MagicMock, ) -> None: @@ -212,7 +211,6 @@ async def test_dynamic_attributes( async def test_service_calls( hass: HomeAssistant, - mock_aircon_api_instances: MagicMock, mock_aircon1_api: MagicMock, mock_aircon2_api: MagicMock, ) -> None: diff --git a/tests/components/whirlpool/test_diagnostics.py b/tests/components/whirlpool/test_diagnostics.py index 2a0b2e6fd18..192339156e1 100644 --- a/tests/components/whirlpool/test_diagnostics.py +++ b/tests/components/whirlpool/test_diagnostics.py @@ -1,7 +1,5 @@ """Test Blink diagnostics.""" -from unittest.mock import MagicMock - from syrupy import SnapshotAssertion from syrupy.filters import props @@ -19,9 +17,6 @@ async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, - mock_appliances_manager_api: MagicMock, - mock_aircon1_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test config entry diagnostics.""" diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index 8f082ff6294..5f04bf84b9e 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -21,7 +21,6 @@ async def test_setup( mock_backend_selector_api: MagicMock, region, brand, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup.""" entry = await init_integration(hass, region[0], brand[0]) @@ -33,7 +32,6 @@ async def test_setup( async def test_setup_region_fallback( hass: HomeAssistant, mock_backend_selector_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup when no region is available on the ConfigEntry. @@ -57,7 +55,6 @@ async def test_setup_brand_fallback( hass: HomeAssistant, region, mock_backend_selector_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup when no brand is available on the ConfigEntry. @@ -81,7 +78,6 @@ async def test_setup_brand_fallback( async def test_setup_http_exception( hass: HomeAssistant, mock_auth_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with an http exception.""" mock_auth_api.return_value.do_auth = AsyncMock( @@ -95,7 +91,6 @@ async def test_setup_http_exception( async def test_setup_auth_failed( hass: HomeAssistant, mock_auth_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with failed auth.""" mock_auth_api.return_value.do_auth = AsyncMock() @@ -108,7 +103,6 @@ async def test_setup_auth_failed( 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 @@ -120,7 +114,6 @@ async def test_setup_auth_account_locked( async def test_setup_fetch_appliances_failed( hass: HomeAssistant, mock_appliances_manager_api: MagicMock, - mock_aircon_api_instances: MagicMock, ) -> None: """Test setup with failed fetch_appliances.""" mock_appliances_manager_api.return_value.fetch_appliances.return_value = False @@ -129,11 +122,7 @@ async def test_setup_fetch_appliances_failed( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_unload_entry( - hass: HomeAssistant, - mock_aircon_api_instances: MagicMock, - mock_sensor_api_instances: MagicMock, -) -> None: +async def test_unload_entry(hass: HomeAssistant) -> None: """Test successful unload of entry.""" entry = await init_integration(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 548025e29bd..95fca331707 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -12,14 +12,13 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import as_timestamp, utc_from_timestamp, utcnow from . import init_integration +from .const import MOCK_SAID3, MOCK_SAID4 from tests.common import async_fire_time_changed, mock_restore_cache_with_extra_data async def update_sensor_state( - hass: HomeAssistant, - entity_id: str, - mock_sensor_api_instance: MagicMock, + hass: HomeAssistant, entity_id: str, mock_sensor_api_instance: MagicMock ) -> State: """Simulate an update trigger from the API.""" @@ -46,10 +45,7 @@ def side_effect_function_open_door(*args, **kwargs): async def test_dryer_sensor_values( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor2_api: MagicMock, - entity_registry: er.EntityRegistry, + hass: HomeAssistant, mock_sensor2_api: MagicMock, entity_registry: er.EntityRegistry ) -> None: """Test the sensor value callbacks.""" hass.set_state(CoreState.not_running) @@ -58,14 +54,11 @@ async def test_dryer_sensor_values( hass, ( ( - State( - "sensor.washer_end_time", - "1", - ), + State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ( - State("sensor.dryer_end_time", "1"), + State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ), @@ -73,7 +66,7 @@ async def test_dryer_sensor_values( await init_integration(hass) - entity_id = "sensor.dryer_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID4}_state" mock_instance = mock_sensor2_api entry = entity_registry.async_get(entity_id) assert entry @@ -83,7 +76,7 @@ async def test_dryer_sensor_values( state = await update_sensor_state(hass, entity_id, mock_instance) assert state is not None - state_id = f"{entity_id.split('_', maxsplit=1)[0]}_end_time" + state_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" state = hass.states.get(state_id) assert state.state == thetimestamp.isoformat() @@ -110,10 +103,7 @@ async def test_dryer_sensor_values( async def test_washer_sensor_values( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor1_api: MagicMock, - entity_registry: er.EntityRegistry, + hass: HomeAssistant, mock_sensor1_api: MagicMock, entity_registry: er.EntityRegistry ) -> None: """Test the sensor value callbacks.""" hass.set_state(CoreState.not_running) @@ -122,14 +112,11 @@ async def test_washer_sensor_values( hass, ( ( - State( - "sensor.washer_end_time", - "1", - ), + State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ( - State("sensor.dryer_end_time", "1"), + State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ), @@ -143,7 +130,7 @@ async def test_washer_sensor_values( ) await hass.async_block_till_done() - entity_id = "sensor.washer_state" + entity_id = f"sensor.washerdryer_{MOCK_SAID3}_state" mock_instance = mock_sensor1_api entry = entity_registry.async_get(entity_id) assert entry @@ -153,11 +140,11 @@ async def test_washer_sensor_values( state = await update_sensor_state(hass, entity_id, mock_instance) assert state is not None - state_id = f"{entity_id.split('_', maxsplit=1)[0]}_end_time" + state_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" state = hass.states.get(state_id) assert state.state == thetimestamp.isoformat() - state_id = f"{entity_id.split('_', maxsplit=1)[0]}_detergent_level" + state_id = f"sensor.washerdryer_{MOCK_SAID3}_detergent_level" entry = entity_registry.async_get(state_id) assert entry assert entry.disabled @@ -277,10 +264,7 @@ async def test_washer_sensor_values( assert state.state == "door_open" -async def test_restore_state( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, -) -> None: +async def test_restore_state(hass: HomeAssistant) -> None: """Test sensor restore state.""" # Home assistant is not running yet hass.set_state(CoreState.not_running) @@ -289,14 +273,11 @@ async def test_restore_state( hass, ( ( - State( - "sensor.washer_end_time", - "1", - ), + State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ( - State("sensor.dryer_end_time", "1"), + State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ), @@ -305,20 +286,18 @@ async def test_restore_state( # create and add entry await init_integration(hass) # restore from cache - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == thetimestamp.isoformat() - state = hass.states.get("sensor.dryer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID4}_end_time") assert state.state == thetimestamp.isoformat() async def test_no_restore_state( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor1_api: MagicMock, + hass: HomeAssistant, mock_sensor1_api: MagicMock ) -> None: """Test sensor restore state with no restore.""" # create and add entry - entity_id = "sensor.washer_end_time" + entity_id = f"sensor.washerdryer_{MOCK_SAID3}_end_time" await init_integration(hass) # restore from cache state = hass.states.get(entity_id) @@ -330,11 +309,7 @@ async def test_no_restore_state( @pytest.mark.freeze_time("2022-11-30 00:00:00") -async def test_callback( - hass: HomeAssistant, - mock_sensor_api_instances: MagicMock, - mock_sensor1_api: MagicMock, -) -> None: +async def test_callback(hass: HomeAssistant, mock_sensor1_api: MagicMock) -> None: """Test callback timestamp callback function.""" hass.set_state(CoreState.not_running) thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) @@ -342,14 +317,11 @@ async def test_callback( hass, ( ( - State( - "sensor.washer_end_time", - "1", - ), + State(f"sensor.washerdryer_{MOCK_SAID3}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ( - State("sensor.dryer_end_time", "1"), + State(f"sensor.washerdryer_{MOCK_SAID4}_end_time", "1"), {"native_value": thetimestamp, "native_unit_of_measurement": None}, ), ), @@ -358,12 +330,12 @@ async def test_callback( # create and add entry await init_integration(hass) # restore from cache - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == thetimestamp.isoformat() callback = mock_sensor1_api.register_attr_callback.call_args_list[1][0][0] callback() - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == thetimestamp.isoformat() mock_sensor1_api.get_machine_state.return_value = MachineState.RunningMainCycle mock_sensor1_api.get_attribute.side_effect = None @@ -371,19 +343,19 @@ async def test_callback( callback() # Test new timestamp when machine starts a cycle. - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") time = state.state assert state.state != thetimestamp.isoformat() # Test no timestamp change for < 60 seconds time change. mock_sensor1_api.get_attribute.return_value = "65" callback() - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") assert state.state == time # Test timestamp change for > 60 seconds. mock_sensor1_api.get_attribute.return_value = "125" callback() - state = hass.states.get("sensor.washer_end_time") + state = hass.states.get(f"sensor.washerdryer_{MOCK_SAID3}_end_time") newtime = utc_from_timestamp(as_timestamp(time) + 65) assert state.state == newtime.isoformat() From 935890e4e046eba0e7f52dceea2ceea9328e044b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 7 Mar 2025 22:28:21 +1000 Subject: [PATCH 1443/1941] Fix shift state default in Teslemetry and Tessie (#140018) * Fix again * Fix Tessie * Update snap --- homeassistant/components/teslemetry/sensor.py | 12 ++++++------ homeassistant/components/tessie/sensor.py | 2 +- tests/components/tessie/snapshots/test_sensor.ambr | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 56c8830d736..f1859ad39de 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -68,7 +68,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): polling: bool = False polling_value_fn: Callable[[StateType], StateType] = lambda x: x - polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None + nullable: bool = False streaming_key: Signal | None = None streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x streaming_firmware: str = "2024.26" @@ -210,7 +210,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_shift_state", polling=True, - polling_available_fn=lambda x: True, + nullable=True, polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), streaming_key=Signal.GEAR, streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), @@ -622,10 +622,10 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) def _async_value_from_stream(self, value) -> None: """Update the value of the entity.""" - if value is None: - self._attr_native_value = None - else: + if self.entity_description.nullable or value is not None: self._attr_native_value = self.entity_description.streaming_value_fn(value) + else: + self._attr_native_value = None class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): @@ -644,7 +644,7 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - if self.entity_description.polling_available_fn(self._value): + if self.entity_description.nullable or self._value is not None: self._attr_available = True self._attr_native_value = self.entity_description.polling_value_fn( self._value diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index 1c26ad633f3..e5b476057fa 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -148,7 +148,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( key="drive_state_shift_state", options=["p", "d", "r", "n"], device_class=SensorDeviceClass.ENUM, - value_fn=lambda x: x.lower() if isinstance(x, str) else x, + value_fn=lambda x: x.lower() if isinstance(x, str) else "p", ), TessieSensorEntityDescription( key="vehicle_state_odometer", diff --git a/tests/components/tessie/snapshots/test_sensor.ambr b/tests/components/tessie/snapshots/test_sensor.ambr index 5465f89d808..b40cf204bca 100644 --- a/tests/components/tessie/snapshots/test_sensor.ambr +++ b/tests/components/tessie/snapshots/test_sensor.ambr @@ -1614,7 +1614,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'p', }) # --- # name: test_sensors[sensor.test_speed-entry] From 11348959ca7f4ed4c1a6ff8915e9ebce2316746e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Mar 2025 13:39:48 +0100 Subject: [PATCH 1444/1941] Make descriptions of `keymitt_ble.calibrate` action UI-friendly (#139866) * Make descriptions of `keymitt_ble.calibrate` action UI-friendly Update the action and field descriptions to better work within the graphical UI (selector / units shown) and for translations. * Change to "press or release" to cover the 'Invert' mode --- homeassistant/components/keymitt_ble/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/keymitt_ble/strings.json b/homeassistant/components/keymitt_ble/strings.json index 2a1f428603e..5e7e895d222 100644 --- a/homeassistant/components/keymitt_ble/strings.json +++ b/homeassistant/components/keymitt_ble/strings.json @@ -34,7 +34,7 @@ "services": { "calibrate": { "name": "Calibrate", - "description": "Calibration - Set depth, press & hold duration, and operation mode. Warning - this will send a push command to the device.", + "description": "Sets the depth, press or release duration, and operation mode. Warning - this will send a push command to the device.", "fields": { "entity_id": { "name": "Entity", @@ -42,15 +42,15 @@ }, "depth": { "name": "Depth", - "description": "Depth in percent." + "description": "How far to extend the push arm." }, "duration": { "name": "Duration", - "description": "Duration in seconds." + "description": "How long to press or release." }, "mode": { "name": "[%key:common::config_flow::data::mode%]", - "description": "Normal | invert | toggle." + "description": "The operation mode of the arm." } } } From 354cd90c92ea9b16fc38d5a303cc3ffe9b8962db Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Fri, 7 Mar 2025 12:53:24 +0000 Subject: [PATCH 1445/1941] Fix Unit of Measurement for Squeezebox duration sensor entity on LMS service (#139861) UOM Fix --- homeassistant/components/squeezebox/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index c0a7a37d539..9d9490208ea 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -43,6 +43,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, ), SensorEntityDescription( key=STATUS_SENSOR_INFO_TOTAL_GENRES, From eadff2938f7c8f1180f75ae4fcee470a9d812d33 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 14:26:43 +0100 Subject: [PATCH 1446/1941] Bump pysmartthings to 2.7.0 (#140047) --- 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 9efa8b81186..2a4e79bff58 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.6.1"] + "requirements": ["pysmartthings==2.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22cbf6bf7cd..b142643b29e 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.6.1 +pysmartthings==2.7.0 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e5dd85decd..6e2bfc7c19f 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.6.1 +pysmartthings==2.7.0 # homeassistant.components.smarty pysmarty2==0.10.2 From 62e45e393d5415a54155fe54f57daa42a40810a5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 14:56:31 +0100 Subject: [PATCH 1447/1941] Fix SmartThings thermostat climate check (#140046) * Fix SmartThings thermostat climate check * Add tests --- .../components/smartthings/climate.py | 4 +- tests/components/smartthings/conftest.py | 1 + .../heatit_ztrm3_thermostat.json | 60 +++++++ .../devices/heatit_ztrm3_thermostat.json | 79 +++++++++ .../smartthings/snapshots/test_climate.ambr | 64 +++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++ .../smartthings/snapshots/test_sensor.ambr | 156 ++++++++++++++++++ 7 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json create mode 100644 tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index b19d65db867..cafd831c5bd 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -164,9 +164,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - if self.get_attribute_value( - Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE - ): + if self.supports_capability(Capability.THERMOSTAT_FAN_MODE): flags |= ClimateEntityFeature.FAN_MODE return flags diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b6e6339af97..730f683fa14 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -121,6 +121,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "ecobee_thermostat", "fake_fan", "generic_fan_3_speed", + "heatit_ztrm3_thermostat", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json b/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json new file mode 100644 index 00000000000..c49cc55d2cb --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/heatit_ztrm3_thermostat.json @@ -0,0 +1,60 @@ +{ + "components": { + "main": { + "powerMeter": { + "power": { + "value": 368.17, + "unit": "W", + "timestamp": "2025-03-07T12:52:08.997Z" + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": "heating", + "timestamp": "2025-03-07T12:49:53.638Z" + } + }, + "energyMeter": { + "energy": { + "value": 2339.5, + "unit": "kWh", + "timestamp": "2025-03-07T12:26:37.133Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 19.0, + "unit": "C", + "timestamp": "2025-03-07T12:52:39.210Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 19.0, + "unit": "C", + "timestamp": "2025-03-06T21:38:22.856Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "heat"] + }, + "timestamp": "2025-03-06T21:38:23.046Z" + }, + "supportedThermostatModes": { + "value": ["off", "heat"], + "timestamp": "2023-09-22T15:41:01.268Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json b/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json new file mode 100644 index 00000000000..e8928f6b3a8 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/heatit_ztrm3_thermostat.json @@ -0,0 +1,79 @@ +{ + "items": [ + { + "deviceId": "69a271f6-6537-4982-8cd9-979866872692", + "name": "heatit-ztrm3-thermostat", + "label": "Hall thermostat", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "8c5c0adc-73d6-33db-a1bd-67d746ab0e00", + "deviceManufacturerCode": "019B-0003-0203", + "locationId": "6cf6637b-9bc5-4e52-bc99-7497e322fb0d", + "ownerId": "7b68139b-d068-45d8-bf27-961320350024", + "roomId": "746b4d54-8026-44f1-b50f-8833dafdeea3", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "powerMeter", + "version": 1 + }, + { + "id": "energyMeter", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-09-22T15:40:58.942Z", + "parentDeviceId": "d04f5ba0-1430-4826-9aa4-fba4efb57c24", + "profile": { + "id": "2677e0e8-9241-3163-815e-6b1d6743f280" + }, + "zwave": { + "networkId": "28", + "driverId": "28198799-de20-4cfd-a9f3-67860a0877d5", + "executingLocally": true, + "hubId": "d04f5ba0-1430-4826-9aa4-fba4efb57c24", + "networkSecurityLevel": "ZWAVE_S2_AUTHENTICATED", + "provisioningState": "PROVISIONED", + "manufacturerId": 411, + "productType": 3, + "productId": 515 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 08ddacf45c6..c85c7af19a6 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -369,6 +369,70 @@ 'state': 'heat', }) # --- +# name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.hall_thermostat', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Hall thermostat', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 19.0, + }), + 'context': , + 'entity_id': 'climate.hall_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[virtual_thermostat][climate.asd-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 7ec2ce79c5b..1918f19911a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -692,6 +692,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[heatit_ztrm3_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', + '69a271f6-6537-4982-8cd9-979866872692', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Hall thermostat', + '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, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 72364d59277..017689f13fd 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4524,6 +4524,162 @@ 'state': '22', }) # --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_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.hall_thermostat_energy', + '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', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.energy', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Hall thermostat Energy', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2339.5', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_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.hall_thermostat_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '69a271f6-6537-4982-8cd9-979866872692.power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Hall thermostat Power', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '368.17', + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_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.hall_thermostat_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': '69a271f6-6537-4982-8cd9-979866872692.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Hall thermostat Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hall_thermostat_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.0', + }) +# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0aa09a2d51fd206c78e91c6362f0683d46c7eed5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 15:04:46 +0100 Subject: [PATCH 1448/1941] Only keep valid powerConsumptionReports in SmartThings (#140049) * power consumption report * Only keep valid powerConsumptionReports in SmartThings --- .../components/smartthings/__init__.py | 55 ++++++++++++++----- .../components/smartthings/sensor.py | 10 ---- .../device_status/c2c_arlo_pro_3_switch.json | 9 +++ 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index cf17e6a110b..535a409bc8d 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -186,7 +186,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -DATA_VALIDATION: dict[ +KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] ] = { Capability.WASHER_OPERATING_STATE: ( @@ -195,26 +195,53 @@ DATA_VALIDATION: dict[ Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, } +POWER_CONSUMPTION_FIELDS = { + "energy", + "power", + "deltaEnergy", + "powerEnergy", + "energySaved", +} + +CAPABILITY_VALIDATION: dict[ + Capability | str, Callable[[dict[Attribute | str, Status]], bool] +] = { + Capability.POWER_CONSUMPTION_REPORT: ( + lambda status: ( + (power_consumption := status[Attribute.POWER_CONSUMPTION].value) is not None + and all( + field in cast(dict, power_consumption) + for field in POWER_CONSUMPTION_FIELDS + ) + ) + ) +} + def process_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 ( + if (main_component := status.get(MAIN)) is None: + return status + if ( disabled_capabilities_capability := main_component.get( Capability.CUSTOM_DISABLED_CAPABILITIES ) - ) is None: - return status - disabled_capabilities = cast( - list[Capability | str], - disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, - ) - if disabled_capabilities is not None: - for capability in disabled_capabilities: - if capability in main_component and ( - capability not in DATA_VALIDATION - or not DATA_VALIDATION[capability](main_component[capability]) - ): + ) is not None: + disabled_capabilities = cast( + list[Capability | str], + disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value, + ) + if disabled_capabilities is not None: + for capability in disabled_capabilities: + if capability in main_component and ( + capability not in KEEP_CAPABILITY_QUIRK + or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability]) + ): + del main_component[capability] + for capability in list(main_component): + if capability in CAPABILITY_VALIDATION: + if not CAPABILITY_VALIDATION[capability](main_component[capability]): del main_component[capability] return status diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 22fdf3084c8..9ef8cb55c92 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -130,7 +130,6 @@ 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[ @@ -581,7 +580,6 @@ CAPABILITY_TO_SENSORS: dict[ 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( key="power_meter", @@ -591,7 +589,6 @@ CAPABILITY_TO_SENSORS: dict[ value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, suggested_display_precision=2, - except_if_state_none=True, ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -601,7 +598,6 @@ CAPABILITY_TO_SENSORS: dict[ 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( key="powerEnergy_meter", @@ -611,7 +607,6 @@ CAPABILITY_TO_SENSORS: dict[ 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( key="energySaved_meter", @@ -621,7 +616,6 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, suggested_display_precision=2, - except_if_state_none=True, ), ] }, @@ -983,10 +977,6 @@ async def async_setup_entry( 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/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 index 371a779f83c..a3d2cabe837 100644 --- 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 @@ -58,6 +58,15 @@ "timestamp": "2025-02-08T21:56:09.761Z" } }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-02-09T15:45:29Z", + "end": "2025-02-09T16:15:33Z" + }, + "timestamp": "2025-02-09T16:15:33.639Z" + } + }, "battery": { "quantity": { "value": null From edd2d4c349440d2c557259c060ad301fd537f159 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Mar 2025 15:25:37 +0100 Subject: [PATCH 1449/1941] Improve strings of `swiss_public_transport.fetch_connections` action (#139911) Improve strings of `swiss_public.transport.fetch_connections` action - use sentence-casing in action name - capitalize the integration name in action description - remove "from [1-15]" from `limit` description as this is handled by the UI --- .../components/swiss_public_transport/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json index 1cdbd527467..f1b28f5ed14 100644 --- a/homeassistant/components/swiss_public_transport/strings.json +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -83,8 +83,8 @@ }, "services": { "fetch_connections": { - "name": "Fetch Connections", - "description": "Fetch a list of connections from the swiss public transport.", + "name": "Fetch connections", + "description": "Fetches a list of connections from Swiss public transport.", "fields": { "config_entry_id": { "name": "Instance", @@ -92,7 +92,7 @@ }, "limit": { "name": "Limit", - "description": "Number of connections to fetch from [1-15]" + "description": "Number of connections to fetch." } } } From 27964e16c12d997b0281fdce4d1a7239f9d01c2c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 7 Mar 2025 15:26:40 +0100 Subject: [PATCH 1450/1941] Clean up ondilo ico oauth2 (#139927) --- .../components/ondilo_ico/__init__.py | 30 +++++++++++------ homeassistant/components/ondilo_ico/api.py | 3 -- .../ondilo_ico/application_credentials.py | 14 ++++++++ .../components/ondilo_ico/config_flow.py | 18 ++++++----- homeassistant/components/ondilo_ico/const.py | 4 +-- .../components/ondilo_ico/manifest.json | 2 +- .../components/ondilo_ico/oauth_impl.py | 32 ------------------- .../generated/application_credentials.py | 1 + tests/components/ondilo_ico/conftest.py | 3 +- .../components/ondilo_ico/test_config_flow.py | 24 ++++---------- tests/components/ondilo_ico/test_sensor.py | 4 ++- 11 files changed, 59 insertions(+), 76 deletions(-) create mode 100644 homeassistant/components/ondilo_ico/application_credentials.py delete mode 100644 homeassistant/components/ondilo_ico/oauth_impl.py diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index ddcd7ab8831..93aadb5b6ea 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -1,27 +1,37 @@ """The Ondilo ICO integration.""" +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.typing import ConfigType from .api import OndiloClient -from .config_flow import OndiloIcoOAuth2FlowHandler -from .const import DOMAIN +from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET from .coordinator import OndiloIcoPoolsCoordinator -from .oauth_impl import OndiloOauth2Implementation +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.SENSOR] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Ondilo ICO integration.""" + # Import the default client credential. + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Ondilo ICO"), + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ondilo ICO from a config entry.""" - - OndiloIcoOAuth2FlowHandler.async_register_implementation( - hass, - OndiloOauth2Implementation(hass), - ) - implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry diff --git a/homeassistant/components/ondilo_ico/api.py b/homeassistant/components/ondilo_ico/api.py index f6ab0baa576..696acf1b2d6 100644 --- a/homeassistant/components/ondilo_ico/api.py +++ b/homeassistant/components/ondilo_ico/api.py @@ -1,15 +1,12 @@ """API for Ondilo ICO bound to Home Assistant OAuth.""" from asyncio import run_coroutine_threadsafe -import logging from ondilo import Ondilo from homeassistant import config_entries, core from homeassistant.helpers import config_entry_oauth2_flow -_LOGGER = logging.getLogger(__name__) - class OndiloClient(Ondilo): """Provide Ondilo ICO authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/ondilo_ico/application_credentials.py b/homeassistant/components/ondilo_ico/application_credentials.py new file mode 100644 index 00000000000..5481a88bc1b --- /dev/null +++ b/homeassistant/components/ondilo_ico/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for Ondilo ICO.""" + +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.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/ondilo_ico/config_flow.py b/homeassistant/components/ondilo_ico/config_flow.py index fe0b89e7258..6839d2089bf 100644 --- a/homeassistant/components/ondilo_ico/config_flow.py +++ b/homeassistant/components/ondilo_ico/config_flow.py @@ -3,11 +3,14 @@ import logging from typing import Any +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.config_entries import ConfigFlowResult from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import DOMAIN -from .oauth_impl import OndiloOauth2Implementation +from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET class OndiloIcoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): @@ -18,14 +21,13 @@ class OndiloIcoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" - await self.async_set_unique_id(DOMAIN) - - self.async_register_implementation( + """Handle a flow start.""" + # Import the default client credential. + await async_import_client_credential( self.hass, - OndiloOauth2Implementation(self.hass), + DOMAIN, + ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Ondilo ICO"), ) - return await super().async_step_user(user_input) @property diff --git a/homeassistant/components/ondilo_ico/const.py b/homeassistant/components/ondilo_ico/const.py index 3c947776857..8dec6072556 100644 --- a/homeassistant/components/ondilo_ico/const.py +++ b/homeassistant/components/ondilo_ico/const.py @@ -4,5 +4,5 @@ DOMAIN = "ondilo_ico" OAUTH2_AUTHORIZE = "https://interop.ondilo.com/oauth2/authorize" OAUTH2_TOKEN = "https://interop.ondilo.com/oauth2/token" -OAUTH2_CLIENTID = "customer_api" -OAUTH2_CLIENTSECRET = "" +OAUTH2_CLIENT_ID = "customer_api" +OAUTH2_CLIENT_SECRET = "" diff --git a/homeassistant/components/ondilo_ico/manifest.json b/homeassistant/components/ondilo_ico/manifest.json index 84862a89fbb..3553797b9cd 100644 --- a/homeassistant/components/ondilo_ico/manifest.json +++ b/homeassistant/components/ondilo_ico/manifest.json @@ -3,7 +3,7 @@ "name": "Ondilo ICO", "codeowners": ["@JeromeHXP"], "config_flow": true, - "dependencies": ["auth"], + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/ondilo_ico", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/ondilo_ico/oauth_impl.py b/homeassistant/components/ondilo_ico/oauth_impl.py deleted file mode 100644 index e1c6e6fdb90..00000000000 --- a/homeassistant/components/ondilo_ico/oauth_impl.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name.""" - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import LocalOAuth2Implementation - -from .const import ( - DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_CLIENTID, - OAUTH2_CLIENTSECRET, - OAUTH2_TOKEN, -) - - -class OndiloOauth2Implementation(LocalOAuth2Implementation): - """Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Just init default class with default values.""" - super().__init__( - hass, - DOMAIN, - OAUTH2_CLIENTID, - OAUTH2_CLIENTSECRET, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, - ) - - @property - def name(self) -> str: - """Name of the implementation.""" - return "Ondilo" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index b891e807a7f..68c6de405e6 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -25,6 +25,7 @@ APPLICATION_CREDENTIALS = [ "neato", "nest", "netatmo", + "ondilo_ico", "onedrive", "point", "senz", diff --git a/tests/components/ondilo_ico/conftest.py b/tests/components/ondilo_ico/conftest.py index d35e5ac0003..891f60eb549 100644 --- a/tests/components/ondilo_ico/conftest.py +++ b/tests/components/ondilo_ico/conftest.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant.components.ondilo_ico.const import DOMAIN +from homeassistant.util.json import JsonArrayType from tests.common import ( MockConfigEntry, @@ -71,7 +72,7 @@ def ico_details2() -> dict[str, Any]: @pytest.fixture(scope="package") -def last_measures() -> list[dict[str, Any]]: +def last_measures() -> JsonArrayType: """Pool measurements.""" return load_json_array_fixture("last_measures.json", DOMAIN) diff --git a/tests/components/ondilo_ico/test_config_flow.py b/tests/components/ondilo_ico/test_config_flow.py index deab2a8e0b9..19407cecb9d 100644 --- a/tests/components/ondilo_ico/test_config_flow.py +++ b/tests/components/ondilo_ico/test_config_flow.py @@ -4,15 +4,13 @@ from unittest.mock import patch import pytest -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.ondilo_ico.const import ( DOMAIN, OAUTH2_AUTHORIZE, - OAUTH2_CLIENTID, - OAUTH2_CLIENTSECRET, + OAUTH2_CLIENT_ID as CLIENT_ID, OAUTH2_TOKEN, ) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -21,13 +19,12 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -CLIENT_ID = OAUTH2_CLIENTID -CLIENT_SECRET = OAUTH2_CLIENTSECRET - -async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: +async def test_abort_if_existing_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -43,15 +40,6 @@ async def test_full_flow( aioclient_mock: AiohttpClientMocker, ) -> None: """Check full flow.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, - "http": {"base_url": "https://example.com"}, - }, - ) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/ondilo_ico/test_sensor.py b/tests/components/ondilo_ico/test_sensor.py index 0043d22f6c0..c944353724e 100644 --- a/tests/components/ondilo_ico/test_sensor.py +++ b/tests/components/ondilo_ico/test_sensor.py @@ -45,7 +45,9 @@ async def test_no_ico_for_one_pool( # Only the second pool is created assert len(hass.states.async_all()) == 7 assert hass.states.get("sensor.pool_1_temperature") is None - assert hass.states.get("sensor.pool_2_rssi").state == next( + state = hass.states.get("sensor.pool_2_rssi") + assert state is not None + assert state.state == next( str(item["value"]) for item in last_measures if item["data_type"] == "rssi" ) From cd2ce5e11b403a172fb52f33a5a51894fcc8e709 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 7 Mar 2025 15:44:58 +0100 Subject: [PATCH 1451/1941] Bump py-synologydsm-api to 2.7.1 (#140052) bump py-synologydsm-api to 2.7.1 --- 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 dc5634e7a84..3804de7f3f1 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.7.0"], + "requirements": ["py-synologydsm-api==2.7.1"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index b142643b29e..a132c4b89ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1755,7 +1755,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.0 +py-synologydsm-api==2.7.1 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e2bfc7c19f..ce6a7ce1d25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1453,7 +1453,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.0 +py-synologydsm-api==2.7.1 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From e51154ae69b590417f78d7ec3caa25cf076ffa3c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Mar 2025 15:46:00 +0100 Subject: [PATCH 1452/1941] Restore SmartThings button event (#140044) * Restore SmartThings button event * Fix --- .../components/smartthings/__init__.py | 32 +++++++++++- homeassistant/components/smartthings/const.py | 2 + tests/components/smartthings/__init__.py | 2 + .../fixtures/device_status/button.json | 21 ++++++++ .../smartthings/fixtures/devices/button.json | 49 +++++++++++++++++++ .../smartthings/snapshots/test_init.ambr | 3 ++ tests/components/smartthings/test_init.py | 36 ++++++++++++-- 7 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/button.json create mode 100644 tests/components/smartthings/fixtures/devices/button.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 535a409bc8d..3e0e66e890f 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -12,6 +12,7 @@ from pysmartthings import ( Attribute, Capability, Device, + DeviceEvent, Scene, SmartThings, SmartThingsAuthenticationFailedError, @@ -29,7 +30,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( async_get_config_entry_implementation, ) -from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA +from .const import ( + CONF_INSTALLED_APP_ID, + CONF_LOCATION_ID, + DOMAIN, + EVENT_BUTTON, + MAIN, + OLD_DATA, +) _LOGGER = logging.getLogger(__name__) @@ -141,6 +149,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) rooms=rooms, ) + def handle_button_press(event: DeviceEvent) -> None: + """Handle a button press.""" + if ( + event.capability is Capability.BUTTON + and event.attribute is Attribute.BUTTON + ): + hass.bus.async_fire( + EVENT_BUTTON, + { + "component_id": event.component_id, + "device_id": event.device_id, + "location_id": event.location_id, + "value": event.value, + "name": entry.runtime_data.devices[event.device_id].device.label, + "data": event.data, + }, + ) + + entry.async_on_unload( + client.add_unspecified_device_event_listener(handle_button_press) + ) + entry.async_create_background_task( hass, client.subscribe( diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 23fd48a4e1e..a6d028aed06 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -32,3 +32,5 @@ CONF_REFRESH_TOKEN = "refresh_token" MAIN = "main" OLD_DATA = "old_data" + +EVENT_BUTTON = "smartthings.button" diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index 6939d3c5dcc..e87d1a8bcdf 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -68,6 +68,8 @@ async def trigger_update( value, data, ) + for call in mock.add_unspecified_device_event_listener.call_args_list: + call[0][0](event) for call in mock.add_device_event_listener.call_args_list: if call[0][0] == device_id: call[0][3](event) diff --git a/tests/components/smartthings/fixtures/device_status/button.json b/tests/components/smartthings/fixtures/device_status/button.json new file mode 100644 index 00000000000..93e320bcb7b --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/button.json @@ -0,0 +1,21 @@ +{ + "components": { + "main": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-03-07T12:20:43.363Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2025-03-07T12:20:43.363Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "pushed_2x"], + "timestamp": "2025-03-07T12:20:43.363Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/button.json b/tests/components/smartthings/fixtures/devices/button.json new file mode 100644 index 00000000000..ba993ca6aa7 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/button.json @@ -0,0 +1,49 @@ +{ + "items": [ + { + "deviceId": "c4bdd19f-85d1-4d58-8f9c-e75ac3cf113b", + "name": "button", + "label": "button", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "238c483a-10e8-359b-b032-1be2b2fcdee7", + "locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f", + "ownerId": "12d4af93-cb68-b108-87f5-625437d7371f", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "button", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-07T12:20:43.273Z", + "profile": { + "id": "b045d731-4d01-35bc-8018-b3da711d8904" + }, + "virtual": { + "name": "button", + "executingLocally": false + }, + "type": "VIRTUAL", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 1918f19911a..5beaf907b70 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1,4 +1,7 @@ # serializer version: 1 +# name: test_button_event[button] + +# --- # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ 'area_id': 'toilet', diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 3ffe2c11a42..e3d865fc5c8 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,15 +2,16 @@ from unittest.mock import AsyncMock -from pysmartthings import DeviceResponse, DeviceStatus +from pysmartthings import Attribute, Capability, DeviceResponse, DeviceStatus import pytest from syrupy import SnapshotAssertion +from homeassistant.components.smartthings import EVENT_BUTTON from homeassistant.components.smartthings.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr -from . import setup_integration +from . import setup_integration, trigger_update from tests.common import MockConfigEntry, load_fixture @@ -33,6 +34,35 @@ async def test_devices( assert device == snapshot +@pytest.mark.parametrize("device_fixture", ["button"]) +async def test_button_event( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test button event.""" + await setup_integration(hass, mock_config_entry) + events = [] + + def capture_event(event: Event) -> None: + events.append(event) + + hass.bus.async_listen_once(EVENT_BUTTON, capture_event) + + await trigger_update( + hass, + devices, + "c4bdd19f-85d1-4d58-8f9c-e75ac3cf113b", + Capability.BUTTON, + Attribute.BUTTON, + "pushed", + ) + + assert len(events) == 1 + assert events[0] == snapshot + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_removing_stale_devices( hass: HomeAssistant, From fe34e6beee0baff9b512a8ab6feb0dcf4ac955e2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 7 Mar 2025 18:16:55 +0100 Subject: [PATCH 1453/1941] Improve user-facing strings of Bang & Olufsen integration (#140062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix typo "Setup …" -> "Set up …" - fix the wrong capitalization of "… all Connected …" - change all action descriptions to match Home Assistant style - reword descriptions of `beolink_expand` and `beolink_unexpand` action using different verbs to better explain them --- homeassistant/components/bang_olufsen/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 57ab828f9fb..278e9b6d47c 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -29,7 +29,7 @@ "description": "Manually configure your Bang & Olufsen device." }, "zeroconf_confirm": { - "title": "Setup Bang & Olufsen device", + "title": "Set up Bang & Olufsen device", "description": "Confirm the configuration of the {model}-{serial_number} @ {host}." } } @@ -197,11 +197,11 @@ "services": { "beolink_allstandby": { "name": "Beolink all standby", - "description": "Set all Connected Beolink devices to standby." + "description": "Sets all connected Beolink devices to standby." }, "beolink_expand": { "name": "Beolink expand", - "description": "Expand current Beolink experience.", + "description": "Adds devices to the current Beolink experience.", "fields": { "all_discovered": { "name": "All discovered", @@ -221,7 +221,7 @@ }, "beolink_join": { "name": "Beolink join", - "description": "Join a Beolink experience.", + "description": "Joins a Beolink experience.", "fields": { "beolink_jid": { "name": "Beolink JID", @@ -241,11 +241,11 @@ }, "beolink_leave": { "name": "Beolink leave", - "description": "Leave a Beolink experience." + "description": "Leaves a Beolink experience." }, "beolink_unexpand": { "name": "Beolink unexpand", - "description": "Unexpand from current Beolink experience.", + "description": "Removes devices from the current Beolink experience.", "fields": { "beolink_jids": { "name": "Beolink JIDs", From 3ccb7d80f3d4e636e023fc808ecccb39d45a12f6 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 7 Mar 2025 19:40:17 +0100 Subject: [PATCH 1454/1941] Add `update_todo` action to Habitica (#139799) * update_todo action * fix strings --- homeassistant/components/habitica/const.py | 9 + homeassistant/components/habitica/icons.json | 10 + homeassistant/components/habitica/services.py | 117 +++++++++-- .../components/habitica/services.yaml | 66 ++++++ .../components/habitica/strings.json | 130 +++++++++++- tests/components/habitica/conftest.py | 13 +- tests/components/habitica/fixtures/tasks.json | 13 +- .../habitica/snapshots/test_services.ambr | 50 +++++ tests/components/habitica/test_services.py | 193 +++++++++++++++++- 9 files changed, 573 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 049f2beb370..c33edc0161d 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -44,6 +44,14 @@ ATTR_UP_DOWN = "up_down" ATTR_FREQUENCY = "frequency" ATTR_COUNTER_UP = "counter_up" ATTR_COUNTER_DOWN = "counter_down" +ATTR_ADD_CHECKLIST_ITEM = "add_checklist_item" +ATTR_REMOVE_CHECKLIST_ITEM = "remove_checklist_item" +ATTR_SCORE_CHECKLIST_ITEM = "score_checklist_item" +ATTR_UNSCORE_CHECKLIST_ITEM = "unscore_checklist_item" +ATTR_REMINDER = "reminder" +ATTR_REMOVE_REMINDER = "remove_reminder" +ATTR_CLEAR_REMINDER = "clear_reminder" +ATTR_CLEAR_DATE = "clear_date" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -63,6 +71,7 @@ SERVICE_UPDATE_REWARD = "update_reward" SERVICE_CREATE_REWARD = "create_reward" SERVICE_UPDATE_HABIT = "update_habit" SERVICE_CREATE_HABIT = "create_habit" +SERVICE_UPDATE_TODO = "update_todo" 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 af4a20acab6..f4f045523d4 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -243,6 +243,16 @@ "sections": { "developer_options": "mdi:test-tube" } + }, + "update_todo": { + "service": "mdi:pencil-box-outline", + "sections": { + "checklist_options": "mdi:format-list-checks", + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube", + "duedate_options": "mdi:calendar-blank", + "reminder_options": "mdi:reminder" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 78f3002c89d..f1e92d863ca 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -3,17 +3,20 @@ from __future__ import annotations from dataclasses import asdict +from datetime import datetime, time import logging from typing import TYPE_CHECKING, Any, cast -from uuid import UUID +from uuid import UUID, uuid4 from aiohttp import ClientError from habiticalib import ( + Checklist, Direction, Frequency, HabiticaException, NotAuthorizedError, NotFoundError, + Reminders, Skill, Task, TaskData, @@ -25,7 +28,7 @@ import voluptuous as vol from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_NAME, CONF_NAME +from homeassistant.const import ATTR_DATE, ATTR_NAME, CONF_NAME from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -38,8 +41,11 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( + ATTR_ADD_CHECKLIST_ITEM, ATTR_ALIAS, ATTR_ARGS, + ATTR_CLEAR_DATE, + ATTR_CLEAR_REMINDER, ATTR_CONFIG_ENTRY, ATTR_COST, ATTR_COUNTER_DOWN, @@ -52,12 +58,17 @@ from .const import ( ATTR_NOTES, ATTR_PATH, ATTR_PRIORITY, + ATTR_REMINDER, + ATTR_REMOVE_CHECKLIST_ITEM, + ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UNSCORE_CHECKLIST_ITEM, ATTR_UP_DOWN, DOMAIN, EVENT_API_CALL_SUCCESS, @@ -77,6 +88,7 @@ from .const import ( SERVICE_TRANSFORMATION, SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, ) from .coordinator import HabiticaConfigEntry @@ -137,6 +149,15 @@ BASE_TASK_SCHEMA = vol.Schema( vol.Optional(ATTR_COUNTER_UP): vol.All(int, vol.Range(0)), vol.Optional(ATTR_COUNTER_DOWN): vol.All(int, vol.Range(0)), vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency), + vol.Optional(ATTR_DATE): cv.date, + vol.Optional(ATTR_CLEAR_DATE): cv.boolean, + vol.Optional(ATTR_REMINDER): vol.All(cv.ensure_list, [cv.datetime]), + vol.Optional(ATTR_REMOVE_REMINDER): vol.All(cv.ensure_list, [cv.datetime]), + vol.Optional(ATTR_CLEAR_REMINDER): cv.boolean, + vol.Optional(ATTR_ADD_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), } ) @@ -192,6 +213,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_CREATE_REWARD: TaskType.REWARD, SERVICE_UPDATE_HABIT: TaskType.HABIT, SERVICE_CREATE_HABIT: TaskType.HABIT, + SERVICE_UPDATE_TODO: TaskType.TODO, } @@ -577,7 +599,11 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data await coordinator.async_refresh() - is_update = call.service in (SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT) + is_update = call.service in ( + SERVICE_UPDATE_HABIT, + SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, + ) current_task = None if is_update: @@ -685,6 +711,69 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if counter_down := call.data.get(ATTR_COUNTER_DOWN): data["counterDown"] = counter_down + if due_date := call.data.get(ATTR_DATE): + data["date"] = datetime.combine(due_date, time()) + + if call.data.get(ATTR_CLEAR_DATE): + data["date"] = None + + checklist = current_task.checklist if current_task else [] + + if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM): + checklist.extend( + Checklist(completed=False, id=uuid4(), text=item) + for item in add_checklist_item + if not any(i.text == item for i in checklist) + ) + if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM): + checklist = [ + item for item in checklist if item.text not in remove_checklist_item + ] + + if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in score_checklist_item: + item.completed = True + + if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in unscore_checklist_item: + item.completed = False + if ( + add_checklist_item + or remove_checklist_item + or score_checklist_item + or unscore_checklist_item + ): + data["checklist"] = checklist + + reminders = current_task.reminders if current_task else [] + + if add_reminders := call.data.get(ATTR_REMINDER): + existing_reminder_datetimes = { + r.time.replace(tzinfo=None) for r in reminders + } + + reminders.extend( + Reminders(id=uuid4(), time=r) + for r in add_reminders + if r not in existing_reminder_datetimes + ) + + if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): + reminders = list( + filter( + lambda r: r.time.replace(tzinfo=None) not in remove_reminder, + reminders, + ) + ) + + if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): + reminders = [] + + if add_reminders or remove_reminder or clear_reminders: + data["reminders"] = reminders + try: if is_update: if TYPE_CHECKING: @@ -714,20 +803,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 else: return response.data.to_dict(omit_none=True) - hass.services.async_register( - DOMAIN, - SERVICE_UPDATE_REWARD, - create_or_update_task, - schema=SERVICE_UPDATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - hass.services.async_register( - DOMAIN, - SERVICE_UPDATE_HABIT, - create_or_update_task, - schema=SERVICE_UPDATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) + for service in (SERVICE_UPDATE_TODO, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT): + hass.services.async_register( + DOMAIN, + service, + create_or_update_task, + schema=SERVICE_UPDATE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) hass.services.async_register( DOMAIN, SERVICE_CREATE_REWARD, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index ed3ae4516e5..2464b39529b 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -262,3 +262,69 @@ create_habit: frequency: *frequency tag: *tag developer_options: *developer_options +update_todo: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + checklist_options: + collapsed: true + fields: + add_checklist_item: + required: false + selector: + text: + multiple: true + remove_checklist_item: + required: false + selector: + text: + multiple: true + score_checklist_item: + required: false + selector: + text: + multiple: true + unscore_checklist_item: + required: false + selector: + text: + multiple: true + priority: *priority + duedate_options: + collapsed: true + fields: + date: + required: false + selector: + date: + clear_date: + required: false + selector: + constant: + value: true + label: "🗑️" + reminder_options: + collapsed: true + fields: + reminder: + required: false + selector: + text: + type: datetime-local + multiple: true + remove_reminder: + required: false + selector: + text: + type: datetime-local + multiple: true + clear_reminder: + required: false + selector: + constant: + value: true + label: "🗑️" + tag_options: *tag_options + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 1f9424eafe1..d77bbd6f2be 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -26,12 +26,30 @@ "tag_options_description": "Add or remove tags from a task.", "name_description": "The title for the Habitica task.", "cost_name": "Cost", - "difficulty_name": "Difficulty", - "difficulty_description": "The difficulty of the task.", + "priority_name": "Difficulty", + "priority_description": "The difficulty of the task.", "frequency_name": "Counter reset", "frequency_description": "The frequency at which the habit's counter resets: daily at the start of a new day, weekly after Sunday night, or monthly at the beginning of a new month.", "up_down_name": "Rewards or losses", - "up_down_description": "Whether the habit is good and rewarding (positive), bad and penalizing (negative), or both." + "up_down_description": "Whether the habit is good and rewarding (positive), bad and penalizing (negative), or both.", + "add_checklist_item_name": "Add checklist items", + "add_checklist_item_description": "The items to add to a task's checklist.", + "remove_checklist_item_name": "Delete items", + "remove_checklist_item_description": "Remove items from a task's checklist.", + "score_checklist_item_name": "Complete items", + "score_checklist_item_description": "Mark items from a task's checklist as completed.", + "unscore_checklist_item_name": "Uncomplete items", + "unscore_checklist_item_description": "Undo completion of items of a task's checklist.", + "checklist_options_name": "Checklist", + "checklist_options_description": "Add, remove, or update status of an item on a task's checklist.", + "reminder_name": "Add reminders", + "reminder_description": "Add reminders to a Habitica task.", + "remove_reminder_name": "Remove reminders", + "remove_reminder_description": "Remove specific reminders from a Habitica task.", + "clear_reminder_name": "Clear all reminders", + "clear_reminder_description": "Remove all reminders from a Habitica task.", + "reminder_options_name": "Reminders", + "reminder_options_description": "Add, remove or clear reminders of a Habitica task." }, "config": { "abort": { @@ -659,7 +677,7 @@ "description": "Filter tasks by type." }, "priority": { - "name": "Difficulty", + "name": "[%key:component::habitica::common::priority_name%]", "description": "Filter tasks by difficulty." }, "task": { @@ -799,8 +817,8 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "priority": { - "name": "[%key:component::habitica::common::difficulty_name%]", - "description": "[%key:component::habitica::common::difficulty_description%]" + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" }, "frequency": { "name": "[%key:component::habitica::common::frequency_name%]", @@ -855,8 +873,8 @@ "description": "[%key:component::habitica::common::alias_description%]" }, "priority": { - "name": "[%key:component::habitica::common::difficulty_name%]", - "description": "[%key:component::habitica::common::difficulty_description%]" + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" }, "frequency": { "name": "[%key:component::habitica::common::frequency_name%]", @@ -873,6 +891,102 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "update_todo": { + "name": "Update a to-do", + "description": "Updates a specific to-do for a selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::common::config_entry_description%]" + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "The name (or task ID) of the to-do you want to update." + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_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%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "date": { + "name": "Due date", + "description": "The to-do's due date." + }, + "clear_date": { + "name": "Clear due date", + "description": "Remove the due date from the to-do." + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + }, + "remove_reminder": { + "name": "[%key:component::habitica::common::remove_reminder_name%]", + "description": "[%key:component::habitica::common::remove_reminder_description%]" + }, + "clear_reminder": { + "name": "[%key:component::habitica::common::clear_reminder_name%]", + "description": "[%key:component::habitica::common::clear_reminder_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "remove_checklist_item": { + "name": "[%key:component::habitica::common::remove_checklist_item_name%]", + "description": "[%key:component::habitica::common::remove_checklist_item_description%]" + }, + "score_checklist_item": { + "name": "[%key:component::habitica::common::score_checklist_item_name%]", + "description": "[%key:component::habitica::common::score_checklist_item_description%]" + }, + "unscore_checklist_item": { + "name": "[%key:component::habitica::common::unscore_checklist_item_name%]", + "description": "[%key:component::habitica::common::unscore_checklist_item_description%]" + } + }, + "sections": { + "checklist_options": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::checklist_options_description%]" + }, + "duedate_options": { + "name": "Due date", + "description": "Set, update or remove due dates of a to-do." + }, + "reminder_options": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_options_description%]" + }, + "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 efb4f7300bf..4ef14699e0b 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -1,7 +1,8 @@ """Tests for the habitica component.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import UUID from habiticalib import ( BadRequestError, @@ -176,3 +177,13 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.habitica.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_uuid4() -> Generator[MagicMock]: + """Mock uuid4.""" + with patch( + "homeassistant.components.habitica.services.uuid4", autospec=True + ) as mock_uuid4: + mock_uuid4.return_value = UUID("12345678-1234-5678-1234-567812345678") + yield mock_uuid4 diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 378652138bc..3dff57bdd51 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -425,7 +425,18 @@ "date": "2024-09-27T22:17:00.000Z", "completed": false, "collapseChecklist": false, - "checklist": [], + "checklist": [ + { + "completed": false, + "id": "fccc26f2-1e2b-4bf8-9dd0-a405be261036", + "text": "Checklist-item1" + }, + { + "completed": true, + "id": "5a897af4-ea94-456a-a2bd-f336bcd79509", + "text": "Checklist-item2" + } + ], "type": "todo", "text": "Buch zu Ende lesen", "notes": "Das Buch, das du angefangen hast, bis zum Wochenende fertig lesen.", diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index 79c9e3eab66..af0ec76f3a4 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -736,6 +736,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -1834,6 +1844,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -2978,6 +2998,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -5615,6 +5645,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -6137,6 +6177,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'fccc26f2-1e2b-4bf8-9dd0-a405be261036', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '5a897af4-ea94-456a-a2bd-f336bcd79509', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 00ad7e6b2e9..3fd477f6858 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -1,15 +1,18 @@ """Test Habitica actions.""" from collections.abc import Generator +from datetime import datetime from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError from habiticalib import ( + Checklist, Direction, Frequency, HabiticaTaskResponse, + Reminders, Skill, Task, TaskPriority, @@ -19,7 +22,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.habitica.const import ( + ATTR_ADD_CHECKLIST_ITEM, ATTR_ALIAS, + ATTR_CLEAR_DATE, + ATTR_CLEAR_REMINDER, ATTR_CONFIG_ENTRY, ATTR_COST, ATTR_COUNTER_DOWN, @@ -30,12 +36,17 @@ from homeassistant.components.habitica.const import ( ATTR_KEYWORD, ATTR_NOTES, ATTR_PRIORITY, + ATTR_REMINDER, + ATTR_REMOVE_CHECKLIST_ITEM, + ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, ATTR_TAG, ATTR_TARGET, ATTR_TASK, ATTR_TYPE, + ATTR_UNSCORE_CHECKLIST_ITEM, ATTR_UP_DOWN, DOMAIN, SERVICE_ABORT_QUEST, @@ -53,10 +64,11 @@ from homeassistant.components.habitica.const import ( SERVICE_TRANSFORMATION, SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, ) from homeassistant.components.todo import ATTR_RENAME from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_NAME +from homeassistant.const import ATTR_DATE, ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -938,6 +950,7 @@ async def test_get_tasks( [ (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), + (SERVICE_UPDATE_TODO, "88de7cd9-af2b-49ce-9afd-bf941d87336b"), ], ) @pytest.mark.usefixtures("habitica") @@ -1318,6 +1331,184 @@ async def test_create_habit( habitica.create_task.assert_awaited_with(call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("fccc26f2-1e2b-4bf8-9dd0-a405be261036"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=True, + ), + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_REMOVE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_SCORE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("fccc26f2-1e2b-4bf8-9dd0-a405be261036"), + text="Checklist-item1", + completed=True, + ), + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_UNSCORE_CHECKLIST_ITEM: "Checklist-item2", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("fccc26f2-1e2b-4bf8-9dd0-a405be261036"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("5a897af4-ea94-456a-a2bd-f336bcd79509"), + text="Checklist-item2", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_DATE: "2025-03-05", + }, + Task(date=datetime(2025, 3, 5)), + ), + ( + { + ATTR_CLEAR_DATE: True, + }, + Task(date=None), + ), + ( + { + ATTR_REMINDER: ["2025-02-25T00:00"], + }, + Task( + { + "reminders": [ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 0, 0), + startDate=None, + ) + ] + } + ), + ), + ( + { + ATTR_REMOVE_REMINDER: ["2025-02-25T00:00"], + }, + Task({"reminders": []}), + ), + ( + { + ATTR_CLEAR_REMINDER: True, + }, + Task({"reminders": []}), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +async def test_update_todo( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update todo action.""" + task_id = "88de7cd9-af2b-49ce-9afd-bf941d87336b" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_TODO, + 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, From 3b03a37f3bd78b9d8771bbf1c94a0e4497ea3c9e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 7 Mar 2025 20:05:13 +0100 Subject: [PATCH 1455/1941] Add file upload service to OneDrive (#139092) * Add file upload service to OneDrive * fix * Add test * docstring * docstring * Fix capitalization in description text. --- homeassistant/components/onedrive/__init__.py | 11 +- homeassistant/components/onedrive/icons.json | 5 + .../components/onedrive/quality_scale.yaml | 9 +- homeassistant/components/onedrive/services.py | 131 ++++++++ .../components/onedrive/services.yaml | 15 + .../components/onedrive/strings.json | 40 +++ tests/components/onedrive/test_services.py | 280 ++++++++++++++++++ 7 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/onedrive/services.py create mode 100644 homeassistant/components/onedrive/services.yaml create mode 100644 tests/components/onedrive/test_services.py diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index f10b8fe0d91..17dead653f0 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -19,12 +19,14 @@ from onedrive_personal_sdk.models.items import Item, ItemUpdate from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) from homeassistant.helpers.instance_id import async_get as async_get_instance_id +from homeassistant.helpers.typing import ConfigType from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN from .coordinator import ( @@ -32,13 +34,20 @@ from .coordinator import ( OneDriveRuntimeData, OneDriveUpdateCoordinator, ) +from .services import async_register_services +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.SENSOR] - _LOGGER = logging.getLogger(__name__) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the OneDrive integration.""" + async_register_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: """Set up OneDrive from a config entry.""" client, get_access_token = await _get_onedrive_client(hass, entry) diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json index b693f69934e..2ac4921439c 100644 --- a/homeassistant/components/onedrive/icons.json +++ b/homeassistant/components/onedrive/icons.json @@ -20,5 +20,10 @@ } } } + }, + "services": { + "upload": { + "service": "mdi:cloud-upload" + } } } diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml index 023410d89b2..1632c2670e0 100644 --- a/homeassistant/components/onedrive/quality_scale.yaml +++ b/homeassistant/components/onedrive/quality_scale.yaml @@ -1,18 +1,13 @@ rules: # Bronze - action-setup: - status: exempt - comment: Integration does not register custom actions. + action-setup: done appropriate-polling: done 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-actions: done docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py new file mode 100644 index 00000000000..1f1afe1507c --- /dev/null +++ b/homeassistant/components/onedrive/services.py @@ -0,0 +1,131 @@ +"""OneDrive services.""" + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from pathlib import Path +from typing import cast + +from onedrive_personal_sdk.exceptions import OneDriveException +import voluptuous as vol + +from homeassistant.const import CONF_FILENAME +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN +from .coordinator import OneDriveConfigEntry + +CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_DESTINATION_FOLDER = "destination_folder" + +UPLOAD_SERVICE = "upload" +UPLOAD_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_DESTINATION_FOLDER): cv.string, + } +) +CONTENT_SIZE_LIMIT = 250 * 1024 * 1024 + + +def _read_file_contents( + hass: HomeAssistant, filenames: list[str] +) -> list[tuple[str, bytes]]: + """Return the mime types and file contents for each file.""" + results = [] + for filename in filenames: + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": filename}, + ) + filename_path = Path(filename) + if not filename_path.exists(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="filename_does_not_exist", + translation_placeholders={"filename": filename}, + ) + if filename_path.stat().st_size > CONTENT_SIZE_LIMIT: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_too_large", + translation_placeholders={ + "filename": filename, + "size": str(filename_path.stat().st_size), + "limit": str(CONTENT_SIZE_LIMIT), + }, + ) + results.append((filename_path.name, filename_path.read_bytes())) + return results + + +def async_register_services(hass: HomeAssistant) -> None: + """Register OneDrive services.""" + + async def async_handle_upload(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + config_entry: OneDriveConfigEntry | None = hass.config_entries.async_get_entry( + call.data[CONF_CONFIG_ENTRY_ID] + ) + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + client = config_entry.runtime_data.client + upload_tasks = [] + file_results = await hass.async_add_executor_job( + _read_file_contents, hass, call.data[CONF_FILENAME] + ) + + # make sure the destination folder exists + try: + folder_id = (await client.get_approot()).id + for folder in ( + cast(str, call.data[CONF_DESTINATION_FOLDER]).strip("/").split("/") + ): + folder_id = (await client.create_folder(folder_id, folder)).id + except OneDriveException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="create_folder_error", + translation_placeholders={"message": str(err)}, + ) from err + + upload_tasks = [ + client.upload_file(folder_id, file_name, content) + for file_name, content in file_results + ] + try: + upload_results = await asyncio.gather(*upload_tasks) + except OneDriveException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"message": str(err)}, + ) from err + + if call.return_response: + return {"files": [asdict(item_result) for item_result in upload_results]} + return None + + if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE): + hass.services.async_register( + DOMAIN, + UPLOAD_SERVICE, + async_handle_upload, + schema=UPLOAD_SERVICE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) diff --git a/homeassistant/components/onedrive/services.yaml b/homeassistant/components/onedrive/services.yaml new file mode 100644 index 00000000000..0cf0faf6b60 --- /dev/null +++ b/homeassistant/components/onedrive/services.yaml @@ -0,0 +1,15 @@ +upload: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: onedrive + filename: + required: false + selector: + object: + destination_folder: + required: true + selector: + text: diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 37e19eb68ca..90fa4efc3ec 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -90,6 +90,24 @@ }, "update_failed": { "message": "Failed to update drive state" + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "no_access_to_path": { + "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + }, + "filename_does_not_exist": { + "message": "`{filename}` does not exist" + }, + "file_too_large": { + "message": "`{filename}` is too large ({size} > {limit})" + }, + "upload_error": { + "message": "Failed to upload content: {message}" + }, + "create_folder_error": { + "message": "Failed to create folder: {message}" } }, "entity": { @@ -113,5 +131,27 @@ } } } + }, + "services": { + "upload": { + "name": "Upload file", + "description": "Uploads files to OneDrive.", + "fields": { + "config_entry_id": { + "name": "Config entry ID", + "description": "The config entry representing the OneDrive you want to upload to." + }, + "filename": { + "name": "Filename", + "description": "Path to the file to upload.", + "example": "/config/www/image.jpg" + }, + "destination_folder": { + "name": "Destination folder", + "description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the file to. Will be created if it does not exist.", + "example": "photos/snapshots" + } + } + } } } diff --git a/tests/components/onedrive/test_services.py b/tests/components/onedrive/test_services.py new file mode 100644 index 00000000000..31d2d932cd0 --- /dev/null +++ b/tests/components/onedrive/test_services.py @@ -0,0 +1,280 @@ +"""Tests for OneDrive services.""" + +from collections.abc import Generator +from dataclasses import dataclass +import re +from typing import Any, cast +from unittest.mock import MagicMock, Mock, patch + +from onedrive_personal_sdk.exceptions import OneDriveException +import pytest + +from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.components.onedrive.services import ( + CONF_CONFIG_ENTRY_ID, + CONF_DESTINATION_FOLDER, + UPLOAD_SERVICE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_FILENAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + +TEST_FILENAME = "doorbell_snapshot.jpg" +DESINATION_FOLDER = "TestFolder" + + +@dataclass +class MockUploadFile: + """Dataclass used to configure the test with a fake file behavior.""" + + content: bytes = b"image bytes" + exists: bool = True + is_allowed_path: bool = True + size: int | None = None + + +@pytest.fixture(name="upload_file") +def upload_file_fixture() -> MockUploadFile: + """Fixture to set up test configuration with a fake file.""" + return MockUploadFile() + + +@pytest.fixture(autouse=True) +def mock_upload_file( + hass: HomeAssistant, upload_file: MockUploadFile +) -> Generator[None]: + """Fixture that mocks out the file calls using the FakeFile fixture.""" + with ( + patch( + "homeassistant.components.onedrive.services.Path.read_bytes", + return_value=upload_file.content, + ), + patch( + "homeassistant.components.onedrive.services.Path.exists", + return_value=upload_file.exists, + ), + patch.object( + hass.config, "is_allowed_path", return_value=upload_file.is_allowed_path + ), + patch("pathlib.Path.stat") as mock_stat, + ): + mock_stat.return_value = Mock() + mock_stat.return_value.st_size = ( + upload_file.size if upload_file.size else len(upload_file.content) + ) + yield + + +async def test_upload_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service call to upload content.""" + await setup_integration(hass, mock_config_entry) + + assert hass.services.has_service(DOMAIN, "upload") + + response = await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + assert response + assert response["files"] + assert cast(list[dict[str, Any]], response["files"])[0]["id"] == "id" + + +async def test_upload_service_no_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service call to upload content without response.""" + await setup_integration(hass, mock_config_entry) + + assert hass.services.has_service(DOMAIN, "upload") + + response = await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + ) + + assert response is None + + +async def test_upload_service_config_entry_not_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id", + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +async def test_config_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that is not loaded.""" + await setup_integration(hass, mock_config_entry) + 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 + + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.unique_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(is_allowed_path=False)]) +async def test_path_is_not_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that is not allowed.""" + await setup_integration(hass, mock_config_entry) + with ( + pytest.raises(HomeAssistantError, match="no access to path"), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(exists=False)]) +async def test_filename_does_not_exist( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(HomeAssistantError, match="does not exist"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +async def test_upload_service_fails_upload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call to upload content.""" + await setup_integration(hass, mock_config_entry) + mock_onedrive_client.upload_file.side_effect = OneDriveException("error") + + with pytest.raises(HomeAssistantError, match="Failed to upload"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize("upload_file", [MockUploadFile(size=260 * 1024 * 1024)]) +async def test_upload_size_limit( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises( + HomeAssistantError, + match=re.escape(f"`{TEST_FILENAME}` is too large (272629760 > 262144000)"), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) + + +async def test_create_album_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test service call when folder creation fails.""" + await setup_integration(hass, mock_config_entry) + assert hass.services.has_service(DOMAIN, "upload") + + mock_onedrive_client.create_folder.side_effect = OneDriveException() + + with pytest.raises(HomeAssistantError, match="Failed to create folder"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + }, + blocking=True, + return_response=True, + ) From ccb0be9df43b048ac7a2c690a779339888a46138 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Mar 2025 20:27:01 +0100 Subject: [PATCH 1456/1941] Update debugpy to 1.8.13 (#140067) --- homeassistant/components/debugpy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 078af8c67a5..21211d334df 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.8.11"] + "requirements": ["debugpy==1.8.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index a132c4b89ed..da9dbcc410d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ datapoint==0.9.9 dbus-fast==2.39.3 # homeassistant.components.debugpy -debugpy==1.8.11 +debugpy==1.8.13 # homeassistant.components.decora_wifi # decora-wifi==1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce6a7ce1d25..cd58ae0c25a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -643,7 +643,7 @@ datapoint==0.9.9 dbus-fast==2.39.3 # homeassistant.components.debugpy -debugpy==1.8.11 +debugpy==1.8.13 # homeassistant.components.ecovacs deebot-client==12.3.1 From 52838d8b8420be3dfb73d9eeb4853f7017be08dc Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Fri, 7 Mar 2025 14:29:11 -0500 Subject: [PATCH 1457/1941] Bump sense-energy lib to 0.13.7 (#140068) --- 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 d607372136c..fc54fb50064 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.6"] + "requirements": ["sense-energy==0.13.7"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index dda49b661e5..0a21dbf4cc3 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.6"] + "requirements": ["sense-energy==0.13.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index da9dbcc410d..47de76d6913 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.6 +sense-energy==0.13.7 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd58ae0c25a..ef52bf7cc5e 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.6 +sense-energy==0.13.7 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From 9b14faa43dfa4bc9f3f7efc48cf31f432963163d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Mar 2025 20:35:36 +0100 Subject: [PATCH 1458/1941] Update jinja to 3.1.6 (#140069) --- 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 0727beae8ed..bce7a2ddcdd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -41,7 +41,7 @@ home-assistant-frontend==20250306.0 home-assistant-intents==2025.3.5 httpx==0.28.1 ifaddr==0.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 orjson==3.10.15 diff --git a/pyproject.toml b/pyproject.toml index 3affa95a082..09c14cbde69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "httpx==0.28.1", "home-assistant-bluetooth==1.13.1", "ifaddr==0.2.0", - "Jinja2==3.1.5", + "Jinja2==3.1.6", "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. diff --git a/requirements.txt b/requirements.txt index 9bf94749ac9..6ae428d5420 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ hass-nabucasa==0.94.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 lru-dict==1.3.0 PyJWT==2.10.1 cryptography==44.0.1 From aa556d8678d6f82b9e9a14901c6a2b96dd615443 Mon Sep 17 00:00:00 2001 From: Evan Farrell Date: Fri, 7 Mar 2025 16:15:22 -0500 Subject: [PATCH 1459/1941] Bump govee_ble to 0.43.1 (#139862) Bump govee_ble to 0.43.0 --- homeassistant/components/govee_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 1c61ae31010..b06dab243af 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -135,5 +135,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.43.0"] + "requirements": ["govee-ble==0.43.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 47de76d6913..523ca5f81f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1058,7 +1058,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.0 +govee-ble==0.43.1 # homeassistant.components.govee_light_local govee-local-api==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef52bf7cc5e..81c92865ac7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -908,7 +908,7 @@ goslide-api==0.7.0 gotailwind==0.3.0 # homeassistant.components.govee_ble -govee-ble==0.43.0 +govee-ble==0.43.1 # homeassistant.components.govee_light_local govee-local-api==2.0.1 From 99b5adaef1b24ae2bd874d5bdd53d13ed6700763 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 7 Mar 2025 23:04:49 +0100 Subject: [PATCH 1460/1941] Cleanup add_to_hass method in Shelly tests (part 1) (#140075) --- tests/components/shelly/__init__.py | 12 ++++--- .../components/shelly/test_device_trigger.py | 5 ++- tests/components/shelly/test_init.py | 36 ++++--------------- 3 files changed, 15 insertions(+), 38 deletions(-) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 7a20560e25f..5cba8e5e3b8 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -40,13 +40,15 @@ async def init_integration( sleep_period=0, options: dict[str, Any] | None = None, skip_setup: bool = False, + data: dict[str, Any] | None = None, ) -> MockConfigEntry: """Set up the Shelly integration in Home Assistant.""" - data = { - CONF_HOST: "192.168.1.37", - CONF_SLEEP_PERIOD: sleep_period, - "model": model, - } + if data is None: + data = { + CONF_HOST: "192.168.1.37", + CONF_SLEEP_PERIOD: sleep_period, + "model": model, + } if gen is not None: data[CONF_GEN] = gen diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index fb68393304b..89045208d20 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -25,7 +25,7 @@ from homeassistant.setup import async_setup_component from . import init_integration -from tests.common import MockConfigEntry, async_get_device_automations +from tests.common import async_get_device_automations @pytest.mark.parametrize( @@ -162,8 +162,7 @@ async def test_get_triggers_for_invalid_device_id( ) -> None: """Test error raised for invalid shelly device_id.""" await init_integration(hass, 1) - config_entry = MockConfigEntry(domain=DOMAIN, data={}) - config_entry.add_to_hass(hass) + config_entry = await init_integration(hass, 1, data={}, skip_setup=True) invalid_device = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index f3ce807b655..036da1bfd64 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -27,16 +27,10 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceRegistry, - format_mac, -) +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from . import MOCK_MAC, init_integration, mutate_rpc_device_status - -from tests.common import MockConfigEntry +from . import init_integration, mutate_rpc_device_status async def test_custom_coap_port( @@ -121,12 +115,6 @@ async def test_shared_device_mac( caplog: pytest.LogCaptureFixture, ) -> None: """Test first time shared device with another domain.""" - config_entry = MockConfigEntry(domain="test", data={}, unique_id="some_id") - config_entry.add_to_hass(hass) - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, - ) await init_integration(hass, gen, sleep_period=1000) assert "will resume when device is online" in caplog.text @@ -135,12 +123,7 @@ async def test_setup_entry_not_shelly( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test not Shelly entry.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) is False - await hass.async_block_till_done() - + await init_integration(hass, 1, data={}) assert "probably comes from a custom integration" in caplog.text @@ -247,12 +230,7 @@ async def test_sleeping_block_device_online( caplog: pytest.LogCaptureFixture, ) -> None: """Test sleeping block device online.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id="shelly") - config_entry.add_to_hass(hass) - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, - ) + await init_integration(hass, 1, data={}) monkeypatch.setitem( mock_block_device.settings, @@ -498,8 +476,7 @@ async def test_entry_missing_port(hass: HomeAssistant) -> None: "model": MODEL_PLUS_2PM, CONF_GEN: 2, } - entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) - entry.add_to_hass(hass) + entry = await init_integration(hass, 2, data=data, skip_setup=True) with ( patch("homeassistant.components.shelly.RpcDevice.initialize"), patch( @@ -523,8 +500,7 @@ async def test_rpc_entry_custom_port(hass: HomeAssistant) -> None: CONF_GEN: 2, CONF_PORT: 8001, } - entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) - entry.add_to_hass(hass) + entry = await init_integration(hass, 2, data=data, skip_setup=True) with ( patch("homeassistant.components.shelly.RpcDevice.initialize"), patch( From 293d455cba56bf92d40dbe9d7265db25048447e3 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 7 Mar 2025 18:09:04 -0500 Subject: [PATCH 1461/1941] Add check for invalid options with specific platforms (#140082) --- homeassistant/components/template/config.py | 91 +++++++++++++-------- tests/components/template/test_config.py | 50 +++++++++++ 2 files changed, 107 insertions(+), 34 deletions(-) create mode 100644 tests/components/template/test_config.py diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 9c92ed2b334..9963731c784 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -1,5 +1,6 @@ """Template config validator.""" +from collections.abc import Callable from contextlib import suppress import logging @@ -52,41 +53,63 @@ from .helpers import async_get_blueprints PACKAGE_MERGE_HINT = "list" + +def ensure_domains_do_not_have_trigger_or_action(*keys: str) -> Callable[[dict], dict]: + """Validate that config does not contain trigger and action.""" + domains = set(keys) + + def validate(obj: dict): + options = set(obj.keys()) + if found_domains := domains.intersection(options): + invalid = {CONF_TRIGGER, CONF_ACTION} + if found_invalid := invalid.intersection(set(obj.keys())): + raise vol.Invalid( + f"Unsupported option(s) found for domain {found_domains.pop()}, please remove ({', '.join(found_invalid)}) from your configuration", + ) + + return obj + + return validate + + CONFIG_SECTION_SCHEMA = vol.Schema( - { - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, - vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA, - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, - vol.Optional(NUMBER_DOMAIN): vol.All( - cv.ensure_list, [number_platform.NUMBER_SCHEMA] - ), - vol.Optional(SENSOR_DOMAIN): vol.All( - cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] - ), - vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( - sensor_platform.LEGACY_SENSOR_SCHEMA - ), - vol.Optional(BINARY_SENSOR_DOMAIN): vol.All( - cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] - ), - vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( - binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA - ), - vol.Optional(SELECT_DOMAIN): vol.All( - cv.ensure_list, [select_platform.SELECT_SCHEMA] - ), - vol.Optional(BUTTON_DOMAIN): vol.All( - cv.ensure_list, [button_platform.BUTTON_SCHEMA] - ), - vol.Optional(IMAGE_DOMAIN): vol.All( - cv.ensure_list, [image_platform.IMAGE_SCHEMA] - ), - vol.Optional(WEATHER_DOMAIN): vol.All( - cv.ensure_list, [weather_platform.WEATHER_SCHEMA] - ), - }, + vol.All( + { + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA, + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + vol.Optional(NUMBER_DOMAIN): vol.All( + cv.ensure_list, [number_platform.NUMBER_SCHEMA] + ), + vol.Optional(SENSOR_DOMAIN): vol.All( + cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] + ), + vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( + sensor_platform.LEGACY_SENSOR_SCHEMA + ), + vol.Optional(BINARY_SENSOR_DOMAIN): vol.All( + cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] + ), + vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( + binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA + ), + vol.Optional(SELECT_DOMAIN): vol.All( + cv.ensure_list, [select_platform.SELECT_SCHEMA] + ), + vol.Optional(BUTTON_DOMAIN): vol.All( + cv.ensure_list, [button_platform.BUTTON_SCHEMA] + ), + vol.Optional(IMAGE_DOMAIN): vol.All( + cv.ensure_list, [image_platform.IMAGE_SCHEMA] + ), + vol.Optional(WEATHER_DOMAIN): vol.All( + cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + ), + }, + ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN), + ) ) TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA = vol.Schema( diff --git a/tests/components/template/test_config.py b/tests/components/template/test_config.py new file mode 100644 index 00000000000..b14ff0efa5a --- /dev/null +++ b/tests/components/template/test_config.py @@ -0,0 +1,50 @@ +"""Test Template config.""" + +from __future__ import annotations + +import pytest +import voluptuous as vol + +from homeassistant.components.template.config import CONFIG_SECTION_SCHEMA +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + "config", + [ + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + { + "trigger": {"trigger": "event", "event_type": "my_event"}, + "action": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "button": { + "press": { + "service": "test.automation", + "data_template": {"caller": "{{ this.entity_id }}"}, + }, + "device_class": "restart", + "unique_id": "test", + "name": "test", + "icon": "mdi:test", + }, + }, + ], +) +async def test_invalid_schema(hass: HomeAssistant, config: dict) -> None: + """Test invalid config schemas.""" + with pytest.raises(vol.Invalid): + CONFIG_SECTION_SCHEMA(config) From b7094c12f7dab7a32c3bcc0e7a5f6728c8de84c0 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Fri, 7 Mar 2025 23:17:29 +0000 Subject: [PATCH 1462/1941] Update evohome-async to 1.0.3 (#140083) bump client to 1.0.3 --- homeassistant/components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 823ad7be5df..700872ef92b 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.2"] + "requirements": ["evohome-async==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 523ca5f81f1..ad1b20370bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -899,7 +899,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.2 +evohome-async==1.0.3 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81c92865ac7..2ee8cf43829 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -765,7 +765,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.2 +evohome-async==1.0.3 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 From d4f205c3669f7a96741331f12ae51760ec3471fe Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 02:36:17 +0100 Subject: [PATCH 1463/1941] Add template function: shuffle (#140077) --- homeassistant/helpers/template.py | 28 ++++++++++++++++- tests/helpers/test_template.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 7dc3097cdb3..ab115203e66 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,7 +6,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Generator, Iterable +from collections.abc import Callable, Generator, Iterable, MutableSequence from contextlib import AbstractContextManager from contextvars import ContextVar from copy import deepcopy @@ -2736,6 +2736,30 @@ def iif( return if_false +def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]: + """Shuffle a list, either with a seed or without.""" + if not args: + raise TypeError("shuffle expected at least 1 argument, got 0") + + # If first argument is iterable and more than 1 argument provided + # but not a named seed, then use 2nd argument as seed. + if isinstance(args[0], Iterable): + items = list(args[0]) + if len(args) > 1 and seed is None: + seed = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + items = list(args) + + if seed: + r = random.Random(seed) + r.shuffle(items) + else: + random.shuffle(items) + return items + + class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -2936,6 +2960,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["bool"] = forgiving_boolean self.filters["version"] = version self.filters["contains"] = contains + self.filters["shuffle"] = shuffle self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2973,6 +2998,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["bool"] = forgiving_boolean self.globals["version"] = version self.globals["zip"] = zip + self.globals["shuffle"] = shuffle self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 8c890bfd53d..28391d97a3c 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -15,6 +15,7 @@ from unittest.mock import patch from freezegun import freeze_time import orjson import pytest +from pytest_unordered import unordered from syrupy import SnapshotAssertion import voluptuous as vol @@ -6672,3 +6673,54 @@ async def test_merge_response_not_mutate_original_object( tpl = template.Template(_template, hass) assert tpl.async_render() + + +def test_shuffle(hass: HomeAssistant) -> None: + """Test the shuffle function and filter.""" + assert list( + template.Template("{{ [1, 2, 3] | shuffle }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template("{{ shuffle([1, 2, 3]) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list( + template.Template("{{ shuffle(1, 2, 3) }}", hass).async_render() + ) == unordered([1, 2, 3]) + + assert list(template.Template("{{ shuffle([]) }}", hass).async_render()) == [] + + assert list(template.Template("{{ [] | shuffle }}", hass).async_render()) == [] + + # Testing using seed + assert list( + template.Template("{{ shuffle([1, 2, 3], 'seed') }}", hass).async_render() + ) == [2, 3, 1] + + assert list( + template.Template( + "{{ shuffle([1, 2, 3], seed='seed') }}", + hass, + ).async_render() + ) == [2, 3, 1] + + assert list( + template.Template( + "{{ [1, 2, 3] | shuffle('seed') }}", + hass, + ).async_render() + ) == [2, 3, 1] + + assert list( + template.Template( + "{{ [1, 2, 3] | shuffle(seed='seed') }}", + hass, + ).async_render() + ) == [2, 3, 1] + + with pytest.raises(TemplateError): + template.Template("{{ 1 | shuffle }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ shuffle() }}", hass).async_render() From 02e90024666362ad3b869c71205e17dff93f282a Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Sat, 8 Mar 2025 01:59:04 +0000 Subject: [PATCH 1464/1941] Set media type correctly in the roon integration- so the media card correctly displays the artist. (#139871) Set media type correctly - so media card display works properly. --- homeassistant/components/roon/media_player.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 0460e2cfc6e..4a87601a24f 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -329,6 +329,11 @@ class RoonDevice(MediaPlayerEntity): """Album artist of current playing media (Music track only).""" return self.media_artist + @property + def media_content_type(self) -> str: + """Return the media type.""" + return MediaType.MUSIC + @property def supports_standby(self): """Return power state of source controls.""" From e2c050ed4033433bd6529acf7ad4e5f455865730 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Mar 2025 16:14:22 -1000 Subject: [PATCH 1465/1941] Cache sensor precision calculation (#140019) --- homeassistant/components/sensor/__init__.py | 17 +++--------- homeassistant/util/unit_conversion.py | 10 +++++++ tests/util/test_unit_conversion.py | 29 ++++++++++++++++++--- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 89f39d4fb8c..e3ee566a855 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -675,22 +675,13 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ): # Deduce the precision by finding the decimal point, if any value_s = str(value) - precision = ( - len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 - ) - # Scale the precision when converting to a larger unit # For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh - ratio_log = max( - 0, - log10( - converter.get_unit_ratio( - native_unit_of_measurement, unit_of_measurement - ) - ), + precision = ( + len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 + ) + converter.get_unit_floored_log_ratio( + native_unit_of_measurement, unit_of_measurement ) - precision = precision + floor(ratio_log) - value = f"{converted_numerical_value:z.{precision}f}" else: value = converted_numerical_value diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 67258c9cd09..f2619c5dd61 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from functools import lru_cache +from math import floor, log10 from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, @@ -144,6 +145,15 @@ class BaseUnitConverter: from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) return from_ratio / to_ratio + @classmethod + @lru_cache + def get_unit_floored_log_ratio( + cls, from_unit: str | None, to_unit: str | None + ) -> float: + """Get floored base10 log ratio between units of measurement.""" + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return floor(max(0, log10(from_ratio / to_ratio))) + @classmethod @lru_cache def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool: diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index aeea4ad9a5a..3f55ceef242 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -902,8 +902,8 @@ def test_convert_nonnumeric_value( ("converter", "from_unit", "to_unit", "expected"), [ # Process all items in _GET_UNIT_RATIO - (converter, item[0], item[1], item[2]) - for converter, item in _GET_UNIT_RATIO.items() + (converter, from_unit, to_unit, expected) + for converter, (from_unit, to_unit, expected) in _GET_UNIT_RATIO.items() ], ) def test_get_unit_ratio( @@ -915,13 +915,34 @@ def test_get_unit_ratio( assert converter.get_unit_ratio(to_unit, from_unit) == pytest.approx(1 / ratio) +@pytest.mark.parametrize( + ("converter", "from_unit", "to_unit", "expected"), + [ + # Process all items in _GET_UNIT_RATIO + (converter, from_unit, to_unit, expected) + for converter, (from_unit, to_unit, expected) in _GET_UNIT_RATIO.items() + ], +) +def get_unit_floored_log_ratio( + converter: type[BaseUnitConverter], from_unit: str, to_unit: str, expected: float +) -> None: + """Test floored log unit ratio. + + Should not use pytest.approx since we are checking these + values are exact. + """ + ratio = converter.get_unit_floored_log_ratio(from_unit, to_unit) + assert ratio == expected + assert converter.get_unit_floored_log_ratio(to_unit, from_unit) == 1 / ratio + + @pytest.mark.parametrize( ("converter", "value", "from_unit", "expected", "to_unit"), [ # Process all items in _CONVERTED_VALUE - (converter, list_item[0], list_item[1], list_item[2], list_item[3]) + (converter, value, from_unit, expected, to_unit) for converter, item in _CONVERTED_VALUE.items() - for list_item in item + for value, from_unit, expected, to_unit in item ], ) def test_unit_conversion( From deea19db51d0a2e5a4c4ad37bf8220b92e0fe89d Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 7 Mar 2025 22:31:32 -0600 Subject: [PATCH 1466/1941] Fix HEOS discovery error when previously ignored (#140091) Abort ignored discovery --- homeassistant/components/heos/config_flow.py | 13 ++++++++--- tests/components/heos/test_config_flow.py | 23 +++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index a2f9671c94b..f1cd11f0914 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -14,7 +14,12 @@ from pyheos import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_IGNORE, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector @@ -141,8 +146,10 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None - # Abort early when discovered host is part of the current system - if entry and hostname in _get_current_hosts(entry): + # Abort early when discovery is ignored or host is part of the current system + if entry and ( + entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) + ): return self.async_abort(reason="single_instance_allowed") # Connect to discovered host and get system information diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 396c3743663..69df3734690 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -14,7 +14,12 @@ from pyheos import ( import pytest from homeassistant.components.heos.const import DOMAIN -from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_IGNORE, + SOURCE_SSDP, + SOURCE_USER, + ConfigEntryState, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -160,6 +165,22 @@ async def test_discovery_aborts_same_system( assert config_entry.data[CONF_HOST] == "127.0.0.1" +async def test_discovery_ignored_aborts( + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, +) -> None: + """Test discovery aborts when ignored.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, source=SOURCE_IGNORE).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + async def test_discovery_fails_to_connect_aborts( hass: HomeAssistant, discovery_data: SsdpServiceInfo, controller: MockHeos ) -> None: From 3a2b446e332e2628850e0e9d2a25e50c0f0f34dd Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 8 Mar 2025 06:48:09 +0100 Subject: [PATCH 1467/1941] Update pyfronius to 0.7.7 and adjust quality scale to platinum (#140084) --- homeassistant/components/fronius/manifest.json | 4 ++-- homeassistant/components/fronius/quality_scale.yaml | 5 +---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 94d0f90b0bd..661d808ad23 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -11,6 +11,6 @@ "documentation": "https://www.home-assistant.io/integrations/fronius", "iot_class": "local_polling", "loggers": ["pyfronius"], - "quality_scale": "gold", - "requirements": ["PyFronius==0.7.3"] + "quality_scale": "platinum", + "requirements": ["PyFronius==0.7.7"] } diff --git a/homeassistant/components/fronius/quality_scale.yaml b/homeassistant/components/fronius/quality_scale.yaml index 2c4b892475b..522b8ab571f 100644 --- a/homeassistant/components/fronius/quality_scale.yaml +++ b/homeassistant/components/fronius/quality_scale.yaml @@ -83,7 +83,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: - status: todo - comment: | - The pyfronius library isn't strictly typed and doesn't export type information. + strict-typing: done diff --git a/requirements_all.txt b/requirements_all.txt index ad1b20370bb..73e0ea497fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -54,7 +54,7 @@ PyFlick==1.1.3 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.3 +PyFronius==0.7.7 # homeassistant.components.pyload PyLoadAPI==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ee8cf43829..49a783a5c69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -51,7 +51,7 @@ PyFlick==1.1.3 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.3 +PyFronius==0.7.7 # homeassistant.components.pyload PyLoadAPI==1.4.2 From f399ffae72d150008e53630a6ea18a4304b68b30 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 10:57:25 +0100 Subject: [PATCH 1468/1941] Map prewash job state in SmartThings (#140097) --- homeassistant/components/smartthings/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 9ef8cb55c92..da0e752fb5b 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -57,6 +57,7 @@ JOB_STATE_MAP = { "freezeProtection": "freeze_protection", "preDrain": "pre_drain", "preWash": "pre_wash", + "prewash": "pre_wash", "wrinklePrevent": "wrinkle_prevent", "unknown": None, } From ea33925afcf1dbf353df303c8690b2c703438fc3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 11:22:09 +0100 Subject: [PATCH 1469/1941] Check support for thermostat operating state in SmartThings (#140103) --- .../components/smartthings/climate.py | 2 + tests/components/smartthings/conftest.py | 1 + .../bosch_radiator_thermostat_ii.json | 89 +++++++++++++++ .../devices/bosch_radiator_thermostat_ii.json | 102 ++++++++++++++++++ .../smartthings/snapshots/test_climate.ambr | 63 +++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++ .../smartthings/snapshots/test_sensor.ambr | 101 +++++++++++++++++ 7 files changed, 391 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json create mode 100644 tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index cafd831c5bd..14e26e23dc1 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -254,6 +254,8 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" + if not self.supports_capability(Capability.THERMOSTAT_OPERATING_STATE): + return None return OPERATING_STATE_TO_ACTION.get( self.get_attribute_value( Capability.THERMOSTAT_OPERATING_STATE, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 730f683fa14..a659e69a2cc 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -122,6 +122,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", + "bosch_radiator_thermostat_ii", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json b/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json new file mode 100644 index 00000000000..6248eb05e93 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/bosch_radiator_thermostat_ii.json @@ -0,0 +1,89 @@ +{ + "components": { + "main": { + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 23.9, + "unit": "C", + "timestamp": "2025-03-07T19:55:13.328Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 22.0, + "unit": "C", + "timestamp": "2025-03-05T03:05:26.510Z" + }, + "heatingSetpointRange": { + "value": { + "minimum": 5.0, + "maximum": 40.0, + "step": 0.1 + }, + "unit": "C", + "timestamp": "2025-03-05T03:05:26.510Z" + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": { + "supportedThermostatModes": ["off", "heat"] + }, + "timestamp": "2025-03-05T03:05:26.489Z" + }, + "supportedThermostatModes": { + "value": ["off", "heat"], + "timestamp": "2025-03-05T03:05:26.509Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 94, + "unit": "%", + "timestamp": "2025-03-07T20:47:27.362Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "2.00.09 (20009)", + "timestamp": "2024-11-29T19:55:02.005Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "normalOperation", + "timestamp": "2024-11-29T19:55:02.009Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2024-11-29T19:55:02.004Z" + }, + "currentVersion": { + "value": "2.00.09 (20009)", + "timestamp": "2024-11-29T19:55:02.037Z" + }, + "lastUpdateTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json b/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json new file mode 100644 index 00000000000..7a2e2d338cd --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/bosch_radiator_thermostat_ii.json @@ -0,0 +1,102 @@ +{ + "items": [ + { + "deviceId": "286ba274-4093-4bcb-849c-a1a3efe7b1e5", + "name": "thermostat", + "label": "Radiator Thermostat II [+M] Wohnzimmer", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "2a1c9915-f61b-3f3a-a02b-703b8cccf3d6", + "deviceManufacturerCode": "BOSCH", + "locationId": "0b6618a6-c3ab-4b6e-968d-59cc8c2761bc", + "ownerId": "8a20b799-9d87-ecdc-39de-c93c6e4d3ea1", + "roomId": "11374ab5-9b4e-416b-91d1-745bbf9b6db4", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-11-29T19:55:00.910Z", + "parentDeviceId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "profile": { + "id": "4da5d086-111e-3084-a039-616974326833" + }, + "matter": { + "driverId": "5f3c42eb-5704-4c95-9705-c51c1a6764bf", + "hubId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "provisioningState": "PROVISIONED", + "networkId": "8EF2CF7A212285B2-46C6B9F266A4521A", + "executingLocally": true, + "uniqueId": "8475B3FEFF6748D4", + "vendorId": 4617, + "productId": 12306, + "serialNumber": "D44867FFFEB37584", + "listeningType": "SLEEPY", + "supportedNetworkInterfaces": ["THREAD"], + "version": { + "hardware": 18, + "hardwareLabel": "1.2.0", + "software": 20009, + "softwareLabel": "2.00.09" + }, + "endpoints": [ + { + "endpointId": 0, + "deviceTypes": [ + { + "deviceTypeId": 22 + } + ] + }, + { + "endpointId": 1, + "deviceTypes": [ + { + "deviceTypeId": 769 + } + ] + } + ], + "syncDrivers": true + }, + "type": "MATTER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index c85c7af19a6..4d3fd15aeb9 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -1,4 +1,67 @@ # serializer version: 1 +# name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.radiator_thermostat_ii_m_wohnzimmer', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 23.9, + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.radiator_thermostat_ii_m_wohnzimmer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[da_ac_rac_000001][climate.ac_office_granit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 5beaf907b70..acee145955c 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -68,6 +68,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[bosch_radiator_thermostat_ii] + 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', + '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Radiator Thermostat II [+M] Wohnzimmer', + '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': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 017689f13fd..cb282e24b27 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -258,6 +258,107 @@ 'state': '938.3', }) # --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_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.radiator_thermostat_ii_m_wohnzimmer_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '94', + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_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.radiator_thermostat_ii_m_wohnzimmer_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': '286ba274-4093-4bcb-849c-a1a3efe7b1e5.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][sensor.radiator_thermostat_ii_m_wohnzimmer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.radiator_thermostat_ii_m_wohnzimmer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.9', + }) +# --- # name: test_all_entities[c2c_arlo_pro_3_switch][sensor.2nd_floor_hallway_alarm-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f209d75f2c0ef4ed35a6b016164c7c0ab04d5e5f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 11:27:26 +0100 Subject: [PATCH 1470/1941] Support null supported Thermostat modes in SmartThings (#140101) --- .../components/smartthings/climate.py | 10 +- tests/components/smartthings/conftest.py | 1 + .../device_status/generic_ef00_v1.json | 76 +++++++++ .../fixtures/devices/generic_ef00_v1.json | 95 +++++++++++ .../smartthings/snapshots/test_climate.ambr | 61 +++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++ .../smartthings/snapshots/test_sensor.ambr | 154 ++++++++++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++++++ 8 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json create mode 100644 tests/components/smartthings/fixtures/devices/generic_ef00_v1.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 14e26e23dc1..650b0c5540a 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -275,11 +275,15 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" - return [ - state - for mode in self.get_attribute_value( + if ( + supported_thermostat_modes := self.get_attribute_value( Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES ) + ) is None: + return [] + return [ + state + for mode in supported_thermostat_modes if (state := AC_MODE_TO_STATE.get(mode)) is not None ] diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index a659e69a2cc..d3b91d058a9 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -122,6 +122,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", + "generic_ef00_v1", "bosch_radiator_thermostat_ii", ] ) diff --git a/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json b/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json new file mode 100644 index 00000000000..cbfdf0d9092 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/generic_ef00_v1.json @@ -0,0 +1,76 @@ +{ + "components": { + "main02": { + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 200.0, + "unit": "C", + "timestamp": "2024-12-02T20:18:52.095Z" + } + } + }, + "main": { + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": null + } + }, + "signalStrength": { + "rssi": { + "value": -84, + "unit": "dBm", + "timestamp": "2025-03-07T20:53:55.346Z" + }, + "lqi": { + "value": 255, + "timestamp": "2025-03-07T20:53:55.387Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 21.0, + "unit": "C", + "timestamp": "2025-03-07T16:58:23.773Z" + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": 23.0, + "unit": "C", + "timestamp": "2025-02-10T17:48:38.299Z" + }, + "heatingSetpointRange": { + "value": null + } + }, + "refresh": {}, + "valleyboard16460.debug": { + "value": { + "value": "\n \n \n \n \n \n \n \n
    Actual_TZE200_rxntag7i
    Expected_TZE200_4hbx5cvx
    Profilenormal-thermostat-v3
    ModeSimilarity
    PreferencesModified
    Exposes EF00Yes
    Default DPNo
    ", + "timestamp": "2025-03-05T03:04:54.025Z" + } + }, + "thermostatMode": { + "thermostatMode": { + "value": "heat", + "data": {}, + "timestamp": "2024-12-30T08:22:19.273Z" + }, + "supportedThermostatModes": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json b/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json new file mode 100644 index 00000000000..96937769b41 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/generic_ef00_v1.json @@ -0,0 +1,95 @@ +{ + "items": [ + { + "deviceId": "656569c2-7976-4232-a789-34b4d1176c3a", + "name": "generic-ef00-v1", + "label": "Thermostat K\u00fcche", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "be641577-f796-315b-af6f-b3ad14dd7a58", + "deviceManufacturerCode": "_TZE200_rxntag7i", + "locationId": "0b6618a6-c3ab-4b6e-968d-59cc8c2761bc", + "ownerId": "8a20b799-9d87-ecdc-39de-c93c6e4d3ea1", + "roomId": "eeb2f9d2-19cc-4dad-9f23-28ec807de97e", + "components": [ + { + "id": "main", + "label": "Thermostat", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "thermostatMode", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatHeatingSetpoint", + "version": 1 + }, + { + "id": "thermostatOperatingState", + "version": 1 + }, + { + "id": "signalStrength", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "valleyboard16460.debug", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "main02", + "label": "Floor", + "capabilities": [ + { + "id": "temperatureMeasurement", + "version": 1 + } + ], + "categories": [ + { + "name": "Thermostat", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-12-02T15:58:01.598Z", + "profile": { + "id": "3ad2e1e3-8867-332c-85b5-b291602c324f" + }, + "zigbee": { + "eui": "A4C1388B31017B5F", + "networkId": "162F", + "driverId": "585328e6-ac85-4ac5-bce4-286efd0ab980", + "executingLocally": true, + "hubId": "61bd280e-71c4-44fb-9b6e-53fdf14718a2", + "provisioningState": "DRIVER_SWITCH" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 4d3fd15aeb9..6b512f93d39 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -432,6 +432,67 @@ 'state': 'heat', }) # --- +# name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_kuche', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.0, + 'friendly_name': 'Thermostat Küche', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_kuche', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- # name: test_all_entities[heatit_ztrm3_thermostat][climate.hall_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index acee145955c..f660e04eb48 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -695,6 +695,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[generic_ef00_v1] + 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', + '656569c2-7976-4232-a789-34b4d1176c3a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Thermostat Küche', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[generic_fan_3_speed] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index cb282e24b27..84092d3f9e3 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4625,6 +4625,160 @@ 'state': '22', }) # --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_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': , + 'entity_id': 'sensor.thermostat_kuche_link_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': 'Link quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_quality', + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.lqi', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostat Küche Link quality', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_link_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '255', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_signal_strength-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': , + 'entity_id': 'sensor.thermostat_kuche_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a.rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Thermostat Küche Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-84', + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_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.thermostat_kuche_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': '656569c2-7976-4232-a789-34b4d1176c3a.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Thermostat Küche Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.thermostat_kuche_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- # name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 00177b3b603..81b73874a6a 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -328,6 +328,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-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.thermostat_kuche', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '656569c2-7976-4232-a789-34b4d1176c3a', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[generic_ef00_v1][switch.thermostat_kuche-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Thermostat Küche', + }), + 'context': , + 'entity_id': 'switch.thermostat_kuche', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[sensibo_airconditioner_1][switch.office-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 2c68be3f7eefc0d85204e7553d2ae3a64f48ba16 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 12:02:41 +0100 Subject: [PATCH 1471/1941] Update pytest to 8.3.5 (#140102) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 0a7a3bb18e5..d9789fab081 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -29,7 +29,7 @@ pytest-timeout==2.3.1 pytest-unordered==0.6.1 pytest-picked==0.5.1 pytest-xdist==3.6.1 -pytest==8.3.4 +pytest==8.3.5 requests-mock==1.12.1 respx==0.22.0 syrupy==4.8.1 From 817597b07a5c75a6d756b0a3f47b79e9b0d91f92 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 12:10:56 +0100 Subject: [PATCH 1472/1941] Update ruff to 0.9.10 (#140105) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 37114684c9f..cf6fe7030e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.8 + rev: v0.9.10 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index c133c4b544a..1cf9ef3fcf5 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.9.8 +ruff==0.9.10 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 9d0bbeefd74..104939c3808 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -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.8 \ + stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.10 \ 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" From 1aed112c2c82f44d8e443b7c909e6d1c9a8a2039 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 12:11:45 +0100 Subject: [PATCH 1473/1941] Update coverage to 7.6.12 (#140104) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index d9789fab081..d5dee887214 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.3.8 -coverage==7.6.10 +coverage==7.6.12 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 From 7507a9c24ee50c00b2ebbcecd68841a2a8d9146b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 8 Mar 2025 12:50:32 +0100 Subject: [PATCH 1474/1941] Bump `accuweather` to version 4.2.0 (#140106) Bump accuweather to version 4.2.0 --- 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 5a019ef968e..810557519eb 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.1.0"], + "requirements": ["accuweather==4.2.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 73e0ea497fc..aeda2ed360b 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.1.0 +accuweather==4.2.0 # homeassistant.components.adax adax==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49a783a5c69..b955e4a134d 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.1.0 +accuweather==4.2.0 # homeassistant.components.adax adax==0.4.0 From 81e6b935292db2fdf6efa4bf308309c10d5b4993 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 8 Mar 2025 07:57:44 -0600 Subject: [PATCH 1475/1941] Fix HEOS user initiated setup when discovery is waiting confirmation (#140119) --- homeassistant/components/heos/config_flow.py | 2 +- tests/components/heos/test_config_flow.py | 29 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index f1cd11f0914..e2d3e2522dc 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -205,7 +205,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Obtain host and validate connection.""" - await self.async_set_unique_id(DOMAIN) + await self.async_set_unique_id(DOMAIN, raise_on_progress=False) self._abort_if_unique_id_configured(error="single_instance_allowed") # Try connecting to host if provided errors: dict[str, str] = {} diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 69df3734690..69d9aa3a38e 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -88,6 +88,35 @@ async def test_create_entry_when_host_valid( assert controller.disconnect.call_count == 1 +async def test_manual_setup_with_discovery_in_progress( + hass: HomeAssistant, + discovery_data: SsdpServiceInfo, + controller: MockHeos, + system: HeosSystem, +) -> None: + """Test user can manually set up when discovery is in progress.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_SSDP}, data=discovery_data + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + + # Setup manually + user_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert user_result["type"] is FlowResultType.FORM + user_result = await hass.config_entries.flow.async_configure( + user_result["flow_id"], user_input={CONF_HOST: "127.0.0.1"} + ) + assert user_result["type"] is FlowResultType.CREATE_ENTRY + + # Discovery flow is removed + assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) + + async def test_discovery( hass: HomeAssistant, discovery_data: SsdpServiceInfo, From 105d9d59702cad3005a2eece845b5bcdb38c5574 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 14:59:10 +0100 Subject: [PATCH 1476/1941] Handle None options in SmartThings (#140110) * Handle None options in SmartThings * Handle None options in SmartThings --- .../components/smartthings/sensor.py | 9 +- tests/components/smartthings/conftest.py | 1 + .../device_status/im_speaker_ai_0001.json | 222 +++++++++++++++ .../fixtures/devices/im_speaker_ai_0001.json | 136 ++++++++++ .../smartthings/snapshots/test_init.ambr | 33 +++ .../smartthings/snapshots/test_sensor.ambr | 255 ++++++++++++++++++ 6 files changed, 653 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json create mode 100644 tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index da0e752fb5b..2164e432edc 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -1031,8 +1031,11 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): 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 - ) + if ( + options := self.get_attribute_value( + self.capability, self.entity_description.options_attribute + ) + ) is None: + return [] return [option.lower() for option in options] return super().options diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index d3b91d058a9..7f27d3eecc4 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -124,6 +124,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "heatit_ztrm3_thermostat", "generic_ef00_v1", "bosch_radiator_thermostat_ii", + "im_speaker_ai_0001", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json b/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json new file mode 100644 index 00000000000..4b23ca7086f --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/im_speaker_ai_0001.json @@ -0,0 +1,222 @@ +{ + "components": { + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": ["play", "pause", "stop"], + "timestamp": "2025-03-08T12:06:24.496Z" + }, + "playbackStatus": { + "value": "stopped", + "timestamp": "2025-03-08T12:06:24.496Z" + } + }, + "audioVolume": { + "volume": { + "value": 52, + "unit": "%", + "timestamp": "2025-03-08T12:08:00.153Z" + } + }, + "mediaInputSource": { + "supportedInputSources": { + "value": null + }, + "inputSource": { + "value": null + } + }, + "audioTrackAddressing": {}, + "samsungim.networkAudioGroupInfo": { + "groupName": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "role": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "channel": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "stereoType": { + "value": "A", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "masterDi": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "acmMode": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "status": { + "value": "single", + "timestamp": "2025-03-08T12:06:24.628Z" + }, + "masterName": { + "value": "", + "timestamp": "2025-03-08T12:06:24.628Z" + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungim.networkAudioMode": { + "mode": { + "value": "wifi", + "timestamp": "2025-03-08T12:06:24.573Z" + } + }, + "mediaPlaybackRepeat": { + "playbackRepeatMode": { + "value": "off", + "timestamp": "2025-03-08T12:06:24.519Z" + } + }, + "musicPlayer": { + "trackDescription": { + "value": null + }, + "level": { + "value": null + }, + "mute": { + "value": null + }, + "trackData": { + "value": null + }, + "status": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "V310XXU1AWK1", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnhw": { + "value": "1.0", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "di": { + "value": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnsl": { + "value": null + }, + "dmv": { + "value": "IoTivity1.2.1", + "timestamp": "2025-03-08T12:06:18.942Z" + }, + "n": { + "value": "Galaxy Home Mini (MQVL)", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnmo": { + "value": "SM-V310", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "vid": { + "value": "IM-SPEAKER-AI-0001", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnml": { + "value": null + }, + "mnpv": { + "value": "4.0.0.1", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "pi": { + "value": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "timestamp": "2025-03-08T12:06:18.931Z" + }, + "icv": { + "value": "core0.0.1", + "timestamp": "2025-03-08T12:06:18.942Z" + } + }, + "samsungim.announcement": { + "enableState": { + "value": null + }, + "supportedCategories": { + "value": null + }, + "supportedTypes": { + "value": null + }, + "supportedMimes": { + "value": null + } + }, + "samsungim.bixbyContent": { + "supportedModes": { + "value": ["news", "weather", "music", "search_all"], + "timestamp": "2025-03-08T12:06:24.817Z" + } + }, + "mediaPlaybackShuffle": { + "playbackShuffle": { + "value": "disabled", + "timestamp": "2025-03-08T12:06:24.592Z" + } + }, + "audioMute": { + "mute": { + "value": "unmuted", + "timestamp": "2025-03-08T12:06:24.478Z" + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null + } + }, + "speechSynthesis": {}, + "samsungim.networkAudioTrackData": { + "appName": { + "value": null + }, + "source": { + "value": "wifi", + "timestamp": "2025-03-08T12:06:24.540Z" + } + }, + "audioTrackData": { + "totalTime": { + "value": null + }, + "audioTrackData": { + "value": null + }, + "elapsedTime": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json b/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json new file mode 100644 index 00000000000..81fb1b07ff2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/im_speaker_ai_0001.json @@ -0,0 +1,136 @@ +{ + "items": [ + { + "deviceId": "c9276e43-fe3c-88c3-1dcc-2eb79e292b8c", + "name": "Galaxy Home Mini (MQVL)", + "label": "Galaxy Home Mini", + "manufacturerName": "Samsung Electronics", + "presentationId": "IM-SPEAKER-AI-0001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "33db9e71-abe9-43a0-acd3-3f0927bbe5b7", + "ownerId": "9a1ee192-04ba-46ca-9c3d-196d8dbcf807", + "roomId": "445c006d-1796-4dd6-8308-1c3cd045e8ff", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "mediaInputSource", + "version": 1 + }, + { + "id": "mediaPlaybackRepeat", + "version": 1 + }, + { + "id": "mediaPlaybackShuffle", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "audioTrackAddressing", + "version": 1 + }, + { + "id": "audioTrackData", + "version": 1 + }, + { + "id": "musicPlayer", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "speechSynthesis", + "version": 1 + }, + { + "id": "samsungim.bixbyContent", + "version": 1 + }, + { + "id": "samsungim.announcement", + "version": 1 + }, + { + "id": "samsungim.networkAudioMode", + "version": 1 + }, + { + "id": "samsungim.networkAudioGroupInfo", + "version": 1 + }, + { + "id": "samsungim.networkAudioTrackData", + "version": 1 + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-08T12:06:18.865Z", + "profile": { + "id": "09df8e36-e94f-339c-9086-9639505e1fb2" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Galaxy Home Mini (MQVL)", + "specVersion": "core0.0.1", + "verticalDomainSpecVersion": "IoTivity1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SM-V310", + "platformVersion": "4.0.0.1", + "platformOS": "Tizen", + "hwVersion": "1.0", + "firmwareVersion": "V310XXU1AWK1", + "vendorId": "IM-SPEAKER-AI-0001", + "lastSignupTime": "2025-03-08T12:06:16.386696652Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index f660e04eb48..7c2589590c5 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -860,6 +860,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[im_speaker_ai_0001] + 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', + 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SM-V310', + 'model_id': None, + 'name': 'Galaxy Home Mini', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'V310XXU1AWK1', + 'via_device_id': None, + }) +# --- # name: test_devices[iphone] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 84092d3f9e3..5909fec2707 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4935,6 +4935,261 @@ 'state': '19.0', }) # --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_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.galaxy_home_mini_media_input_source', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media input source', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_input_source', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.inputSource', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Galaxy Home Mini Media input source', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_input_source', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-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.galaxy_home_mini_media_playback_repeat', + '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': 'Media playback repeat', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_repeat', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackRepeatMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_repeat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Galaxy Home Mini Media playback repeat', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_playback_repeat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-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.galaxy_home_mini_media_playback_shuffle', + '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': 'Media playback shuffle', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_shuffle', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackShuffle', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_shuffle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Galaxy Home Mini Media playback shuffle', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_playback_shuffle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disabled', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media playback status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media_playback_status', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.playbackStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_playback_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Galaxy Home Mini Media playback status', + 'options': list([ + 'paused', + 'playing', + 'stopped', + 'fast_forwarding', + 'rewinding', + 'buffering', + ]), + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_media_playback_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stopped', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_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.galaxy_home_mini_volume', + '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': 'Volume', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'audio_volume', + 'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c.volume', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Galaxy Home Mini Volume', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.galaxy_home_mini_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52', + }) +# --- # name: test_all_entities[multipurpose_sensor][sensor.deck_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 244b666dee3604eaa851089c6a0469818f8c0fe3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 14:59:29 +0100 Subject: [PATCH 1477/1941] Add Dependency Review action (#140108) --- .github/workflows/ci.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9ef851009f6..3f970ce5874 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -638,6 +638,25 @@ jobs: . venv/bin/activate python -m script.gen_requirements_all validate + dependency-review: + name: Dependency review + runs-on: ubuntu-24.04 + needs: + - info + - base + if: | + github.event.inputs.pylint-only != 'true' + && github.event.inputs.mypy-only != 'true' + && needs.info.outputs.requirements == 'true' + && github.event_name == 'pull_request' + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.2.2 + - name: Dependency review + uses: actions/dependency-review-action@v4.5.0 + with: + license-check: false # We use our own license audit checks + audit-licenses: name: Audit licenses runs-on: ubuntu-24.04 From 6754bf2466e27840c3f7862ef14723bc1d57efe7 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 8 Mar 2025 13:04:40 -0500 Subject: [PATCH 1478/1941] Send Roborock commands via cloud api when needed (#138496) * Send via cloud api when needed * Extract logic to helper function * change to class method --- homeassistant/components/roborock/entity.py | 29 ++++++++++++++++----- tests/components/roborock/test_vacuum.py | 24 +++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index 4a16ada5967..d417ac17159 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -8,7 +8,11 @@ from roborock.containers import Consumable, Status from roborock.exceptions import RoborockException from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand -from roborock.version_1_apis.roborock_client_v1 import AttributeCache, RoborockClientV1 +from roborock.version_1_apis.roborock_client_v1 import ( + CLOUD_REQUIRED, + AttributeCache, + RoborockClientV1, +) from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1 from roborock.version_a01_apis import RoborockClientA01 @@ -53,14 +57,16 @@ class RoborockEntityV1(RoborockEntity): """Get an item from the api cache.""" return self._api.cache[attribute] - async def send( - self, + @classmethod + async def _send_command( + cls, command: RoborockCommand | str, + api: RoborockClientV1, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: - """Send a command to a vacuum cleaner.""" + """Send a Roborock command with params to a given api.""" try: - response: dict = await self._api.send_command(command, params) + response: dict = await api.send_command(command, params) except RoborockException as err: if isinstance(command, RoborockCommand): command_name = command.name @@ -75,6 +81,14 @@ class RoborockEntityV1(RoborockEntity): ) from err return response + async def send( + self, + command: RoborockCommand | str, + params: dict[str, Any] | list[Any] | int | None = None, + ) -> dict: + """Send a command to a vacuum cleaner.""" + return await self._send_command(command, self._api, params) + @property def api(self) -> RoborockClientV1: """Returns the api.""" @@ -152,7 +166,10 @@ class RoborockCoordinatedEntityV1( params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Overloads normal send command but refreshes coordinator.""" - res = await super().send(command, params) + if command in CLOUD_REQUIRED: + res = await self._send_command(command, self.coordinator.cloud_api, params) + else: + res = await self._send_command(command, self._api, params) await self.coordinator.async_refresh() return res diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index d9d4340ec83..15fdeb4767c 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -117,6 +117,30 @@ async def test_commands( assert mock_send_command.call_args[0][1] == called_params +async def test_cloud_command( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test sending commands to the vacuum.""" + + vacuum = hass.states.get(ENTITY_ID) + assert vacuum + + data = {ATTR_ENTITY_ID: ENTITY_ID, "command": "get_map_v1"} + with patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.send_command" + ) as mock_send_command: + await hass.services.async_call( + Platform.VACUUM, + SERVICE_SEND_COMMAND, + data, + blocking=True, + ) + assert mock_send_command.call_count == 1 + assert mock_send_command.call_args[0][0] == RoborockCommand.GET_MAP_V1 + + @pytest.mark.parametrize( ("in_cleaning_int", "expected_command"), [ From 2d22a60b8fc4211e138b5d210c6350e8e30296cc Mon Sep 17 00:00:00 2001 From: John Hillery <34005807+jrhillery@users.noreply.github.com> Date: Sat, 8 Mar 2025 13:22:26 -0500 Subject: [PATCH 1479/1941] Label emergency heat switch (#139872) * Add label to emergency heat switch * Use sentence case names Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/nexia/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index d88ce0b898d..05d86d3a495 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -58,6 +58,9 @@ "switch": { "hold": { "name": "Hold" + }, + "emergency_heat": { + "name": "Emergency heat" } } }, From b910bc78026f79a04a292516f95d580130434653 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Mar 2025 19:58:10 +0100 Subject: [PATCH 1480/1941] Set device class for Oven Completion time in SmartThings (#140139) --- homeassistant/components/smartthings/sensor.py | 2 ++ tests/components/smartthings/snapshots/test_sensor.ambr | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 2164e432edc..5a2fdcf3854 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -561,6 +561,8 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.COMPLETION_TIME, translation_key="completion_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=dt_util.parse_datetime, ) ], }, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 5909fec2707..b939547ca32 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -1710,7 +1710,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Completion time', 'platform': 'smartthings', @@ -1724,6 +1724,7 @@ # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_completion_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', 'friendly_name': 'Microwave Completion time', }), 'context': , @@ -1731,7 +1732,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2025-02-08T21:13:36.184Z', + 'state': '2025-02-08T21:13:36+00:00', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_job_state-entry] From 06fd6442b6a7df1f0e076852c68b6077126f6287 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 8 Mar 2025 20:03:25 +0100 Subject: [PATCH 1481/1941] Use the set language for condition sensors in Accuweather integration (#140107) * Use the set language for condition sensors * Update strings * Update test snapshots * Add missing string --- homeassistant/components/accuweather/const.py | 14 +++- .../components/accuweather/coordinator.py | 4 +- .../components/accuweather/sensor.py | 28 +++++--- .../components/accuweather/strings.json | 72 +++++++++---------- .../accuweather/snapshots/test_sensor.ambr | 40 +++-------- 5 files changed, 80 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 1bbf5a36187..f09b9771ab6 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -24,7 +24,7 @@ from homeassistant.components.weather import ( API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" -ATTR_CATEGORY: Final = "Category" +ATTR_CATEGORY_VALUE = "CategoryValue" ATTR_DIRECTION: Final = "Direction" ATTR_ENGLISH: Final = "English" ATTR_LEVEL: Final = "level" @@ -55,5 +55,17 @@ CONDITION_MAP = { for cond_ha, cond_codes in CONDITION_CLASSES.items() for cond_code in cond_codes } +AIR_QUALITY_CATEGORY_MAP = { + 1: "good", + 2: "moderate", + 3: "unhealthy", + 4: "hazardous", +} +POLLEN_CATEGORY_MAP = { + 1: "low", + 2: "moderate", + 3: "high", + 4: "very high", +} UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 40ff3ad2c87..67e3e2ad76e 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -117,7 +117,9 @@ class AccuWeatherDailyForecastDataUpdateCoordinator( """Update data via library.""" try: async with timeout(10): - result = await self.accuweather.async_get_daily_forecast() + result = await self.accuweather.async_get_daily_forecast( + language=self.hass.config.language + ) except EXCEPTIONS as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index f14584cf08c..415df402d55 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -29,8 +29,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + AIR_QUALITY_CATEGORY_MAP, API_METRIC, - ATTR_CATEGORY, + ATTR_CATEGORY_VALUE, ATTR_DIRECTION, ATTR_ENGLISH, ATTR_LEVEL, @@ -38,6 +39,7 @@ from .const import ( ATTR_VALUE, ATTRIBUTION, MAX_FORECAST_DAYS, + POLLEN_CATEGORY_MAP, ) from .coordinator import ( AccuWeatherConfigEntry, @@ -59,9 +61,9 @@ class AccuWeatherSensorDescription(SensorEntityDescription): FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( AccuWeatherSensorDescription( key="AirQuality", - value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), + value_fn=lambda data: AIR_QUALITY_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]], device_class=SensorDeviceClass.ENUM, - options=["good", "hazardous", "high", "low", "moderate", "unhealthy"], + options=list(AIR_QUALITY_CATEGORY_MAP.values()), translation_key="air_quality", ), AccuWeatherSensorDescription( @@ -83,7 +85,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="grass_pollen", ), AccuWeatherSensorDescription( @@ -107,7 +111,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( entity_registry_enabled_default=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="mold_pollen", ), AccuWeatherSensorDescription( @@ -115,7 +121,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="ragweed_pollen", ), AccuWeatherSensorDescription( @@ -181,14 +189,18 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, entity_registry_enabled_default=False, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="tree_pollen", ), AccuWeatherSensorDescription( key="UVIndex", native_unit_of_measurement=UV_INDEX, value_fn=lambda data: cast(int, data[ATTR_VALUE]), - attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]}, + attr_fn=lambda data: { + ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]] + }, translation_key="uv_index_forecast", ), AccuWeatherSensorDescription( diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index d0250a382e9..e5190b7a8da 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -26,10 +26,18 @@ "state": { "good": "Good", "hazardous": "Hazardous", - "high": "High", - "low": "Low", "moderate": "Moderate", "unhealthy": "Unhealthy" + }, + "state_attributes": { + "options": { + "state": { + "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", + "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", + "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + } + } } }, "apparent_temperature": { @@ -62,12 +70,10 @@ "level": { "name": "Level", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "High", + "low": "Low", + "moderate": "Moderate", + "very_high": "Very high" } } } @@ -81,12 +87,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } @@ -108,12 +112,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } @@ -154,12 +156,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } @@ -170,12 +170,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } @@ -186,12 +184,10 @@ "level": { "name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]", "state": { - "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", - "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", - "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]", - "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]", - "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]", + "low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]", + "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]", + "very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]" } } } diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index 257d29ae844..3176f0a88bd 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -7,11 +7,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -50,11 +48,9 @@ 'friendly_name': 'Home Air quality day 0', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , @@ -73,11 +69,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -116,11 +110,9 @@ 'friendly_name': 'Home Air quality day 1', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , @@ -139,11 +131,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -182,11 +172,9 @@ 'friendly_name': 'Home Air quality day 2', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , @@ -205,11 +193,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -248,11 +234,9 @@ 'friendly_name': 'Home Air quality day 3', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , @@ -271,11 +255,9 @@ 'capabilities': dict({ 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'config_entry_id': , @@ -314,11 +296,9 @@ 'friendly_name': 'Home Air quality day 4', 'options': list([ 'good', - 'hazardous', - 'high', - 'low', 'moderate', 'unhealthy', + 'hazardous', ]), }), 'context': , From d94bdb7ecd7048db31de504db7d2839560e273ed Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 8 Mar 2025 20:15:56 +0100 Subject: [PATCH 1482/1941] Fix MQTT JSON light not reporting color temp status if color is not supported (#140113) --- .../components/mqtt/light/schema_json.py | 3 +- tests/components/mqtt/test_light_json.py | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 4473385d550..d18da9e917a 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -31,7 +31,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, brightness_supported, - color_supported, valid_supported_color_modes, ) from homeassistant.const import ( @@ -293,7 +292,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): elif values["state"] is None: self._attr_is_on = None - if color_supported(self.supported_color_modes) and "color_mode" in values: + if "color_mode" in values: self._update_color(values) if brightness_supported(self.supported_color_modes): diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index bcf9d4bd736..67d382826ae 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -432,6 +432,65 @@ async def test_brightness_only( assert state.state == STATE_OFF +@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": ["color_temp"], + } + } + }, + ], +) +async def test_color_temp_only( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test a light that only support color_temp as supported color mode.""" + 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.COLOR_TEMP + ] + 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", "color_mode": "color_temp", "color_temp": 250, "brightness": 50}', + ) + + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("rgb_color") == (255, 206, 166) + assert state.attributes.get("brightness") == 50 + assert state.attributes.get("color_temp_kelvin") == 4000 + assert state.attributes.get("effect") is None + assert state.attributes.get("xy_color") == (0.42, 0.365) + assert state.attributes.get("hs_color") == (26.812, 34.87) + + 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 e54febdc1e4effe9d609939ba48040cece5e552f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 20:16:21 +0100 Subject: [PATCH 1483/1941] Add template function: typeof (#140081) --- homeassistant/helpers/template.py | 7 +++++++ tests/helpers/test_template.py | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ab115203e66..e5a155a5c36 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2760,6 +2760,11 @@ def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]: return items +def typeof(value: Any) -> Any: + """Return the type of value passed to debug types.""" + return value.__class__.__name__ + + class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -2961,6 +2966,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["version"] = version self.filters["contains"] = contains self.filters["shuffle"] = shuffle + self.filters["typeof"] = typeof self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2999,6 +3005,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["version"] = version self.globals["zip"] = zip self.globals["shuffle"] = shuffle + self.globals["typeof"] = typeof self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 28391d97a3c..5ae821bce24 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6724,3 +6724,30 @@ def test_shuffle(hass: HomeAssistant) -> None: with pytest.raises(TemplateError): template.Template("{{ shuffle() }}", hass).async_render() + + +def test_typeof(hass: HomeAssistant) -> None: + """Test the typeof debug filter/function.""" + assert template.Template("{{ True | typeof }}", hass).async_render() == "bool" + assert template.Template("{{ typeof(True) }}", hass).async_render() == "bool" + + assert template.Template("{{ [1, 2, 3] | typeof }}", hass).async_render() == "list" + assert template.Template("{{ typeof([1, 2, 3]) }}", hass).async_render() == "list" + + assert template.Template("{{ 1 | typeof }}", hass).async_render() == "int" + assert template.Template("{{ typeof(1) }}", hass).async_render() == "int" + + assert template.Template("{{ 1.1 | typeof }}", hass).async_render() == "float" + assert template.Template("{{ typeof(1.1) }}", hass).async_render() == "float" + + assert template.Template("{{ None | typeof }}", hass).async_render() == "NoneType" + assert template.Template("{{ typeof(None) }}", hass).async_render() == "NoneType" + + assert ( + template.Template("{{ 'Home Assistant' | typeof }}", hass).async_render() + == "str" + ) + assert ( + template.Template("{{ typeof('Home Assistant') }}", hass).async_render() + == "str" + ) From e0cff8de84f3157d803f0c11d37b98b59d5a79f2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:21:24 +0100 Subject: [PATCH 1484/1941] Fix typo "an problem" in `nmbs` integration (#140151) --- homeassistant/components/nmbs/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json index 3e7aa8d05bd..ac11026577a 100644 --- a/homeassistant/components/nmbs/strings.json +++ b/homeassistant/components/nmbs/strings.json @@ -29,7 +29,7 @@ "issues": { "deprecated_yaml_import_issue_station_not_found": { "title": "The {integration_title} YAML configuration import failed", - "description": "Configuring {integration_title} using YAML is being removed but there was an problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + "description": "Configuring {integration_title} using YAML is being removed but there was a problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } } } From 9bbf4fe8c1cfdfd57d1f36f4f7a51169d748fa2b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:21:46 +0100 Subject: [PATCH 1485/1941] Make spelling of "MELCloud" consistent, fix typo "an connection" (#140150) --- homeassistant/components/melcloud/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index 19ef0b76aad..8c168295e88 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -11,20 +11,20 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The Melcloud integration needs to re-authenticate your connection details", + "description": "The MELCloud integration needs to re-authenticate your connection details", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } }, "reconfigure": { - "title": "Reconfigure your MelCloud", + "title": "Reconfigure your MELCloud", "description": "Reconfigure the entry to obtain a new token, for your account: `{username}`.", "data": { "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "Enter the (new) password for MelCloud." + "password": "Enter the (new) password for MELCloud." } } }, @@ -70,7 +70,7 @@ }, "deprecated_yaml_import_issue_cannot_connect": { "title": "The MELCloud YAML configuration import failed", - "description": "Configuring MELCloud using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to MELCloud works and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually." + "description": "Configuring MELCloud using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to MELCloud works and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually." } }, "entity": { From 726bd5b01208d25fc1dc011a078aabf611bcaed5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:22:06 +0100 Subject: [PATCH 1486/1941] Fix typo "an connection" in `aftership` integration (#140148) --- homeassistant/components/aftership/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json index ace8eb6d2d3..c3817a0cd24 100644 --- a/homeassistant/components/aftership/strings.json +++ b/homeassistant/components/aftership/strings.json @@ -51,7 +51,7 @@ "issues": { "deprecated_yaml_import_issue_cannot_connect": { "title": "The {integration_title} YAML configuration import failed", - "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } } } From 40f92bac93751375fa753260d1317b42e1a2272d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:22:30 +0100 Subject: [PATCH 1487/1941] Fix typo "an comma" in `doorbird` integration (#140146) --- homeassistant/components/doorbird/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 090ba4f161f..ad43e8c1c1c 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -6,7 +6,7 @@ "events": "Comma separated list of events." }, "data_description": { - "events": "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion" + "events": "Add a comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion" } } } From b0b28bd98ad6d7a480eab6ef00f96a240b8ea516 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:23:27 +0100 Subject: [PATCH 1488/1941] Replace typo "an code" with "alarm code" in `elkm1` integration (#140143) The use of "alarm code" matches the online docs, too. --- homeassistant/components/elkm1/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index f184483646d..b50c1817838 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -53,7 +53,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to authorize the bypass of the alarm control panel." + "description": "Alarm code to authorize the bypass of the alarm control panel." } } }, @@ -63,7 +63,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to authorize the bypass clear of the alarm control panel." + "description": "Alarm code to authorize the bypass clear of the alarm control panel." } } }, @@ -73,7 +73,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to arm the alarm control panel." + "description": "Alarm code to arm the alarm control panel." } } }, @@ -181,7 +181,7 @@ "fields": { "code": { "name": "Code", - "description": "An code to authorize the bypass of the zone." + "description": "Alarm code to authorize the bypass of the zone." } } }, From be67f320b5fe6e7a6174aca46d49bf296bd4be38 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 8 Mar 2025 21:23:44 +0100 Subject: [PATCH 1489/1941] Fix typos in `homeassistant_hardware` strings (#140154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "addon" -> "add-on" - "internet" -> "Internet" - "an Thread border router" -> "a …" --- .../components/homeassistant_hardware/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index de328a54bb7..5456f418c75 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -40,7 +40,7 @@ }, "otbr_failed": { "title": "Failed to setup OpenThread Border Router", - "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists." + "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists." }, "confirm_otbr": { "title": "OpenThread Border Router setup complete", @@ -48,16 +48,16 @@ } }, "abort": { - "not_hassio_thread": "The OpenThread Border Router addon can only be installed with Home Assistant OS. If you would like to use the {model} as an Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.", + "not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.", "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." }, "progress": { - "install_zigbee_flasher_addon": "The Silicon Labs Flasher addon is installed, this may take a few minutes.", + "install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.", "run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.", - "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher addon is being removed." + "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed." } } }, From 62c025fd1268f78f8f3ca972f5ddf51dc2a73228 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 8 Mar 2025 21:46:15 +0100 Subject: [PATCH 1490/1941] Use HAs configured timezone for KNX expose time (#140158) * Use HAs configured timezone for KNX expose time * use `hass.config.async_set_time_zone` in tests --- homeassistant/components/knx/expose.py | 3 ++- tests/components/knx/test_expose.py | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 6585b848d8a..461e6f25879 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -30,6 +30,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.util import dt as dt_util from .const import CONF_RESPOND_TO_READ, KNX_ADDRESS from .schema import ExposeSchema @@ -217,7 +218,7 @@ class KNXExposeTime: self.device = xknx_device_cls( self.xknx, name=expose_type.capitalize(), - localtime=True, + localtime=dt_util.get_default_time_zone(), group_address=config[KNX_ADDRESS], ) diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 0fd790a3e33..f7a3f4e94f2 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -348,19 +348,20 @@ async def test_expose_conversion_exception( ) -@freeze_time("2022-1-7 9:13:14") +@freeze_time("2022-1-7 9:13:14") # UTC -> +1h = Vienna in winter (9 -> 0xA) @pytest.mark.parametrize( ("time_type", "raw"), [ - ("time", (0xA9, 0x0D, 0x0E)), # localtime includes day of week + ("time", (0xAA, 0x0D, 0x0E)), # localtime includes day of week ("date", (0x07, 0x01, 0x16)), - ("datetime", (0x7A, 0x1, 0x7, 0xA9, 0xD, 0xE, 0x20, 0xC0)), + ("datetime", (0x7A, 0x1, 0x7, 0xAA, 0xD, 0xE, 0x20, 0xC0)), ], ) async def test_expose_with_date( hass: HomeAssistant, knx: KNXTestKit, time_type: str, raw: tuple[int, ...] ) -> None: """Test an expose with a date.""" + await hass.config.async_set_time_zone("Europe/Vienna") await knx.setup_integration( { CONF_KNX_EXPOSE: { From 9aa8a786a5148125258a5c071272e5365ef9a7f4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Mar 2025 22:14:27 +0100 Subject: [PATCH 1491/1941] Add template function: flatten (#140157) --- homeassistant/helpers/template.py | 21 ++++++++++++++++++ tests/helpers/test_template.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index e5a155a5c36..357fe15f3be 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2765,6 +2765,25 @@ def typeof(value: Any) -> Any: return value.__class__.__name__ +def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: + """Flattens list of lists.""" + if not isinstance(value, Iterable) or isinstance(value, str): + raise TypeError(f"flatten expected a list, got {type(value).__name__}") + + flattened: list[Any] = [] + for item in value: + if isinstance(item, Iterable) and not isinstance(item, str): + if levels is None: + flattened.extend(flatten(item)) + elif levels >= 1: + flattened.extend(flatten(item, levels=(levels - 1))) + else: + flattened.append(item) + else: + flattened.append(item) + return flattened + + class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -2967,6 +2986,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["contains"] = contains self.filters["shuffle"] = shuffle self.filters["typeof"] = typeof + self.filters["flatten"] = flatten self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -3006,6 +3026,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["zip"] = zip self.globals["shuffle"] = shuffle self.globals["typeof"] = typeof + self.globals["flatten"] = flatten self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 5ae821bce24..f9154b23bad 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6751,3 +6751,40 @@ def test_typeof(hass: HomeAssistant) -> None: template.Template("{{ typeof('Home Assistant') }}", hass).async_render() == "str" ) + + +def test_flatten(hass: HomeAssistant) -> None: + """Test the flatten function and filter.""" + assert template.Template( + "{{ flatten([1, [2, [3]], 4, [5 , 6]]) }}", hass + ).async_render() == [1, 2, 3, 4, 5, 6] + + assert template.Template( + "{{ [1, [2, [3]], 4, [5 , 6]] | flatten }}", hass + ).async_render() == [1, 2, 3, 4, 5, 6] + + assert template.Template( + "{{ flatten([1, [2, [3]], 4, [5 , 6]], 1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template( + "{{ flatten([1, [2, [3]], 4, [5 , 6]], levels=1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template( + "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template( + "{{ [1, [2, [3]], 4, [5 , 6]] | flatten(levels=1) }}", hass + ).async_render() == [1, 2, [3], 4, 5, 6] + + assert template.Template("{{ flatten([]) }}", hass).async_render() == [] + + assert template.Template("{{ [] | flatten }}", hass).async_render() == [] + + with pytest.raises(TemplateError): + template.Template("{{ 'string' | flatten }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ flatten() }}", hass).async_render() From 0d3011f0fbb004cdb038ac31ea0fc0ada744ce09 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 8 Mar 2025 23:04:05 +0100 Subject: [PATCH 1492/1941] Revert "Check if the unit of measurement is valid before creating the entity" (#140155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "Check if the unit of measurement is valid before creating the entity …" This reverts commit 99e1a7a676b2fc14f9f8a8db64bee2840fae4646. --- homeassistant/components/mqtt/sensor.py | 15 -------------- tests/components/mqtt/test_sensor.py | 26 ------------------------- 2 files changed, 41 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 432431c96d9..3e8a4fef0fa 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, - DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, @@ -108,20 +107,6 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) - if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( - unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) - ) is None: - return config - - if ( - device_class in DEVICE_CLASS_UNITS - and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] - ): - raise vol.Invalid( - f"The unit of measurement `{unit_of_measurement}` is not valid " - f"together with device class `{device_class}`" - ) - return config diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index f40082d84be..9226b03a7d2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -870,32 +870,6 @@ async def test_invalid_device_class( assert "expected SensorDeviceClass or one of" in caplog.text -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_topic": "test-topic", - "device_class": "energy", - "unit_of_measurement": "ppm", - } - } - } - ], -) -async def test_invalid_unit_of_measurement( - mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture -) -> None: - """Test device_class with invalid unit of measurement.""" - assert await mqtt_mock_entry() - assert ( - "The unit of measurement `ppm` is not valid together with device class `energy`" - in caplog.text - ) - - @pytest.mark.parametrize( "hass_config", [ From ffcc0496f1e7b7a9c673517797513fa5c738e7ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Mar 2025 12:52:51 -1000 Subject: [PATCH 1493/1941] Bump aioesphomeapi to 29.4.1 (#140165) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.4.0...v29.4.1 --- 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 aa0f6f3752b..25d9e407044 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.4.0", + "aioesphomeapi==29.4.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.11.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index aeda2ed360b..675bb5f66f5 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.4.0 +aioesphomeapi==29.4.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b955e4a134d..f00c318ce25 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.4.0 +aioesphomeapi==29.4.1 # homeassistant.components.flo aioflo==2021.11.0 From f0c5e00cc1a2b7b5d890c86ad2806ae13d7b9863 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 9 Mar 2025 04:23:24 +0100 Subject: [PATCH 1494/1941] Fix conversation trigger with variables (#140066) --- homeassistant/helpers/trigger.py | 12 ++++++------ tests/components/conversation/test_trigger.py | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 67e9010df79..a27c85a5c58 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -265,18 +265,18 @@ def _trigger_action_wrapper( while isinstance(check_func, functools.partial): check_func = check_func.func - wrapper_func: Callable[..., None] | Callable[..., Coroutine[Any, Any, None]] + wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]] if asyncio.iscoroutinefunction(check_func): - async_action = cast(Callable[..., Coroutine[Any, Any, None]], action) + async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action) @functools.wraps(async_action) async def async_with_vars( run_variables: dict[str, Any], context: Context | None = None - ) -> None: + ) -> Any: """Wrap action with extra vars.""" trigger_variables = conf[CONF_VARIABLES] run_variables.update(trigger_variables.async_render(hass, run_variables)) - await action(run_variables, context) + return await action(run_variables, context) wrapper_func = async_with_vars @@ -285,11 +285,11 @@ def _trigger_action_wrapper( @functools.wraps(action) async def with_vars( run_variables: dict[str, Any], context: Context | None = None - ) -> None: + ) -> Any: """Wrap action with extra vars.""" trigger_variables = conf[CONF_VARIABLES] run_variables.update(trigger_variables.async_render(hass, run_variables)) - action(run_variables, context) + return action(run_variables, context) if is_callback(check_func): with_vars = callback(with_vars) diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 3aa8ae2939f..a01f4cd8112 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -104,6 +104,7 @@ async def test_response(hass: HomeAssistant) -> None: "trigger": { "platform": "conversation", "command": ["Open the pod bay door Hal"], + "variables": {"name": "Dr. David Bowman"}, }, "action": { "set_conversation_response": response, From 6675b497bd8680b8b1d54fd9b117af8ce288f0b8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 8 Mar 2025 19:28:35 -0800 Subject: [PATCH 1495/1941] Improve LLM tool descriptions for brightness and volume percentage (#138685) * Improve tool descriptions for brightness and volume percentage * Address lint errors * Update intent.py to revert of a light * Create explicit types to make intent slots more future proof * Remove comments about slot type --------- Co-authored-by: Franck Nijhof --- homeassistant/components/light/intent.py | 18 ++-- .../components/media_player/intent.py | 13 ++- homeassistant/helpers/intent.py | 84 ++++++++++++------- 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 83f2ee58b5e..250e1f5b2c1 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -28,13 +28,21 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_TURN_ON, optional_slots={ - ("color", ATTR_RGB_COLOR): color_util.color_name_to_rgb, - ("temperature", ATTR_COLOR_TEMP_KELVIN): cv.positive_int, - ("brightness", ATTR_BRIGHTNESS_PCT): vol.All( - vol.Coerce(int), vol.Range(0, 100) + "color": intent.IntentSlotInfo( + service_data_name=ATTR_RGB_COLOR, + value_schema=color_util.color_name_to_rgb, + ), + "temperature": intent.IntentSlotInfo( + service_data_name=ATTR_COLOR_TEMP_KELVIN, + value_schema=cv.positive_int, + ), + "brightness": intent.IntentSlotInfo( + service_data_name=ATTR_BRIGHTNESS_PCT, + description="The brightness percentage of the light between 0 and 100, where 0 is off and 100 is fully lit", + value_schema=vol.All(vol.Coerce(int), vol.Range(0, 100)), ), }, - description="Sets the brightness or color of a light", + description="Sets the brightness percentage or color of a light", platforms={DOMAIN}, ), ) diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index edfab2a668f..af37c0d68bb 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -96,11 +96,16 @@ async def async_setup_intents(hass: HomeAssistant) -> None: required_states={MediaPlayerState.PLAYING}, required_features=MediaPlayerEntityFeature.VOLUME_SET, required_slots={ - ATTR_MEDIA_VOLUME_LEVEL: vol.All( - vol.Coerce(int), vol.Range(min=0, max=100), lambda val: val / 100 - ) + ATTR_MEDIA_VOLUME_LEVEL: intent.IntentSlotInfo( + description="The volume percentage of the media player", + value_schema=vol.All( + vol.Coerce(int), + vol.Range(min=0, max=100), + lambda val: val / 100, + ), + ), }, - description="Sets the volume of a media player", + description="Sets the volume percentage of a media player", platforms={DOMAIN}, device_classes={MediaPlayerDeviceClass}, ), diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 0bb96615d3f..75572194bb8 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -38,7 +38,7 @@ from .typing import VolSchemaType _LOGGER = logging.getLogger(__name__) type _SlotsType = dict[str, Any] type _IntentSlotsType = dict[ - str | tuple[str, str], VolSchemaType | Callable[[Any], Any] + str | tuple[str, str], IntentSlotInfo | VolSchemaType | Callable[[Any], Any] ] INTENT_TURN_OFF = "HassTurnOff" @@ -874,6 +874,34 @@ def non_empty_string(value: Any) -> str: return value_str +@dataclass(kw_only=True) +class IntentSlotInfo: + """Details about how intent slots are processed and validated.""" + + service_data_name: str | None = None + """Optional name of the service data input to map to this slot.""" + + description: str | None = None + """Human readable description of the slot.""" + + value_schema: VolSchemaType | Callable[[Any], Any] = vol.Any + """Validator for the slot.""" + + +def _convert_slot_info( + key: str | tuple[str, str], + value: IntentSlotInfo | VolSchemaType | Callable[[Any], Any], +) -> tuple[str, IntentSlotInfo]: + """Create an IntentSlotInfo from the various supported input arguments.""" + if isinstance(value, IntentSlotInfo): + if not isinstance(key, str): + raise TypeError("Tuple key and IntentSlotDescription value not supported") + return key, value + if isinstance(key, tuple): + return key[0], IntentSlotInfo(service_data_name=key[1], value_schema=value) + return key, IntentSlotInfo(value_schema=value) + + class DynamicServiceIntentHandler(IntentHandler): """Service Intent handler registration (dynamic). @@ -907,23 +935,14 @@ class DynamicServiceIntentHandler(IntentHandler): self.platforms = platforms self.device_classes = device_classes - self.required_slots: _IntentSlotsType = {} - if required_slots: - for key, value_schema in required_slots.items(): - if isinstance(key, str): - # Slot name/service data key - key = (key, key) - - self.required_slots[key] = value_schema - - self.optional_slots: _IntentSlotsType = {} - if optional_slots: - for key, value_schema in optional_slots.items(): - if isinstance(key, str): - # Slot name/service data key - key = (key, key) - - self.optional_slots[key] = value_schema + self.required_slots: dict[str, IntentSlotInfo] = dict( + _convert_slot_info(key, value) + for key, value in (required_slots or {}).items() + ) + self.optional_slots: dict[str, IntentSlotInfo] = dict( + _convert_slot_info(key, value) + for key, value in (optional_slots or {}).items() + ) @cached_property def slot_schema(self) -> dict: @@ -964,16 +983,20 @@ class DynamicServiceIntentHandler(IntentHandler): if self.required_slots: slot_schema.update( { - vol.Required(key[0]): validator - for key, validator in self.required_slots.items() + vol.Required( + key, description=slot_info.description + ): slot_info.value_schema + for key, slot_info in self.required_slots.items() } ) if self.optional_slots: slot_schema.update( { - vol.Optional(key[0]): validator - for key, validator in self.optional_slots.items() + vol.Optional( + key, description=slot_info.description + ): slot_info.value_schema + for key, slot_info in self.optional_slots.items() } ) @@ -1156,18 +1179,15 @@ class DynamicServiceIntentHandler(IntentHandler): service_data: dict[str, Any] = {ATTR_ENTITY_ID: state.entity_id} if self.required_slots: - service_data.update( - { - key[1]: intent_obj.slots[key[0]]["value"] - for key in self.required_slots - } - ) + for key, slot_info in self.required_slots.items(): + service_data[slot_info.service_data_name or key] = intent_obj.slots[ + key + ]["value"] if self.optional_slots: - for key in self.optional_slots: - value = intent_obj.slots.get(key[0]) - if value: - service_data[key[1]] = value["value"] + for key, slot_info in self.optional_slots.items(): + if value := intent_obj.slots.get(key): + service_data[slot_info.service_data_name or key] = value["value"] await self._run_then_background( hass.async_create_task_internal( From aa2a1fc5ef748b827eff6ef2f5797248a348332e Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 9 Mar 2025 04:42:57 +0100 Subject: [PATCH 1496/1941] Fix not available source in Onkyo (#140175) --- homeassistant/components/onkyo/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 8f9587bc426..f7fe83c57a3 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -588,7 +588,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._attr_volume_level = min(1, volume_level) elif command in ["muting", "audio-muting"]: self._attr_is_volume_muted = bool(value == "on") - elif command in ["selector", "input-selector"]: + elif command in ["selector", "input-selector"] and value != "N/A": self._parse_source(value) self._query_av_info_delayed() elif command == "hdmi-output-selector": From 60db3555771117eeaef81204e6366185cdf7c8bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Mar 2025 20:13:09 -1000 Subject: [PATCH 1497/1941] Bump aioshelly to 13.2.0 (#140178) Adds support for getting the Bluetooth MAC changelog: https://github.com/home-assistant-libs/aioshelly/compare/13.1.0...13.2.0 --- 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 722fd4c128a..c8ac5520b13 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.1.0"], + "requirements": ["aioshelly==13.2.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 675bb5f66f5..950d4aa12cb 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.1.0 +aioshelly==13.2.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f00c318ce25..503d33e8a8b 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.1.0 +aioshelly==13.2.0 # homeassistant.components.skybell aioskybell==22.7.0 From 4e7dd92a3d8b83bfeffb6ab0ee01e7340d73400f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 9 Mar 2025 12:27:02 +0100 Subject: [PATCH 1498/1941] Add Ogemray virtual integration (#140185) --- homeassistant/components/ogemray/__init__.py | 1 + homeassistant/components/ogemray/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/ogemray/__init__.py create mode 100644 homeassistant/components/ogemray/manifest.json diff --git a/homeassistant/components/ogemray/__init__.py b/homeassistant/components/ogemray/__init__.py new file mode 100644 index 00000000000..94e19234a6b --- /dev/null +++ b/homeassistant/components/ogemray/__init__.py @@ -0,0 +1 @@ +"""Ogemray virtual integration.""" diff --git a/homeassistant/components/ogemray/manifest.json b/homeassistant/components/ogemray/manifest.json new file mode 100644 index 00000000000..6a8eb315c7a --- /dev/null +++ b/homeassistant/components/ogemray/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "ogemray", + "name": "Ogemray", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index eee1d22dcb0..b916526aaf3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4426,6 +4426,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "ogemray": { + "name": "Ogemray", + "integration_type": "virtual", + "supported_by": "shelly" + }, "ohmconnect": { "name": "OhmConnect", "integration_type": "hub", From d9d47f7203569e105c71492aaa2b5b69b76b787b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Mar 2025 01:28:56 -1000 Subject: [PATCH 1499/1941] Migrate shelly Bluetooth scanner to use correct MAC address (#140180) --- homeassistant/components/shelly/__init__.py | 5 +++-- .../components/shelly/bluetooth/__init__.py | 4 +--- homeassistant/components/shelly/coordinator.py | 12 +++++++++++- homeassistant/components/shelly/diagnostics.py | 4 +--- tests/components/shelly/test_diagnostics.py | 6 +++--- tests/components/shelly/test_init.py | 7 +++++-- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 5ca58ec7d01..55b75b3face 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -12,7 +12,7 @@ from aioshelly.exceptions import ( InvalidAuthError, MacAddressMismatchError, ) -from aioshelly.rpc_device import RpcDevice +from aioshelly.rpc_device import RpcDevice, bluetooth_mac_from_primary_mac import voluptuous as vol from homeassistant.components.bluetooth import async_remove_scanner @@ -339,4 +339,5 @@ async def async_remove_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> N if get_device_entry_gen(entry) in RPC_GENERATIONS and ( mac_address := entry.unique_id ): - async_remove_scanner(hass, mac_address) + source = dr.format_mac(bluetooth_mac_from_primary_mac(mac_address)).upper() + async_remove_scanner(hass, source) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index d7eb020d671..cad1b9f044d 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -9,7 +9,6 @@ from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION from homeassistant.components.bluetooth import async_register_scanner from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from homeassistant.helpers.device_registry import format_mac from ..const import BLEScannerMode @@ -26,8 +25,7 @@ async def async_connect_scanner( """Connect scanner.""" device = coordinator.device entry = coordinator.config_entry - source = format_mac(coordinator.mac).upper() - scanner = create_scanner(source, entry.title) + scanner = create_scanner(coordinator.bluetooth_source, entry.title) unload_callbacks = [ async_register_scanner( hass, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 7b4da241043..bebf8efbdd7 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -18,6 +18,7 @@ from aioshelly.exceptions import ( RpcCallError, ) from aioshelly.rpc_device import RpcDevice, RpcUpdateType +from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac from propcache.api import cached_property from homeassistant.components.bluetooth import async_remove_scanner @@ -496,6 +497,15 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self._connect_task: asyncio.Task | None = None entry.async_on_unload(entry.add_update_listener(self._async_update_listener)) + @cached_property + def bluetooth_source(self) -> str: + """Return the Bluetooth source address. + + This is the Bluetooth MAC address of the device that is used + for the Bluetooth scanner. + """ + return format_mac(bluetooth_mac_from_primary_mac(self.mac)).upper() + async def async_device_online(self, source: str) -> None: """Handle device going online.""" if not self.sleep_period: @@ -706,7 +716,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) - async_remove_scanner(self.hass, format_mac(self.mac).upper()) + async_remove_scanner(self.hass, self.bluetooth_source) return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index a5fe1f5b6c0..d56a2884e17 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -8,7 +8,6 @@ from homeassistant.components.bluetooth import async_scanner_by_source from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import format_mac from .coordinator import ShellyConfigEntry from .utils import get_rpc_ws_url @@ -86,8 +85,7 @@ async def async_get_config_entry_diagnostics( if k in ["sys", "wifi"] } - source = format_mac(rpc_coordinator.mac).upper() - if scanner := async_scanner_by_source(hass, source): + if scanner := async_scanner_by_source(hass, rpc_coordinator.bluetooth_source): bluetooth = { "scanner": await scanner.async_diagnostics(), } diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index c0f78d48d9b..3826631c580 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -134,17 +134,17 @@ async def test_rpc_config_entry_diagnostics( -62, [], ], - "details": {"source": "12:34:56:78:9A:BC"}, + "details": {"source": "12:34:56:78:9A:BE"}, "name": None, "rssi": -62, } ], "last_detection": ANY, "monotonic_time": ANY, - "name": "Mock Title (12:34:56:78:9A:BC)", + "name": "Mock Title (12:34:56:78:9A:BE)", "scanning": True, "start_time": ANY, - "source": "12:34:56:78:9A:BC", + "source": "12:34:56:78:9A:BE", "time_since_last_device_detection": {"AA:BB:CC:DD:EE:FF": ANY}, "type": "ShellyBLEScanner", } diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 036da1bfd64..c9e4ce253e4 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -11,6 +11,7 @@ from aioshelly.exceptions import ( InvalidAuthError, MacAddressMismatchError, ) +from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac import pytest from homeassistant.components.shelly.const import ( @@ -27,7 +28,7 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.device_registry import DeviceRegistry, format_mac from homeassistant.setup import async_setup_component from . import init_integration, mutate_rpc_device_status @@ -545,4 +546,6 @@ async def test_bluetooth_cleanup_on_remove_entry( await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - remove_mock.assert_called_once_with(hass, entry.unique_id.upper()) + remove_mock.assert_called_once_with( + hass, format_mac(bluetooth_mac_from_primary_mac(entry.unique_id)).upper() + ) From 03aff0d6625c40ca8eb3c079c803c18aa21b17d8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 9 Mar 2025 13:07:20 +0100 Subject: [PATCH 1500/1941] Use CONF_* const in Shelly tests (#140189) --- tests/components/shelly/__init__.py | 4 +- tests/components/shelly/test_config_flow.py | 237 ++++++++++++-------- tests/components/shelly/test_coordinator.py | 4 +- tests/components/shelly/test_init.py | 18 +- 4 files changed, 163 insertions(+), 100 deletions(-) diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 5cba8e5e3b8..ddece280d8a 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -18,7 +18,7 @@ from homeassistant.components.shelly.const import ( RPC_SENSORS_POLLING_INTERVAL, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import ( @@ -47,7 +47,7 @@ async def init_integration( data = { CONF_HOST: "192.168.1.37", CONF_SLEEP_PERIOD: sleep_period, - "model": model, + CONF_MODEL: model, } if gen is not None: data[CONF_GEN] = gen diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 50b8b552268..0b2d355cfd8 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -18,10 +18,19 @@ from homeassistant import config_entries from homeassistant.components.shelly import MacAddressMismatchError, config_flow from homeassistant.components.shelly.const import ( CONF_BLE_SCANNER_MODE, + CONF_GEN, + CONF_SLEEP_PERIOD, DOMAIN, BLEScannerMode, ) from homeassistant.components.shelly.coordinator import ENTRY_RELOAD_COOLDOWN +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ( @@ -100,18 +109,18 @@ async def test_form( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "port": port}, + {CONF_HOST: "1.1.1.1", CONF_PORT: port}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "port": port, - "model": model, - "sleep_period": 0, - "gen": gen, + CONF_HOST: "1.1.1.1", + CONF_PORT: port, + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -163,18 +172,18 @@ async def test_user_flow_overrides_existing_discovery( assert result["errors"] == {} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1", "port": 80}, + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "port": 80, - "model": MODEL_PLUS_2PM, - "sleep_period": 0, - "gen": 2, + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 2, } assert result2["context"]["unique_id"] == "AABBCCDDEEFF" assert len(mock_setup.mock_calls) == 1 @@ -220,19 +229,19 @@ async def test_form_gen1_custom_port( ( 1, MODEL_1, - {"username": "test user", "password": "test1 password"}, + {CONF_USERNAME: "test user", CONF_PASSWORD: "test1 password"}, "test user", ), ( 2, MODEL_PLUS_2PM, - {"password": "test2 password"}, + {CONF_PASSWORD: "test2 password"}, "admin", ), ( 3, MODEL_PLUS_2PM, - {"password": "test2 password"}, + {CONF_PASSWORD: "test2 password"}, "admin", ), ], @@ -259,7 +268,7 @@ async def test_form_auth( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -282,13 +291,13 @@ async def test_form_auth( assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == "Test name" assert result3["data"] == { - "host": "1.1.1.1", - "port": DEFAULT_HTTP_PORT, - "model": model, - "sleep_period": 0, - "gen": gen, - "username": username, - "password": user_input["password"], + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, + CONF_USERNAME: username, + CONF_PASSWORD: user_input[CONF_PASSWORD], } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -312,7 +321,7 @@ async def test_form_errors_get_info( with patch("homeassistant.components.shelly.config_flow.get_info", side_effect=exc): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -333,7 +342,7 @@ async def test_form_missing_model_key( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -356,7 +365,7 @@ async def test_form_missing_model_key_auth_enabled( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -364,7 +373,7 @@ async def test_form_missing_model_key_auth_enabled( monkeypatch.setattr(mock_rpc_device, "shelly", {"gen": 2}) result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {"password": "1234"} + result2["flow_id"], {CONF_PASSWORD: "1234"} ) assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": "firmware_not_fully_provisioned"} @@ -424,7 +433,7 @@ async def test_form_errors_test_connection( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.FORM @@ -435,7 +444,7 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0"} ) entry.add_to_hass(hass) @@ -449,14 +458,14 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" # Test config entry got updated with latest IP - assert entry.data["host"] == "1.1.1.1" + assert entry.data[CONF_HOST] == "1.1.1.1" async def test_user_setup_ignored_device( @@ -467,7 +476,7 @@ async def test_user_setup_ignored_device( entry = MockConfigEntry( domain="shelly", unique_id="test-mac", - data={"host": "0.0.0.0"}, + data={CONF_HOST: "0.0.0.0"}, source=config_entries.SOURCE_IGNORE, ) entry.add_to_hass(hass) @@ -491,13 +500,13 @@ async def test_user_setup_ignored_device( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) assert result2["type"] is FlowResultType.CREATE_ENTRY # Test config entry got updated with latest IP - assert entry.data["host"] == "1.1.1.1" + assert entry.data[CONF_HOST] == "1.1.1.1" assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -525,7 +534,7 @@ async def test_form_auth_errors_test_connection_gen1( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) with patch( @@ -534,7 +543,7 @@ async def test_form_auth_errors_test_connection_gen1( ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - {"username": "test username", "password": "test password"}, + {CONF_USERNAME: "test username", CONF_PASSWORD: "test password"}, ) assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": base_error} @@ -563,7 +572,7 @@ async def test_form_auth_errors_test_connection_gen2( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) with patch( @@ -571,7 +580,7 @@ async def test_form_auth_errors_test_connection_gen2( new=AsyncMock(side_effect=exc), ): result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {"password": "test password"} + result2["flow_id"], {CONF_PASSWORD: "test password"} ) assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": base_error} @@ -642,10 +651,10 @@ async def test_zeroconf( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "model": model, - "sleep_period": 0, - "gen": gen, + CONF_HOST: "1.1.1.1", + CONF_MODEL: model, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: gen, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -700,10 +709,10 @@ async def test_zeroconf_sleeping_device( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "model": MODEL_1, - "sleep_period": 600, - "gen": 1, + CONF_HOST: "1.1.1.1", + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 600, + CONF_GEN: 1, } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -739,7 +748,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: """Test we get the form.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0"} ) entry.add_to_hass(hass) @@ -756,7 +765,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" # Test config entry got updated with latest IP - assert entry.data["host"] == "1.1.1.1" + assert entry.data[CONF_HOST] == "1.1.1.1" async def test_zeroconf_ignored(hass: HomeAssistant) -> None: @@ -787,7 +796,7 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: """Test we ignore the Wi-FI AP IP.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "2.2.2.2"} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "2.2.2.2"} ) entry.add_to_hass(hass) @@ -806,7 +815,7 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" # Test config entry was not updated with the wifi ap ip - assert entry.data["host"] == "2.2.2.2" + assert entry.data[CONF_HOST] == "2.2.2.2" async def test_zeroconf_cannot_connect(hass: HomeAssistant) -> None: @@ -852,20 +861,20 @@ async def test_zeroconf_require_auth( ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": "test username", "password": "test password"}, + {CONF_USERNAME: "test username", CONF_PASSWORD: "test password"}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test name" assert result2["data"] == { - "host": "1.1.1.1", - "port": DEFAULT_HTTP_PORT, - "model": MODEL_1, - "sleep_period": 0, - "gen": 1, - "username": "test username", - "password": "test password", + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_1, + CONF_SLEEP_PERIOD: 0, + CONF_GEN: 1, + CONF_USERNAME: "test username", + CONF_PASSWORD: "test password", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -874,9 +883,9 @@ async def test_zeroconf_require_auth( @pytest.mark.parametrize( ("gen", "user_input"), [ - (1, {"username": "test user", "password": "test1 password"}), - (2, {"password": "test2 password"}), - (3, {"password": "test2 password"}), + (1, {CONF_USERNAME: "test user", CONF_PASSWORD: "test1 password"}), + (2, {CONF_PASSWORD: "test2 password"}), + (3, {CONF_PASSWORD: "test2 password"}), ], ) async def test_reauth_successful( @@ -888,7 +897,9 @@ async def test_reauth_successful( ) -> None: """Test starting a reauthentication flow.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -912,9 +923,9 @@ async def test_reauth_successful( @pytest.mark.parametrize( ("gen", "user_input"), [ - (1, {"username": "test user", "password": "test1 password"}), - (2, {"password": "test2 password"}), - (3, {"password": "test2 password"}), + (1, {CONF_USERNAME: "test user", CONF_PASSWORD: "test1 password"}), + (2, {CONF_PASSWORD: "test2 password"}), + (3, {CONF_PASSWORD: "test2 password"}), ], ) @pytest.mark.parametrize( @@ -933,7 +944,9 @@ async def test_reauth_unsuccessful( ) -> None: """Test reauthentication flow failed.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -943,7 +956,12 @@ async def test_reauth_unsuccessful( with ( patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "test-mac", "type": MODEL_1, "auth": True, "gen": gen}, + return_value={ + "mac": "test-mac", + "type": MODEL_1, + "auth": True, + "gen": gen, + }, ), patch( "aioshelly.block_device.BlockDevice.create", new=AsyncMock(side_effect=exc) @@ -962,7 +980,7 @@ async def test_reauth_unsuccessful( async def test_reauth_get_info_error(hass: HomeAssistant) -> None: """Test reauthentication flow failed with error in get_info().""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": 2} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0", CONF_GEN: 2} ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) @@ -975,7 +993,7 @@ async def test_reauth_get_info_error(hass: HomeAssistant) -> None: ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"password": "test2 password"}, + user_input={CONF_PASSWORD: "test2 password"}, ) assert result["type"] is FlowResultType.ABORT @@ -1106,7 +1124,12 @@ async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 0, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1141,7 +1164,12 @@ async def test_zeroconf_already_configured_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 0, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 0, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1181,7 +1209,12 @@ async def test_zeroconf_sleeping_device_not_triggers_refresh( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1228,7 +1261,12 @@ async def test_zeroconf_sleeping_device_attempts_configure( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1288,7 +1326,12 @@ async def test_zeroconf_sleeping_device_attempts_configure_ws_disabled( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1348,7 +1391,12 @@ async def test_zeroconf_sleeping_device_attempts_configure_no_url_available( entry = MockConfigEntry( domain="shelly", unique_id="AABBCCDDEEFF", - data={"host": "1.1.1.1", "gen": 2, "sleep_period": 1000, "model": MODEL_1}, + data={ + CONF_HOST: "1.1.1.1", + CONF_GEN: 2, + CONF_SLEEP_PERIOD: 1000, + CONF_MODEL: MODEL_1, + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -1415,20 +1463,20 @@ async def test_sleeping_device_gen2_with_new_firmware( ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"host": "1.1.1.1"}, + {CONF_HOST: "1.1.1.1"}, ) await hass.async_block_till_done() assert result["data"] == { - "host": "1.1.1.1", - "port": DEFAULT_HTTP_PORT, - "model": MODEL_PLUS_2PM, - "sleep_period": 666, - "gen": 2, + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_HTTP_PORT, + CONF_MODEL: MODEL_PLUS_2PM, + CONF_SLEEP_PERIOD: 666, + CONF_GEN: 2, } -@pytest.mark.parametrize("gen", [1, 2, 3]) +@pytest.mark.parametrize(CONF_GEN, [1, 2, 3]) async def test_reconfigure_successful( hass: HomeAssistant, gen: int, @@ -1437,7 +1485,9 @@ async def test_reconfigure_successful( ) -> None: """Test starting a reconfiguration flow.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) @@ -1452,12 +1502,12 @@ async def test_reconfigure_successful( ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"host": "10.10.10.10", "port": 99}, + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert entry.data == {"host": "10.10.10.10", "port": 99, "gen": gen} + assert entry.data == {CONF_HOST: "10.10.10.10", CONF_PORT: 99, CONF_GEN: gen} @pytest.mark.parametrize("gen", [1, 2, 3]) @@ -1469,7 +1519,9 @@ async def test_reconfigure_unsuccessful( ) -> None: """Test reconfiguration flow failed.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": gen} + domain="shelly", + unique_id="test-mac", + data={CONF_HOST: "0.0.0.0", CONF_GEN: gen}, ) entry.add_to_hass(hass) @@ -1480,11 +1532,16 @@ async def test_reconfigure_unsuccessful( with patch( "homeassistant.components.shelly.config_flow.get_info", - return_value={"mac": "another-mac", "type": MODEL_1, "auth": False, "gen": gen}, + return_value={ + "mac": "another-mac", + "type": MODEL_1, + "auth": False, + "gen": gen, + }, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"host": "10.10.10.10", "port": 99}, + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, ) assert result["type"] is FlowResultType.ABORT @@ -1506,7 +1563,7 @@ async def test_reconfigure_with_exception( ) -> None: """Test reconfiguration flow when an exception is raised.""" entry = MockConfigEntry( - domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0", "gen": 2} + domain="shelly", unique_id="test-mac", data={CONF_HOST: "0.0.0.0", CONF_GEN: 2} ) entry.add_to_hass(hass) @@ -1518,7 +1575,7 @@ async def test_reconfigure_with_exception( with patch("homeassistant.components.shelly.config_flow.get_info", side_effect=exc): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"host": "10.10.10.10", "port": 99}, + user_input={CONF_HOST: "10.10.10.10", CONF_PORT: 99}, ) assert result["errors"] == {"base": base_error} diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8de434d19d0..55a1d8958cd 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -562,7 +562,7 @@ async def test_rpc_update_entry_sleep_period( mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) - assert entry.data["sleep_period"] == 600 + assert entry.data[CONF_SLEEP_PERIOD] == 600 # Move time to generate sleep period update monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 3600) @@ -570,7 +570,7 @@ async def test_rpc_update_entry_sleep_period( async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - assert entry.data["sleep_period"] == 3600 + assert entry.data[CONF_SLEEP_PERIOD] == 3600 async def test_rpc_sleeping_device_no_periodic_updates( diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index c9e4ce253e4..ef9b8f72616 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -25,7 +25,13 @@ from homeassistant.components.shelly.const import ( BLEScannerMode, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PORT, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceRegistry, format_mac @@ -245,7 +251,7 @@ async def test_sleeping_block_device_online( await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text - assert entry.data["sleep_period"] == device_sleep + assert entry.data[CONF_SLEEP_PERIOD] == device_sleep @pytest.mark.parametrize(("entry_sleep", "device_sleep"), [(None, 0), (1000, 1000)]) @@ -267,7 +273,7 @@ async def test_sleeping_rpc_device_online( await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text - assert entry.data["sleep_period"] == device_sleep + assert entry.data[CONF_SLEEP_PERIOD] == device_sleep async def test_sleeping_rpc_device_online_new_firmware( @@ -286,7 +292,7 @@ async def test_sleeping_rpc_device_online_new_firmware( await hass.async_block_till_done(wait_background_tasks=True) assert "online, resuming setup" in caplog.text - assert entry.data["sleep_period"] == 1500 + assert entry.data[CONF_SLEEP_PERIOD] == 1500 async def test_sleeping_rpc_device_online_during_setup( @@ -474,7 +480,7 @@ async def test_entry_missing_port(hass: HomeAssistant) -> None: data = { CONF_HOST: "192.168.1.37", CONF_SLEEP_PERIOD: 0, - "model": MODEL_PLUS_2PM, + CONF_MODEL: MODEL_PLUS_2PM, CONF_GEN: 2, } entry = await init_integration(hass, 2, data=data, skip_setup=True) @@ -497,7 +503,7 @@ async def test_rpc_entry_custom_port(hass: HomeAssistant) -> None: data = { CONF_HOST: "192.168.1.37", CONF_SLEEP_PERIOD: 0, - "model": MODEL_PLUS_2PM, + CONF_MODEL: MODEL_PLUS_2PM, CONF_GEN: 2, CONF_PORT: 8001, } From f1a6e949c03722f47581cfe8e44307482c246092 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 9 Mar 2025 13:12:08 +0100 Subject: [PATCH 1501/1941] Update mypy-dev to 1.16.0a5 (#140188) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index d5dee887214..f40ed46a82f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.12 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 -mypy-dev==1.16.0a3 +mypy-dev==1.16.0a5 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.4 From e2d4e8b65d8f92a76283743a1f3ea132722c4b75 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 9 Mar 2025 13:47:15 +0100 Subject: [PATCH 1502/1941] Add create_todo action to Habitica integration (#140090) --- homeassistant/components/habitica/const.py | 1 + homeassistant/components/habitica/icons.json | 6 ++ homeassistant/components/habitica/services.py | 24 ++--- .../components/habitica/services.yaml | 17 +++- .../components/habitica/strings.json | 58 ++++++++++- tests/components/habitica/test_services.py | 99 ++++++++++++++++++- 6 files changed, 183 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index c33edc0161d..cf9d08c160c 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -72,6 +72,7 @@ SERVICE_CREATE_REWARD = "create_reward" SERVICE_UPDATE_HABIT = "update_habit" SERVICE_CREATE_HABIT = "create_habit" SERVICE_UPDATE_TODO = "update_todo" +SERVICE_CREATE_TODO = "create_todo" 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 f4f045523d4..85adfa09304 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -253,6 +253,12 @@ "duedate_options": "mdi:calendar-blank", "reminder_options": "mdi:reminder" } + }, + "create_todo": { + "service": "mdi:pencil-box-outline", + "sections": { + "developer_options": "mdi:test-tube" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index f1e92d863ca..bb8f69a8d11 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -79,6 +79,7 @@ from .const import ( SERVICE_CAST_SKILL, SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -214,6 +215,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_UPDATE_HABIT: TaskType.HABIT, SERVICE_CREATE_HABIT: TaskType.HABIT, SERVICE_UPDATE_TODO: TaskType.TODO, + SERVICE_CREATE_TODO: TaskType.TODO, } @@ -811,20 +813,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_REWARD, - create_or_update_task, - schema=SERVICE_CREATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_HABIT, - create_or_update_task, - schema=SERVICE_CREATE_TASK_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) + for service in (SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, SERVICE_CREATE_TODO): + hass.services.async_register( + DOMAIN, + service, + create_or_update_task, + schema=SERVICE_CREATE_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 2464b39529b..acbe4e62824 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -271,7 +271,7 @@ update_todo: checklist_options: collapsed: true fields: - add_checklist_item: + add_checklist_item: &add_checklist_item required: false selector: text: @@ -295,7 +295,7 @@ update_todo: duedate_options: collapsed: true fields: - date: + date: &due_date required: false selector: date: @@ -308,7 +308,7 @@ update_todo: reminder_options: collapsed: true fields: - reminder: + reminder: &reminder required: false selector: text: @@ -328,3 +328,14 @@ update_todo: label: "🗑️" tag_options: *tag_options developer_options: *developer_options +create_todo: + fields: + config_entry: *config_entry + name: *name + notes: *notes + add_checklist_item: *add_checklist_item + priority: *priority + date: *due_date + reminder: *reminder + tag: *tag + developer_options: *developer_options diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index d77bbd6f2be..513c0b36b27 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -49,7 +49,9 @@ "clear_reminder_name": "Clear all reminders", "clear_reminder_description": "Remove all reminders from a Habitica task.", "reminder_options_name": "Reminders", - "reminder_options_description": "Add, remove or clear reminders of a Habitica task." + "reminder_options_description": "Add, remove or clear reminders of a Habitica task.", + "date_name": "Due date", + "date_description": "The to-do's due date." }, "config": { "abort": { @@ -929,8 +931,8 @@ "description": "[%key:component::habitica::common::priority_description%]" }, "date": { - "name": "Due date", - "description": "The to-do's due date." + "name": "[%key:component::habitica::common::date_name%]", + "description": "[%key:component::habitica::common::date_description%]" }, "clear_date": { "name": "Clear due date", @@ -971,7 +973,7 @@ "description": "[%key:component::habitica::common::checklist_options_description%]" }, "duedate_options": { - "name": "Due date", + "name": "[%key:component::habitica::common::date_name%]", "description": "Set, update or remove due dates of a to-do." }, "reminder_options": { @@ -987,6 +989,54 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "create_todo": { + "name": "Create to-do", + "description": "Adds a new to-do.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica account to create a to-do." + }, + "name": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "[%key:component::habitica::common::name_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_description%]" + }, + "tag": { + "name": "[%key:component::habitica::common::tag_name%]", + "description": "[%key:component::habitica::common::tag_description%]" + }, + "alias": { + "name": "[%key:component::habitica::common::alias_name%]", + "description": "[%key:component::habitica::common::alias_description%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "date": { + "name": "[%key:component::habitica::common::date_name%]", + "description": "[%key:component::habitica::common::date_description%]" + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + } + }, + "sections": { + "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/test_services.py b/tests/components/habitica/test_services.py index 3fd477f6858..238cb8412ba 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -55,6 +55,7 @@ from homeassistant.components.habitica.const import ( SERVICE_CAST_SKILL, SERVICE_CREATE_HABIT, SERVICE_CREATE_REWARD, + SERVICE_CREATE_TODO, SERVICE_GET_TASKS, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, @@ -1002,7 +1003,7 @@ async def test_update_task_exceptions( ) @pytest.mark.parametrize( "service", - [SERVICE_CREATE_REWARD, SERVICE_CREATE_HABIT], + [SERVICE_CREATE_REWARD, SERVICE_CREATE_HABIT, SERVICE_CREATE_TODO], ) @pytest.mark.usefixtures("habitica") async def test_create_task_exceptions( @@ -1509,6 +1510,102 @@ async def test_update_todo( habitica.update_task.assert_awaited_with(UUID(task_id), call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_NAME: "TITLE", + }, + Task(type=TaskType.TODO, text="TITLE"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_NOTES: "NOTES", + }, + Task(type=TaskType.TODO, text="TITLE", notes="NOTES"), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + type=TaskType.TODO, + text="TITLE", + checklist=[ + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_PRIORITY: "trivial", + }, + Task(type=TaskType.TODO, text="TITLE", priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_DATE: "2025-03-05", + }, + Task(type=TaskType.TODO, text="TITLE", date=datetime(2025, 3, 5)), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_REMINDER: ["2025-02-25T00:00"], + }, + Task( + type=TaskType.TODO, + text="TITLE", + reminders=[ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 0, 0), + startDate=None, + ) + ], + ), + ), + ( + { + ATTR_NAME: "TITLE", + ATTR_ALIAS: "ALIAS", + }, + Task(type=TaskType.TODO, text="TITLE", alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +async def test_create_todo( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica create todo action.""" + + await hass.services.async_call( + DOMAIN, + SERVICE_CREATE_TODO, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + habitica.create_task.assert_awaited_with(call_args) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From 1a46edffaa2c55c947997ba665dc5e3c6b7355e0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 9 Mar 2025 14:20:31 +0100 Subject: [PATCH 1503/1941] Deprecate use of invalid unit of measurement for mqtt sensor (#140164) * Deprecate use of invalid unit of measurement for mqtt sensor * Update learn more URL to point to user docs instead * typo --- homeassistant/components/mqtt/sensor.py | 57 +++++++++++++++++- homeassistant/components/mqtt/strings.json | 4 ++ tests/components/mqtt/test_sensor.py | 68 +++++++++++++++++++++- 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3e8a4fef0fa..4d67b0d56e6 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import sensor from homeassistant.components.sensor import ( CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, STATE_CLASSES_SCHEMA, @@ -33,13 +34,14 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_OPTIONS, CONF_STATE_TOPIC, PAYLOAD_NONE +from .const import CONF_OPTIONS, CONF_STATE_TOPIC, DOMAIN, PAYLOAD_NONE from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -63,6 +65,10 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False +URL_DOCS_SUPPORTED_SENSOR_UOM = ( + "https://www.home-assistant.io/integrations/sensor/#device-class" +) + _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), @@ -107,6 +113,23 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) + if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is None: + return config + + if ( + device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + _LOGGER.warning( + "The unit of measurement `%s` is not valid " + "together with device class `%s`. " + "this will stop working in HA Core 2025.7.0", + unit_of_measurement, + device_class, + ) + return config @@ -155,8 +178,40 @@ class MqttSensor(MqttEntity, RestoreSensor): None ) + @callback + def async_check_uom(self) -> None: + """Check if the unit of measurement is valid with the device class.""" + if ( + self._discovery_data is not None + or self.device_class is None + or self.native_unit_of_measurement is None + ): + return + if ( + self.device_class in DEVICE_CLASS_UNITS + and self.native_unit_of_measurement + not in DEVICE_CLASS_UNITS[self.device_class] + ): + async_create_issue( + self.hass, + DOMAIN, + self.entity_id, + issue_domain=sensor.DOMAIN, + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url=URL_DOCS_SUPPORTED_SENSOR_UOM, + translation_placeholders={ + "uom": self.native_unit_of_measurement, + "device_class": self.device_class.value, + "entity_id": self.entity_id, + }, + translation_key="invalid_unit_of_measurement", + breaks_in_ha_version="2025.7.0", + ) + async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" + self.async_check_uom() last_state: State | None last_sensor_data: SensorExtraStoredData | None if ( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 8805f447d69..4eb41b9e39a 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -3,6 +3,10 @@ "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." + }, + "invalid_unit_of_measurement": { + "title": "Sensor with invalid unit of measurement", + "description": "Manual configured Sensor entity **{entity_id}** has a configured unit of measurement **{uom}** which is not valid with configured device class **{device_class}**. Make sure a valid unit of measurement is configured or remove the device class, and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." } }, "config": { diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 9226b03a7d2..1fcd70a0b10 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -71,6 +71,7 @@ from .test_common import ( from tests.common import ( MockConfigEntry, + async_capture_events, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache_with_extra_data, @@ -870,6 +871,71 @@ async def test_invalid_device_class( assert "expected SensorDeviceClass or one of" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "device_class": "energy", + "unit_of_measurement": "ppm", + } + } + } + ], +) +async def test_invalid_unit_of_measurement( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device_class with invalid unit of measurement.""" + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + assert await mqtt_mock_entry() + assert ( + "The unit of measurement `ppm` is not valid together with device class `energy`" + in caplog.text + ) + # A repair issue was logged + assert len(events) == 1 + assert events[0].data["issue_id"] == "sensor.test" + # Assert the sensor works + async_fire_mqtt_message(hass, "test-topic", "100") + await hass.async_block_till_done() + state = hass.states.get("sensor.test") + assert state is not None + assert state.state == "100" + + caplog.clear() + + discovery_payload = { + "name": "bla", + "state_topic": "test-topic2", + "device_class": "temperature", + "unit_of_measurement": "C", + } + # Now discover an other invalid sensor + async_fire_mqtt_message( + hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload) + ) + await hass.async_block_till_done() + assert ( + "The unit of measurement `C` is not valid together with device class `temperature`" + in caplog.text + ) + # Assert the sensor works + async_fire_mqtt_message(hass, "test-topic2", "21") + await hass.async_block_till_done() + state = hass.states.get("sensor.bla") + assert state is not None + assert state.state == "21" + + # No new issue was registered for the discovered entity + assert len(events) == 1 + + @pytest.mark.parametrize( "hass_config", [ From e8069e1c073e26003956533d3900d83687cc1095 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 9 Mar 2025 15:15:27 +0100 Subject: [PATCH 1504/1941] Add template functions: md5, sha1, sha256, sha512 (#140192) --- homeassistant/helpers/template.py | 29 +++++++++++++++++ tests/helpers/test_template.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 357fe15f3be..20531596fdd 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -12,6 +12,7 @@ from contextvars import ContextVar from copy import deepcopy from datetime import date, datetime, time, timedelta from functools import cache, lru_cache, partial, wraps +import hashlib import json import logging import math @@ -2784,6 +2785,26 @@ def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: return flattened +def md5(value: str) -> str: + """Generate md5 hash from a string.""" + return hashlib.md5(value.encode()).hexdigest() + + +def sha1(value: str) -> str: + """Generate sha1 hash from a string.""" + return hashlib.sha1(value.encode()).hexdigest() + + +def sha256(value: str) -> str: + """Generate sha256 hash from a string.""" + return hashlib.sha256(value.encode()).hexdigest() + + +def sha512(value: str) -> str: + """Generate sha512 hash from a string.""" + return hashlib.sha512(value.encode()).hexdigest() + + class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -2987,6 +3008,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["shuffle"] = shuffle self.filters["typeof"] = typeof self.filters["flatten"] = flatten + self.filters["md5"] = md5 + self.filters["sha1"] = sha1 + self.filters["sha256"] = sha256 + self.filters["sha512"] = sha512 self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -3027,6 +3052,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["shuffle"] = shuffle self.globals["typeof"] = typeof self.globals["flatten"] = flatten + self.globals["md5"] = md5 + self.globals["sha1"] = sha1 + self.globals["sha256"] = sha256 + self.globals["sha512"] = sha512 self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f9154b23bad..bdf400ce357 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6788,3 +6788,55 @@ def test_flatten(hass: HomeAssistant) -> None: with pytest.raises(TemplateError): template.Template("{{ flatten() }}", hass).async_render() + + +def test_md5(hass: HomeAssistant) -> None: + """Test the md5 function and filter.""" + assert ( + template.Template("{{ md5('Home Assistant') }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + assert ( + template.Template("{{ 'Home Assistant' | md5 }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + +def test_sha1(hass: HomeAssistant) -> None: + """Test the sha1 function and filter.""" + assert ( + template.Template("{{ sha1('Home Assistant') }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha1 }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + +def test_sha256(hass: HomeAssistant) -> None: + """Test the sha256 function and filter.""" + assert ( + template.Template("{{ sha256('Home Assistant') }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha256 }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + +def test_sha512(hass: HomeAssistant) -> None: + """Test the sha512 function and filter.""" + assert ( + template.Template("{{ sha512('Home Assistant') }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha512 }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) From 8a67e89e9154c23a28937f6e1cc19d84d851ca3b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 9 Mar 2025 15:18:26 +0100 Subject: [PATCH 1505/1941] Improve category map for air quality and pollen sensors in AccuWeather (#140193) * Fix typo * Improve category map for air quality and pollen * Update test snapshot --- homeassistant/components/accuweather/const.py | 5 +++-- homeassistant/components/accuweather/strings.json | 6 ++++-- .../components/accuweather/snapshots/test_sensor.ambr | 10 ++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index f09b9771ab6..7216f5a0b9b 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -59,13 +59,14 @@ AIR_QUALITY_CATEGORY_MAP = { 1: "good", 2: "moderate", 3: "unhealthy", - 4: "hazardous", + 4: "very_unhealthy", + 5: "hazardous", } POLLEN_CATEGORY_MAP = { 1: "low", 2: "moderate", 3: "high", - 4: "very high", + 4: "very_high", } UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index e5190b7a8da..d9777352b93 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -27,7 +27,8 @@ "good": "Good", "hazardous": "Hazardous", "moderate": "Moderate", - "unhealthy": "Unhealthy" + "unhealthy": "Unhealthy", + "very_unhealthy": "Very unhealthy" }, "state_attributes": { "options": { @@ -35,7 +36,8 @@ "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]", "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]", "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]", - "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]" + "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]", + "very_unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::very_unhealthy%]" } } } diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index 3176f0a88bd..cbd2e14207e 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -9,6 +9,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -50,6 +51,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -71,6 +73,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -112,6 +115,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -133,6 +137,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -174,6 +179,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -195,6 +201,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -236,6 +243,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -257,6 +265,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), @@ -298,6 +307,7 @@ 'good', 'moderate', 'unhealthy', + 'very_unhealthy', 'hazardous', ]), }), From 264d4a53a2a8c9ac3ea22d8d6b54d76356cde2ed Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Sun, 9 Mar 2025 15:23:44 +0100 Subject: [PATCH 1506/1941] Update govee-local-api to 2.1.0 (#140201) --- homeassistant/components/govee_light_local/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index cba341cd482..55a6b9e8578 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==2.0.1"] + "requirements": ["govee-local-api==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 950d4aa12cb..c20da4f9034 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1061,7 +1061,7 @@ gotailwind==0.3.0 govee-ble==0.43.1 # homeassistant.components.govee_light_local -govee-local-api==2.0.1 +govee-local-api==2.1.0 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 503d33e8a8b..cc29e4af080 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -911,7 +911,7 @@ gotailwind==0.3.0 govee-ble==0.43.1 # homeassistant.components.govee_light_local -govee-local-api==2.0.1 +govee-local-api==2.1.0 # homeassistant.components.gpsd gps3==0.33.3 From befcd632217eeb1b3c7a2d6faec12730c3d34cb0 Mon Sep 17 00:00:00 2001 From: msm595 Date: Sun, 9 Mar 2025 11:07:35 -0400 Subject: [PATCH 1507/1941] Fix the order of the group members attribute of the Music Assistant integration (#140204) --- .../music_assistant/media_player.py | 32 +++++++++++-------- .../snapshots/test_media_player.ambr | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index c079fd20e91..56bde7bbae7 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -276,22 +276,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState(player.state.value) else: self._attr_state = MediaPlayerState(STATE_OFF) - group_members_entity_ids: list[str] = [] + + group_members: list[str] = [] if player.group_childs: - # translate MA group_childs to HA group_members as entity id's - entity_registry = er.async_get(self.hass) - group_members_entity_ids = [ - entity_id - for child_id in player.group_childs - if ( - entity_id := entity_registry.async_get_entity_id( - self.platform.domain, DOMAIN, child_id - ) + group_members = player.group_childs + elif player.synced_to and (parent := self.mass.players.get(player.synced_to)): + group_members = parent.group_childs + + # translate MA group_childs to HA group_members as entity id's + entity_registry = er.async_get(self.hass) + group_members_entity_ids: list[str] = [ + entity_id + for child_id in group_members + if ( + entity_id := entity_registry.async_get_entity_id( + self.platform.domain, DOMAIN, child_id ) - ] - # NOTE: we sort the group_members for now, - # until the MA API returns them sorted (group_childs is now a set) - self._attr_group_members = sorted(group_members_entity_ids) + ) + ] + + self._attr_group_members = group_members_entity_ids self._attr_volume_level = ( player.volume_level / 100 if player.volume_level is not None else None ) diff --git a/tests/components/music_assistant/snapshots/test_media_player.ambr b/tests/components/music_assistant/snapshots/test_media_player.ambr index a07bde4b29d..50223ddf623 100644 --- a/tests/components/music_assistant/snapshots/test_media_player.ambr +++ b/tests/components/music_assistant/snapshots/test_media_player.ambr @@ -109,8 +109,8 @@ 'entity_picture_local': None, 'friendly_name': 'Test Group Player 1', 'group_members': list([ - 'media_player.my_super_test_player_2', 'media_player.test_player_1', + 'media_player.my_super_test_player_2', ]), 'icon': 'mdi:speaker-multiple', 'is_volume_muted': False, From 8a51644d1db59bc39642043a2c11c4ad42394715 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 9 Mar 2025 18:04:00 +0100 Subject: [PATCH 1508/1941] Align CONF_ in Shelly integration (#140202) * Allign CONST_ in Shelly integration * apply review comment --- homeassistant/components/shelly/__init__.py | 10 +++++-- .../components/shelly/config_flow.py | 27 ++++++++++--------- .../components/shelly/coordinator.py | 3 ++- .../components/shelly/diagnostics.py | 20 +++++++++----- homeassistant/components/shelly/utils.py | 11 +++++--- 5 files changed, 46 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 55b75b3face..7440013940c 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -16,7 +16,13 @@ from aioshelly.rpc_device import RpcDevice, bluetooth_mac_from_primary_mac import voluptuous as vol from homeassistant.components.bluetooth import async_remove_scanner -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( @@ -159,7 +165,7 @@ async def _async_setup_block_entry( # Following code block will force the right value for affected devices if ( sleep_period == BLOCK_WRONG_SLEEP_PERIOD - and entry.data["model"] in MODELS_WITH_WRONG_SLEEP_PERIOD + and entry.data[CONF_MODEL] in MODELS_WITH_WRONG_SLEEP_PERIOD ): LOGGER.warning( "Updating stored sleep period for %s: from %s to %s", diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 5c5e187a0f4..8e47235c981 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -31,6 +31,7 @@ from homeassistant.config_entries import ( from homeassistant.const import ( CONF_HOST, CONF_MAC, + CONF_MODEL, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, @@ -116,7 +117,9 @@ async def validate_input( return { "title": rpc_device.name, CONF_SLEEP_PERIOD: sleep_period, - "model": rpc_device.xmod_info.get("p") or rpc_device.shelly.get("model"), + CONF_MODEL: ( + rpc_device.xmod_info.get("p") or rpc_device.shelly.get(CONF_MODEL) + ), CONF_GEN: gen, } @@ -136,7 +139,7 @@ async def validate_input( return { "title": block_device.name, CONF_SLEEP_PERIOD: sleep_period, - "model": block_device.model, + CONF_MODEL: block_device.model, CONF_GEN: gen, } @@ -191,14 +194,14 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if device_info["model"]: + if device_info[CONF_MODEL]: return self.async_create_entry( title=device_info["title"], data={ CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], - "model": device_info["model"], + CONF_MODEL: device_info[CONF_MODEL], CONF_GEN: device_info[CONF_GEN], }, ) @@ -230,7 +233,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if device_info["model"]: + if device_info[CONF_MODEL]: return self.async_create_entry( title=device_info["title"], data={ @@ -238,7 +241,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self.host, CONF_PORT: self.port, CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD], - "model": device_info["model"], + CONF_MODEL: device_info[CONF_MODEL], CONF_GEN: device_info[CONF_GEN], }, ) @@ -336,7 +339,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle discovery confirm.""" errors: dict[str, str] = {} - if not self.device_info["model"]: + if not self.device_info[CONF_MODEL]: errors["base"] = "firmware_not_fully_provisioned" model = "Shelly" else: @@ -345,9 +348,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=self.device_info["title"], data={ - "host": self.host, + CONF_HOST: self.host, CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD], - "model": self.device_info["model"], + CONF_MODEL: self.device_info[CONF_MODEL], CONF_GEN: self.device_info[CONF_GEN], }, ) @@ -356,8 +359,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", description_placeholders={ - "model": model, - "host": self.host, + CONF_MODEL: model, + CONF_HOST: self.host, }, errors=errors, ) @@ -466,7 +469,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): return ( get_device_entry_gen(config_entry) in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD) - and config_entry.data.get("model") != MODEL_WALL_DISPLAY + and config_entry.data.get(CONF_MODEL) != MODEL_WALL_DISPLAY ) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index bebf8efbdd7..95812c12e10 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -26,6 +26,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, CONF_HOST, + CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform, ) @@ -139,7 +140,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @cached_property def model(self) -> str: """Model of the device.""" - return cast(str, self.config_entry.data["model"]) + return cast(str, self.config_entry.data[CONF_MODEL]) @cached_property def mac(self) -> str: diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index d56a2884e17..cac2bb2f16b 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -6,7 +6,13 @@ from typing import Any from homeassistant.components.bluetooth import async_scanner_by_source from homeassistant.components.diagnostics import async_redact_data -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from .coordinator import ShellyConfigEntry @@ -30,9 +36,9 @@ async def async_get_config_entry_diagnostics( block_coordinator = shelly_entry_data.block assert block_coordinator device_info = { - "name": block_coordinator.name, - "model": block_coordinator.model, - "sw_version": block_coordinator.sw_version, + ATTR_NAME: block_coordinator.name, + ATTR_MODEL: block_coordinator.model, + ATTR_SW_VERSION: block_coordinator.sw_version, } if block_coordinator.device.initialized: device_settings = { @@ -65,9 +71,9 @@ async def async_get_config_entry_diagnostics( rpc_coordinator = shelly_entry_data.rpc assert rpc_coordinator device_info = { - "name": rpc_coordinator.name, - "model": rpc_coordinator.model, - "sw_version": rpc_coordinator.sw_version, + ATTR_NAME: rpc_coordinator.name, + ATTR_MODEL: rpc_coordinator.model, + ATTR_SW_VERSION: rpc_coordinator.sw_version, } if rpc_coordinator.device.initialized: device_settings = { diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index b478e416c50..626cb287f64 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -28,7 +28,12 @@ from yarl import URL from homeassistant.components import network from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_MODEL, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( device_registry as dr, @@ -322,7 +327,7 @@ def get_info_gen(info: dict[str, Any]) -> int: def get_model_name(info: dict[str, Any]) -> str: """Return the device model name.""" if get_info_gen(info) in RPC_GENERATIONS: - return cast(str, MODEL_NAMES.get(info["model"], info["model"])) + return cast(str, MODEL_NAMES.get(info[CONF_MODEL], info[CONF_MODEL])) return cast(str, MODEL_NAMES.get(info["type"], info["type"])) @@ -514,7 +519,7 @@ def async_create_issue_unsupported_firmware( translation_key="unsupported_firmware", translation_placeholders={ "device_name": entry.title, - "ip_address": entry.data["host"], + "ip_address": entry.data[CONF_HOST], }, ) From 7cbcdbe6104136b9ba81adfefa934c1e33104f84 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 9 Mar 2025 20:01:07 +0100 Subject: [PATCH 1509/1941] Log broad exception in Electricity Maps config flow (#140219) --- homeassistant/components/co2signal/config_flow.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 530496811d9..00acd2829a6 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -3,11 +3,11 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from aioelectricitymaps import ( ElectricityMaps, - ElectricityMapsError, ElectricityMapsInvalidTokenError, ElectricityMapsNoDataError, ) @@ -36,6 +36,8 @@ TYPE_USE_HOME = "use_home_location" TYPE_SPECIFY_COORDINATES = "specify_coordinates" TYPE_SPECIFY_COUNTRY = "specify_country_code" +_LOGGER = logging.getLogger(__name__) + class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Co2signal.""" @@ -158,7 +160,8 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except ElectricityMapsNoDataError: errors["base"] = "no_data" - except ElectricityMapsError: + except Exception: + _LOGGER.exception("Unexpected error occurred while checking API key") errors["base"] = "unknown" else: if self.source == SOURCE_REAUTH: From 7eeb3df1c29c9b01661976e1f31cd19c90570a3a Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sun, 9 Mar 2025 15:03:03 -0400 Subject: [PATCH 1510/1941] Bump upb-lib to 0.6.1 (#140212) --- homeassistant/components/upb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index e5da4c4d621..b40388be71b 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.6.0"] + "requirements": ["upb-lib==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c20da4f9034..2cf57251ac6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2977,7 +2977,7 @@ unifiled==0.11 universal-silabs-flasher==0.0.29 # homeassistant.components.upb -upb-lib==0.6.0 +upb-lib==0.6.1 # homeassistant.components.upcloud upcloud-api==2.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc29e4af080..c17f56f5eb1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2393,7 +2393,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.upb -upb-lib==0.6.0 +upb-lib==0.6.1 # homeassistant.components.upcloud upcloud-api==2.6.0 From f3a43e273aa9dfa6ddc7c09fdec9cfb205b848ad Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 9 Mar 2025 20:11:13 +0100 Subject: [PATCH 1511/1941] Fix mysensors climate target temps (#140220) * Test hvac node only hvac * Assert supported features in all climate tests * Fix mysensors climate target temperatures --- homeassistant/components/mysensors/climate.py | 27 +++--- tests/components/mysensors/conftest.py | 15 ++++ .../fixtures/hvac_node_only_hvac_state.json | 22 +++++ tests/components/mysensors/test_climate.py | 88 ++++++++++++++++++- 4 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d1b697a3458..a42861c5fa2 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -82,7 +82,10 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): and set_req.V_HVAC_SETPOINT_HEAT in self._values ): features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - else: + elif ( + set_req.V_HVAC_SETPOINT_COOL in self._values + or set_req.V_HVAC_SETPOINT_HEAT in self._values + ): features = features | ClimateEntityFeature.TARGET_TEMPERATURE return features @@ -108,13 +111,11 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): @property def target_temperature(self) -> float | None: - """Return the temperature we try to reach.""" + """Return the temperature we try to reach. + + Either V_HVAC_SETPOINT_COOL or V_HVAC_SETPOINT_HEAT may be used. + """ set_req = self.gateway.const.SetReq - if ( - set_req.V_HVAC_SETPOINT_COOL in self._values - and set_req.V_HVAC_SETPOINT_HEAT in self._values - ): - return None temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) if temp is None: temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) @@ -124,21 +125,13 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SETPOINT_HEAT in self._values: - temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL) - return float(temp) if temp is not None else None - - return None + return float(self._values[set_req.V_HVAC_SETPOINT_COOL]) @property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" set_req = self.gateway.const.SetReq - if set_req.V_HVAC_SETPOINT_COOL in self._values: - temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT) - return float(temp) if temp is not None else None - - return None + return float(self._values[set_req.V_HVAC_SETPOINT_HEAT]) @property def hvac_mode(self) -> HVACMode: diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index b14a3f9c529..c2c110466e6 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -320,6 +320,21 @@ def hvac_node_heat( return nodes[1] +@pytest.fixture(name="hvac_node_only_hvac_state", scope="package") +def hvac_node_only_hvac_state_fixture() -> dict: + """Load the hvac node only hvac state.""" + return load_nodes_state("hvac_node_only_hvac_state.json") + + +@pytest.fixture +def hvac_node_only_hvac( + gateway_nodes: dict[int, Sensor], hvac_node_only_hvac_state: dict +) -> Sensor: + """Load the hvac only hvac child node.""" + nodes = update_gateway_nodes(gateway_nodes, deepcopy(hvac_node_only_hvac_state)) + return nodes[1] + + @pytest.fixture(name="power_sensor_state", scope="package") def power_sensor_state_fixture() -> dict: """Load the power sensor state.""" diff --git a/tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json b/tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json new file mode 100644 index 00000000000..b41470e6076 --- /dev/null +++ b/tests/components/mysensors/fixtures/hvac_node_only_hvac_state.json @@ -0,0 +1,22 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 29, + "description": "", + "values": { + "0": "20.0", + "21": "Off" + } + } + }, + "type": 17, + "sketch_name": "HVAC Node", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/components/mysensors/test_climate.py b/tests/components/mysensors/test_climate.py index 959f92ff512..b919287e046 100644 --- a/tests/components/mysensors/test_climate.py +++ b/tests/components/mysensors/test_climate.py @@ -38,6 +38,8 @@ async def test_hvac_node_auto( assert state assert state.state == HVACMode.OFF assert state.attributes[ATTR_BATTERY_LEVEL] == 0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 394 # Test set hvac mode auto await hass.services.async_call( @@ -153,6 +155,8 @@ async def test_hvac_node_heat( assert state assert state.state == HVACMode.OFF assert state.attributes[ATTR_BATTERY_LEVEL] == 0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 393 # Test set hvac mode heat await hass.services.async_call( @@ -263,8 +267,10 @@ async def test_hvac_node_cool( assert state assert state.state == HVACMode.OFF assert state.attributes[ATTR_BATTERY_LEVEL] == 0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 393 - # Test set hvac mode heat + # Test set hvac mode cool await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -357,3 +363,83 @@ async def test_hvac_node_cool( assert state assert state.state == HVACMode.OFF + + +async def test_hvac_node_only_hvac( + hass: HomeAssistant, + hvac_node_only_hvac: Sensor, + receive_message: Callable[[str], None], + transport_write: MagicMock, +) -> None: + """Test a hvac only hvac node.""" + entity_id = "climate.hvac_node_1_1" + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes["supported_features"] == 384 + + # Test set hvac mode heat + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;21;HeatOn\n") + + receive_message("1;1;1;0;21;HeatOn\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + + transport_write.reset_mock() + + # Test set hvac mode cool + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;21;CoolOn\n") + + receive_message("1;1;1;0;21;CoolOn\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + + transport_write.reset_mock() + + # Test set hvac mode off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + assert transport_write.call_count == 1 + assert transport_write.call_args == call("1;1;1;1;21;Off\n") + + receive_message("1;1;1;0;21;Off\n") + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.state == HVACMode.OFF From 8b4d9f96d443f40abc56038bfa5348719fe0b55c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 9 Mar 2025 20:16:34 +0100 Subject: [PATCH 1512/1941] Remove mysensors assumed state dead code (#140227) --- homeassistant/components/mysensors/climate.py | 12 ------- homeassistant/components/mysensors/cover.py | 20 +---------- homeassistant/components/mysensors/light.py | 33 +------------------ homeassistant/components/mysensors/switch.py | 10 +----- 4 files changed, 3 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index a42861c5fa2..eb54a76b8a8 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -175,10 +175,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, value_type, value, ack=1 ) - if self.assumed_state: - # Optimistically assume that device has changed state - self._values[value_type] = value - self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target temperature.""" @@ -186,10 +182,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode, ack=1 ) - if self.assumed_state: - # Optimistically assume that device has changed state - self._values[set_req.V_HVAC_SPEED] = fan_mode - self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target temperature.""" @@ -200,10 +192,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): DICT_HA_TO_MYS[hvac_mode], ack=1, ) - if self.assumed_state: - # Optimistically assume that device has changed state - self._values[self.value_type] = hvac_mode - self.async_write_ha_state() @callback def _async_update(self) -> None: diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index 2ac0367d1fc..84346a5d10a 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -110,13 +110,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_UP, 1, ack=1 ) - if self.assumed_state: - # Optimistically assume that cover has changed state. - if set_req.V_DIMMER in self._values: - self._values[set_req.V_DIMMER] = 100 - else: - self._values[set_req.V_LIGHT] = STATE_ON - self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" @@ -124,13 +117,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DOWN, 1, ack=1 ) - if self.assumed_state: - # Optimistically assume that cover has changed state. - if set_req.V_DIMMER in self._values: - self._values[set_req.V_DIMMER] = 0 - else: - self._values[set_req.V_LIGHT] = STATE_OFF - self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" @@ -139,10 +125,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity): self.gateway.set_child_value( self.node_id, self.child_id, set_req.V_DIMMER, position, ack=1 ) - if self.assumed_state: - # Optimistically assume that cover has changed state. - self._values[set_req.V_DIMMER] = position - self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device.""" diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 4fa9eaa8ea6..fa5e625c72b 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -12,7 +12,7 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -77,11 +77,6 @@ class MySensorsLight(MySensorsChildEntity, LightEntity): self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._state = True - self._values[set_req.V_LIGHT] = STATE_ON - def _turn_on_dimmer(self, **kwargs: Any) -> None: """Turn on dimmer child device.""" set_req = self.gateway.const.SetReq @@ -98,20 +93,10 @@ class MySensorsLight(MySensorsChildEntity, LightEntity): self.node_id, self.child_id, set_req.V_DIMMER, percent, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._attr_brightness = brightness - self._values[set_req.V_DIMMER] = percent - async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value(self.node_id, self.child_id, value_type, 0, ack=1) - if self.assumed_state: - # optimistically assume that light has changed state - self._state = False - self._values[value_type] = STATE_OFF - self.async_write_ha_state() @callback def _async_update_light(self) -> None: @@ -139,8 +124,6 @@ class MySensorsLightDimmer(MySensorsLight): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) - if self.assumed_state: - self.async_write_ha_state() @callback def _async_update(self) -> None: @@ -161,8 +144,6 @@ class MySensorsLightRGB(MySensorsLight): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb(**kwargs) - if self.assumed_state: - self.async_write_ha_state() def _turn_on_rgb(self, **kwargs: Any) -> None: """Turn on RGB child device.""" @@ -176,11 +157,6 @@ class MySensorsLightRGB(MySensorsLight): self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._attr_rgb_color = new_rgb - self._values[self.value_type] = hex_color - @callback def _async_update(self) -> None: """Update the controller with the latest value from a sensor.""" @@ -209,8 +185,6 @@ class MySensorsLightRGBW(MySensorsLightRGB): self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgbw(**kwargs) - if self.assumed_state: - self.async_write_ha_state() def _turn_on_rgbw(self, **kwargs: Any) -> None: """Turn on RGBW child device.""" @@ -224,11 +198,6 @@ class MySensorsLightRGBW(MySensorsLightRGB): self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) - if self.assumed_state: - # optimistically assume that light has changed state - self._attr_rgbw_color = new_rgbw - self._values[self.value_type] = hex_color - @callback def _async_update_rgb_or_w(self) -> None: """Update the controller with values from RGBW child.""" diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 499124919b5..9b57102a94c 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -69,17 +69,9 @@ class MySensorsSwitch(MySensorsChildEntity, SwitchEntity): self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 1, ack=1 ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = STATE_ON - self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 0, ack=1 ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = STATE_OFF - self.async_write_ha_state() From ff622af888a02adaa7ef1e8cc665e0374228b678 Mon Sep 17 00:00:00 2001 From: Keith <22891515+keithle888@users.noreply.github.com> Date: Sun, 9 Mar 2025 20:47:13 +0100 Subject: [PATCH 1513/1941] Add locking and unlocking feature to igloohome integration (#136002) * - Added lock platform - Added creation of IgloohomeLockEntity when bridge devices are included. * - Migrated retrieval of linked_bridge utility to utils module. - Added ability for lock to update it's own linked bridge automatically * - Added mock bridge device to test fixture * - Added snapshot test for lock module * - Added bridge with no linked devices - Added test for util.get_linked_bridge * - Added handling of errors from API call * - Bump igloohome-api to v0.1.0 * - Minor change * - Removed async update for locks. Focus on MVP * - Removed need for update on entity creation * - Updated snapshot test * - Updated snapshot * - Updated to use walrus during lock entity creation - Updated callback class for async_setup_entry based on lint suggestion * - Set _attr_name as None - Updated snapshot test * Update homeassistant/components/igloohome/lock.py * Update homeassistant/components/igloohome/lock.py --------- Co-authored-by: Josef Zweck --- .../components/igloohome/__init__.py | 3 +- homeassistant/components/igloohome/lock.py | 91 +++++++++++++++++++ homeassistant/components/igloohome/utils.py | 16 ++++ tests/components/igloohome/conftest.py | 29 +++++- .../igloohome/snapshots/test_lock.ambr | 50 ++++++++++ tests/components/igloohome/test_lock.py | 26 ++++++ tests/components/igloohome/test_utils.py | 31 +++++++ 7 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/igloohome/lock.py create mode 100644 homeassistant/components/igloohome/utils.py create mode 100644 tests/components/igloohome/snapshots/test_lock.ambr create mode 100644 tests/components/igloohome/test_lock.py create mode 100644 tests/components/igloohome/test_utils.py diff --git a/homeassistant/components/igloohome/__init__.py b/homeassistant/components/igloohome/__init__.py index 5e5e21452cf..a3907fcbcf3 100644 --- a/homeassistant/components/igloohome/__init__.py +++ b/homeassistant/components/igloohome/__init__.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR] @dataclass @@ -35,7 +35,6 @@ type IgloohomeConfigEntry = ConfigEntry[IgloohomeRuntimeData] async def async_setup_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool: """Set up igloohome from a config entry.""" - authentication = IgloohomeAuth( session=async_get_clientsession(hass), client_id=entry.data[CONF_CLIENT_ID], diff --git a/homeassistant/components/igloohome/lock.py b/homeassistant/components/igloohome/lock.py new file mode 100644 index 00000000000..b434c055145 --- /dev/null +++ b/homeassistant/components/igloohome/lock.py @@ -0,0 +1,91 @@ +"""Implementation of the lock platform.""" + +from datetime import timedelta + +from aiohttp import ClientError +from igloohome_api import ( + BRIDGE_JOB_LOCK, + BRIDGE_JOB_UNLOCK, + DEVICE_TYPE_LOCK, + Api as IgloohomeApi, + ApiException, + GetDeviceInfoResponse, +) + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import IgloohomeConfigEntry +from .entity import IgloohomeBaseEntity +from .utils import get_linked_bridge + +# Scan interval set to allow Lock entity update the bridge linked to it. +SCAN_INTERVAL = timedelta(hours=1) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IgloohomeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up lock entities.""" + async_add_entities( + IgloohomeLockEntity( + api_device_info=device, + api=entry.runtime_data.api, + bridge_id=str(bridge), + ) + for device in entry.runtime_data.devices + if device.type == DEVICE_TYPE_LOCK + and (bridge := get_linked_bridge(device.deviceId, entry.runtime_data.devices)) + is not None + ) + + +class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity): + """Implementation of a device that has locking capabilities.""" + + # Operating on assumed state because there is no API to query the state. + _attr_assumed_state = True + _attr_supported_features = LockEntityFeature.OPEN + _attr_name = None + + def __init__( + self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi, bridge_id: str + ) -> None: + """Initialize the class.""" + super().__init__( + api_device_info=api_device_info, + api=api, + unique_key="lock", + ) + self.bridge_id = bridge_id + + async def async_lock(self, **kwargs): + """Lock this lock.""" + try: + await self.api.create_bridge_proxied_job( + self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_LOCK + ) + except (ApiException, ClientError) as err: + raise HomeAssistantError from err + + async def async_unlock(self, **kwargs): + """Unlock this lock.""" + try: + await self.api.create_bridge_proxied_job( + self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_UNLOCK + ) + except (ApiException, ClientError) as err: + raise HomeAssistantError from err + + async def async_open(self, **kwargs): + """Open (unlatch) this lock.""" + try: + await self.api.create_bridge_proxied_job( + self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_UNLOCK + ) + except (ApiException, ClientError) as err: + raise HomeAssistantError from err diff --git a/homeassistant/components/igloohome/utils.py b/homeassistant/components/igloohome/utils.py new file mode 100644 index 00000000000..be17912b8b8 --- /dev/null +++ b/homeassistant/components/igloohome/utils.py @@ -0,0 +1,16 @@ +"""House utility functions.""" + +from igloohome_api import DEVICE_TYPE_BRIDGE, GetDeviceInfoResponse + + +def get_linked_bridge( + device_id: str, devices: list[GetDeviceInfoResponse] +) -> str | None: + """Return the ID of the bridge that is linked to the device. None if no bridge is linked.""" + bridges = (bridge for bridge in devices if bridge.type == DEVICE_TYPE_BRIDGE) + for bridge in bridges: + if device_id in ( + linked_device.deviceId for linked_device in bridge.linkedDevices + ): + return bridge.deviceId + return None diff --git a/tests/components/igloohome/conftest.py b/tests/components/igloohome/conftest.py index d630f5af7cb..6c4eb4904ae 100644 --- a/tests/components/igloohome/conftest.py +++ b/tests/components/igloohome/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from igloohome_api import GetDeviceInfoResponse, GetDevicesResponse +from igloohome_api import GetDeviceInfoResponse, GetDevicesResponse, LinkedDevice import pytest from homeassistant.components.igloohome.const import DOMAIN @@ -23,6 +23,28 @@ GET_DEVICE_INFO_RESPONSE_LOCK = GetDeviceInfoResponse( batteryLevel=100, ) +GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK = GetDeviceInfoResponse( + id="001", + type="Bridge", + deviceId="EB1X04eeeeee", + deviceName="Home Bridge", + pairedAt="2024-11-09T12:19:25+00:00", + homeId=[], + linkedDevices=[LinkedDevice(type="Lock", deviceId="OE1X123cbb11")], + batteryLevel=None, +) + +GET_DEVICE_INFO_RESPONSE_BRIDGE_NO_LINKED_DEVICE = GetDeviceInfoResponse( + id="001", + type="Bridge", + deviceId="EB1X04eeeeee", + deviceName="Home Bridge", + pairedAt="2024-11-09T12:19:25+00:00", + homeId=[], + linkedDevices=[], + batteryLevel=None, +) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -66,7 +88,10 @@ def mock_api() -> Generator[AsyncMock]: api = api_mock.return_value api.get_devices.return_value = GetDevicesResponse( nextCursor="", - payload=[GET_DEVICE_INFO_RESPONSE_LOCK], + payload=[ + GET_DEVICE_INFO_RESPONSE_LOCK, + GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK, + ], ) api.get_device_info.return_value = GET_DEVICE_INFO_RESPONSE_LOCK yield api diff --git a/tests/components/igloohome/snapshots/test_lock.ambr b/tests/components/igloohome/snapshots/test_lock.ambr new file mode 100644 index 00000000000..5d94cf27c6b --- /dev/null +++ b/tests/components/igloohome/snapshots/test_lock.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_lock[lock.front_door-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.front_door', + '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': 'igloohome', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lock_OE1X123cbb11', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[lock.front_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'Front Door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.front_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/igloohome/test_lock.py b/tests/components/igloohome/test_lock.py new file mode 100644 index 00000000000..324a4ab231a --- /dev/null +++ b/tests/components/igloohome/test_lock.py @@ -0,0 +1,26 @@ +"""Test lock module for igloohome integration.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_lock( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test lock entity created.""" + with patch("homeassistant.components.igloohome.PLATFORMS", [Platform.LOCK]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/igloohome/test_utils.py b/tests/components/igloohome/test_utils.py new file mode 100644 index 00000000000..a6262076eed --- /dev/null +++ b/tests/components/igloohome/test_utils.py @@ -0,0 +1,31 @@ +"""Test functions in utils module.""" + +from homeassistant.components.igloohome.utils import get_linked_bridge + +from .conftest import ( + GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK, + GET_DEVICE_INFO_RESPONSE_BRIDGE_NO_LINKED_DEVICE, + GET_DEVICE_INFO_RESPONSE_LOCK, +) + + +def test_get_linked_bridge_expect_bridge_id_returned() -> None: + """Test that get_linked_bridge returns the bridge ID.""" + assert ( + get_linked_bridge( + GET_DEVICE_INFO_RESPONSE_LOCK.deviceId, + [GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK], + ) + == GET_DEVICE_INFO_RESPONSE_BRIDGE_LINKED_LOCK.deviceId + ) + + +def test_get_linked_bridge_expect_none_returned() -> None: + """Test that get_linked_bridge returns None.""" + assert ( + get_linked_bridge( + GET_DEVICE_INFO_RESPONSE_LOCK.deviceId, + [GET_DEVICE_INFO_RESPONSE_BRIDGE_NO_LINKED_DEVICE], + ) + is None + ) From 717e5b95e65117139d35ef7fe8d1bee246a16f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 9 Mar 2025 21:40:15 +0100 Subject: [PATCH 1514/1941] Add 900 RPM option to washer spin speed options at Home Connect (#140234) Add 900 RPM option to washer spin speed options --- homeassistant/components/home_connect/const.py | 1 + homeassistant/components/home_connect/strings.json | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 66c635f5d95..999bb5da13d 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -285,6 +285,7 @@ SPIN_SPEED_OPTIONS = { "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM900", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1200", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1400", diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7b06128dbe6..ec95f5fdb92 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -461,6 +461,7 @@ "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", @@ -1430,6 +1431,7 @@ "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_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]", "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%]", From 1766f87620bb0f3f63f438e01676fc5e575c183e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 9 Mar 2025 21:59:09 +0100 Subject: [PATCH 1515/1941] Refresh Home Connect token during config entry setup (#140233) * Refresh token during config entry setup * Test 500 error --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 16 ++++- tests/components/home_connect/test_init.py | 61 +++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 3e1bd1da156..6814ab3eed2 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -16,11 +16,17 @@ from aiohomeconnect.model import ( SettingKey, ) from aiohomeconnect.model.error import HomeConnectError +import aiohttp import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, @@ -611,6 +617,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) config_entry_auth = AsyncConfigEntryAuth(hass, session) + try: + await config_entry_auth.async_get_access_token() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err home_connect_client = HomeConnectClient(config_entry_auth) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 6e4e428bf6a..4287ac9d227 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -8,9 +8,8 @@ from unittest.mock import MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError +import aiohttp import pytest -import requests_mock -import respx from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -221,14 +220,12 @@ async def test_exception_handling( @pytest.mark.parametrize("token_expiration_time", [12345]) -@respx.mock async def test_token_refresh_success( 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: @@ -236,7 +233,6 @@ async def test_token_refresh_success( assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN - requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN) aioclient_mock.post( OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN, @@ -280,6 +276,61 @@ async def test_token_refresh_success( ) +@pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize( + ("aioclient_mock_args", "expected_config_entry_state"), + [ + ( + { + "status": 400, + "json": {"error": "invalid_grant"}, + }, + ConfigEntryState.SETUP_ERROR, + ), + ( + { + "status": 500, + }, + ConfigEntryState.SETUP_RETRY, + ), + ( + { + "exc": aiohttp.ClientError, + }, + ConfigEntryState.SETUP_RETRY, + ), + ], +) +async def test_token_refresh_error( + aioclient_mock_args: dict[str, Any], + expected_config_entry_state: ConfigEntryState, + hass: HomeAssistant, + platforms: list[Platform], + integration_setup: Callable[[MagicMock], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + client: MagicMock, +) -> None: + """Test where token is expired and the refresh attempt fails.""" + + config_entry.data["token"]["access_token"] = FAKE_ACCESS_TOKEN + + aioclient_mock.post( + OAUTH2_TOKEN, + **aioclient_mock_args, + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with patch( + "homeassistant.components.home_connect.HomeConnectClient", return_value=client + ): + assert not await integration_setup(client) + await hass.async_block_till_done() + + assert config_entry.state == expected_config_entry_state + + @pytest.mark.parametrize( ("exception", "expected_state"), [ From 3c6b49b34fde4eaf799dac75200414b0b285c13d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Mar 2025 11:03:19 -1000 Subject: [PATCH 1516/1941] Bump aioesphomeapi to 29.5.1 (#140231) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.4.1...v29.5.1 Adds a `--verbose` flag to `aioesphomeapi-discover` to help track down https://github.com/esphome/issues/issues/6311 --- 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 25d9e407044..f0eeecfdb1e 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.4.1", + "aioesphomeapi==29.5.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.11.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 2cf57251ac6..72d00d4fcfc 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.4.1 +aioesphomeapi==29.5.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c17f56f5eb1..a1ae217ef65 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.4.1 +aioesphomeapi==29.5.1 # homeassistant.components.flo aioflo==2021.11.0 From 93982241a210eb76e846ba1c140fab43384aab52 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 9 Mar 2025 21:45:47 +0000 Subject: [PATCH 1517/1941] Bump evohome-async to 1.0.4 to fix #140194 (#140230) bump client, add test for fix #140194 --- .../components/evohome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fixtures/botched/user_locations.json | 10 +-- .../evohome/snapshots/test_climate.ambr | 62 +++++++++---------- .../evohome/snapshots/test_water_heater.ambr | 8 +-- 6 files changed, 43 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 700872ef92b..44e4cdb1128 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["evohome", "evohomeasync", "evohomeasync2"], "quality_scale": "legacy", - "requirements": ["evohome-async==1.0.3"] + "requirements": ["evohome-async==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 72d00d4fcfc..fea08e809d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -899,7 +899,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==1.0.3 +evohome-async==1.0.4 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1ae217ef65..d5bd6a6317f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -765,7 +765,7 @@ eternalegypt==0.0.16 eufylife-ble-client==0.1.8 # homeassistant.components.evohome -evohome-async==1.0.3 +evohome-async==1.0.4 # homeassistant.components.bryant_evolution evolutionhttp==0.0.18 diff --git a/tests/components/evohome/fixtures/botched/user_locations.json b/tests/components/evohome/fixtures/botched/user_locations.json index f2f4091a2dc..0016c5db007 100644 --- a/tests/components/evohome/fixtures/botched/user_locations.json +++ b/tests/components/evohome/fixtures/botched/user_locations.json @@ -8,14 +8,14 @@ "country": "UnitedKingdom", "postcode": "E1 1AA", "locationType": "Residential", - "useDaylightSaveSwitching": true, "timeZone": { - "timeZoneId": "GMTStandardTime", - "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", - "offsetMinutes": 0, - "currentOffsetMinutes": 60, + "timeZoneId": "PacificSAStandardTime", + "displayName": "(UTC-04:00) Santiago", + "offsetMinutes": -240, + "currentOffsetMinutes": -180, "supportsDaylightSaving": true }, + "useDaylightSaveSwitching": true, "locationOwner": { "userId": "2263181", "username": "user_2263181@gmail.com", diff --git a/tests/components/evohome/snapshots/test_climate.ambr b/tests/components/evohome/snapshots/test_climate.ambr index 5a6a6bff863..7fb0ae5aaec 100644 --- a/tests/components/evohome/snapshots/test_climate.ambr +++ b/tests/components/evohome/snapshots/test_climate.ambr @@ -168,10 +168,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -215,10 +215,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': False, @@ -257,19 +257,19 @@ 'activeFaults': tuple( dict({ 'fault_type': 'TempZoneActuatorLowBattery', - 'since': '2022-03-02T04:50:20+00:00', + 'since': '2022-03-02T04:50:20-03:00', }), ), 'setpoint_status': dict({ 'setpoint_mode': 'TemporaryOverride', 'target_heat_temperature': 21.0, - 'until': '2022-03-07T19:00:00+00:00', + 'until': '2022-03-07T16:00:00-03:00', }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -313,10 +313,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -360,10 +360,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -407,10 +407,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, @@ -450,7 +450,7 @@ 'activeFaults': tuple( dict({ 'fault_type': 'TempZoneActuatorCommunicationLost', - 'since': '2022-03-02T15:56:01+00:00', + 'since': '2022-03-02T15:56:01-03:00', }), ), 'setpoint_status': dict({ @@ -458,10 +458,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'next_sp_temp': 18.6, - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), - 'this_sp_temp': 16.0, + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'next_sp_temp': 16.0, + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), + 'this_sp_temp': 18.1, }), 'temperature_status': dict({ 'is_available': True, diff --git a/tests/components/evohome/snapshots/test_water_heater.ambr b/tests/components/evohome/snapshots/test_water_heater.ambr index 7b1bc44550a..13fb375c097 100644 --- a/tests/components/evohome/snapshots/test_water_heater.ambr +++ b/tests/components/evohome/snapshots/test_water_heater.ambr @@ -2,10 +2,10 @@ # name: test_set_operation_mode[botched] list([ dict({ - 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 30, tzinfo=datetime.timezone.utc), }), dict({ - 'until': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=datetime.timezone.utc), + 'until': HAFakeDatetime(2024, 7, 10, 12, 30, tzinfo=datetime.timezone.utc), }), ]) # --- @@ -39,9 +39,9 @@ ), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': HAFakeDatetime(2024, 7, 10, 13, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), + 'next_sp_from': HAFakeDatetime(2024, 7, 10, 8, 30, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), 'next_sp_state': 'Off', - 'this_sp_from': HAFakeDatetime(2024, 7, 10, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')), + 'this_sp_from': HAFakeDatetime(2024, 7, 10, 6, 30, tzinfo=zoneinfo.ZoneInfo(key='America/Santiago')), 'this_sp_state': 'On', }), 'state_status': dict({ From b3d640982d764d0dd6bf0045802bad364d579dee Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 10 Mar 2025 00:29:25 +0100 Subject: [PATCH 1518/1941] Bump `nettigo_air_monitor` to version 4.1.0 (#140241) * Bump nam to 4.1.0 * Update test snapshot --- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nam/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index c3a559de50b..1c3b9db7a86 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], - "requirements": ["nettigo-air-monitor==4.0.0"], + "requirements": ["nettigo-air-monitor==4.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index fea08e809d5..5e0d36fbe1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1477,7 +1477,7 @@ netdata==1.3.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.0.0 +nettigo-air-monitor==4.1.0 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5bd6a6317f..b136c1127cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1243,7 +1243,7 @@ nessclient==1.1.2 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.0.0 +nettigo-air-monitor==4.1.0 # homeassistant.components.nexia nexia==2.2.2 diff --git a/tests/components/nam/snapshots/test_diagnostics.ambr b/tests/components/nam/snapshots/test_diagnostics.ambr index e92e02fa1d8..135266e3376 100644 --- a/tests/components/nam/snapshots/test_diagnostics.ambr +++ b/tests/components/nam/snapshots/test_diagnostics.ambr @@ -2,6 +2,7 @@ # name: test_entry_diagnostics dict({ 'data': dict({ + 'bh1750_illuminance': None, 'bme280_humidity': 45.69, 'bme280_pressure': 1011.0117, 'bme280_temperature': 7.56, From 8192f2ef2e401b05c7fef295b70f6143daf4c970 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Mon, 10 Mar 2025 00:17:55 -0300 Subject: [PATCH 1519/1941] Fix ONVIF camera entities ids getting shuffled on reload (#139676) --- homeassistant/components/onvif/__init__.py | 60 +++++++++++- homeassistant/components/onvif/camera.py | 5 +- tests/components/onvif/__init__.py | 4 +- tests/components/onvif/test_init.py | 102 +++++++++++++++++++++ 4 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 tests/components/onvif/test_init.py diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 02e7e28ea18..09a4aba52bf 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -19,8 +19,9 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from .const import ( CONF_ENABLE_WEBHOOKS, @@ -99,6 +100,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if device.capabilities.imaging: device.platforms += [Platform.SWITCH] + _async_migrate_camera_entities_unique_ids(hass, entry, device) + await hass.config_entries.async_forward_entry_setups(entry, device.platforms) entry.async_on_unload( @@ -155,3 +158,58 @@ async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> Non } hass.config_entries.async_update_entry(entry, options=options) + + +@callback +def _async_migrate_camera_entities_unique_ids( + hass: HomeAssistant, config_entry: ConfigEntry, device: ONVIFDevice +) -> None: + """Migrate unique ids of camera entities from profile index to profile token.""" + entity_reg = er.async_get(hass) + entities: list[er.RegistryEntry] = er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + + mac_or_serial = device.info.mac or device.info.serial_number + old_uid_start = f"{mac_or_serial}_" + new_uid_start = f"{mac_or_serial}#" + + for entity in entities: + if entity.domain != Platform.CAMERA: + continue + + if ( + not entity.unique_id.startswith(old_uid_start) + and entity.unique_id != mac_or_serial + ): + continue + + index = 0 + if entity.unique_id.startswith(old_uid_start): + try: + index = int(entity.unique_id[len(old_uid_start) :]) + except ValueError: + LOGGER.error( + "Failed to migrate unique id for '%s' as the ONVIF profile index could not be parsed from unique id '%s'", + entity.entity_id, + entity.unique_id, + ) + continue + try: + token = device.profiles[index].token + except IndexError: + LOGGER.error( + "Failed to migrate unique id for '%s' as the ONVIF profile index '%d' parsed from unique id '%s' could not be found", + entity.entity_id, + index, + entity.unique_id, + ) + continue + new_uid = f"{new_uid_start}{token}" + LOGGER.debug( + "Migrating unique id for '%s' from '%s' to '%s'", + entity.entity_id, + entity.unique_id, + new_uid, + ) + entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_uid) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index da99e170ff6..fc17e912fcc 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -117,10 +117,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): self._attr_entity_registry_enabled_default = ( device.max_resolution == profile.video.resolution.width ) - if profile.index: - self._attr_unique_id = f"{self.mac_or_serial}_{profile.index}" - else: - self._attr_unique_id = self.mac_or_serial + self._attr_unique_id = f"{self.mac_or_serial}#{profile.token}" self._attr_name = f"{device.name} {profile.name}" @property diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index 8a86538b977..868624fb2e4 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -123,7 +123,7 @@ def setup_mock_onvif_camera( mock_onvif_camera.side_effect = mock_constructor -def setup_mock_device(mock_device, capabilities=None): +def setup_mock_device(mock_device, capabilities=None, profiles=None): """Prepare mock ONVIFDevice.""" mock_device.async_setup = AsyncMock(return_value=True) mock_device.port = 80 @@ -145,7 +145,7 @@ def setup_mock_device(mock_device, capabilities=None): ptz=None, video_source_token=None, ) - mock_device.profiles = [profile1] + mock_device.profiles = profiles or [profile1] mock_device.events = MagicMock( webhook_manager=MagicMock(state=WebHookManagerState.STARTED), pullpoint_manager=MagicMock(state=PullPointManagerState.PAUSED), diff --git a/tests/components/onvif/test_init.py b/tests/components/onvif/test_init.py new file mode 100644 index 00000000000..c176bdcc112 --- /dev/null +++ b/tests/components/onvif/test_init.py @@ -0,0 +1,102 @@ +"""Tests for the ONVIF integration __init__ module.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MAC, setup_mock_device + +from tests.common import MockConfigEntry + + +@pytest.mark.asyncio +async def test_migrate_camera_entities_unique_ids(hass: HomeAssistant) -> None: + """Test that camera entities unique ids get migrated properly.""" + config_entry = MockConfigEntry(domain="onvif", unique_id=MAC) + config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + + entity_with_only_mac = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=MAC, + config_entry=config_entry, + ) + entity_with_index = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}_1", + config_entry=config_entry, + ) + # This one should not be migrated (different domain) + entity_sensor = entity_registry.async_get_or_create( + domain="sensor", + platform="onvif", + unique_id=MAC, + config_entry=config_entry, + ) + # This one should not be migrated (already migrated) + entity_migrated = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}#profile_token_2", + config_entry=config_entry, + ) + # Unparsable index + entity_unparsable_index = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}_a", + config_entry=config_entry, + ) + # Unexisting index + entity_unexisting_index = entity_registry.async_get_or_create( + domain="camera", + platform="onvif", + unique_id=f"{MAC}_9", + config_entry=config_entry, + ) + + with patch("homeassistant.components.onvif.ONVIFDevice") as mock_device: + setup_mock_device( + mock_device, + capabilities=None, + profiles=[ + MagicMock(token="profile_token_0"), + MagicMock(token="profile_token_1"), + MagicMock(token="profile_token_2"), + ], + ) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_with_only_mac = entity_registry.async_get(entity_with_only_mac.entity_id) + entity_with_index = entity_registry.async_get(entity_with_index.entity_id) + entity_sensor = entity_registry.async_get(entity_sensor.entity_id) + entity_migrated = entity_registry.async_get(entity_migrated.entity_id) + + assert entity_with_only_mac is not None + assert entity_with_only_mac.unique_id == f"{MAC}#profile_token_0" + + assert entity_with_index is not None + assert entity_with_index.unique_id == f"{MAC}#profile_token_1" + + # Make sure the sensor entity is unchanged + assert entity_sensor is not None + assert entity_sensor.unique_id == MAC + + # Make sure the already migrated entity is unchanged + assert entity_migrated is not None + assert entity_migrated.unique_id == f"{MAC}#profile_token_2" + + # Make sure the unparsable index entity is unchanged + assert entity_unparsable_index is not None + assert entity_unparsable_index.unique_id == f"{MAC}_a" + + # Make sure the unexisting index entity is unchanged + assert entity_unexisting_index is not None + assert entity_unexisting_index.unique_id == f"{MAC}_9" From 40292a154d72ee2e206d642bc31c03daed3250d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:11:15 +0100 Subject: [PATCH 1520/1941] Bump github/codeql-action from 3.28.10 to 3.28.11 (#140254) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.10 to 3.28.11. - [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.10...v3.28.11) --- 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 4bdddf50c25..c4f98f2d863 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.10 + uses: github/codeql-action/init@v3.28.11 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.10 + uses: github/codeql-action/analyze@v3.28.11 with: category: "/language:python" From 0abe7514b99c84ef36609bad96c3094b9df64301 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Mar 2025 22:15:41 -1000 Subject: [PATCH 1521/1941] Bump inkbird-ble to 0.8.0 (#140244) Adds support for the ITH-21-B and ITH-13-B models --- homeassistant/components/inkbird/manifest.json | 10 +++++++++- homeassistant/generated/bluetooth.py | 10 ++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index acc7414edac..e2e9550dd7c 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -21,6 +21,14 @@ { "local_name": "tps", "connectable": false + }, + { + "local_name": "ITH-13-B", + "connectable": false + }, + { + "local_name": "ITH-21-B", + "connectable": false } ], "codeowners": ["@bdraco"], @@ -28,5 +36,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.7.1"] + "requirements": ["inkbird-ble==0.8.0"] } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 587fea8b941..be75c675a91 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -356,6 +356,16 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "tps", }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "ITH-13-B", + }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "ITH-21-B", + }, { "connectable": True, "domain": "iron_os", diff --git a/requirements_all.txt b/requirements_all.txt index 5e0d36fbe1b..0f345997a7a 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.1 +inkbird-ble==0.8.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b136c1127cb..c2d38aea5cb 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.1 +inkbird-ble==0.8.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From a3e981f1489b1b9ce9f526cc5e985e50a313d34e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:16:05 +0100 Subject: [PATCH 1522/1941] Fix version not always available in onewire (#140260) --- homeassistant/components/onewire/onewirehub.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index d65d7a90950..dc894a4242e 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib from datetime import datetime, timedelta import logging import os @@ -58,7 +59,7 @@ class OneWireHub: owproxy: protocol._Proxy devices: list[OWDeviceDescription] - _version: str + _version: str | None = None def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None: """Initialize.""" @@ -74,7 +75,9 @@ 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() + with contextlib.suppress(protocol.OwnetError): + # Version is not available on all servers + self._version = self.owproxy.read(protocol.PTH_VERSION).decode() self.devices = _discover_devices(self.owproxy) async def initialize(self) -> None: From e831b1b2301ec0834f52c90a0015f539d54cb455 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 10 Mar 2025 09:38:44 +0100 Subject: [PATCH 1523/1941] Add support for BH1750 illuminance sensor in Nettigo Air Monitor integration (#140242) * Add support for BH1750 illuminance sensor * Update strings * Update test snapshot --- homeassistant/components/nam/const.py | 1 + homeassistant/components/nam/sensor.py | 11 ++++ homeassistant/components/nam/strings.json | 3 + tests/components/nam/fixtures/nam_data.json | 1 + .../nam/snapshots/test_diagnostics.ambr | 2 +- .../components/nam/snapshots/test_sensor.ambr | 55 +++++++++++++++++++ 6 files changed, 72 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index 4b7b50b309a..2dedcf3c68a 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -11,6 +11,7 @@ SUFFIX_P1: Final = "_p1" SUFFIX_P2: Final = "_p2" SUFFIX_P4: Final = "_p4" +ATTR_BH1750_ILLUMINANCE: Final = "bh1750_illuminance" ATTR_BME280_HUMIDITY: Final = "bme280_humidity" ATTR_BME280_PRESSURE: Final = "bme280_pressure" ATTR_BME280_TEMPERATURE: Final = "bme280_temperature" diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 4478507dc59..45cfd313e8f 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, @@ -33,6 +34,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .const import ( + ATTR_BH1750_ILLUMINANCE, ATTR_BME280_HUMIDITY, ATTR_BME280_PRESSURE, ATTR_BME280_TEMPERATURE, @@ -83,6 +85,15 @@ class NAMSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[NAMSensorEntityDescription, ...] = ( + NAMSensorEntityDescription( + key=ATTR_BH1750_ILLUMINANCE, + translation_key="bh1750_illuminance", + suggested_display_precision=0, + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda sensors: sensors.bh1750_illuminance, + ), NAMSensorEntityDescription( key=ATTR_BME280_HUMIDITY, translation_key="bme280_humidity", diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 2caa4d8bd97..22fb1dc30d2 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -54,6 +54,9 @@ }, "entity": { "sensor": { + "bh1750_illuminance": { + "name": "BH1750 illuminance" + }, "bme280_humidity": { "name": "BME280 humidity" }, diff --git a/tests/components/nam/fixtures/nam_data.json b/tests/components/nam/fixtures/nam_data.json index 82dacbefb34..47ebe099dc7 100644 --- a/tests/components/nam/fixtures/nam_data.json +++ b/tests/components/nam/fixtures/nam_data.json @@ -26,6 +26,7 @@ { "value_type": "temperature", "value": "6.26" }, { "value_type": "HECA_temperature", "value": "7.95" }, { "value_type": "HECA_humidity", "value": "49.97" }, + { "value_type": "ambient_light", "value": "298.45" }, { "value_type": "signal", "value": "-72" } ] } diff --git a/tests/components/nam/snapshots/test_diagnostics.ambr b/tests/components/nam/snapshots/test_diagnostics.ambr index 135266e3376..c0009899d16 100644 --- a/tests/components/nam/snapshots/test_diagnostics.ambr +++ b/tests/components/nam/snapshots/test_diagnostics.ambr @@ -2,7 +2,7 @@ # name: test_entry_diagnostics dict({ 'data': dict({ - 'bh1750_illuminance': None, + 'bh1750_illuminance': 298.45, 'bme280_humidity': 45.69, 'bme280_pressure': 1011.0117, 'bme280_temperature': 7.56, diff --git a/tests/components/nam/snapshots/test_sensor.ambr b/tests/components/nam/snapshots/test_sensor.ambr index 429d069b741..c6c32737a31 100644 --- a/tests/components/nam/snapshots/test_sensor.ambr +++ b/tests/components/nam/snapshots/test_sensor.ambr @@ -1,4 +1,59 @@ # serializer version: 1 +# name: test_sensor[sensor.nettigo_air_monitor_bh1750_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.nettigo_air_monitor_bh1750_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'BH1750 illuminance', + 'platform': 'nam', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bh1750_illuminance', + 'unique_id': 'aa:bb:cc:dd:ee:ff-bh1750_illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_sensor[sensor.nettigo_air_monitor_bh1750_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'Nettigo Air Monitor BH1750 illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.nettigo_air_monitor_bh1750_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '298.45', + }) +# --- # name: test_sensor[sensor.nettigo_air_monitor_bme280_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 25f15c11494854fa1eb487f4503bc9bab797c95c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:46:54 +0100 Subject: [PATCH 1524/1941] Use short-hand attributes in remote-rpi-gpio (#140263) --- .../remote_rpi_gpio/binary_sensor.py | 23 ++++++---------- .../components/remote_rpi_gpio/switch.py | 27 +++++-------------- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index 42e8517c1e8..1d970bb3541 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from gpiozero import DigitalInputDevice import requests import voluptuous as vol @@ -48,10 +49,10 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Raspberry PI GPIO devices.""" - address = config["host"] + address = config[CONF_HOST] invert_logic = config[CONF_INVERT_LOGIC] pull_mode = config[CONF_PULL_MODE] - ports = config["ports"] + ports = config[CONF_PORTS] bouncetime = config[CONF_BOUNCETIME] / 1000 devices = [] @@ -71,9 +72,11 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity): _attr_should_poll = False - def __init__(self, name, sensor, invert_logic): + def __init__( + self, name: str | None, sensor: DigitalInputDevice, invert_logic: bool + ) -> None: """Initialize the RPi binary sensor.""" - self._name = name + self._attr_name = name self._invert_logic = invert_logic self._state = False self._sensor = sensor @@ -90,20 +93,10 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity): self._sensor.when_activated = read_gpio @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the entity.""" return self._state != self._invert_logic - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return - def update(self) -> None: """Update the GPIO state.""" try: diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index 91b389c5a1e..25f95045e4b 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any +from gpiozero import LED import voluptuous as vol from homeassistant.components.switch import ( @@ -57,37 +58,23 @@ def setup_platform( class RemoteRPiGPIOSwitch(SwitchEntity): """Representation of a Remote Raspberry Pi GPIO.""" + _attr_assumed_state = True _attr_should_poll = False - def __init__(self, name, led): + def __init__(self, name: str | None, led: LED) -> None: """Initialize the pin.""" - self._name = name or DEVICE_DEFAULT_NAME - self._state = False + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_is_on = False self._switch = led - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def assumed_state(self): - """If unable to access real state of the entity.""" - return True - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" write_output(self._switch, 1) - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" write_output(self._switch, 0) - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() From 6284a83a34baa5db490781edbea660510063c143 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 10 Mar 2025 11:04:49 +0100 Subject: [PATCH 1525/1941] Fix `client_id` not generated when connecting to the MQTT broker (#140264) Fix client_id not generated when connecting to the MQTT broker --- homeassistant/components/mqtt/client.py | 10 ++++--- tests/components/mqtt/test_client.py | 36 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index d35b3db7518..e985dc9b87f 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -15,6 +15,7 @@ import socket import ssl import time from typing import TYPE_CHECKING, Any +from uuid import uuid4 import certifi @@ -292,7 +293,7 @@ class MqttClientSetup: """ # 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 + from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel # pylint: disable-next=import-outside-toplevel from .async_client import AsyncMQTTClient @@ -309,9 +310,10 @@ class MqttClientSetup: clean_session = True if (client_id := config.get(CONF_CLIENT_ID)) is None: - # PAHO MQTT relies on the MQTT server to generate random client IDs. - # However, that feature is not mandatory so we generate our own. - client_id = None + # PAHO MQTT relies on the MQTT server to generate random client ID + # for protocol version 3.1, however, that feature is not mandatory + # so we generate our own. + client_id = mqtt._base62(uuid4().int, padding=22) # noqa: SLF001 transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( callback_api_version=mqtt.CallbackAPIVersion.VERSION2, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 9d5401fd437..0dbbff58026 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -1556,6 +1556,42 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( assert insecure_check["insecure"] == insecure_param +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "client_id"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + "client_id": "random01234random0124", + }, + "random01234random0124", + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + }, + None, + ), + ], +) +async def test_client_id_is_set( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + client_id: str | None, +) -> None: + """Test setup defaults for tls.""" + with patch( + "homeassistant.components.mqtt.async_client.AsyncMQTTClient" + ) as async_client_mock: + await mqtt_mock_entry() + await hass.async_block_till_done() + assert async_client_mock.call_count == 1 + call_params: dict[str, Any] = async_client_mock.call_args[1] + assert "client_id" in call_params + assert client_id is None or client_id == call_params["client_id"] + assert call_params["client_id"] is not None + + @pytest.mark.parametrize( "mqtt_config_entry_data", [ From 994bf2702402da9252321be46db5abd07aff9332 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 10 Mar 2025 11:45:37 +0100 Subject: [PATCH 1526/1941] Bump velbusaio to 2025.3.0 (#140267) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 29504277651..ff30ee14a8a 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.1.1"], + "requirements": ["velbus-aio==2025.3.0"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 0f345997a7a..11079d72e1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3000,7 +3000,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.1 +velbus-aio==2025.3.0 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2d38aea5cb..8d82c9f673e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2416,7 +2416,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.1.1 +velbus-aio==2025.3.0 # homeassistant.components.venstar venstarcolortouch==0.19 From 76e76a417c372db1dd5c7a9e4434f95e106c608c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 10 Mar 2025 12:19:18 +0100 Subject: [PATCH 1527/1941] Fix dryer operating state in SmartThings (#140277) --- .../components/smartthings/__init__.py | 3 + tests/components/smartthings/conftest.py | 1 + .../device_status/da_wm_wd_000001_1.json | 692 ++++++++++++++++++ .../fixtures/devices/da_wm_wd_000001_1.json | 205 ++++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 467 ++++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++ 7 files changed, 1448 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json create mode 100644 tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 3e0e66e890f..9d8881bc1c1 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -219,6 +219,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] ] = { + Capability.DRYER_OPERATING_STATE: ( + lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None + ), Capability.WASHER_OPERATING_STATE: ( lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None ), diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 7f27d3eecc4..db6e49b2135 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -104,6 +104,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "iphone", "da_wm_dw_000001", "da_wm_wd_000001", + "da_wm_wd_000001_1", "da_wm_wm_000001", "da_wm_wm_000001_1", "da_rvc_normal_000001", diff --git a/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json new file mode 100644 index 00000000000..b45bac95237 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_wm_wd_000001_1.json @@ -0,0 +1,692 @@ +{ + "components": { + "hca.main": { + "hca.dryerMode": { + "mode": { + "value": "normal", + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedModes": { + "value": ["normal", "quickDry", "mix", "timeDry"], + "timestamp": "2025-03-09T16:31:40.486Z" + } + } + }, + "main": { + "custom.dryerWrinklePrevent": { + "operatingState": { + "value": "ready", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "dryerWrinklePrevent": { + "value": "off", + "timestamp": "2025-03-09T16:31:41.077Z" + } + }, + "samsungce.dryerDryingTemperature": { + "dryingTemperature": { + "value": null, + "timestamp": "2021-04-02T18:31:36.756Z" + }, + "supportedDryingTemperature": { + "value": null, + "timestamp": "2021-04-02T18:29:52.258Z" + } + }, + "samsungce.welcomeMessage": { + "welcomeMessage": { + "value": null, + "timestamp": "2021-04-02T18:32:37.913Z" + } + }, + "samsungce.dongleSoftwareInstallation": { + "status": { + "value": "completed", + "timestamp": "2022-06-17T17:07:35.734Z" + } + }, + "samsungce.dryerCyclePreset": { + "maxNumberOfPresets": { + "value": 10, + "timestamp": "2025-03-09T16:31:41.229Z" + }, + "presets": { + "value": null, + "timestamp": "2021-04-02T18:30:36.772Z" + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "20221341", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "modelName": { + "value": null, + "timestamp": "2021-04-02T18:29:53.622Z" + }, + "serialNumber": { + "value": null, + "timestamp": "2021-04-02T18:29:52.641Z" + }, + "serialNumberExtra": { + "value": null, + "timestamp": "2021-04-02T18:29:51.653Z" + }, + "modelClassificationCode": { + "value": "30010102001211000103000000000000", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "description": { + "value": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "DA_WM_A51_20_COMMON", + "timestamp": "2025-03-09T19:07:40.295Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T19:47:36.549Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": null + } + }, + "samsungce.dryerFreezePrevent": { + "operatingState": { + "value": null + } + }, + "ocf": { + "st": { + "value": null, + "timestamp": "2020-06-20T10:01:02.741Z" + }, + "mndt": { + "value": null, + "timestamp": "2020-06-25T01:53:25.278Z" + }, + "mnfv": { + "value": "DA_WM_A51_20_COMMON_30230708", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnhw": { + "value": "ARTIK051", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "di": { + "value": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "n": { + "value": "[dryer] Samsung", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnmo": { + "value": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "vid": { + "value": "DA-WM-WD-000001", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "mnos": { + "value": "TizenRT 1.0 + IPv6", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "pi": { + "value": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "timestamp": "2024-12-15T10:53:49.561Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-12-15T10:53:49.561Z" + } + }, + "custom.dryerDryLevel": { + "dryerDryLevel": { + "value": "2", + "timestamp": "2025-03-09T19:47:36.806Z" + }, + "supportedDryerDryLevel": { + "value": ["none", "1", "2", "3"], + "timestamp": "2020-11-18T20:16:43.428Z" + } + }, + "samsungce.dryerAutoCycleLink": { + "dryerAutoCycleLink": { + "value": null, + "timestamp": "2020-08-11T12:41:38.646Z" + } + }, + "samsungce.dryerCycle": { + "dryerCycle": { + "value": "Table_00_Course_9A", + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedCycles": { + "value": [ + { + "cycle": "9A", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "CA", + "supportedOptions": { + "dryingLevel": { + "raw": "D10E", + "default": "1", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "DB", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "99", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + }, + { + "cycle": "93", + "supportedOptions": { + "dryingLevel": { + "raw": "D102", + "default": "1", + "options": ["1"] + } + } + }, + { + "cycle": "B5", + "supportedOptions": { + "dryingLevel": { + "raw": "D102", + "default": "1", + "options": ["1"] + } + } + }, + { + "cycle": "D7", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "A5", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "96", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "97", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "7F", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "98", + "supportedOptions": { + "dryingLevel": { + "raw": "D000", + "default": "none", + "options": [] + } + } + }, + { + "cycle": "EB", + "supportedOptions": { + "dryingLevel": { + "raw": "D204", + "default": "2", + "options": ["2"] + } + } + }, + { + "cycle": "B6", + "supportedOptions": { + "dryingLevel": { + "raw": "D20E", + "default": "2", + "options": ["1", "2", "3"] + } + } + } + ], + "timestamp": "2025-02-10T02:24:03.524Z" + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "specializedFunctionClassification": { + "value": 4, + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.dryerDelayEnd", + "dryerOperatingState", + "samsungce.dryerCyclePreset", + "samsungce.welcomeMessage", + "samsungce.dongleSoftwareInstallation", + "sec.wifiConfiguration", + "samsungce.quickControl", + "samsungce.deviceInfoPrivate", + "demandResponseLoadControl", + "samsungce.dryerFreezePrevent", + "samsungce.dryerDryingTemperature", + "sec.diagnosticsInformation" + ], + "timestamp": "2024-07-02T14:42:38.334Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24110101, + "timestamp": "2024-12-02T07:43:41.263Z" + } + }, + "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-03-09T16:31:40.882Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": null + } + }, + "samsungce.detergentOrder": { + "alarmEnabled": { + "value": false, + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "orderThreshold": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 796400, + "deltaEnergy": 0, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-03-09T19:47:26Z", + "end": "2025-03-09T19:47:37Z" + }, + "timestamp": "2025-03-09T19:47:37.283Z" + } + }, + "dryerOperatingState": { + "completionTime": { + "value": "2025-03-09T22:55:37Z", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "machineState": { + "value": "stop", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "supportedMachineStates": { + "value": ["stop", "run", "pause"], + "timestamp": "2025-03-09T16:31:41.172Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-03-09T19:47:37.015Z" + } + }, + "samsungce.detergentState": { + "remainingAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "dosage": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "initialAmount": { + "value": 0, + "unit": "cc", + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "detergentType": { + "value": "none", + "timestamp": "2021-04-02T18:29:51.428Z" + } + }, + "samsungce.dryerDelayEnd": { + "remainingTime": { + "value": 0, + "unit": "min", + "timestamp": "2025-03-09T16:31:41.172Z" + } + }, + "refresh": {}, + "custom.jobBeginningStatus": { + "jobBeginningStatus": { + "value": null, + "timestamp": "2020-06-25T01:53:34.974Z" + } + }, + "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|20221341|30010102001211000103000000000000", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DV6800N/DC92-01967B_0404", + "x.com.samsung.da.serialNum": "0T625AEN100200N", + "x.com.samsung.da.otnDUID": "SHCDM6YAPCCXC", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", + "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": "17111305,19060420", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-07T00:06:05.984Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": null + }, + "minVersion": { + "value": null + }, + "supportedWiFiFreq": { + "value": null + }, + "supportedAuthType": { + "value": null + }, + "protocolType": { + "value": null + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "false", + "timestamp": "2025-03-09T16:31:41.180Z" + } + }, + "custom.supportedOptions": { + "course": { + "value": null + }, + "referenceTable": { + "value": { + "id": "Table_00" + }, + "timestamp": "2025-03-09T16:31:41.247Z" + }, + "supportedCourses": { + "value": [ + "9A", + "CA", + "DB", + "99", + "93", + "B5", + "D7", + "A5", + "96", + "97", + "7F", + "98", + "EB", + "B6" + ], + "timestamp": "2025-03-09T16:31:40.486Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-06-17T17:07:35.734Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-06-17T17:07:35.734Z" + }, + "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-03-09T19:47:37.015Z" + }, + "supportedOperatingStates": { + "value": ["ready", "running", "paused"], + "timestamp": "2022-11-01T12:48:22.390Z" + }, + "scheduledJobs": { + "value": [ + { + "jobName": "drying", + "timeInMin": 192 + }, + { + "jobName": "cooling", + "timeInMin": 1 + } + ], + "timestamp": "2025-03-09T16:31:40.486Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "remainingTimeStr": { + "value": "03:08", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "dryerJobState": { + "value": "none", + "timestamp": "2025-03-09T19:47:37.015Z" + }, + "remainingTime": { + "value": 188, + "unit": "min", + "timestamp": "2025-03-09T19:47:37.015Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "SHCDM6YAPCCXC", + "timestamp": "2025-03-09T16:31:40.834Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-01T21:16:50.598Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-01T21:16:50.598Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.dryerDryingTime": { + "supportedDryingTime": { + "value": ["0", "30", "60", "90", "120", "150"], + "timestamp": "2021-04-02T18:29:51.428Z" + }, + "dryingTime": { + "value": "0", + "unit": "min", + "timestamp": "2025-03-09T16:31:41.077Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json new file mode 100644 index 00000000000..995646438c4 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_wm_wd_000001_1.json @@ -0,0 +1,205 @@ +{ + "items": [ + { + "deviceId": "3a6c4e05-811d-5041-e956-3d04c424cbcd", + "name": "[dryer] Samsung", + "label": "Seca-Roupa", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-WM-WD-000001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "06efa178-ad2f-4d22-838c-d63e05e5a58a", + "ownerId": "1a5f5619-e9ec-4302-beb9-633bb1657897", + "roomId": "dde24053-9707-49a5-ba0e-f19681514f37", + "deviceTypeName": "Samsung OCF Dryer", + "components": [ + { + "id": "main", + "label": "Seca-Roupa", + "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": "2020-06-20T10:00:42Z", + "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|20221341|30010102001211000103000000000000", + "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": "2020-11-19T04:43:50.736Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 7c2589590c5..2c45c466fa2 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -497,6 +497,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_wm_wd_000001_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': 'ARTIK051', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '3a6c4e05-811d-5041-e956-3d04c424cbcd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'DA_WM_A51_20_COMMON', + 'model_id': None, + 'name': 'Seca-Roupa', + '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': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b939547ca32..e7b36e7d028 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3532,6 +3532,473 @@ 'state': '0.0', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_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.seca_roupa_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Seca-Roupa Completion time', + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-09T22:55:37+00:00', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_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.seca_roupa_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '796.4', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_difference-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.seca_roupa_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_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.seca_roupa_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + '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': '3a6c4e05-811d-5041-e956-3d04c424cbcd.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_job_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.dryerJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Seca-Roupa 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.seca_roupa_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.seca_roupa_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dryer_machine_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Seca-Roupa Machine state', + 'options': list([ + 'pause', + 'run', + 'stop', + ]), + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_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.seca_roupa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Seca-Roupa Power', + 'power_consumption_end': '2025-03-09T19:47:37Z', + 'power_consumption_start': '2025-03-09T19:47:26Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power_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.seca_roupa_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][sensor.seca_roupa_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Seca-Roupa Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.seca_roupa_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[da_wm_wm_000001][sensor.washer_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 81b73874a6a..e119428c183 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -234,6 +234,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-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.seca_roupa', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa', + }), + 'context': , + 'entity_id': 'switch.seca_roupa', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][switch.washer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 219b441be0d66d5a016ea2d726846f249eb2ed0a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 10 Mar 2025 14:40:08 +0100 Subject: [PATCH 1528/1941] Don't allow creating backups if Home Assistant is not running (#139499) * Don't allow creating backups if hass is not running * Revert "Don't allow creating backups if hass is not running" This reverts commit 1bf545eb25f20fc27fe161691a94531cba7e005c. * Set backup manager to idle only after Home Assistant has started * Update according to discussion, add tests * Add more test --- homeassistant/components/backup/manager.py | 21 ++++++- tests/components/backup/test_manager.py | 66 +++++++++++++++++++++- tests/components/hassio/conftest.py | 3 +- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index bfaa5c5a48e..998e443a3b2 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -120,6 +120,7 @@ class BackupManagerState(StrEnum): IDLE = "idle" CREATE_BACKUP = "create_backup" + BLOCKED = "blocked" RECEIVE_BACKUP = "receive_backup" RESTORE_BACKUP = "restore_backup" @@ -228,6 +229,13 @@ class RestoreBackupEvent(ManagerStateEvent): state: RestoreBackupState +@dataclass(frozen=True, kw_only=True, slots=True) +class BlockedEvent(ManagerStateEvent): + """Backup manager blocked, Home Assistant is starting.""" + + manager_state: BackupManagerState = BackupManagerState.BLOCKED + + class BackupPlatformProtocol(Protocol): """Define the format that backup platforms can have.""" @@ -342,7 +350,7 @@ class BackupManager: self.remove_next_delete_event: Callable[[], None] | None = None # Latest backup event and backup event subscribers - self.last_event: ManagerStateEvent = IdleEvent() + self.last_event: ManagerStateEvent = BlockedEvent() self.last_non_idle_event: ManagerStateEvent | None = None self._backup_event_subscriptions = hass.data[ DATA_BACKUP @@ -356,10 +364,19 @@ class BackupManager: self.known_backups.load(stored["backups"]) await self._reader_writer.async_validate_config(config=self.config) + await self._reader_writer.async_resume_restore_progress_after_restart( on_progress=self.async_on_backup_event ) + async def set_manager_idle_after_start(hass: HomeAssistant) -> None: + """Set manager to idle after start.""" + self.async_on_backup_event(IdleEvent()) + + if self.state == BackupManagerState.BLOCKED: + # If we're not finishing a restore job, set the manager to idle after start + start.async_at_started(self.hass, set_manager_idle_after_start) + await self.load_platforms() @property @@ -1319,7 +1336,7 @@ class BackupManager: if (current_state := self.state) != (new_state := event.manager_state): LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event - if not isinstance(event, IdleEvent): + if not isinstance(event, (BlockedEvent, IdleEvent)): self.last_non_idle_event = event for subscription in self._backup_event_subscriptions: subscription(event) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index e4762f35327..41f98d6fa53 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -47,7 +47,8 @@ from homeassistant.components.backup.manager import ( WrittenBackup, ) from homeassistant.components.backup.util import password_to_key -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -3469,3 +3470,66 @@ async def test_restore_progress_after_restart_fail_to_remove( "Unexpected error deleting backup restore result file: Boom!" in caplog.text ) + + +async def test_manager_blocked_until_home_assistant_started( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test backup manager's state is blocked until Home Assistant has started.""" + + hass.set_state(CoreState.not_running) + + await setup_backup_integration(hass) + manager = hass.data[DATA_MANAGER] + + assert manager.state == BackupManagerState.BLOCKED + assert manager.last_non_idle_event is None + + # Fired when Home Assistant changes to starting state + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert manager.state == BackupManagerState.BLOCKED + assert manager.last_non_idle_event is None + + # Fired when Home Assistant changes to running state + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + assert manager.state == BackupManagerState.IDLE + assert manager.last_non_idle_event is None + + +async def test_manager_not_blocked_after_restore( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test restore backup progress after restart.""" + restore_result = {"error": None, "error_type": None, "success": True} + + hass.set_state(CoreState.not_running) + with patch( + "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() + ): + await setup_backup_integration(hass) + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 7075b9d6982..c9fbf1a7c56 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -11,7 +11,7 @@ import pytest from homeassistant.auth.models import RefreshToken from homeassistant.components.hassio.handler import HassIO, HassioAPIError -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component @@ -75,7 +75,6 @@ def hassio_stubs( "homeassistant.components.hassio.issues.SupervisorIssues.setup", ), ): - hass.set_state(CoreState.starting) hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) return hass_api.call_args[0][1] From f5c73027bb5ac783952f932cdd8ff90310b06d2f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 10 Mar 2025 14:45:14 +0100 Subject: [PATCH 1529/1941] Improve description of `schedule.get_schedule` action (#140284) Changes to descriptive style and adds a little more detail from the online docs. --- homeassistant/components/schedule/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index 8638e4a8a84..bb81c029dbf 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -28,7 +28,7 @@ }, "get_schedule": { "name": "Get schedule", - "description": "Retrieve one or multiple schedules." + "description": "Retrieves the configured time ranges of one or multiple schedules." } } } From 00fc3f294b6910b705d2204e030690875d162222 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 10 Mar 2025 15:45:48 +0200 Subject: [PATCH 1530/1941] Bump zwave-js-server-python to 0.61.0 (#140282) --- 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 3178bdf46ad..16831853290 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.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.61.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 11079d72e1d..76b13da45d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3161,7 +3161,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.1 +zwave-js-server-python==0.61.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d82c9f673e..49eabe61ec1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2541,7 +2541,7 @@ zeversolar==0.3.2 zha==0.0.51 # homeassistant.components.zwave_js -zwave-js-server-python==0.60.1 +zwave-js-server-python==0.61.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 9edec57a82263b38e15a290ba60b54c8609c0e1f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 10 Mar 2025 14:46:09 +0100 Subject: [PATCH 1531/1941] Improve action descriptions in `energyzero` integration (#140283) - use descriptive style to match HA standard - fix sentence-casing of "Config entry" --- homeassistant/components/energyzero/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index 7788f4d4d8e..48682ab31ee 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -54,10 +54,10 @@ "services": { "get_gas_prices": { "name": "Get gas prices", - "description": "Request gas prices from EnergyZero.", + "description": "Requests gas prices from EnergyZero.", "fields": { "config_entry": { - "name": "Config Entry", + "name": "Config entry", "description": "The config entry to use for this action." }, "incl_vat": { @@ -76,7 +76,7 @@ }, "get_energy_prices": { "name": "Get energy prices", - "description": "Request energy prices from EnergyZero.", + "description": "Requests energy prices from EnergyZero.", "fields": { "config_entry": { "name": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::name%]", From 688d5bb4c98130358e4cf9ae5ec8d30139cf6f19 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Mar 2025 03:54:02 -1000 Subject: [PATCH 1532/1941] Bump bluetooth-data-tools to 1.26.0 (#140262) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.25.0...v1.26.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index ec617b82a04..f6fb4f68e91 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.4", - "bluetooth-data-tools==1.25.0", + "bluetooth-data-tools==1.26.0", "dbus-fast==2.39.3", "habluetooth==3.25.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index c92bcb3294f..f0d06a4e880 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.25.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.26.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 8f624a3c225..5e12c395c2c 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.25.0", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.26.0", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 98a9f757585..d79b93388f5 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.25.0"] + "requirements": ["bluetooth-data-tools==1.26.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bce7a2ddcdd..d9c761e6341 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.4 -bluetooth-data-tools==1.25.0 +bluetooth-data-tools==1.26.0 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 76b13da45d9..0a6e67f18c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -640,7 +640,7 @@ bluetooth-auto-recovery==1.4.4 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.25.0 +bluetooth-data-tools==1.26.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49eabe61ec1..0d246d59b1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.4 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.25.0 +bluetooth-data-tools==1.26.0 # homeassistant.components.bond bond-async==0.2.1 From 8620309f9e2284823f8d16c24f25dff415b8455c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 11 Mar 2025 00:06:40 +1000 Subject: [PATCH 1533/1941] Add streaming to Teslemetry update platform (#140021) * Update platform * Tests * fix tests --- homeassistant/components/teslemetry/update.py | 158 ++++++++++++++++-- .../teslemetry/snapshots/test_update.ambr | 125 ++++++++++++++ tests/components/teslemetry/test_update.py | 94 ++++++++++- 3 files changed, 363 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index f560f25a8ff..0b0255508e0 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -2,16 +2,22 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any from tesla_fleet_api.const import Scope +from tesla_fleet_api.vehiclespecific import VehicleSpecific from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -32,12 +38,31 @@ async def async_setup_entry( """Set up the Teslemetry update platform from a config entry.""" async_add_entities( - TeslemetryUpdateEntity(vehicle, entry.runtime_data.scopes) + TeslemetryPollingUpdateEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) -class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity): +class TeslemetryUpdateEntity(TeslemetryRootEntity, UpdateEntity): + """Teslemetry Updates entity.""" + + api: VehicleSpecific + _attr_supported_features = UpdateEntityFeature.PROGRESS + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.schedule_software_update(offset_sec=0)) + self._attr_in_progress = True + self.async_write_ha_state() + + +class TeslemetryPollingUpdateEntity(TeslemetryVehicleEntity, TeslemetryUpdateEntity): """Teslemetry Updates entity.""" def __init__( @@ -94,18 +119,125 @@ class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity): ): self._attr_in_progress = True if install_perc := self.get("vehicle_state_software_update_install_perc"): - self._attr_update_percentage = cast(int, install_perc) + self._attr_update_percentage = install_perc else: self._attr_in_progress = False self._attr_update_percentage = None - async def async_install( - self, version: str | None, backup: bool, **kwargs: Any + +class TeslemetryStreamingUpdateEntity( + TeslemetryVehicleStreamEntity, TeslemetryUpdateEntity, RestoreEntity +): + """Teslemetry Updates entity.""" + + _download_percentage: int = 0 + _install_percentage: int = 0 + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], ) -> None: - """Install an update.""" - self.raise_for_scope(Scope.ENERGY_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.schedule_software_update(offset_sec=60)) - self._attr_in_progress = True - self._attr_update_percentage = None + """Initialize the Update.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__( + data, + "vehicle_state_software_update_status", + ) + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + self._attr_in_progress = state.attributes.get("in_progress", False) + self._install_percentage = state.attributes.get("install_percentage", False) + self._attr_installed_version = state.attributes.get("installed_version") + self._attr_latest_version = state.attributes.get("latest_version") + self._attr_supported_features = UpdateEntityFeature( + state.attributes.get( + "supported_features", self._attr_supported_features + ) + ) + self.async_write_ha_state() + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateDownloadPercentComplete( + self._async_handle_software_update_download_percent_complete + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateInstallationPercentComplete( + self._async_handle_software_update_installation_percent_complete + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateScheduledStartTime( + self._async_handle_software_update_scheduled_start_time + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_SoftwareUpdateVersion( + self._async_handle_software_update_version + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_Version(self._async_handle_version) + ) + + def _async_handle_software_update_download_percent_complete( + self, value: float | None + ): + """Handle software update download percent complete.""" + + self._download_percentage = round(value) if value is not None else 0 + if self.scoped and self._download_percentage == 100: + self._attr_supported_features = ( + UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL + ) + else: + self._attr_supported_features = UpdateEntityFeature.PROGRESS + self._async_update_progress() self.async_write_ha_state() + + def _async_handle_software_update_installation_percent_complete( + self, value: float | None + ): + """Handle software update installation percent complete.""" + + self._install_percentage = round(value) if value is not None else 0 + self._async_update_progress() + self.async_write_ha_state() + + def _async_handle_software_update_scheduled_start_time(self, value: str | None): + """Handle software update scheduled start time.""" + + self._attr_in_progress = value is not None + self.async_write_ha_state() + + def _async_handle_software_update_version(self, value: str | None): + """Handle software update version.""" + + self._attr_latest_version = ( + value if value and value != " " else self._attr_installed_version + ) + self.async_write_ha_state() + + def _async_handle_version(self, value: str | None): + """Handle version.""" + + if value is not None: + self._attr_installed_version = value.split(" ")[0] + self.async_write_ha_state() + + def _async_update_progress(self) -> None: + """Update the progress of the update.""" + + if self._download_percentage > 1 and self._download_percentage < 100: + self._attr_in_progress = True + self._attr_update_percentage = self._download_percentage + elif self._install_percentage > 1: + self._attr_in_progress = True + self._attr_update_percentage = self._install_percentage + else: + self._attr_in_progress = False + self._attr_update_percentage = None diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index 1c7d525af86..fcd6f421993 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -117,3 +117,128 @@ 'state': 'off', }) # --- +# name: test_update_streaming[downloading] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.1.1', + 'latest_version': '2025.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[installing] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.1.1', + 'latest_version': '2025.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[ready] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.1.1', + 'latest_version': '2025.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_streaming[restored] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.2.1', + 'latest_version': '2025.1.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update_streaming[updated] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2025.2.1', + 'latest_version': '2025.1.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py index 448f31afd67..0f26b162043 100644 --- a/tests/components/teslemetry/test_update.py +++ b/tests/components/teslemetry/test_update.py @@ -4,7 +4,9 @@ import copy from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.update import INSTALLING @@ -13,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, setup_platform +from . import assert_entities, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -23,6 +25,7 @@ async def test_update( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the update entities are correct.""" @@ -35,6 +38,7 @@ async def test_update_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the update entities are correct.""" @@ -48,6 +52,7 @@ async def test_update_services( mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, + mock_legacy: AsyncMock, ) -> None: """Tests that the update services work.""" @@ -78,3 +83,90 @@ async def test_update_services( state = hass.states.get(entity_id) assert state.attributes["in_progress"] == 1 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the select entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.UPDATE]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 50, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + state = hass.states.get("update.test_update") + assert state == snapshot(name="downloading") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 1, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state == snapshot(name="ready") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: 100, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: 50, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "2025.2.1", + Signal.VERSION: "2025.1.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state == snapshot(name="installing") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SOFTWARE_UPDATE_DOWNLOAD_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_INSTALLATION_PERCENT_COMPLETE: None, + Signal.SOFTWARE_UPDATE_SCHEDULED_START_TIME: None, + Signal.SOFTWARE_UPDATE_VERSION: "", + Signal.VERSION: "2025.2.1", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_update") + assert state == snapshot(name="updated") + + await reload_platform(hass, entry, [Platform.UPDATE]) + + state = hass.states.get("update.test_update") + assert state == snapshot(name="restored") From e4e476f83edb59999242fdb3f250abe0ccb3c7d1 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Mon, 10 Mar 2025 07:18:13 -0700 Subject: [PATCH 1534/1941] TotalConnect add partition arming_state in diagnostic (#140140) add partition arming_state --- homeassistant/components/totalconnect/diagnostics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py index f42ed5e44c3..fc310bf850c 100644 --- a/homeassistant/components/totalconnect/diagnostics.py +++ b/homeassistant/components/totalconnect/diagnostics.py @@ -83,6 +83,7 @@ async def async_get_config_entry_diagnostics( "is_new_partition": partition.is_new_partition, "is_night_stay_enabled": partition.is_night_stay_enabled, "exit_delay_timer": partition.exit_delay_timer, + "arming_state": partition.arming_state, } new_location["partitions"].append(new_partition) From ed20947e30b25c50af47dc338ed9686c9eace346 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 10 Mar 2025 15:49:29 +0100 Subject: [PATCH 1535/1941] Fix events without user in Bring integration (#140213) Fix events without publicUserUuid --- homeassistant/components/bring/event.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index 08d06b596b8..403856405ce 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -77,9 +77,12 @@ class BringEventEntity(BringBaseEntity, EventEntity): attributes = asdict(activity.content) attributes["last_activity_by"] = next( - x.name - for x in bring_list.users.users - if x.publicUuid == activity.content.publicUserUuid + ( + x.name + for x in bring_list.users.users + if x.publicUuid == activity.content.publicUserUuid + ), + None, ) self._trigger_event( From 290116029b3b651b6496430dd44b5a6a41195411 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Mon, 10 Mar 2025 14:54:18 +0000 Subject: [PATCH 1536/1941] Add strict typing of account & instance to Mastodon (#139739) Add strict typing of account & instance --- homeassistant/components/mastodon/__init__.py | 6 +- .../components/mastodon/config_flow.py | 11 +- homeassistant/components/mastodon/const.py | 8 - .../components/mastodon/coordinator.py | 13 +- .../components/mastodon/diagnostics.py | 4 +- homeassistant/components/mastodon/entity.py | 4 +- homeassistant/components/mastodon/sensor.py | 16 +- homeassistant/components/mastodon/utils.py | 12 +- tests/components/mastodon/conftest.py | 11 +- .../fixtures/account_verify_credentials.json | 104 +++----- .../mastodon/fixtures/instance.json | 159 ++--------- .../mastodon/snapshots/test_diagnostics.ambr | 247 +++--------------- .../mastodon/snapshots/test_init.ambr | 2 +- .../mastodon/snapshots/test_sensor.ambr | 6 +- 14 files changed, 146 insertions(+), 457 deletions(-) diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index ab8514c8321..17b8614a2e9 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from mastodon.Mastodon import Mastodon, MastodonError +from mastodon.Mastodon import Account, Instance, InstanceV2, Mastodon, MastodonError from homeassistant.const import ( CONF_ACCESS_TOKEN, @@ -107,7 +107,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) - return True -def setup_mastodon(entry: MastodonConfigEntry) -> tuple[Mastodon, dict, dict]: +def setup_mastodon( + entry: MastodonConfigEntry, +) -> tuple[Mastodon, InstanceV2 | Instance, Account]: """Get mastodon details.""" client = create_mastodon_client( entry.data[CONF_BASE_URL], diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 1b93cbecd98..1ae1e6b229e 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -4,7 +4,12 @@ from __future__ import annotations from typing import Any -from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError +from mastodon.Mastodon import ( + Account, + Instance, + MastodonNetworkError, + MastodonUnauthorizedError, +) import voluptuous as vol from yarl import URL @@ -56,8 +61,8 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN): client_secret: str, access_token: str, ) -> tuple[ - dict[str, str] | None, - dict[str, str] | None, + Instance | None, + Account | None, dict[str, str], ]: """Check connection to the Mastodon instance.""" diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index a4af49a27a6..2efda329467 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -12,14 +12,6 @@ DATA_HASS_CONFIG = "mastodon_hass_config" DEFAULT_URL: Final = "https://mastodon.social" DEFAULT_NAME: Final = "Mastodon" -INSTANCE_VERSION: Final = "version" -INSTANCE_URI: Final = "uri" -INSTANCE_DOMAIN: Final = "domain" -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" diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index 5d2b193b4a8..99785eca80b 100644 --- a/homeassistant/components/mastodon/coordinator.py +++ b/homeassistant/components/mastodon/coordinator.py @@ -4,10 +4,9 @@ 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 mastodon.Mastodon import Account, Instance, InstanceV2, MastodonError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,15 +20,15 @@ class MastodonData: """Mastodon data type.""" client: Mastodon - instance: dict - account: dict + instance: InstanceV2 | Instance + account: Account coordinator: MastodonCoordinator type MastodonConfigEntry = ConfigEntry[MastodonData] -class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class MastodonCoordinator(DataUpdateCoordinator[Account]): """Class to manage fetching Mastodon data.""" config_entry: MastodonConfigEntry @@ -47,9 +46,9 @@ class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) self.client = client - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> Account: try: - account: dict = await self.hass.async_add_executor_job( + account: Account = await self.hass.async_add_executor_job( self.client.account_verify_credentials ) except MastodonError as ex: diff --git a/homeassistant/components/mastodon/diagnostics.py b/homeassistant/components/mastodon/diagnostics.py index dc7c1b785ab..31444413dfd 100644 --- a/homeassistant/components/mastodon/diagnostics.py +++ b/homeassistant/components/mastodon/diagnostics.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from mastodon.Mastodon import Account, Instance + from homeassistant.core import HomeAssistant from .coordinator import MastodonConfigEntry @@ -25,7 +27,7 @@ async def async_get_config_entry_diagnostics( } -def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[dict, dict]: +def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[Instance, Account]: """Get mastodon diagnostics.""" client = config_entry.runtime_data.client diff --git a/homeassistant/components/mastodon/entity.py b/homeassistant/components/mastodon/entity.py index 2ae8c0d852e..60224e75e41 100644 --- a/homeassistant/components/mastodon/entity.py +++ b/homeassistant/components/mastodon/entity.py @@ -4,7 +4,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN, INSTANCE_VERSION +from .const import DEFAULT_NAME, DOMAIN from .coordinator import MastodonConfigEntry, MastodonCoordinator from .utils import construct_mastodon_username @@ -40,7 +40,7 @@ class MastodonEntity(CoordinatorEntity[MastodonCoordinator]): manufacturer="Mastodon gGmbH", model=full_account_name, entry_type=DeviceEntryType.SERVICE, - sw_version=data.runtime_data.instance[INSTANCE_VERSION], + sw_version=data.runtime_data.instance.version, name=name, ) diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py index 74537e33cae..bfdc9c90333 100644 --- a/homeassistant/components/mastodon/sensor.py +++ b/homeassistant/components/mastodon/sensor.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any + +from mastodon.Mastodon import Account from homeassistant.components.sensor import ( SensorEntity, @@ -15,11 +16,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import ( - ACCOUNT_FOLLOWERS_COUNT, - ACCOUNT_FOLLOWING_COUNT, - ACCOUNT_STATUSES_COUNT, -) from .coordinator import MastodonConfigEntry from .entity import MastodonEntity @@ -31,7 +27,7 @@ PARALLEL_UPDATES = 0 class MastodonSensorEntityDescription(SensorEntityDescription): """Describes Mastodon sensor entity.""" - value_fn: Callable[[dict[str, Any]], StateType] + value_fn: Callable[[Account], StateType] ENTITY_DESCRIPTIONS = ( @@ -39,19 +35,19 @@ ENTITY_DESCRIPTIONS = ( key="followers", translation_key="followers", state_class=SensorStateClass.TOTAL, - value_fn=lambda data: data.get(ACCOUNT_FOLLOWERS_COUNT), + value_fn=lambda data: data.followers_count, ), MastodonSensorEntityDescription( key="following", translation_key="following", state_class=SensorStateClass.TOTAL, - value_fn=lambda data: data.get(ACCOUNT_FOLLOWING_COUNT), + value_fn=lambda data: data.following_count, ), MastodonSensorEntityDescription( key="posts", translation_key="posts", state_class=SensorStateClass.TOTAL, - value_fn=lambda data: data.get(ACCOUNT_STATUSES_COUNT), + value_fn=lambda data: data.statuses_count, ), ) diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py index e9c2567b675..898578c931b 100644 --- a/homeassistant/components/mastodon/utils.py +++ b/homeassistant/components/mastodon/utils.py @@ -6,8 +6,9 @@ import mimetypes from typing import Any from mastodon import Mastodon +from mastodon.Mastodon import Account, Instance, InstanceV2 -from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI +from .const import DEFAULT_NAME def create_mastodon_client( @@ -23,14 +24,13 @@ def create_mastodon_client( def construct_mastodon_username( - instance: dict[str, str] | None, account: dict[str, str] | None + instance: InstanceV2 | Instance | None, account: Account | None ) -> str: """Construct a mastodon username from the account and instance.""" if instance and account: - return ( - f"@{account[ACCOUNT_USERNAME]}@" - f"{instance.get(INSTANCE_URI, instance.get(INSTANCE_DOMAIN))}" - ) + if type(instance) is InstanceV2: + return f"@{account.username}@{instance.domain}" + return f"@{account.username}@{instance.uri}" return DEFAULT_NAME diff --git a/tests/components/mastodon/conftest.py b/tests/components/mastodon/conftest.py index ac23141be55..d8979083de9 100644 --- a/tests/components/mastodon/conftest.py +++ b/tests/components/mastodon/conftest.py @@ -3,12 +3,13 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from mastodon.Mastodon import Account, InstanceV2 import pytest from homeassistant.components.mastodon.const import CONF_BASE_URL, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, load_fixture @pytest.fixture @@ -31,9 +32,11 @@ def mock_mastodon_client() -> Generator[AsyncMock]: ) as mock_client, ): client = mock_client.return_value - client.instance.return_value = load_json_object_fixture("instance.json", DOMAIN) - client.account_verify_credentials.return_value = load_json_object_fixture( - "account_verify_credentials.json", DOMAIN + client.instance.return_value = InstanceV2.from_json( + load_fixture("instance.json", DOMAIN) + ) + client.account_verify_credentials.return_value = Account.from_json( + load_fixture("account_verify_credentials.json", DOMAIN) ) client.status_post.return_value = None yield client diff --git a/tests/components/mastodon/fixtures/account_verify_credentials.json b/tests/components/mastodon/fixtures/account_verify_credentials.json index 401caa121ae..7806d280ab9 100644 --- a/tests/components/mastodon/fixtures/account_verify_credentials.json +++ b/tests/components/mastodon/fixtures/account_verify_credentials.json @@ -1,78 +1,60 @@ { - "id": "14715", - "username": "trwnh", - "acct": "trwnh", - "display_name": "infinite love ⴳ", - "locked": false, - "bot": false, - "created_at": "2016-11-24T10:02:12.085Z", - "note": "

    i have approximate knowledge of many things. perpetual student. (nb/ace/they)

    xmpp/email: a@trwnh.com
    https://trwnh.com
    help me live: https://liberapay.com/at or https://paypal.me/trwnh

    - my triggers are moths and glitter
    - i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
    - dm me if i did something wrong, so i can improve
    - purest person on fedi, do not lewd in my presence
    - #1 ami cole fan account

    :fatyoshi:

    ", - "url": "https://mastodon.social/@trwnh", - "avatar": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png", - "avatar_static": "https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png", - "header": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", - "header_static": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", - "followers_count": 821, - "following_count": 178, - "statuses_count": 33120, - "last_status_at": "2019-11-24T15:49:42.251Z", - "source": { - "privacy": "public", - "sensitive": false, - "language": "", - "note": "i have approximate knowledge of many things. perpetual student. (nb/ace/they)\r\n\r\nxmpp/email: a@trwnh.com\r\nhttps://trwnh.com\r\nhelp me live: https://liberapay.com/at or https://paypal.me/trwnh\r\n\r\n- my triggers are moths and glitter\r\n- i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise\r\n- dm me if i did something wrong, so i can improve\r\n- purest person on fedi, do not lewd in my presence\r\n- #1 ami cole fan account\r\n\r\n:fatyoshi:", + "_mastopy_version": "2.0.0", + "_mastopy_type": "Account", + "_mastopy_data": { + "id": "14715", + "username": "trwnh", + "acct": "trwnh", + "display_name": "infinite love \u2d33", + "discoverable": true, + "group": false, + "locked": false, + "created_at": "2016-11-24T00:00:00+00:00", + "following_count": 328, + "followers_count": 3169, + "statuses_count": 69523, + "note": "

    i have approximate knowledge of many things. perpetual student. (nb/ace/they)

    xmpp/email: a@trwnh.com
    https://trwnh.com
    help me live:
    - https://donate.stripe.com/4gwcPCaMpcQ19RC4gg
    - https://liberapay.com/trwnh

    notes:
    - my triggers are moths and glitter
    - i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
    - dm me if i did something wrong, so i can improve
    - purest person on fedi, do not lewd in my presence

    ", + "url": "https://mastodon.social/@trwnh", + "uri": "https://mastodon.social/users/trwnh", + "avatar": "https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png", + "header": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", + "avatar_static": "https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png", + "header_static": "https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg", + "moved_to_account": null, + "suspended": null, + "limited": null, + "bot": true, "fields": [ { "name": "Website", - "value": "https://trwnh.com", + "value": "https://trwnh.com", "verified_at": "2019-08-29T04:14:55.571+00:00" }, { - "name": "Sponsor", - "value": "https://liberapay.com/at", - "verified_at": "2019-11-15T10:06:15.557+00:00" + "name": "Portfolio", + "value": "https://abdullahtarawneh.com", + "verified_at": "2021-02-11T20:34:13.574+00:00" }, { "name": "Fan of:", - "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", + "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", "verified_at": null }, { - "name": "Main topics:", - "value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", + "name": "What to expect:", + "value": "talking about various things i find interesting, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people! and to spill my thoughts.", "verified_at": null } ], - "follow_requests_count": 0 - }, - "emojis": [ - { - "shortcode": "fatyoshi", - "url": "https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png", - "static_url": "https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png", - "visible_in_picker": true - } - ], - "fields": [ - { - "name": "Website", - "value": "https://trwnh.com", - "verified_at": "2019-08-29T04:14:55.571+00:00" - }, - { - "name": "Sponsor", - "value": "https://liberapay.com/at", - "verified_at": "2019-11-15T10:06:15.557+00:00" - }, - { - "name": "Fan of:", - "value": "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", - "verified_at": null - }, - { - "name": "Main topics:", - "value": "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", - "verified_at": null - } - ] + "emojis": [], + "last_status_at": "2025-03-04T00:00:00", + "noindex": false, + "roles": [], + "role": null, + "source": null, + "mute_expires_at": null, + "indexable": false, + "hide_collections": true, + "memorial": null + } } diff --git a/tests/components/mastodon/fixtures/instance.json b/tests/components/mastodon/fixtures/instance.json index b0e904e80ef..2e3dfe2d46d 100644 --- a/tests/components/mastodon/fixtures/instance.json +++ b/tests/components/mastodon/fixtures/instance.json @@ -1,147 +1,18 @@ { - "domain": "mastodon.social", - "title": "Mastodon", - "version": "4.0.0rc1", - "source_url": "https://github.com/mastodon/mastodon", - "description": "The original server operated by the Mastodon gGmbH non-profit", - "usage": { - "users": { - "active_month": 123122 + "_mastopy_version": "2.0.0", + "_mastopy_type": "InstanceV2", + "_mastopy_data": { + "uri": "mastodon.social", + "domain": "mastodon.social", + "title": "Mastodon", + "version": "4.4.0-nightly.2025-02-07", + "source_url": "https://github.com/mastodon/mastodon", + + "description": "The original server operated by the Mastodon gGmbH non-profit", + "usage": { + "users": { + "active_month": 380143 + } } - }, - "thumbnail": { - "url": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", - "blurhash": "UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$", - "versions": { - "@1x": "https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png", - "@2x": "https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png" - } - }, - "languages": ["en"], - "configuration": { - "urls": { - "streaming": "wss://mastodon.social" - }, - "vapid": { - "public_key": "BCkMmVdKDnKYwzVCDC99Iuc9GvId-x7-kKtuHnLgfF98ENiZp_aj-UNthbCdI70DqN1zUVis-x0Wrot2sBagkMc=" - }, - "accounts": { - "max_featured_tags": 10, - "max_pinned_statuses": 4 - }, - "statuses": { - "max_characters": 500, - "max_media_attachments": 4, - "characters_reserved_per_url": 23 - }, - "media_attachments": { - "supported_mime_types": [ - "image/jpeg", - "image/png", - "image/gif", - "image/heic", - "image/heif", - "image/webp", - "video/webm", - "video/mp4", - "video/quicktime", - "video/ogg", - "audio/wave", - "audio/wav", - "audio/x-wav", - "audio/x-pn-wave", - "audio/vnd.wave", - "audio/ogg", - "audio/vorbis", - "audio/mpeg", - "audio/mp3", - "audio/webm", - "audio/flac", - "audio/aac", - "audio/m4a", - "audio/x-m4a", - "audio/mp4", - "audio/3gpp", - "video/x-ms-asf" - ], - "image_size_limit": 10485760, - "image_matrix_limit": 16777216, - "video_size_limit": 41943040, - "video_frame_rate_limit": 60, - "video_matrix_limit": 2304000 - }, - "polls": { - "max_options": 4, - "max_characters_per_option": 50, - "min_expiration": 300, - "max_expiration": 2629746 - }, - "translation": { - "enabled": true - } - }, - "registrations": { - "enabled": false, - "approval_required": false, - "message": null - }, - "contact": { - "email": "staff@mastodon.social", - "account": { - "id": "1", - "username": "Gargron", - "acct": "Gargron", - "display_name": "Eugen 💀", - "locked": false, - "bot": false, - "discoverable": true, - "group": false, - "created_at": "2016-03-16T00:00:00.000Z", - "note": "

    Founder, CEO and lead developer @Mastodon, Germany.

    ", - "url": "https://mastodon.social/@Gargron", - "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", - "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg", - "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", - "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg", - "followers_count": 133026, - "following_count": 311, - "statuses_count": 72605, - "last_status_at": "2022-10-31", - "noindex": false, - "emojis": [], - "fields": [ - { - "name": "Patreon", - "value": "https://www.patreon.com/mastodon", - "verified_at": null - } - ] - } - }, - "rules": [ - { - "id": "1", - "text": "Sexually explicit or violent media must be marked as sensitive when posting" - }, - { - "id": "2", - "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" - }, - { - "id": "3", - "text": "No incitement of violence or promotion of violent ideologies" - }, - { - "id": "4", - "text": "No harassment, dogpiling or doxxing of other users" - }, - { - "id": "5", - "text": "No content illegal in Germany" - }, - { - "id": "7", - "text": "Do not share intentionally false or misleading information" - } - ] + } } diff --git a/tests/components/mastodon/snapshots/test_diagnostics.ambr b/tests/components/mastodon/snapshots/test_diagnostics.ambr index 982ecee7ee2..9198410f066 100644 --- a/tests/components/mastodon/snapshots/test_diagnostics.ambr +++ b/tests/components/mastodon/snapshots/test_diagnostics.ambr @@ -3,245 +3,82 @@ dict({ 'account': dict({ 'acct': 'trwnh', - 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png', - 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/34aa222f4ae2e0a9.png', - 'bot': False, - 'created_at': '2016-11-24T10:02:12.085Z', + 'avatar': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/014/715/original/051c958388818705.png', + 'bot': True, + 'created_at': '2016-11-24T00:00:00+00:00', + 'discoverable': True, 'display_name': 'infinite love ⴳ', 'emojis': list([ - dict({ - 'shortcode': 'fatyoshi', - 'static_url': 'https://files.mastodon.social/custom_emojis/images/000/023/920/static/e57ecb623faa0dc9.png', - 'url': 'https://files.mastodon.social/custom_emojis/images/000/023/920/original/e57ecb623faa0dc9.png', - 'visible_in_picker': True, - }), ]), 'fields': list([ dict({ 'name': 'Website', - 'value': 'trwnh.com', + 'value': 'trwnh.com', 'verified_at': '2019-08-29T04:14:55.571+00:00', }), dict({ - 'name': 'Sponsor', - 'value': 'liberapay.com/at', - 'verified_at': '2019-11-15T10:06:15.557+00:00', + 'name': 'Portfolio', + 'value': 'abdullahtarawneh.com', + 'verified_at': '2021-02-11T20:34:13.574+00:00', }), dict({ 'name': 'Fan of:', - 'value': 'Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)', + 'value': 'Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)', 'verified_at': None, }), dict({ - 'name': 'Main topics:', - 'value': 'systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!', + 'name': 'What to expect:', + 'value': 'talking about various things i find interesting, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people! and to spill my thoughts.', 'verified_at': None, }), ]), - 'followers_count': 821, - 'following_count': 178, + 'followers_count': 3169, + 'following_count': 328, + 'group': False, 'header': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', 'header_static': 'https://files.mastodon.social/accounts/headers/000/014/715/original/5c6fc24edb3bb873.jpg', + 'hide_collections': True, 'id': '14715', - 'last_status_at': '2019-11-24T15:49:42.251Z', + 'indexable': False, + 'last_status_at': '2025-03-04T00:00:00', + 'limited': None, 'locked': False, - 'note': '

    i have approximate knowledge of many things. perpetual student. (nb/ace/they)

    xmpp/email: a@trwnh.com
    trwnh.com
    help me live: liberapay.com/at or paypal.me/trwnh

    - my triggers are moths and glitter
    - i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
    - dm me if i did something wrong, so i can improve
    - purest person on fedi, do not lewd in my presence
    - #1 ami cole fan account

    :fatyoshi:

    ', - 'source': dict({ - 'fields': list([ - dict({ - 'name': 'Website', - 'value': 'https://trwnh.com', - 'verified_at': '2019-08-29T04:14:55.571+00:00', - }), - dict({ - 'name': 'Sponsor', - 'value': 'https://liberapay.com/at', - 'verified_at': '2019-11-15T10:06:15.557+00:00', - }), - dict({ - 'name': 'Fan of:', - 'value': "Punk-rock and post-hardcore (Circa Survive, letlive., La Dispute, THE FEVER 333)Manga (Yu-Gi-Oh!, One Piece, JoJo's Bizarre Adventure, Death Note, Shaman King)Platformers and RPGs (Banjo-Kazooie, Boktai, Final Fantasy Crystal Chronicles)", - 'verified_at': None, - }), - dict({ - 'name': 'Main topics:', - 'value': "systemic analysis, design patterns, anticapitalism, info/tech freedom, theory and philosophy, and otherwise being a genuine and decent wholesome poster. i'm just here to hang out and talk to cool people!", - 'verified_at': None, - }), - ]), - 'follow_requests_count': 0, - 'language': '', - 'note': ''' - i have approximate knowledge of many things. perpetual student. (nb/ace/they) - - xmpp/email: a@trwnh.com - https://trwnh.com - help me live: https://liberapay.com/at or https://paypal.me/trwnh - - - my triggers are moths and glitter - - i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise - - dm me if i did something wrong, so i can improve - - purest person on fedi, do not lewd in my presence - - #1 ami cole fan account - - :fatyoshi: - ''', - 'privacy': 'public', - 'sensitive': False, - }), - 'statuses_count': 33120, + 'memorial': None, + 'moved_to_account': None, + 'mute_expires_at': None, + 'noindex': False, + 'note': '

    i have approximate knowledge of many things. perpetual student. (nb/ace/they)

    xmpp/email: a@trwnh.com
    trwnh.com
    help me live:
    - donate.stripe.com/4gwcPCaMpcQ1
    - liberapay.com/trwnh

    notes:
    - my triggers are moths and glitter
    - i have all notifs except mentions turned off, so please interact if you wanna be friends! i literally will not notice otherwise
    - dm me if i did something wrong, so i can improve
    - purest person on fedi, do not lewd in my presence

    ', + 'role': None, + 'roles': list([ + ]), + 'source': None, + 'statuses_count': 69523, + 'suspended': None, + 'uri': 'https://mastodon.social/users/trwnh', 'url': 'https://mastodon.social/@trwnh', 'username': 'trwnh', }), 'instance': dict({ - 'configuration': dict({ - 'accounts': dict({ - 'max_featured_tags': 10, - 'max_pinned_statuses': 4, - }), - 'media_attachments': dict({ - 'image_matrix_limit': 16777216, - 'image_size_limit': 10485760, - 'supported_mime_types': list([ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/heic', - 'image/heif', - 'image/webp', - 'video/webm', - 'video/mp4', - 'video/quicktime', - 'video/ogg', - 'audio/wave', - 'audio/wav', - 'audio/x-wav', - 'audio/x-pn-wave', - 'audio/vnd.wave', - 'audio/ogg', - 'audio/vorbis', - 'audio/mpeg', - 'audio/mp3', - 'audio/webm', - 'audio/flac', - 'audio/aac', - 'audio/m4a', - 'audio/x-m4a', - 'audio/mp4', - 'audio/3gpp', - 'video/x-ms-asf', - ]), - 'video_frame_rate_limit': 60, - 'video_matrix_limit': 2304000, - 'video_size_limit': 41943040, - }), - 'polls': dict({ - 'max_characters_per_option': 50, - 'max_expiration': 2629746, - 'max_options': 4, - 'min_expiration': 300, - }), - 'statuses': dict({ - 'characters_reserved_per_url': 23, - 'max_characters': 500, - 'max_media_attachments': 4, - }), - 'translation': dict({ - 'enabled': True, - }), - 'urls': dict({ - 'streaming': 'wss://mastodon.social', - }), - 'vapid': dict({ - 'public_key': 'BCkMmVdKDnKYwzVCDC99Iuc9GvId-x7-kKtuHnLgfF98ENiZp_aj-UNthbCdI70DqN1zUVis-x0Wrot2sBagkMc=', - }), - }), - 'contact': dict({ - 'account': dict({ - 'acct': 'Gargron', - 'avatar': 'https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg', - 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg', - 'bot': False, - 'created_at': '2016-03-16T00:00:00.000Z', - 'discoverable': True, - 'display_name': 'Eugen 💀', - 'emojis': list([ - ]), - 'fields': list([ - dict({ - 'name': 'Patreon', - 'value': 'patreon.com/mastodon', - 'verified_at': None, - }), - ]), - 'followers_count': 133026, - 'following_count': 311, - 'group': False, - 'header': 'https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg', - 'header_static': 'https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg', - 'id': '1', - 'last_status_at': '2022-10-31', - 'locked': False, - 'noindex': False, - 'note': '

    Founder, CEO and lead developer @Mastodon, Germany.

    ', - 'statuses_count': 72605, - 'url': 'https://mastodon.social/@Gargron', - 'username': 'Gargron', - }), - 'email': 'staff@mastodon.social', - }), + 'api_versions': None, + 'configuration': None, + 'contact': None, 'description': 'The original server operated by the Mastodon gGmbH non-profit', 'domain': 'mastodon.social', - 'languages': list([ - 'en', - ]), - 'registrations': dict({ - 'approval_required': False, - 'enabled': False, - 'message': None, - }), - 'rules': list([ - dict({ - 'id': '1', - 'text': 'Sexually explicit or violent media must be marked as sensitive when posting', - }), - dict({ - 'id': '2', - 'text': 'No racism, sexism, homophobia, transphobia, xenophobia, or casteism', - }), - dict({ - 'id': '3', - 'text': 'No incitement of violence or promotion of violent ideologies', - }), - dict({ - 'id': '4', - 'text': 'No harassment, dogpiling or doxxing of other users', - }), - dict({ - 'id': '5', - 'text': 'No content illegal in Germany', - }), - dict({ - 'id': '7', - 'text': 'Do not share intentionally false or misleading information', - }), - ]), + 'icon': None, + 'languages': None, + 'registrations': None, + 'rules': None, 'source_url': 'https://github.com/mastodon/mastodon', - 'thumbnail': dict({ - 'blurhash': 'UeKUpFxuo~R%0nW;WCnhF6RjaJt757oJodS$', - 'url': 'https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png', - 'versions': dict({ - '@1x': 'https://files.mastodon.social/site_uploads/files/000/000/001/@1x/57c12f441d083cde.png', - '@2x': 'https://files.mastodon.social/site_uploads/files/000/000/001/@2x/57c12f441d083cde.png', - }), - }), + 'thumbnail': None, 'title': 'Mastodon', + 'uri': 'mastodon.social', 'usage': dict({ 'users': dict({ - 'active_month': 123122, + 'active_month': 380143, }), }), - 'version': '4.0.0rc1', + 'version': '4.4.0-nightly.2025-02-07', }), }) # --- diff --git a/tests/components/mastodon/snapshots/test_init.ambr b/tests/components/mastodon/snapshots/test_init.ambr index 28157b9e6eb..46fb4c1d4e0 100644 --- a/tests/components/mastodon/snapshots/test_init.ambr +++ b/tests/components/mastodon/snapshots/test_init.ambr @@ -28,7 +28,7 @@ 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': '4.0.0rc1', + 'sw_version': '4.4.0-nightly.2025-02-07', 'via_device_id': None, }) # --- diff --git a/tests/components/mastodon/snapshots/test_sensor.ambr b/tests/components/mastodon/snapshots/test_sensor.ambr index 22ac2671c36..40986210454 100644 --- a/tests/components/mastodon/snapshots/test_sensor.ambr +++ b/tests/components/mastodon/snapshots/test_sensor.ambr @@ -47,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '821', + 'state': '3169', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_following-entry] @@ -98,7 +98,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '178', + 'state': '328', }) # --- # name: test_sensors[sensor.mastodon_trwnh_mastodon_social_posts-entry] @@ -149,6 +149,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '33120', + 'state': '69523', }) # --- From 8807e326d1d1fc9a53e9d438728aecaff2c99770 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 10 Mar 2025 17:15:52 +0100 Subject: [PATCH 1537/1941] Bump go2rtc to 1.9.9 (#140302) --- Dockerfile | 2 +- homeassistant/components/go2rtc/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3ab0bb37b9a..251c92539a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN \ "armv7") go2rtc_suffix='arm' ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \ esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed && go2rtc --version diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 234411936cb..491b2269043 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" -RECOMMENDED_VERSION = "1.9.8" +RECOMMENDED_VERSION = "1.9.9" From d498dbd5ace41dc9657c265e42749dc49b9c8ea7 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Mon, 10 Mar 2025 12:37:30 -0400 Subject: [PATCH 1538/1941] FGLair : Upgrade to ayla-iot-unofficial 1.4.7 (#140296) Upgrade to ayla-iot-unofficial 1.4.7 --- homeassistant/components/fujitsu_fglair/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index 330685f89fc..c8fed9b45c9 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.5"] + "requirements": ["ayla-iot-unofficial==1.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a6e67f18c0..b06f8d2bb00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -557,7 +557,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.5 +ayla-iot-unofficial==1.4.7 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d246d59b1e..8e6d0d61e23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ av==13.1.0 axis==64 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.5 +ayla-iot-unofficial==1.4.7 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From 1665d9474f72f71f4012b9c1b31192dd0db96bfe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Mar 2025 15:12:37 -0400 Subject: [PATCH 1539/1941] Enable TTS streaming implementations (#140176) * Enable TTS streaming implementations * Update comment * Revert type change --- homeassistant/components/tts/__init__.py | 12 ++++-- homeassistant/components/tts/entity.py | 49 +++++++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 98ce76cafde..31a92c62258 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -62,7 +62,7 @@ from .const import ( DOMAIN, TtsAudioType, ) -from .entity import TextToSpeechEntity +from .entity import TextToSpeechEntity, TTSAudioRequest from .helper import get_engine_instance from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy from .media_source import generate_media_source_id, media_source_id_to_kwargs @@ -795,9 +795,15 @@ class SpeechManager: message, language, options ) else: - extension, data = await engine_instance.internal_async_get_tts_audio( - message, language, options + + async def message_gen() -> AsyncGenerator[str]: + yield message + + tts_result = await engine_instance.internal_async_stream_tts_audio( + TTSAudioRequest(language, options, message_gen()) ) + extension = tts_result.extension + data = b"".join([chunk async for chunk in tts_result.data_gen]) if data is None or extension is None: raise HomeAssistantError( diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index ef65886452d..199d673398e 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -1,6 +1,7 @@ """Entity for Text-to-Speech.""" -from collections.abc import Mapping +from collections.abc import AsyncGenerator, Mapping +from dataclasses import dataclass from functools import partial from typing import Any, final @@ -16,6 +17,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -31,6 +33,23 @@ CACHED_PROPERTIES_WITH_ATTR_ = { } +@dataclass +class TTSAudioRequest: + """Request to get TTS audio.""" + + language: str + options: dict[str, Any] + message_gen: AsyncGenerator[str] + + +@dataclass +class TTSAudioResponse: + """Response containing TTS audio stream.""" + + extension: str + data_gen: AsyncGenerator[bytes] + + class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Represent a single TTS engine.""" @@ -128,19 +147,37 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH ) @final - async def internal_async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] - ) -> TtsAudioType: + async def internal_async_stream_tts_audio( + self, request: TTSAudioRequest + ) -> TTSAudioResponse: """Process an audio stream to TTS service. Only streaming content is allowed! """ self.__last_tts_loaded = dt_util.utcnow().isoformat() self.async_write_ha_state() - return await self.async_get_tts_audio( - message=message, language=language, options=options + return await self.async_stream_tts_audio(request) + + async def async_stream_tts_audio( + self, request: TTSAudioRequest + ) -> TTSAudioResponse: + """Generate speech from an incoming message. + + The default implementation is backwards compatible with async_get_tts_audio. + """ + message = "".join([chunk async for chunk in request.message_gen]) + extension, data = await self.async_get_tts_audio( + message, request.language, request.options ) + if extension is None or data is None: + raise HomeAssistantError(f"No TTS from {self.entity_id} for '{message}'") + + async def data_gen() -> AsyncGenerator[bytes]: + yield data + + return TTSAudioResponse(extension, data_gen()) + def get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: From 49a62d52947ff8e92f8e9e9921cd71e23acf84ca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Mar 2025 15:15:10 -0400 Subject: [PATCH 1540/1941] Standardize conversation.async_process method (#140125) --- .../components/anthropic/conversation.py | 14 +--- .../components/conversation/default_agent.py | 76 +++++++++---------- .../components/conversation/entity.py | 16 +++- .../conversation.py | 14 +--- .../components/ollama/conversation.py | 14 +--- .../openai_conversation/conversation.py | 14 +--- 6 files changed, 55 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 8d3ba5085ee..ff403e61a91 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -30,7 +30,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry @@ -226,18 +226,6 @@ class AnthropicConversationEntity( self.entry.add_update_listener(self._async_entry_update_listener) ) - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3a7aa0c26e8..c30e8bb4a92 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -42,7 +42,6 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.helpers import ( area_registry as ar, - chat_session, device_registry as dr, entity_registry as er, floor_registry as fr, @@ -56,7 +55,7 @@ from homeassistant.helpers.event import async_track_state_added_domain from homeassistant.util import language as language_util from homeassistant.util.json import JsonObjectType, json_loads_object -from .chat_log import AssistantContent, async_get_chat_log +from .chat_log import AssistantContent, ChatLog from .const import ( DATA_DEFAULT_ENTITY, DEFAULT_EXPOSED_ATTRIBUTES, @@ -332,49 +331,46 @@ class DefaultAgent(ConversationEntity): return result - async def async_process(self, user_input: ConversationInput) -> ConversationResult: - """Process a sentence.""" + async def _async_handle_message( + self, + user_input: ConversationInput, + chat_log: ChatLog, + ) -> ConversationResult: + """Handle a message.""" response: intent.IntentResponse | None = None - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - # Check if a trigger matched - if trigger_result := await self.async_recognize_sentence_trigger( - user_input - ): - # Process callbacks and get response - response_text = await self._handle_trigger_result( - trigger_result, user_input - ) - # Convert to conversation result - response = intent.IntentResponse( - language=user_input.language or self.hass.config.language - ) - response.response_type = intent.IntentResponseType.ACTION_DONE - response.async_set_speech(response_text) - - if response is None: - # Match intents - intent_result = await self.async_recognize_intent(user_input) - response = await self._async_process_intent_result( - intent_result, user_input - ) - - speech: str = response.speech.get("plain", {}).get("speech", "") - chat_log.async_add_assistant_content_without_tools( - AssistantContent( - agent_id=user_input.agent_id, - content=speech, - ) + # Check if a trigger matched + if trigger_result := await self.async_recognize_sentence_trigger(user_input): + # Process callbacks and get response + response_text = await self._handle_trigger_result( + trigger_result, user_input ) - return ConversationResult( - response=response, conversation_id=session.conversation_id + # Convert to conversation result + response = intent.IntentResponse( + language=user_input.language or self.hass.config.language ) + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_speech(response_text) + + if response is None: + # Match intents + intent_result = await self.async_recognize_intent(user_input) + response = await self._async_process_intent_result( + intent_result, user_input + ) + + speech: str = response.speech.get("plain", {}).get("speech", "") + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id=user_input.agent_id, + content=speech, + ) + ) + + return ConversationResult( + response=response, conversation_id=chat_log.conversation_id + ) async def _async_process_intent_result( self, diff --git a/homeassistant/components/conversation/entity.py b/homeassistant/components/conversation/entity.py index d9598dee7eb..ca4d18ab9f5 100644 --- a/homeassistant/components/conversation/entity.py +++ b/homeassistant/components/conversation/entity.py @@ -4,9 +4,11 @@ from abc import abstractmethod from typing import Literal, final from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.helpers.chat_session import async_get_chat_session from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util +from .chat_log import ChatLog, async_get_chat_log from .const import ConversationEntityFeature from .models import ConversationInput, ConversationResult @@ -51,9 +53,21 @@ class ConversationEntity(RestoreEntity): def supported_languages(self) -> list[str] | Literal["*"]: """Return a list of supported languages.""" - @abstractmethod async def async_process(self, user_input: ConversationInput) -> ConversationResult: """Process a sentence.""" + with ( + async_get_chat_session(self.hass, user_input.conversation_id) as session, + async_get_chat_log(self.hass, session, user_input) as chat_log, + ): + return await self._async_handle_message(user_input, chat_log) + + async def _async_handle_message( + self, + user_input: ConversationInput, + chat_log: ChatLog, + ) -> ConversationResult: + """Call the API.""" + raise NotImplementedError async def async_prepare(self, language: str | None = None) -> None: """Load intents for a language.""" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index b43558c6768..93b7bbe5ebc 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -25,7 +25,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -264,18 +264,6 @@ class GoogleGenerativeAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 85daf742035..ab9e05b5fbe 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, intent, llm +from homeassistant.helpers import intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -206,18 +206,6 @@ class OllamaConversationEntity( """Return a list of supported languages.""" return MATCH_ALL - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 37be41947f7..e42319f8e96 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, device_registry as dr, intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry @@ -223,18 +223,6 @@ class OpenAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - with ( - chat_session.async_get_chat_session( - self.hass, user_input.conversation_id - ) as session, - conversation.async_get_chat_log(self.hass, session, user_input) as chat_log, - ): - return await self._async_handle_message(user_input, chat_log) - async def _async_handle_message( self, user_input: conversation.ConversationInput, From 8fe45fb994e037b12ef99b265a047367e3a86771 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 10 Mar 2025 17:02:07 -0400 Subject: [PATCH 1541/1941] Fix todo tool broken with Gemini 2.0 models. (#140246) * Change tool name for addlist item * Change to HasListAddItem * extract to function --- .../conversation.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 93b7bbe5ebc..93546431391 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -264,6 +264,13 @@ class GoogleGenerativeAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() + def _fix_tool_name(self, tool_name: str) -> str: + """Fix tool name if needed.""" + # The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool + # name. This makes sure when it incorrectly changes the name, that we change it + # back for HA to call. + return tool_name if tool_name != "HasListAddItem" else "HassListAddItem" + async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -423,7 +430,10 @@ class GoogleGenerativeAIConversationEntity( tool_name = tool_call.name tool_args = _escape_decode(tool_call.args) tool_calls.append( - llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + llm.ToolInput( + tool_name=self._fix_tool_name(tool_name), + tool_args=tool_args, + ) ) chat_request = _create_google_tool_response_content( From 058c965b88779ce46a1d4b62b5dfcf6e7422960a Mon Sep 17 00:00:00 2001 From: Glen Robertson Date: Mon, 10 Mar 2025 17:25:38 -0400 Subject: [PATCH 1542/1941] Set anthemav volume_step to 0.01 (#140130) --- homeassistant/components/anthemav/media_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index cfbd3c29547..317498e96b5 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -22,6 +22,7 @@ from . import AnthemavConfigEntry from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) +VOLUME_STEP = 0.01 async def async_setup_entry( @@ -60,6 +61,7 @@ class AnthemAVR(MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE ) + _attr_volume_step = VOLUME_STEP def __init__( self, From bf50ee9b5e365ae2bdf58bb7a0f21962d4f6442d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 10 Mar 2025 23:12:47 +0100 Subject: [PATCH 1543/1941] Capitalize abbreviations in `lektrico` integration (#140311) * Capitalize abbreviations in `lektrico` integration * Update test_number.ambr * Update test_binary_sensor.ambr * Update test_binary_sensor.ambr * Update test_number.ambr --- homeassistant/components/lektrico/strings.json | 8 ++++---- .../lektrico/snapshots/test_binary_sensor.ambr | 12 ++++++------ tests/components/lektrico/snapshots/test_number.ambr | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index 3b4417c346a..eb0203e0661 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -24,7 +24,7 @@ "entity": { "binary_sensor": { "state_e_activated": { - "name": "Ev error" + "name": "EV error" }, "overtemp": { "name": "Thermal throttling" @@ -45,10 +45,10 @@ "name": "Overvoltage" }, "rcd_error": { - "name": "Rcd error" + "name": "RCD error" }, "cp_diode_failure": { - "name": "Ev diode short" + "name": "EV diode short" }, "contactor_failure": { "name": "Relay contacts welded" @@ -64,7 +64,7 @@ }, "number": { "led_max_brightness": { - "name": "Led brightness" + "name": "LED brightness" }, "dynamic_limit": { "name": "Dynamic limit" diff --git a/tests/components/lektrico/snapshots/test_binary_sensor.ambr b/tests/components/lektrico/snapshots/test_binary_sensor.ambr index b365ff84187..7d812c0fc67 100644 --- a/tests/components/lektrico/snapshots/test_binary_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_binary_sensor.ambr @@ -24,7 +24,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Ev diode short', + 'original_name': 'EV diode short', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -37,7 +37,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': '1p7k_500006 Ev diode short', + 'friendly_name': '1p7k_500006 EV diode short', }), 'context': , 'entity_id': 'binary_sensor.1p7k_500006_ev_diode_short', @@ -72,7 +72,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Ev error', + 'original_name': 'EV error', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -85,7 +85,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': '1p7k_500006 Ev error', + 'friendly_name': '1p7k_500006 EV error', }), 'context': , 'entity_id': 'binary_sensor.1p7k_500006_ev_error', @@ -312,7 +312,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Rcd error', + 'original_name': 'RCD error', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -325,7 +325,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': '1p7k_500006 Rcd error', + 'friendly_name': '1p7k_500006 RCD error', }), 'context': , 'entity_id': 'binary_sensor.1p7k_500006_rcd_error', diff --git a/tests/components/lektrico/snapshots/test_number.ambr b/tests/components/lektrico/snapshots/test_number.ambr index 57cf40567e7..368479cdd06 100644 --- a/tests/components/lektrico/snapshots/test_number.ambr +++ b/tests/components/lektrico/snapshots/test_number.ambr @@ -86,7 +86,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Led brightness', + 'original_name': 'LED brightness', 'platform': 'lektrico', 'previous_unique_id': None, 'supported_features': 0, @@ -98,7 +98,7 @@ # name: test_all_entities[number.1p7k_500006_led_brightness-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '1p7k_500006 Led brightness', + 'friendly_name': '1p7k_500006 LED brightness', 'max': 100, 'min': 0, 'mode': , From 37213503b1b389aa3d2bce388695859aae5a04bc Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Mon, 10 Mar 2025 18:16:44 -0400 Subject: [PATCH 1544/1941] Do not add outside temperature sensor for FGLair if reading is None (#140298) * Do not add outside temperature sensor if reading is None * Fix comments --- .../components/fujitsu_fglair/sensor.py | 1 + tests/components/fujitsu_fglair/test_sensor.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py index 0ad5bec3117..3bb693e1068 100644 --- a/homeassistant/components/fujitsu_fglair/sensor.py +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -24,6 +24,7 @@ async def async_setup_entry( async_add_entities( FGLairOutsideTemperature(entry.runtime_data, device) for device in entry.runtime_data.data.values() + if device.outdoor_temperature is not None ) diff --git a/tests/components/fujitsu_fglair/test_sensor.py b/tests/components/fujitsu_fglair/test_sensor.py index e3f6109a2e8..b8200f114ad 100644 --- a/tests/components/fujitsu_fglair/test_sensor.py +++ b/tests/components/fujitsu_fglair/test_sensor.py @@ -31,3 +31,20 @@ async def test_entities( assert await integration_setup() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_no_outside_temperature( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_ayla_api: AsyncMock, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test that the outside sensor doesn't get added if the reading is None.""" + mock_ayla_api.async_get_devices.return_value[0].outdoor_temperature = None + + assert await integration_setup() + + assert ( + len(entity_registry.entities) + == len(mock_ayla_api.async_get_devices.return_value) - 1 + ) From 2e79db369585e1ec0fb9f2bc92fef9140ff13d2c Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 11 Mar 2025 02:29:26 +0100 Subject: [PATCH 1545/1941] Fix hass stop in bootstrap (#132795) --- homeassistant/bootstrap.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 734439842b2..e301912806c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -299,14 +299,6 @@ async def async_setup_hass( return hass - async def stop_hass(hass: core.HomeAssistant) -> None: - """Stop hass.""" - # Ask integrations to shut down. It's messy but we can't - # do a clean stop without knowing what is broken - with contextlib.suppress(TimeoutError): - async with hass.timeout.async_timeout(10): - await hass.async_stop() - hass = await create_hass() if runtime_config.skip_pip or runtime_config.skip_pip_packages: @@ -345,7 +337,7 @@ async def async_setup_hass( if config_dict is None: recovery_mode = True - await stop_hass(hass) + await hass.async_stop(force=True) hass = await create_hass() elif not basic_setup_success: @@ -353,7 +345,7 @@ async def async_setup_hass( "Unable to set up core integrations. Activating recovery mode" ) recovery_mode = True - await stop_hass(hass) + await hass.async_stop(force=True) hass = await create_hass() elif any( @@ -368,7 +360,7 @@ async def async_setup_hass( old_logging = hass.data.get(DATA_LOGGING) recovery_mode = True - await stop_hass(hass) + await hass.async_stop(force=True) hass = await create_hass() if old_logging: From b6df07b2ed1e89326fac4c29da0bbca91599ac4d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 06:14:22 +0100 Subject: [PATCH 1546/1941] Improve user-facing strings of `nordpool` integration (#140286) --- homeassistant/components/nordpool/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index cc10a1a0640..7b33f032de1 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -15,7 +15,7 @@ }, "data_description": { "currency": "Select currency to display prices in, EUR is the base currency.", - "areas": "Areas to display prices for according to Nordpool market areas." + "areas": "Areas to display prices for according to Nord Pool market areas." } }, "reconfigure": { @@ -95,11 +95,11 @@ "services": { "get_prices_for_date": { "name": "Get prices for date", - "description": "Retrieve the prices for a specific date.", + "description": "Retrieves the prices for a specific date.", "fields": { "config_entry": { - "name": "Select Nord Pool configuration entry", - "description": "Choose the configuration entry." + "name": "Config entry", + "description": "The Nord Pool configuration entry for this action." }, "date": { "name": "Date", From a65bf35a06022f15e2e5e251f8f5b837921d92ba Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 11 Mar 2025 18:06:29 +1000 Subject: [PATCH 1547/1941] Bump teslemetry-stream (#140335) Bump --- homeassistant/components/teslemetry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 4e9228acd2f..7c27024d9f0 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.12", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index b06f8d2bb00..56991204f5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2881,7 +2881,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.10 +teslemetry-stream==0.6.12 # homeassistant.components.tessie tessie-api==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e6d0d61e23..580190feef5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2321,7 +2321,7 @@ tesla-powerwall==0.5.2 tesla-wall-connector==1.0.2 # homeassistant.components.teslemetry -teslemetry-stream==0.6.10 +teslemetry-stream==0.6.12 # homeassistant.components.tessie tessie-api==0.1.1 From 873cf6ac09c010e31ebd0e69ae5c09bcdfc7da5d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 09:09:15 +0100 Subject: [PATCH 1548/1941] Fix sentence-casing and spelling of "LED" in `baf` integration (#140343) --- homeassistant/components/baf/strings.json | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json index e2f02a6095e..64956984bb8 100644 --- a/homeassistant/components/baf/strings.json +++ b/homeassistant/components/baf/strings.json @@ -23,7 +23,7 @@ "entity": { "climate": { "auto_comfort": { - "name": "Auto comfort" + "name": "Auto Comfort" } }, "fan": { @@ -39,25 +39,25 @@ }, "number": { "comfort_min_speed": { - "name": "Auto Comfort Minimum Speed" + "name": "Auto Comfort minimum speed" }, "comfort_max_speed": { - "name": "Auto Comfort Maximum Speed" + "name": "Auto Comfort maximum speed" }, "comfort_heat_assist_speed": { - "name": "Auto Comfort Heat Assist Speed" + "name": "Auto Comfort Heat Assist speed" }, "return_to_auto_timeout": { - "name": "Return to Auto Timeout" + "name": "Return to Auto timeout" }, "motion_sense_timeout": { - "name": "Motion Sense Timeout" + "name": "Motion sense timeout" }, "light_return_to_auto_timeout": { - "name": "Light Return to Auto Timeout" + "name": "Light return to Auto timeout" }, "light_auto_motion_timeout": { - "name": "Light Motion Sense Timeout" + "name": "Light motion sense timeout" } }, "sensor": { @@ -76,10 +76,10 @@ }, "switch": { "legacy_ir_remote_enable": { - "name": "Legacy IR Remote" + "name": "Legacy IR remote" }, "led_indicators_enable": { - "name": "Led Indicators" + "name": "LED indicators" }, "comfort_heat_assist_enable": { "name": "Auto Comfort Heat Assist" @@ -88,10 +88,10 @@ "name": "Beep" }, "eco_enable": { - "name": "Eco Mode" + "name": "Eco mode" }, "motion_sense_enable": { - "name": "Motion Sense" + "name": "Motion sense" }, "return_to_auto_enable": { "name": "Return to Auto" @@ -103,7 +103,7 @@ "name": "Dim to Warm" }, "light_return_to_auto_enable": { - "name": "Light Return to Auto" + "name": "Light return to Auto" } } } From 6b601b9aad866ffbc522d965d370207a7454a07c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 11 Mar 2025 04:09:53 -0400 Subject: [PATCH 1549/1941] Bump ZHA to 0.0.52 (#140325) --- 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 0cc2524469e..d16ce5a64bf 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.51"], + "requirements": ["zha==0.0.52"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 56991204f5a..aeb3a52f625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3149,7 +3149,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.51 +zha==0.0.52 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 580190feef5..b2a59379f68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2538,7 +2538,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.51 +zha==0.0.52 # homeassistant.components.zwave_js zwave-js-server-python==0.61.0 From cdff2e46480188156567ea104881e51108be4622 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 11 Mar 2025 08:11:46 +0000 Subject: [PATCH 1550/1941] Add strict typing of post to Mastodon (#140299) * Type post API * Update quality scale --- homeassistant/components/mastodon/notify.py | 10 ++++++---- homeassistant/components/mastodon/quality_scale.yaml | 5 +---- homeassistant/components/mastodon/services.py | 6 +++--- tests/components/mastodon/test_services.py | 8 +++++--- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py index 8af98ec3ab1..149ef1f6a48 100644 --- a/homeassistant/components/mastodon/notify.py +++ b/homeassistant/components/mastodon/notify.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any, cast from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError +from mastodon.Mastodon import MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.components.notify import ( @@ -114,7 +114,7 @@ class MastodonNotificationService(BaseNotificationService): message, visibility=target, spoiler_text=content_warning, - media_ids=mediadata["id"], + media_ids=mediadata.id, sensitive=sensitive, ) except MastodonAPIError as err: @@ -134,12 +134,14 @@ class MastodonNotificationService(BaseNotificationService): translation_key="unable_to_send_message", ) from err - def _upload_media(self, media_path: Any = None) -> Any: + def _upload_media(self, media_path: Any = None) -> MediaAttachment: """Upload media.""" with open(media_path, "rb"): media_type = get_media_type(media_path) try: - mediadata = self.client.media_post(media_path, mime_type=media_type) + mediadata: MediaAttachment = self.client.media_post( + media_path, mime_type=media_type + ) except MastodonAPIError as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index 43636ed6924..f07f7e0a8ad 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -93,7 +93,4 @@ rules: # Platinum async-dependency: todo inject-websession: todo - strict-typing: - status: todo - comment: | - Requirement 'Mastodon.py==1.8.1' appears untyped + strict-typing: done diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 7ab351f8c29..68e95e726a1 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -5,7 +5,7 @@ from functools import partial from typing import Any, cast from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError +from mastodon.Mastodon import MastodonAPIError, MediaAttachment import voluptuous as vol from homeassistant.config_entries import ConfigEntryState @@ -104,7 +104,7 @@ def setup_services(hass: HomeAssistant) -> None: def _post(client: Mastodon, **kwargs: Any) -> None: """Post to Mastodon.""" - media_data: dict[str, Any] | None = None + media_data: MediaAttachment | None = None media_path = kwargs.get("media_path") if media_path: @@ -137,7 +137,7 @@ def setup_services(hass: HomeAssistant) -> None: try: media_ids: str | None = None if media_data: - media_ids = media_data["id"] + media_ids = media_data.id client.status_post(media_ids=media_ids, **kwargs) except MastodonAPIError as err: raise HomeAssistantError( diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index 4dafa9a8e5b..f51d39f8687 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, Mock, patch -from mastodon.Mastodon import MastodonAPIError +from mastodon.Mastodon import MastodonAPIError, MediaAttachment import pytest from homeassistant.components.mastodon.const import ( @@ -106,7 +106,9 @@ async def test_service_post( with ( patch.object(hass.config, "is_allowed_path", return_value=True), - patch.object(mock_mastodon_client, "media_post", return_value={"id": "1"}), + patch.object( + mock_mastodon_client, "media_post", return_value=MediaAttachment(id="1") + ), ): await hass.services.async_call( DOMAIN, @@ -163,7 +165,7 @@ async def test_post_service_failed( 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.media_post.return_value = MediaAttachment(id="1") mock_mastodon_client.status_post.side_effect = MastodonAPIError From 711f9ab900373eb85a58f4e472a4891fe559fcf2 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 09:12:29 +0100 Subject: [PATCH 1551/1941] Correct sentence-casing and spelling of "LED" in `zha` integration (#140342) --- homeassistant/components/zha/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index be1642227bd..23bb9ae051e 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -176,7 +176,7 @@ }, "config_panel": { "zha_options": { - "title": "Global Options", + "title": "Global options", "enhanced_light_transition": "Enable enhanced light color/temperature transition from an off-state", "light_transitioning_flag": "Enable enhanced brightness slider during light transition", "group_members_assume_state": "Group members assume state of group", @@ -187,7 +187,7 @@ "consider_unavailable_battery": "Consider battery powered devices unavailable after (seconds)" }, "zha_alarm_options": { - "title": "Alarm Control Panel Options", + "title": "Alarm control panel options", "alarm_master_code": "Master code for the alarm control panel(s)", "alarm_failed_tries": "The number of consecutive failed code entries to trigger an alarm", "alarm_arm_requires_code": "Code required for arming actions" @@ -1144,10 +1144,10 @@ "name": "Switch type" }, "led_scaling_mode": { - "name": "Led scaling mode" + "name": "LED scaling mode" }, "smart_fan_led_display_levels": { - "name": "Smart fan led display levels" + "name": "Smart fan LED display levels" }, "increased_non_neutral_output": { "name": "Non neutral output" From a45ce3083bc3a889335d099a2716cd6cda99f5bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Mar 2025 22:15:20 -1000 Subject: [PATCH 1552/1941] Bump pylutron-caseta 0.24.0 (#140338) changelog: https://github.com/gurumitts/pylutron-caseta/compare/v0.23.0...v0.24.0 --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index bbb6df41a89..96b00a1f392 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.23.0"], + "requirements": ["pylutron-caseta==0.24.0"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index aeb3a52f625..fc586ec35d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2098,7 +2098,7 @@ pylitejet==0.6.3 pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.23.0 +pylutron-caseta==0.24.0 # homeassistant.components.lutron pylutron==0.2.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2a59379f68..e957aa518f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1712,7 +1712,7 @@ pylitejet==0.6.3 pylitterbot==2024.0.0 # homeassistant.components.lutron_caseta -pylutron-caseta==0.23.0 +pylutron-caseta==0.24.0 # homeassistant.components.lutron pylutron==0.2.16 From e0f4da390af6e37be49c2a34ea554d9f5095c5f9 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Tue, 11 Mar 2025 04:16:44 -0400 Subject: [PATCH 1553/1941] Bump pydrawise to 2025.3.0 (#140330) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 73423882e4a..0c355c34a71 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.2.0"] + "requirements": ["pydrawise==2025.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index fc586ec35d0..7530b75b0b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1906,7 +1906,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.2.0 +pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e957aa518f9..a1c8c3ff509 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1556,7 +1556,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.2.0 +pydrawise==2025.3.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 6e2148193a9a904f72a1a0b35296beb2f8ba688a Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 11 Mar 2025 03:18:31 -0500 Subject: [PATCH 1554/1941] Bump pyheos to v1.0.3 (#140310) Bump pyheos v1.0.3 --- homeassistant/components/heos/coordinator.py | 3 +- homeassistant/components/heos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../heos/snapshots/test_diagnostics.ambr | 4 ++ tests/components/heos/test_init.py | 4 +- tests/components/heos/test_media_player.py | 38 +------------------ 7 files changed, 11 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 0303d150794..93fe069d9be 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -159,13 +159,12 @@ class HeosCoordinator(DataUpdateCoordinator[None]): async def _async_on_reconnected(self) -> None: """Handle when reconnected so resources are updated and entities marked available.""" - await self._async_update_players() await self._async_update_sources() _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) self.async_update_listeners() async def _async_on_controller_event( - self, event: str, data: PlayerUpdateResult | None + self, event: str, data: PlayerUpdateResult | None = None ) -> None: """Handle a controller event, such as players or groups changed.""" if event == const.EVENT_PLAYERS_CHANGED: diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 573deda2132..19feffd8ef1 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "platinum", - "requirements": ["pyheos==1.0.2"], + "requirements": ["pyheos==1.0.3"], "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/requirements_all.txt b/requirements_all.txt index 7530b75b0b3..0f8e41cddf2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1996,7 +1996,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.2 +pyheos==1.0.3 # homeassistant.components.hive pyhive-integration==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1c8c3ff509..8e0569e161b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1625,7 +1625,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.2 +pyheos==1.0.3 # homeassistant.components.hive pyhive-integration==1.0.2 diff --git a/tests/components/heos/snapshots/test_diagnostics.ambr b/tests/components/heos/snapshots/test_diagnostics.ambr index 98ce8a7bcbf..58685f5cf8f 100644 --- a/tests/components/heos/snapshots/test_diagnostics.ambr +++ b/tests/components/heos/snapshots/test_diagnostics.ambr @@ -106,6 +106,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -116,6 +117,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -125,6 +127,7 @@ 'model': 'Speaker', 'name': 'Test Player 2', 'network': 'wifi', + 'preferred_host': False, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', @@ -137,6 +140,7 @@ 'model': 'HEOS Drive HS2', 'name': 'Test Player', 'network': 'wired', + 'preferred_host': True, 'serial': '**REDACTED**', 'supported_version': True, 'version': '1.0.0', diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 87cc8dd7dde..b155abaf0e9 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -285,11 +285,11 @@ async def test_reconnected_new_entities_created( players = controller.players.copy() players[3] = player_factory(3, "Test Player 3", "HEOS Link") controller.mock_set_players(players) - controller.load_players.return_value = PlayerUpdateResult([3], [], {}) + update = PlayerUpdateResult([3], [], {}) # Simulate reconnection await controller.dispatcher.wait_send( - SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + SignalType.CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED, update ) await hass.async_block_till_done() diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index 3e755a29a0a..debfe31f427 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -158,7 +158,6 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_IDLE - assert controller.load_players.call_count == 1 # Disconnected controller.load_players.reset_mock() @@ -170,11 +169,8 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_UNAVAILABLE - assert controller.load_players.call_count == 0 - # Connected handles refresh failure - controller.load_players.reset_mock() - controller.load_players.side_effect = CommandFailedError("", "Failure", 1) + # Reconnect and state updates player.available = True await controller.dispatcher.wait_send( SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED @@ -183,38 +179,6 @@ async def test_updates_from_connection_event( state = hass.states.get("media_player.test_player") assert state is not None assert state.state == STATE_IDLE - assert controller.load_players.call_count == 1 - assert "Unable to refresh players" in caplog.text - - -async def test_updates_from_connection_event_new_player_ids( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - controller: MockHeos, - change_data_mapped_ids: PlayerUpdateResult, -) -> None: - """Test player ids changed after reconnection updates ids.""" - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - - # Assert current IDs - assert device_registry.async_get_device(identifiers={(DOMAIN, "1")}) - assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") - - # Send event which will result in updated IDs. - controller.load_players.return_value = change_data_mapped_ids - await controller.dispatcher.wait_send( - SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED - ) - await hass.async_block_till_done() - - # Assert updated IDs and previous don't exist - assert not device_registry.async_get_device(identifiers={(DOMAIN, "1")}) - assert device_registry.async_get_device(identifiers={(DOMAIN, "101")}) - assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") - assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "101") async def test_updates_from_sources_updated( From 3b115506b99335ce25b8e4bc3ca2eff2cb742c3c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Mar 2025 22:19:21 -1000 Subject: [PATCH 1555/1941] Bump inkbird-ble to 0.9.0 (#140339) changelog: https://github.com/Bluetooth-Devices/inkbird-ble/compare/v0.8.0...v0.9.0 --- 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 e2e9550dd7c..6b570b27fe2 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -36,5 +36,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/inkbird", "iot_class": "local_push", - "requirements": ["inkbird-ble==0.8.0"] + "requirements": ["inkbird-ble==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0f8e41cddf2..e2d63ab0ccd 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.8.0 +inkbird-ble==0.9.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e0569e161b..e4b4e91e1d9 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.8.0 +inkbird-ble==0.9.0 # homeassistant.components.insteon insteon-frontend-home-assistant==0.5.0 From 52408e67b2e98ff7708ff5667f8b944535ee8094 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Tue, 11 Mar 2025 10:43:29 +0200 Subject: [PATCH 1556/1941] Update hdate dependency to 1.0.3 (#137247) * Update hdate version * Update code to reflect changes from hdate==1.0.0 * Fix some tests * Fix parasha tests * Fix holiday tests * Cleanup holidays changes * Zmanim objects should now access the local attribute * Fix binary sensors * Update test values on upcoming shabbat times * Update hdate to 1.0.1 * Adapt to changes from 1.0.0 -> 1.0.1 * Change shabbat candle lighthing test scenario to 40 minutes as expected in Jerusalem * Update to version 1.0.2 * Update keys based on updated nomenclature in library * Update HolidayDatabase .get_all_names in test * Make holiday type an ordered set * Fix freeze_time * Fix imports * Fix tests and minor change * Update hdate version 1.0.3, add migration method * Fix migration code * Add test for migration * The change is not backwards compatible if config is not restored --- .../components/jewish_calendar/__init__.py | 51 ++++++++- .../jewish_calendar/binary_sensor.py | 22 ++-- .../components/jewish_calendar/config_flow.py | 2 +- .../components/jewish_calendar/entity.py | 4 +- .../components/jewish_calendar/manifest.json | 2 +- .../components/jewish_calendar/sensor.py | 101 +++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/jewish_calendar/__init__.py | 4 +- .../jewish_calendar/test_binary_sensor.py | 11 +- tests/components/jewish_calendar/test_init.py | 43 ++++++++ .../components/jewish_calendar/test_sensor.py | 68 ++++++------ 12 files changed, 202 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 823e9bd59be..9f7ec6ba976 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import partial +import logging from hdate import Location @@ -14,7 +15,8 @@ from homeassistant.const import ( CONF_TIME_ZONE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from .const import ( CONF_CANDLE_LIGHT_MINUTES, @@ -27,6 +29,7 @@ from .const import ( ) from .entity import JewishCalendarConfigEntry, JewishCalendarData +_LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -80,3 +83,49 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, config_entry: JewishCalendarConfigEntry +) -> bool: + """Migrate old entry.""" + + _LOGGER.debug("Migrating from version %s", config_entry.version) + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + key_translations = { + "first_light": "alot_hashachar", + "talit": "talit_and_tefillin", + "sunrise": "netz_hachama", + "gra_end_shma": "sof_zman_shema_gra", + "mga_end_shma": "sof_zman_shema_mga", + "gra_end_tfila": "sof_zman_tfilla_gra", + "mga_end_tfila": "sof_zman_tfilla_mga", + "midday": "chatzot_hayom", + "big_mincha": "mincha_gedola", + "small_mincha": "mincha_ketana", + "plag_mincha": "plag_hamincha", + "sunset": "shkia", + "first_stars": "tset_hakohavim_tsom", + "three_stars": "tset_hakohavim_shabbat", + } + new_keys = tuple(key_translations.values()) + if not entity_entry.unique_id.endswith(new_keys): + old_key = entity_entry.unique_id.split("-")[1] + new_unique_id = f"{config_entry.entry_id}-{key_translations[old_key]}" + return {"new_unique_id": new_unique_id} + return None + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + hass.config_entries.async_update_entry(config_entry, version=2) + + return True diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 5ff3171b7de..f33d79a01f5 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -5,9 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import datetime as dt -from datetime import datetime -import hdate from hdate.zmanim import Zmanim from homeassistant.components.binary_sensor import ( @@ -27,7 +25,7 @@ from .entity import JewishCalendarConfigEntry, JewishCalendarEntity class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): """Binary Sensor description mixin class for Jewish Calendar.""" - is_on: Callable[[Zmanim], bool] = lambda _: False + is_on: Callable[[Zmanim, dt.datetime], bool] = lambda _, __: False @dataclass(frozen=True) @@ -42,18 +40,18 @@ BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( key="issur_melacha_in_effect", name="Issur Melacha in Effect", icon="mdi:power-plug-off", - is_on=lambda state: bool(state.issur_melacha_in_effect), + is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)), ), JewishCalendarBinarySensorEntityDescription( key="erev_shabbat_hag", name="Erev Shabbat/Hag", - is_on=lambda state: bool(state.erev_shabbat_chag), + is_on=lambda state, now: bool(state.erev_shabbat_chag(now)), entity_registry_enabled_default=False, ), JewishCalendarBinarySensorEntityDescription( key="motzei_shabbat_hag", name="Motzei Shabbat/Hag", - is_on=lambda state: bool(state.motzei_shabbat_chag), + is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)), entity_registry_enabled_default=False, ), ) @@ -84,16 +82,16 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): def is_on(self) -> bool: """Return true if sensor is on.""" zmanim = self._get_zmanim() - return self.entity_description.is_on(zmanim) + return self.entity_description.is_on(zmanim, dt_util.now()) def _get_zmanim(self) -> Zmanim: """Return the Zmanim object for now().""" - return hdate.Zmanim( - date=dt_util.now(), + return Zmanim( + date=dt.date.today(), location=self._location, candle_lighting_offset=self._candle_lighting_offset, havdalah_offset=self._havdalah_offset, - hebrew=self._hebrew, + language=self._language, ) async def async_added_to_hass(self) -> None: @@ -109,7 +107,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): return await super().async_will_remove_from_hass() @callback - def _update(self, now: datetime | None = None) -> None: + def _update(self, now: dt.datetime | None = None) -> None: """Update the state of the sensor.""" self._update_unsub = None self._schedule_update() @@ -119,7 +117,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): """Schedule the next update of the sensor.""" now = dt_util.now() zmanim = self._get_zmanim() - update = zmanim.zmanim["sunrise"] + dt.timedelta(days=1) + update = zmanim.netz_hachama.local + dt.timedelta(days=1) candle_lighting = zmanim.candle_lighting if candle_lighting is not None and now < candle_lighting < update: update = candle_lighting diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index a2eadbf57bd..23bcb23435b 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -86,7 +86,7 @@ def _get_data_schema(hass: HomeAssistant) -> vol.Schema: class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Jewish calendar.""" - VERSION = 1 + VERSION = 2 @staticmethod @callback diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index 1d2a6e45c0a..2c031f0d160 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from hdate import Location +from hdate.translator import Language from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -17,7 +18,7 @@ type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] class JewishCalendarData: """Jewish Calendar runtime dataclass.""" - language: str + language: Language diaspora: bool location: Location candle_lighting_offset: int @@ -43,7 +44,6 @@ class JewishCalendarEntity(Entity): ) data = config_entry.runtime_data self._location = data.location - self._hebrew = data.language == "hebrew" self._language = data.language self._candle_lighting_offset = data.candle_lighting_offset self._havdalah_offset = data.havdalah_offset diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index aca45320002..877c4cf9a99 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate==0.11.1"], + "requirements": ["hdate[astral]==1.0.3"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index eee1d966ae6..7cb281b3af4 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -2,12 +2,13 @@ from __future__ import annotations -from datetime import date as Date +import datetime as dt import logging -from typing import Any, cast +from typing import Any -from hdate import HDate, HebrewDate, htables -from hdate.zmanim import Zmanim +from hdate import HDateInfo, Zmanim +from hdate.holidays import HolidayDatabase +from hdate.parasha import Parasha from homeassistant.components.sensor import ( SensorDeviceClass, @@ -59,83 +60,83 @@ INFO_SENSORS: tuple[SensorEntityDescription, ...] = ( TIME_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key="first_light", + key="alot_hashachar", name="Alot Hashachar", # codespell:ignore alot icon="mdi:weather-sunset-up", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="talit", + key="talit_and_tefillin", name="Talit and Tefillin", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="sunrise", + key="netz_hachama", name="Hanetz Hachama", icon="mdi:calendar-clock", ), SensorEntityDescription( - key="gra_end_shma", + key="sof_zman_shema_gra", name='Latest time for Shma Gr"a', icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="mga_end_shma", + key="sof_zman_shema_mga", name='Latest time for Shma MG"A', icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="gra_end_tfila", + key="sof_zman_tfilla_gra", name='Latest time for Tefilla Gr"a', icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="mga_end_tfila", + key="sof_zman_tfilla_mga", name='Latest time for Tefilla MG"A', icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="midday", + key="chatzot_hayom", name="Chatzot Hayom", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="big_mincha", + key="mincha_gedola", name="Mincha Gedola", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="small_mincha", + key="mincha_ketana", name="Mincha Ketana", icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="plag_mincha", + key="plag_hamincha", name="Plag Hamincha", icon="mdi:weather-sunset-down", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="sunset", + key="shkia", name="Shkia", icon="mdi:weather-sunset", ), SensorEntityDescription( - key="first_stars", + key="tset_hakohavim_tsom", name="T'set Hakochavim", icon="mdi:weather-night", entity_registry_enabled_default=False, ), SensorEntityDescription( - key="three_stars", + key="tset_hakohavim_shabbat", name="T'set Hakochavim, 3 stars", icon="mdi:weather-night", entity_registry_enabled_default=False, @@ -212,7 +213,9 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): _LOGGER.debug("Now: %s Sunset: %s", now, sunset) - daytime_date = HDate(today, diaspora=self._diaspora, hebrew=self._hebrew) + daytime_date = HDateInfo( + today, diaspora=self._diaspora, language=self._language + ) # The Jewish day starts after darkness (called "tzais") and finishes at # sunset ("shkia"). The time in between is a gray area @@ -238,14 +241,14 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): "New value for %s: %s", self.entity_description.key, self._attr_native_value ) - def make_zmanim(self, date: Date) -> Zmanim: + def make_zmanim(self, date: dt.date) -> Zmanim: """Create a Zmanim object.""" return Zmanim( date=date, location=self._location, candle_lighting_offset=self._candle_lighting_offset, havdalah_offset=self._havdalah_offset, - hebrew=self._hebrew, + language=self._language, ) @property @@ -254,43 +257,40 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): return self._attrs def get_state( - self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate + self, + daytime_date: HDateInfo, + after_shkia_date: HDateInfo, + after_tzais_date: HDateInfo, ) -> Any | None: """For a given type of sensor, return the state.""" # Terminology note: by convention in py-libhdate library, "upcoming" # refers to "current" or "upcoming" dates. if self.entity_description.key == "date": - hdate = cast(HebrewDate, after_shkia_date.hdate) - month = htables.MONTHS[hdate.month.value - 1] + hdate = after_shkia_date.hdate + hdate.month.set_language(self._language) self._attrs = { - "hebrew_year": hdate.year, - "hebrew_month_name": month.hebrew if self._hebrew else month.english, - "hebrew_day": hdate.day, + "hebrew_year": str(hdate.year), + "hebrew_month_name": str(hdate.month), + "hebrew_day": str(hdate.day), } - return after_shkia_date.hebrew_date + return after_shkia_date.hdate if self.entity_description.key == "weekly_portion": - self._attr_options = [ - (p.hebrew if self._hebrew else p.english) for p in htables.PARASHAOT - ] + self._attr_options = list(Parasha) # Compute the weekly portion based on the upcoming shabbat. return after_tzais_date.upcoming_shabbat.parasha if self.entity_description.key == "holiday": - _id = _type = _type_id = "" - _holiday_type = after_shkia_date.holiday_type - if isinstance(_holiday_type, list): - _id = ", ".join(after_shkia_date.holiday_name) - _type = ", ".join([_htype.name for _htype in _holiday_type]) - _type_id = ", ".join([str(_htype.value) for _htype in _holiday_type]) - else: - _id = after_shkia_date.holiday_name - _type = _holiday_type.name - _type_id = _holiday_type.value - self._attrs = {"id": _id, "type": _type, "type_id": _type_id} - self._attr_options = htables.get_all_holidays(self._language) - - return after_shkia_date.holiday_description + _holidays = after_shkia_date.holidays + _id = ", ".join(holiday.name for holiday in _holidays) + _type = ", ".join( + dict.fromkeys(_holiday.type.name for _holiday in _holidays) + ) + self._attrs = {"id": _id, "type": _type} + self._attr_options = HolidayDatabase(self._diaspora).get_all_names( + self._language + ) + return ", ".join(str(holiday) for holiday in _holidays) if _holidays else "" if self.entity_description.key == "omer_count": - return after_shkia_date.omer_day + return after_shkia_date.omer.total_days if after_shkia_date.omer else 0 if self.entity_description.key == "daf_yomi": return daytime_date.daf_yomi @@ -303,7 +303,10 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): _attr_device_class = SensorDeviceClass.TIMESTAMP def get_state( - self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate + self, + daytime_date: HDateInfo, + after_shkia_date: HDateInfo, + after_tzais_date: HDateInfo, ) -> Any | None: """For a given type of sensor, return the state.""" if self.entity_description.key == "upcoming_shabbat_candle_lighting": @@ -325,5 +328,5 @@ class JewishCalendarTimeSensor(JewishCalendarSensor): ) return times.havdalah - times = self.make_zmanim(dt_util.now()).zmanim - return times[self.entity_description.key] + times = self.make_zmanim(dt_util.now().date()) + return times.zmanim[self.entity_description.key].local diff --git a/requirements_all.txt b/requirements_all.txt index e2d63ab0ccd..8a2aa375b3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ hass-splunk==0.1.1 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate==0.11.1 +hdate[astral]==1.0.3 # homeassistant.components.heatmiser heatmiserV3==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4b4e91e1d9..bfc9262316c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -959,7 +959,7 @@ hass-nabucasa==0.94.0 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate==0.11.1 +hdate[astral]==1.0.3 # homeassistant.components.here_travel_time here-routing==1.0.1 diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index ba0a2b4835e..dc66c1e0d7d 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -3,8 +3,6 @@ from collections import namedtuple from datetime import datetime -from freezegun import freeze_time as alter_time # noqa: F401 - from homeassistant.components import jewish_calendar from homeassistant.util import dt as dt_util @@ -49,7 +47,7 @@ def make_jerusalem_test_params(dtime, results, havdalah_offset=0): } return ( dtime, - jewish_calendar.DEFAULT_CANDLE_LIGHT, + 40, havdalah_offset, False, "Asia/Jerusalem", diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 5cfaaedfc72..194e6fe9d01 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime as dt, timedelta import logging +from freezegun import freeze_time import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -18,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import alter_time, make_jerusalem_test_params, make_nyc_test_params +from . import make_jerusalem_test_params, make_nyc_test_params from tests.common import MockConfigEntry, async_fire_time_changed @@ -191,7 +192,7 @@ async def test_issur_melacha_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry( title=DEFAULT_NAME, domain=DOMAIN, @@ -213,7 +214,7 @@ async def test_issur_melacha_sensor( == result["state"] ) - with alter_time(result["update"]): + with freeze_time(result["update"]): async_fire_time_changed(hass, result["update"]) await hass.async_block_till_done() assert ( @@ -264,7 +265,7 @@ async def test_issur_melacha_sensor_update( hass.config.latitude = latitude hass.config.longitude = longitude - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry( title=DEFAULT_NAME, domain=DOMAIN, @@ -286,7 +287,7 @@ async def test_issur_melacha_sensor_update( ) test_time += timedelta(microseconds=1) - with alter_time(test_time): + with freeze_time(test_time): async_fire_time_changed(hass, test_time) await hass.async_block_till_done() assert ( diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py index cb982afec0f..6a4f57513fa 100644 --- a/tests/components/jewish_calendar/test_init.py +++ b/tests/components/jewish_calendar/test_init.py @@ -1 +1,44 @@ """Tests for the Jewish Calendar component's init.""" + +import pytest + +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("old_key", "new_key"), + [ + ("first_light", "alot_hashachar"), + ("sunset", "shkia"), + ("havdalah", "havdalah"), # Test no change + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + old_key: str, + new_key: str, +) -> None: + """Test unique id migration.""" + entry = MockConfigEntry(domain=DOMAIN, data={}) + entry.add_to_hass(hass) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=f"{entry.entry_id}-{old_key}", + config_entry=entry, + ) + assert entity.unique_id.endswith(f"-{old_key}") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}-{new_key}" diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index aac0f583b05..bc9e69a9717 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -2,10 +2,11 @@ from datetime import datetime as dt, timedelta -from hdate import htables +from freezegun import freeze_time +from hdate.holidays import HolidayDatabase +from hdate.parasha import Parasha import pytest -from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, @@ -13,12 +14,13 @@ from homeassistant.components.jewish_calendar.const import ( DEFAULT_NAME, DOMAIN, ) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import alter_time, make_jerusalem_test_params, make_nyc_test_params +from . import make_jerusalem_test_params, make_nyc_test_params from tests.common import MockConfigEntry, async_fire_time_changed @@ -92,8 +94,7 @@ TEST_PARAMS = [ "icon": "mdi:calendar-star", "id": "rosh_hashana_i", "type": "YOM_TOV", - "type_id": 1, - "options": htables.get_all_holidays("english"), + "options": HolidayDatabase(False).get_all_names("english"), }, ), ( @@ -111,8 +112,7 @@ TEST_PARAMS = [ "icon": "mdi:calendar-star", "id": "chanukah, rosh_chodesh", "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", - "type_id": "4, 10", - "options": htables.get_all_holidays("english"), + "options": HolidayDatabase(False).get_all_names("english"), }, ), ( @@ -128,7 +128,7 @@ TEST_PARAMS = [ "device_class": "enum", "friendly_name": "Jewish Calendar Parshat Hashavua", "icon": "mdi:book-open-variant", - "options": [p.hebrew for p in htables.PARASHAOT], + "options": list(Parasha), }, ), ( @@ -139,7 +139,7 @@ TEST_PARAMS = [ "hebrew", "t_set_hakochavim", True, - dt(2018, 9, 8, 19, 45), + dt(2018, 9, 8, 19, 47), None, ), ( @@ -150,7 +150,7 @@ TEST_PARAMS = [ "hebrew", "t_set_hakochavim", False, - dt(2018, 9, 8, 19, 19), + dt(2018, 9, 8, 19, 21), None, ), ( @@ -185,9 +185,9 @@ TEST_PARAMS = [ False, "ו' מרחשוון ה' תשע\"ט", { - "hebrew_year": 5779, + "hebrew_year": "5779", "hebrew_month_name": "מרחשוון", - "hebrew_day": 6, + "hebrew_day": "6", "icon": "mdi:star-david", "friendly_name": "Jewish Calendar Date", }, @@ -245,7 +245,7 @@ async def test_jewish_calendar_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry( title=DEFAULT_NAME, domain=DOMAIN, @@ -258,7 +258,7 @@ async def test_jewish_calendar_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - future = dt_util.utcnow() + timedelta(seconds=30) + future = test_time + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -424,9 +424,9 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2018, 9, 29, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 7), + "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45), "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 1), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), "english_parshat_hashavua": "Bereshit", "hebrew_parshat_hashavua": "בראשית", @@ -437,22 +437,22 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2018, 9, 30, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 7), + "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45), "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 1), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), "english_parshat_hashavua": "Bereshit", "hebrew_parshat_hashavua": "בראשית", - "english_holiday": "Shmini Atzeret", - "hebrew_holiday": "שמיני עצרת", + "english_holiday": "Shmini Atzeret, Simchat Torah", + "hebrew_holiday": "שמיני עצרת, שמחת תורה", }, ), make_jerusalem_test_params( dt(2018, 10, 1, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 10, 5, 18, 1), + "english_upcoming_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_havdalah": dt(2018, 10, 6, 18, 54), - "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 1), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), "english_parshat_hashavua": "Bereshit", "hebrew_parshat_hashavua": "בראשית", @@ -487,9 +487,9 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2017, 9, 21, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 20), + "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 12), + "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), "english_parshat_hashavua": "Ha'Azinu", "hebrew_parshat_hashavua": "האזינו", @@ -500,9 +500,9 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2017, 9, 22, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 20), + "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 12), + "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), "english_parshat_hashavua": "Ha'Azinu", "hebrew_parshat_hashavua": "האזינו", @@ -513,9 +513,9 @@ SHABBAT_PARAMS = [ make_jerusalem_test_params( dt(2017, 9, 23, 8, 25), { - "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 20), + "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), "english_upcoming_havdalah": dt(2017, 9, 23, 19, 11), - "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 12), + "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 17, 56), "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 11), "english_parshat_hashavua": "Ha'Azinu", "hebrew_parshat_hashavua": "האזינו", @@ -587,7 +587,7 @@ async def test_shabbat_times_sensor( hass.config.latitude = latitude hass.config.longitude = longitude - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry( title=DEFAULT_NAME, domain=DOMAIN, @@ -604,7 +604,7 @@ async def test_shabbat_times_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - future = dt_util.utcnow() + timedelta(seconds=30) + future = test_time + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -649,13 +649,13 @@ async def test_omer_sensor(hass: HomeAssistant, test_time, result) -> None: """Test Omer Count sensor output.""" test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - future = dt_util.utcnow() + timedelta(seconds=30) + future = test_time + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -684,13 +684,13 @@ async def test_dafyomi_sensor(hass: HomeAssistant, test_time, result) -> None: """Test Daf Yomi sensor output.""" test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - with alter_time(test_time): + with freeze_time(test_time): entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - future = dt_util.utcnow() + timedelta(seconds=30) + future = test_time + timedelta(seconds=30) async_fire_time_changed(hass, future) await hass.async_block_till_done() From 4f25296c5024b2ef45a7b31cae04a2e82b96f5e7 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 11 Mar 2025 10:12:23 +0100 Subject: [PATCH 1557/1941] Improve dependencies resolution (#138502) * Improve dependencies resolution * Improve tests * Better docstrings * Fix comment * Improve tests * Improve logging * Address feedback * Address feedback * Address feedback * Address feedback * Address feedback * Simplify error handling * small log change * Add comment * Address feedback * shorter comments * Add test --- homeassistant/bootstrap.py | 281 ++++++++++++++++++------------------ homeassistant/loader.py | 285 ++++++++++++++++++++++++++----------- homeassistant/setup.py | 2 +- tests/test_bootstrap.py | 15 +- tests/test_loader.py | 62 +++++--- 5 files changed, 398 insertions(+), 247 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e301912806c..02a3b8c8fcc 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -93,6 +93,7 @@ from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.storage import get_internal_store_manager from .helpers.system_info import async_get_system_info from .helpers.typing import ConfigType +from .loader import Integration from .setup import ( # _setup_started is marked as protected to make it clear # that it is not part of the public API and should not be used @@ -711,20 +712,25 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: return domains -async def _async_resolve_domains_to_setup( +async def _async_resolve_domains_and_preload( hass: core.HomeAssistant, config: dict[str, Any] -) -> tuple[set[str], dict[str, loader.Integration]]: - """Resolve all dependencies and return list of domains to set up.""" +) -> tuple[dict[str, Integration], dict[str, Integration]]: + """Resolve all dependencies and return integrations to set up. + + The return value is a tuple of two dictionaries: + - The first dictionary contains integrations + specified by the configuration (including config entries). + - The second dictionary contains the same integrations as the first dictionary + together with all their dependencies. + """ domains_to_setup = _get_domains(hass, config) - needed_requirements: set[str] = set() platform_integrations = conf_util.extract_platform_integrations( config, BASE_PLATFORMS ) - # Ensure base platforms that have platform integrations are added to - # to `domains_to_setup so they can be setup first instead of - # discovering them when later when a config entry setup task - # notices its needed and there is already a long line to use - # the import executor. + # Ensure base platforms that have platform integrations are added to `domains`, + # so they can be setup first instead of discovering them later when a config + # entry setup task notices that it's needed and there is already a long line + # to use the import executor. # # For example if we have # sensor: @@ -740,111 +746,78 @@ async def _async_resolve_domains_to_setup( # so this will be less of a problem in the future. domains_to_setup.update(platform_integrations) - # Load manifests for base platforms and platform based integrations - # that are defined under base platforms right away since we do not require - # the manifest to list them as dependencies and we want to avoid the lock - # contention when multiple integrations try to load them at once - additional_manifests_to_load = { + # Additionally process base platforms since we do not require the manifest + # to list them as dependencies. + # We want to later avoid lock contention when multiple integrations try to load + # their manifests at once. + # Also process integrations that are defined under base platforms + # to speed things up. + additional_domains_to_process = { *BASE_PLATFORMS, *chain.from_iterable(platform_integrations.values()), } - translations_to_load = additional_manifests_to_load.copy() - # Resolve all dependencies so we know all integrations # that will have to be loaded and start right-away - integration_cache: dict[str, loader.Integration] = {} - to_resolve: set[str] = domains_to_setup - while to_resolve or additional_manifests_to_load: - old_to_resolve: set[str] = to_resolve - to_resolve = set() + integrations_or_excs = await loader.async_get_integrations( + hass, {*domains_to_setup, *additional_domains_to_process} + ) + # Eliminate those missing or with invalid manifest + integrations_to_process = { + domain: itg + for domain, itg in integrations_or_excs.items() + if isinstance(itg, Integration) + } + integrations_dependencies = await loader.resolve_integrations_dependencies( + hass, integrations_to_process.values() + ) + # Eliminate those without valid dependencies + integrations_to_process = { + domain: integrations_to_process[domain] for domain in integrations_dependencies + } - if additional_manifests_to_load: - to_get = {*old_to_resolve, *additional_manifests_to_load} - additional_manifests_to_load.clear() - else: - to_get = old_to_resolve + integrations_to_setup = { + domain: itg + for domain, itg in integrations_to_process.items() + if domain in domains_to_setup + } + all_integrations_to_setup = integrations_to_setup.copy() + all_integrations_to_setup.update( + (dep, loader.async_get_loaded_integration(hass, dep)) + for domain in integrations_to_setup + for dep in integrations_dependencies[domain].difference( + all_integrations_to_setup + ) + ) - manifest_deps: set[str] = set() - resolve_dependencies_tasks: list[asyncio.Task[bool]] = [] - integrations_to_process: list[loader.Integration] = [] - - for domain, itg in (await loader.async_get_integrations(hass, to_get)).items(): - if not isinstance(itg, loader.Integration): - continue - integration_cache[domain] = itg - needed_requirements.update(itg.requirements) - - # Make sure manifests for dependencies are loaded in the next - # loop to try to group as many as manifest loads in a single - # call to avoid the creating one-off executor jobs later in - # the setup process - additional_manifests_to_load.update( - dep - for dep in chain(itg.dependencies, itg.after_dependencies) - if dep not in integration_cache - ) - - if domain not in old_to_resolve: - continue - - integrations_to_process.append(itg) - manifest_deps.update(itg.dependencies) - manifest_deps.update(itg.after_dependencies) - if not itg.all_dependencies_resolved: - resolve_dependencies_tasks.append( - create_eager_task( - itg.resolve_dependencies(), - name=f"resolve dependencies {domain}", - loop=hass.loop, - ) - ) - - if unseen_deps := manifest_deps - integration_cache.keys(): - # If there are dependencies, try to preload all - # the integrations manifest at once and add them - # to the list of requirements we need to install - # so we can try to check if they are already installed - # in a single call below which avoids each integration - # having to wait for the lock to do it individually - deps = await loader.async_get_integrations(hass, unseen_deps) - for dependant_domain, dependant_itg in deps.items(): - if isinstance(dependant_itg, loader.Integration): - integration_cache[dependant_domain] = dependant_itg - needed_requirements.update(dependant_itg.requirements) - - if resolve_dependencies_tasks: - await asyncio.gather(*resolve_dependencies_tasks) - - for itg in integrations_to_process: - try: - all_deps = itg.all_dependencies - except RuntimeError: - # Integration.all_dependencies raises RuntimeError if - # dependencies could not be resolved - continue - for dep in all_deps: - if dep in domains_to_setup: - continue - domains_to_setup.add(dep) - to_resolve.add(dep) - - _LOGGER.info("Domains to be set up: %s", domains_to_setup) + # Gather requirements for all integrations, + # their dependencies and after dependencies. + # To gather all the requirements we must ignore exceptions here. + # The exceptions will be detected and handled later in the bootstrap process. + integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, integrations_to_process.values(), ignore_exceptions=True + ) + ) + integrations_requirements = { + domain: itg.requirements for domain, itg in integrations_to_process.items() + } + integrations_requirements.update( + (dep, loader.async_get_loaded_integration(hass, dep).requirements) + for deps in integrations_after_dependencies.values() + for dep in deps.difference(integrations_requirements) + ) + all_requirements = set(chain.from_iterable(integrations_requirements.values())) # Optimistically check if requirements are already installed # ahead of setting up the integrations so we can prime the cache - # We do not wait for this since its an optimization only + # We do not wait for this since it's an optimization only hass.async_create_background_task( - requirements.async_load_installed_versions(hass, needed_requirements), + requirements.async_load_installed_versions(hass, all_requirements), "check installed requirements", eager_start=True, ) - # - # Only add the domains_to_setup after we finish resolving - # as new domains are likely to added in the process - # - translations_to_load.update(domains_to_setup) # Start loading translations for all integrations we are going to set up # in the background so they are ready when we need them. This avoids a # lot of waiting for the translation load lock and a thundering herd of @@ -855,6 +828,7 @@ async def _async_resolve_domains_to_setup( # hold the translation load lock and if anything is fast enough to # wait for the translation load lock, loading will be done by the # time it gets to it. + translations_to_load = {*all_integrations_to_setup, *additional_domains_to_process} hass.async_create_background_task( translation.async_load_integrations(hass, translations_to_load), "load translations", @@ -866,13 +840,13 @@ async def _async_resolve_domains_to_setup( # in the setup process. hass.async_create_background_task( get_internal_store_manager(hass).async_preload( - [*PRELOAD_STORAGE, *domains_to_setup] + [*PRELOAD_STORAGE, *all_integrations_to_setup] ), "preload storage", eager_start=True, ) - return domains_to_setup, integration_cache + return integrations_to_setup, all_integrations_to_setup async def _async_set_up_integrations( @@ -882,69 +856,90 @@ async def _async_set_up_integrations( watcher = _WatchPendingSetups(hass, _setup_started(hass)) watcher.async_start() - domains_to_setup, integration_cache = await _async_resolve_domains_to_setup( + integrations, all_integrations = await _async_resolve_domains_and_preload( hass, config ) - stage_2_domains = domains_to_setup.copy() + all_domains = set(all_integrations) + domains = set(integrations) + + _LOGGER.info( + "Domains to be set up: %s | %s", + domains, + all_domains - domains, + ) # Initialize recorder - if "recorder" in domains_to_setup: + if "recorder" in all_domains: recorder.async_initialize_recorder(hass) # Initialize backup - if "backup" in domains_to_setup: + if "backup" in all_domains: backup.async_initialize_backup(hass) - stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ + stages: list[tuple[str, set[str], int | None]] = [ *( - (name, domain_group & domains_to_setup, timeout) + (name, domain_group, timeout) for name, domain_group, timeout in STAGE_0_INTEGRATIONS ), - ("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT), + ("1", STAGE_1_INTEGRATIONS, STAGE_1_TIMEOUT), + ("2", domains, STAGE_2_TIMEOUT), ] - _LOGGER.info("Setting up stage 0 and 1") - for name, domain_group, timeout in stage_0_and_1_domains: - if not domain_group: + _LOGGER.info("Setting up stage 0") + for name, domain_group, timeout in stages: + stage_domains_unfiltered = domain_group & all_domains + if not stage_domains_unfiltered: + _LOGGER.info("Nothing to set up in stage %s: %s", name, domain_group) continue - _LOGGER.info("Setting up %s: %s", name, domain_group) - to_be_loaded = domain_group.copy() - to_be_loaded.update( + stage_domains = stage_domains_unfiltered - hass.config.components + if not stage_domains: + _LOGGER.info("Already set up stage %s: %s", name, stage_domains_unfiltered) + continue + + stage_dep_domains_unfiltered = { dep - for domain in domain_group - if (integration := integration_cache.get(domain)) is not None - for dep in integration.all_dependencies + for domain in stage_domains + for dep in all_integrations[domain].all_dependencies + if dep not in stage_domains + } + stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components + + stage_all_domains = stage_domains | stage_dep_domains + stage_all_integrations = { + domain: all_integrations[domain] for domain in stage_all_domains + } + # Detect all cycles + stage_integrations_after_dependencies = ( + await loader.resolve_integrations_after_dependencies( + hass, stage_all_integrations.values(), stage_all_domains + ) ) - async_set_domains_to_be_loaded(hass, to_be_loaded) - stage_2_domains -= to_be_loaded + stage_all_domains = set(stage_integrations_after_dependencies) + stage_domains &= stage_all_domains + stage_dep_domains &= stage_all_domains + + _LOGGER.info( + "Setting up stage %s: %s | %s\nDependencies: %s | %s", + name, + stage_domains, + stage_domains_unfiltered - stage_domains, + stage_dep_domains, + stage_dep_domains_unfiltered - stage_dep_domains, + ) + + async_set_domains_to_be_loaded(hass, stage_all_domains) if timeout is None: - await _async_setup_multi_components(hass, domain_group, config) - else: - try: - async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME): - await _async_setup_multi_components(hass, domain_group, config) - except TimeoutError: - _LOGGER.warning( - "Setup timed out for %s waiting on %s - moving forward", - name, - hass._active_tasks, # noqa: SLF001 - ) - - # Add after dependencies when setting up stage 2 domains - async_set_domains_to_be_loaded(hass, stage_2_domains) - - if stage_2_domains: - _LOGGER.info("Setting up stage 2: %s", stage_2_domains) + await _async_setup_multi_components(hass, stage_all_domains, config) + continue try: - async with hass.timeout.async_timeout( - STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME - ): - await _async_setup_multi_components(hass, stage_2_domains, config) + async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME): + await _async_setup_multi_components(hass, stage_all_domains, config) except TimeoutError: _LOGGER.warning( - "Setup timed out for stage 2 waiting on %s - moving forward", + "Setup timed out for stage %s waiting on %s - moving forward", + name, hass._active_tasks, # noqa: SLF001 ) @@ -1046,8 +1041,6 @@ async def _async_setup_multi_components( config: dict[str, Any], ) -> None: """Set up multiple domains. Log on failure.""" - # Avoid creating tasks for domains that were setup in a previous stage - domains_not_yet_setup = domains - hass.config.components # Create setup tasks for base platforms first since everything will have # to wait to be imported, and the sooner we can get the base platforms # loaded the sooner we can start loading the rest of the integrations. @@ -1057,9 +1050,7 @@ async def _async_setup_multi_components( f"setup component {domain}", eager_start=True, ) - for domain in sorted( - domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True - ) + for domain in sorted(domains, key=SETUP_ORDER_SORT_KEY, reverse=True) } results = await asyncio.gather(*futures.values(), return_exceptions=True) for idx, domain in enumerate(futures): diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 3bc33f8374c..20763dc7b30 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -40,6 +40,8 @@ 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, UndefinedType +from .util.async_ import create_eager_task from .util.hass_dict import HassKey from .util.json import JSON_DECODE_EXCEPTIONS, json_loads @@ -758,10 +760,8 @@ class Integration: manifest["overwrites_built_in"] = self.overwrites_built_in if self.dependencies: - self._all_dependencies_resolved: bool | None = None - self._all_dependencies: set[str] | None = None + self._all_dependencies: set[str] | Exception | None = None else: - self._all_dependencies_resolved = True self._all_dependencies = set() self._platforms_to_preload = hass.data[DATA_PRELOAD_PLATFORMS] @@ -933,47 +933,25 @@ class Integration: """Return all dependencies including sub-dependencies.""" if self._all_dependencies is None: raise RuntimeError("Dependencies not resolved!") + if isinstance(self._all_dependencies, Exception): + raise self._all_dependencies return self._all_dependencies @property def all_dependencies_resolved(self) -> bool: """Return if all dependencies have been resolved.""" - return self._all_dependencies_resolved is not None + return self._all_dependencies is not None - async def resolve_dependencies(self) -> bool: + async def resolve_dependencies(self) -> set[str] | None: """Resolve all dependencies.""" - if self._all_dependencies_resolved is not None: - return self._all_dependencies_resolved + if self._all_dependencies is not None: + if isinstance(self._all_dependencies, Exception): + return None + return self._all_dependencies - self._all_dependencies_resolved = False - try: - dependencies = await _async_component_dependencies(self.hass, self) - except IntegrationNotFound as err: - _LOGGER.error( - ( - "Unable to resolve dependencies for %s: unable to resolve" - " (sub)dependency %s" - ), - self.domain, - err.domain, - ) - except CircularDependency as err: - _LOGGER.error( - ( - "Unable to resolve dependencies for %s: it contains a circular" - " dependency: %s -> %s" - ), - self.domain, - err.from_domain, - err.to_domain, - ) - else: - dependencies.discard(self.domain) - self._all_dependencies = dependencies - self._all_dependencies_resolved = True - - return self._all_dependencies_resolved + result = await resolve_integrations_dependencies(self.hass, (self,)) + return result.get(self.domain) async def async_get_component(self) -> ComponentProtocol: """Return the component. @@ -1441,6 +1419,189 @@ async def async_get_integrations( return results +class _ResolveDependenciesCacheProtocol(Protocol): + def get(self, itg: Integration) -> set[str] | Exception | None: ... + + def __setitem__( + self, itg: Integration, all_dependencies: set[str] | Exception + ) -> None: ... + + +class _ResolveDependenciesCache(_ResolveDependenciesCacheProtocol): + """Cache for resolve_integrations_dependencies.""" + + def get(self, itg: Integration) -> set[str] | Exception | None: + return itg._all_dependencies # noqa: SLF001 + + def __setitem__( + self, itg: Integration, all_dependencies: set[str] | Exception + ) -> None: + itg._all_dependencies = all_dependencies # noqa: SLF001 + + +async def resolve_integrations_dependencies( + hass: HomeAssistant, integrations: Iterable[Integration] +) -> dict[str, set[str]]: + """Resolve all dependencies for integrations. + + Detects circular dependencies and missing integrations. + """ + resolved = _ResolveDependenciesCache() + + async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None: + try: + return await _do_resolve_dependencies(itg, cache=resolved) + except Exception as exc: # noqa: BLE001 + _LOGGER.error("Unable to resolve dependencies for %s: %s", itg.domain, exc) + return None + + resolve_dependencies_tasks = { + itg.domain: create_eager_task( + _resolve_deps_catch_exceptions(itg), + name=f"resolve dependencies {itg.domain}", + loop=hass.loop, + ) + for itg in integrations + } + + result = await asyncio.gather(*resolve_dependencies_tasks.values()) + + return { + domain: deps + for domain, deps in zip(resolve_dependencies_tasks, result, strict=True) + if deps is not None + } + + +async def resolve_integrations_after_dependencies( + hass: HomeAssistant, + integrations: Iterable[Integration], + possible_after_dependencies: set[str] | None = None, + *, + ignore_exceptions: bool = False, +) -> dict[str, set[str]]: + """Resolve all dependencies, including after_dependencies, for integrations. + + Detects circular dependencies and missing integrations. + """ + resolved: dict[Integration, set[str] | Exception] = {} + + async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None: + try: + return await _do_resolve_dependencies( + itg, + cache=resolved, + possible_after_dependencies=possible_after_dependencies, + ignore_exceptions=ignore_exceptions, + ) + except Exception as exc: # noqa: BLE001 + _LOGGER.error( + "Unable to resolve (after) dependencies for %s: %s", itg.domain, exc + ) + return None + + resolve_dependencies_tasks = { + itg.domain: create_eager_task( + _resolve_deps_catch_exceptions(itg), + name=f"resolve after dependencies {itg.domain}", + loop=hass.loop, + ) + for itg in integrations + } + + result = await asyncio.gather(*resolve_dependencies_tasks.values()) + + return { + domain: deps + for domain, deps in zip(resolve_dependencies_tasks, result, strict=True) + if deps is not None + } + + +async def _do_resolve_dependencies( + itg: Integration, + *, + cache: _ResolveDependenciesCacheProtocol, + possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED, + ignore_exceptions: bool = False, +) -> set[str]: + """Recursively resolve all dependencies. + + Uses `cache` to cache the results. + + If `possible_after_dependencies` is not UNDEFINED, + listed after dependencies are also considered. + If `possible_after_dependencies` is None, + all the possible after dependencies are considered. + + If `ignore_exceptions` is True, exceptions are caught and ignored + and the normal resolution algorithm continues. + Otherwise, exceptions are raised. + """ + resolved = cache + resolving: set[str] = set() + + async def do_resolve_dependencies_impl(itg: Integration) -> set[str]: + domain = itg.domain + + # If it's already resolved, no point doing it again. + if (result := resolved.get(itg)) is not None: + if isinstance(result, Exception): + raise result + return result + + # If we are already resolving it, we have a circular dependency. + if domain in resolving: + if ignore_exceptions: + resolved[itg] = set() + return set() + exc = CircularDependency([domain]) + resolved[itg] = exc + raise exc + + resolving.add(domain) + + dependencies_domains = set(itg.dependencies) + if possible_after_dependencies is not UNDEFINED: + if possible_after_dependencies is None: + after_dependencies: Iterable[str] = itg.after_dependencies + else: + after_dependencies = ( + set(itg.after_dependencies) & possible_after_dependencies + ) + dependencies_domains.update(after_dependencies) + dependencies = await async_get_integrations(itg.hass, dependencies_domains) + + all_dependencies: set[str] = set() + for dep_domain, dep_integration in dependencies.items(): + if isinstance(dep_integration, Exception): + if ignore_exceptions: + continue + resolved[itg] = dep_integration + raise dep_integration + + all_dependencies.add(dep_domain) + + try: + dep_dependencies = await do_resolve_dependencies_impl(dep_integration) + except CircularDependency as exc: + exc.extend_cycle(domain) + resolved[itg] = exc + raise + except Exception as exc: + resolved[itg] = exc + raise + + all_dependencies.update(dep_dependencies) + + resolving.remove(domain) + + resolved[itg] = all_dependencies + return all_dependencies + + return await do_resolve_dependencies_impl(itg) + + class LoaderError(Exception): """Loader base error.""" @@ -1466,11 +1627,13 @@ class IntegrationNotLoaded(LoaderError): class CircularDependency(LoaderError): """Raised when a circular dependency is found when resolving components.""" - def __init__(self, from_domain: str | set[str], to_domain: str) -> None: + def __init__(self, domain_cycle: list[str]) -> None: """Initialize circular dependency error.""" - super().__init__(f"Circular dependency detected: {from_domain} -> {to_domain}.") - self.from_domain = from_domain - self.to_domain = to_domain + super().__init__("Circular dependency detected", domain_cycle) + + def extend_cycle(self, domain: str) -> None: + """Extend the cycle with the domain.""" + self.args[1].insert(0, domain) def _load_file( @@ -1624,50 +1787,6 @@ def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: return func -async def _async_component_dependencies( - hass: HomeAssistant, - integration: Integration, -) -> set[str]: - """Get component dependencies.""" - loading: set[str] = set() - loaded: set[str] = set() - - async def component_dependencies_impl(integration: Integration) -> None: - """Recursively get component dependencies.""" - domain = integration.domain - if not (dependencies := integration.dependencies): - loaded.add(domain) - return - - loading.add(domain) - dep_integrations = await async_get_integrations(hass, dependencies) - for dependency_domain, dep_integration in dep_integrations.items(): - if isinstance(dep_integration, Exception): - raise dep_integration - - # If we are already loading it, we have a circular dependency. - # We have to check it here to make sure that every integration that - # depends on us, does not appear in our own after_dependencies. - if conflict := loading.intersection(dep_integration.after_dependencies): - raise CircularDependency(conflict, dependency_domain) - - # If we have already loaded it, no point doing it again. - if dependency_domain in loaded: - continue - - # If we are already loading it, we have a circular dependency. - if dependency_domain in loading: - raise CircularDependency(dependency_domain, domain) - - await component_dependencies_impl(dep_integration) - loading.remove(domain) - loaded.add(domain) - - await component_dependencies_impl(integration) - - return loaded - - def _async_mount_config_dir(hass: HomeAssistant) -> None: """Mount config dir in order to load custom_component. diff --git a/homeassistant/setup.py b/homeassistant/setup.py index dc4d0988b91..9572136559a 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -323,7 +323,7 @@ async def _async_setup_component( translation.async_load_integrations(hass, integration_set), loop=hass.loop ) # Validate all dependencies exist and there are no circular dependencies - if not await integration.resolve_dependencies(): + if await integration.resolve_dependencies() is None: return False # Process requirements as soon as possible, so we can import the component diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index e89d038f8ce..050963316dc 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -572,7 +572,7 @@ async def test_setup_after_deps_not_present(hass: HomeAssistant) -> None: MockModule( domain="second_dep", async_setup=gen_domain_setup("second_dep"), - partial_manifest={"after_dependencies": ["first_dep"]}, + partial_manifest={"after_dependencies": ["first_dep", "root"]}, ), ) @@ -1169,6 +1169,7 @@ async def test_bootstrap_is_cancellation_safe( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test cancellation during async_setup_component does not cancel bootstrap.""" + mock_integration(hass, MockModule(domain="cancel_integration")) with patch.object( bootstrap, "async_setup_component", side_effect=asyncio.CancelledError ): @@ -1185,6 +1186,18 @@ async def test_bootstrap_empty_integrations(hass: HomeAssistant) -> None: await hass.async_block_till_done() +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_log_already_setup_stage( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test logging when all integrations in a stage were already setup.""" + with patch.object(bootstrap, "STAGE_1_INTEGRATIONS", {"frontend"}): + await bootstrap._async_set_up_integrations(hass, {}) + await hass.async_block_till_done() + + assert "Already set up stage 1: {'frontend'}" in caplog.text + + @pytest.fixture(name="mock_mqtt_config_flow") def mock_mqtt_config_flow_fixture() -> Generator[None]: """Mock MQTT config flow.""" diff --git a/tests/test_loader.py b/tests/test_loader.py index 548091a3503..0b83ddee3ea 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -27,33 +27,42 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) mock_integration(hass, MockModule("mod3", dependencies=["mod1"])) mod_4 = mock_integration(hass, MockModule("mod4", dependencies=["mod2", "mod3"])) + all_domains = {"mod1", "mod2", "mod3", "mod4"} - deps = await loader._async_component_dependencies(hass, mod_4) - assert deps == {"mod1", "mod2", "mod3", "mod4"} + deps = await loader._do_resolve_dependencies(mod_4, cache={}) + assert deps == {"mod1", "mod2", "mod3"} # Create a circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod4"])) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._do_resolve_dependencies(mod_4, cache={}) # Create a different circular dependency mock_integration(hass, MockModule("mod1", dependencies=["mod3"])) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._do_resolve_dependencies(mod_4, cache={}) # Create a circular after_dependency mock_integration( hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) ) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._do_resolve_dependencies( + mod_4, + cache={}, + possible_after_dependencies=all_domains, + ) # Create a different circular after_dependency mock_integration( hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]}) ) with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, mod_4) + await loader._do_resolve_dependencies( + mod_4, + cache={}, + possible_after_dependencies=all_domains, + ) # Create a circular after_dependency without a hard dependency mock_integration( @@ -62,29 +71,48 @@ async def test_circular_component_dependencies(hass: HomeAssistant) -> None: mod_4 = mock_integration( hass, MockModule("mod4", partial_manifest={"after_dependencies": ["mod2"]}) ) - # this currently doesn't raise, but it should. Will be improved in a follow-up. - await loader._async_component_dependencies(hass, mod_4) + with pytest.raises(loader.CircularDependency): + await loader._do_resolve_dependencies( + mod_4, + cache={}, + possible_after_dependencies=all_domains, + ) + + result = await loader.resolve_integrations_after_dependencies(hass, (mod_4,)) + assert result == {} + result = await loader.resolve_integrations_after_dependencies( + hass, (mod_4,), ignore_exceptions=True + ) + assert result["mod4"] == {"mod4", "mod2", "mod1"} async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: """Test if we can detect nonexistent dependencies of components.""" mod_1 = mock_integration(hass, MockModule("mod1", dependencies=["nonexistent"])) - with pytest.raises(loader.IntegrationNotFound): - await loader._async_component_dependencies(hass, mod_1) mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) - assert not await mod_2.resolve_dependencies() + assert await mod_2.resolve_dependencies() is None assert mod_2.all_dependencies_resolved - with pytest.raises(RuntimeError): + with pytest.raises(loader.IntegrationNotFound): mod_2.all_dependencies # noqa: B018 - # this currently is not resolved, because intermediate results are not cached - # this will be improved in a follow-up - assert not mod_1.all_dependencies_resolved - assert not await mod_1.resolve_dependencies() - with pytest.raises(RuntimeError): + assert mod_1.all_dependencies_resolved + assert await mod_1.resolve_dependencies() is None + with pytest.raises(loader.IntegrationNotFound): mod_1.all_dependencies # noqa: B018 + result = await loader.resolve_integrations_dependencies(hass, (mod_2, mod_1)) + assert result == {} + + mod_1 = mock_integration( + hass, + MockModule("mod1", partial_manifest={"after_dependencies": ["non.existent"]}), + ) + mod_2 = mock_integration(hass, MockModule("mod2", dependencies=["mod1"])) + + result = await loader.resolve_integrations_after_dependencies(hass, (mod_2, mod_1)) + assert result == {} + def test_component_loader(hass: HomeAssistant) -> None: """Test loading components.""" From 3199b538eee6941c897e5f6053b16b189a822ab5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 11:12:22 +0100 Subject: [PATCH 1558/1941] Capitalize "HVAC" abbreviation in `fritzbox` integration (#140344) * Capitalize "HVAC" abbreviation in `fritzbox` integration * Update test_climate.py * Update test_climate.py (2) --- homeassistant/components/fritzbox/strings.json | 2 +- tests/components/fritzbox/test_climate.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json index c7c2439b566..e0df30875bc 100644 --- a/homeassistant/components/fritzbox/strings.json +++ b/homeassistant/components/fritzbox/strings.json @@ -89,7 +89,7 @@ "message": "Can't change preset while holiday or summer mode is active on the device." }, "change_hvac_while_active_mode": { - "message": "Can't change hvac mode while holiday or summer mode is active on the device." + "message": "Can't change HVAC mode while holiday or summer mode is active on the device." } } } diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index f170836fa9b..0784d7b6188 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -528,7 +528,7 @@ async def test_holidy_summer_mode( with pytest.raises( HomeAssistantError, - match="Can't change hvac mode while holiday or summer mode is active on the device", + match="Can't change HVAC mode while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", @@ -564,7 +564,7 @@ async def test_holidy_summer_mode( with pytest.raises( HomeAssistantError, - match="Can't change hvac mode while holiday or summer mode is active on the device", + match="Can't change HVAC mode while holiday or summer mode is active on the device", ): await hass.services.async_call( "climate", From 47a9f25ba675bdf336ecb1d22890a00e6b3e1fc1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 11:14:22 +0100 Subject: [PATCH 1559/1941] Improve name and description of `nexia.set_hvac_run_mode` action (#140348) - use proper capitalization of "HVAC" in action name - better explain that you can set the run mode ("permanent_hold" / "run_schedule") and / or the operation mode ("auto" / "cool" / "heat") of the HVAC system --- homeassistant/components/nexia/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index 05d86d3a495..43da2cf05c7 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -86,8 +86,8 @@ } }, "set_hvac_run_mode": { - "name": "Set hvac run mode", - "description": "Sets the HVAC operation mode.", + "name": "Set HVAC run mode", + "description": "Sets the run and/or operation mode of the HVAC system.", "fields": { "run_mode": { "name": "Run mode", From d3a96ba688b4f7d21e2a3616531884a1e618d3f6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 11:18:35 +0100 Subject: [PATCH 1560/1941] Use trademark "Time-of-Use Price Plan" in `srp_energy` integration (#140350) --- homeassistant/components/srp_energy/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index eca4f465435..5fa97b00b57 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -3,10 +3,10 @@ "step": { "user": { "data": { - "id": "Account Id", + "id": "Account ID", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "is_tou": "Is Time of Use Plan" + "is_tou": "Is Time-of-Use Price Plan" } } }, From 98cf936ff54fe594aa4989b449b4c0066e73ae4e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 11 Mar 2025 12:52:40 +0100 Subject: [PATCH 1561/1941] Improve config flow for GIOS (#139935) * Initial commit * Use TYPE_CHECKING * Update strings * Remove default value * Improve tests --- homeassistant/components/gios/config_flow.py | 61 ++++++++++++++------ homeassistant/components/gios/strings.json | 6 +- tests/components/gios/test_config_flow.py | 58 +++++++++++-------- 3 files changed, 79 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index ecd0baee6f9..9b242a8cc99 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp.client_exceptions import ClientConnectorError from gios import ApiError, Gios, InvalidSensorsDataError, NoStationError @@ -12,6 +12,12 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import API_TIMEOUT, CONF_STATION_ID, DOMAIN @@ -27,40 +33,59 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} + websession = async_get_clientsession(self.hass) + if user_input is not None: + station_id = user_input[CONF_STATION_ID] + try: - await self.async_set_unique_id( - str(user_input[CONF_STATION_ID]), raise_on_progress=False - ) + await self.async_set_unique_id(station_id, raise_on_progress=False) self._abort_if_unique_id_configured() - websession = async_get_clientsession(self.hass) - async with asyncio.timeout(API_TIMEOUT): - gios = await Gios.create(websession, user_input[CONF_STATION_ID]) + gios = await Gios.create(websession, int(station_id)) await gios.async_update() - assert gios.station_name is not None + # GIOS treats station ID as int + user_input[CONF_STATION_ID] = int(station_id) + + if TYPE_CHECKING: + assert gios.station_name is not None + return self.async_create_entry( title=gios.station_name, data=user_input, ) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" - except NoStationError: - errors[CONF_STATION_ID] = "wrong_station_id" except InvalidSensorsDataError: errors[CONF_STATION_ID] = "invalid_sensors_data" + try: + gios = await Gios.create(websession) + except (ApiError, ClientConnectorError, NoStationError): + return self.async_abort(reason="cannot_connect") + + options: list[SelectOptionDict] = [ + SelectOptionDict(value=str(station.id), label=station.name) + for station in gios.measurement_stations.values() + ] + + schema: vol.Schema = vol.Schema( + { + vol.Required(CONF_STATION_ID): SelectSelector( + SelectSelectorConfig( + options=options, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + ), + ), + vol.Optional(CONF_NAME, default=self.hass.config.location_name): str, + } + ) + return self.async_show_form( step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_STATION_ID): int, - vol.Optional( - CONF_NAME, default=self.hass.config.location_name - ): str, - } - ), + data_schema=schema, errors=errors, ) diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index fc82f1c843d..ff4c2a80b16 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -5,17 +5,17 @@ "title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)", "data": { "name": "[%key:common::config_flow::data::name%]", - "station_id": "ID of the measuring station" + "station_id": "Measuring station" } } }, "error": { - "wrong_station_id": "ID of the measuring station is not correct.", "invalid_sensors_data": "Invalid sensors' data for this measuring station.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, "system_health": { diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index d81758b0de0..3764c52a810 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -6,7 +6,8 @@ from unittest.mock import patch from gios import ApiError from homeassistant.components.gios import config_flow -from homeassistant.components.gios.const import CONF_STATION_ID +from homeassistant.components.gios.const import CONF_STATION_ID, DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -17,36 +18,35 @@ from tests.common import load_fixture CONFIG = { CONF_NAME: "Foo", - CONF_STATION_ID: 123, + CONF_STATION_ID: "123", } async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" - flow = config_flow.GiosFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=None) + with patch( + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" -async def test_invalid_station_id(hass: HomeAssistant) -> None: - """Test that errors are shown when measuring station ID is invalid.""" +async def test_form_with_api_error(hass: HomeAssistant) -> None: + """Test the form is aborted because of API error.""" with patch( "homeassistant.components.gios.coordinator.Gios._get_stations", - return_value=STATIONS, + side_effect=ApiError("error"), ): - flow = config_flow.GiosFlowHandler() - flow.hass = hass - flow.context = {} - - result = await flow.async_step_user( - user_input={CONF_NAME: "Foo", CONF_STATION_ID: 0} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - assert result["errors"] == {CONF_STATION_ID: "wrong_station_id"} + assert result["type"] is FlowResultType.ABORT async def test_invalid_sensor_data(hass: HomeAssistant) -> None: @@ -76,17 +76,25 @@ async def test_invalid_sensor_data(hass: HomeAssistant) -> None: async def test_cannot_connect(hass: HomeAssistant) -> None: """Test that errors are shown when cannot connect to GIOS server.""" - with patch( - "homeassistant.components.gios.coordinator.Gios._async_get", - side_effect=ApiError("error"), + with ( + patch( + "homeassistant.components.gios.coordinator.Gios._get_stations", + return_value=STATIONS, + ), + patch( + "homeassistant.components.gios.coordinator.Gios._async_get", + side_effect=ApiError("error"), + ), ): - flow = config_flow.GiosFlowHandler() - flow.hass = hass - flow.context = {} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], CONFIG + ) + await hass.async_block_till_done() - result = await flow.async_step_user(user_input=CONFIG) - - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "cannot_connect"} async def test_create_entry(hass: HomeAssistant) -> None: From b160ce21fce41bdcb12786752ff8376b5cb8328f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:21:00 +0100 Subject: [PATCH 1562/1941] Migrate google_assistant tests to use unit system (#140357) --- .../components/google_assistant/test_trait.py | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index dafe85d97b2..1fc4a0e3a0c 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -79,6 +79,11 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, St from homeassistant.core_config import async_process_ha_core_config from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from . import BASIC_CONFIG, MockConfig @@ -1072,7 +1077,7 @@ async def test_temperature_setting_climate_onoff(hass: HomeAssistant) -> None: assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass.config.units = US_CUSTOMARY_SYSTEM trt = trait.TemperatureSettingTrait( hass, @@ -1123,8 +1128,6 @@ async def test_temperature_setting_climate_no_modes(hass: HomeAssistant) -> None assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureSettingTrait( hass, State( @@ -1153,7 +1156,7 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass.config.units = US_CUSTOMARY_SYSTEM trt = trait.TemperatureSettingTrait( hass, @@ -1261,7 +1264,6 @@ async def test_temperature_setting_climate_range(hass: HomeAssistant) -> None: ATTR_ENTITY_ID: "climate.bla", climate.ATTR_TEMPERATURE: 75, } - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None: @@ -1269,8 +1271,6 @@ async def test_temperature_setting_climate_setpoint(hass: HomeAssistant) -> None assert helpers.get_google_type(climate.DOMAIN, None) is not None assert trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None, None) - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureSettingTrait( hass, State( @@ -1356,8 +1356,6 @@ async def test_temperature_setting_climate_setpoint_auto(hass: HomeAssistant) -> Setpoint in auto mode. """ - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureSettingTrait( hass, State( @@ -1407,8 +1405,6 @@ async def test_temperature_setting_climate_setpoint_auto(hass: HomeAssistant) -> async def test_temperature_control(hass: HomeAssistant) -> None: """Test TemperatureControl trait support for sensor domain.""" - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - trt = trait.TemperatureControlTrait( hass, State("sensor.temp", 18), @@ -1431,13 +1427,13 @@ async def test_temperature_control(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("unit_in", "unit_out", "temp_in", "temp_out", "current_in", "current_out"), [ - (UnitOfTemperature.CELSIUS, "C", "120", 120, "130", 130), - (UnitOfTemperature.FAHRENHEIT, "F", "248", 120, "266", 130), + (METRIC_SYSTEM, "C", "120", 120, "130", 130), + (US_CUSTOMARY_SYSTEM, "F", "248", 120, "266", 130), ], ) async def test_temperature_control_water_heater( hass: HomeAssistant, - unit_in: UnitOfTemperature, + unit_in: UnitSystem, unit_out: str, temp_in: str, temp_out: float, @@ -1445,17 +1441,17 @@ async def test_temperature_control_water_heater( current_out: float, ) -> None: """Test TemperatureControl trait support for water heater domain.""" - hass.config.units.temperature_unit = unit_in + hass.config.units = unit_in min_temp = TemperatureConverter.convert( water_heater.DEFAULT_MIN_TEMP, UnitOfTemperature.CELSIUS, - unit_in, + unit_in.temperature_unit, ) max_temp = TemperatureConverter.convert( water_heater.DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, - unit_in, + unit_in.temperature_unit, ) trt = trait.TemperatureControlTrait( @@ -1489,30 +1485,30 @@ async def test_temperature_control_water_heater( @pytest.mark.parametrize( ("unit", "temp_init", "temp_in", "temp_out", "current_init"), [ - (UnitOfTemperature.CELSIUS, "180", 220, 220, "180"), - (UnitOfTemperature.FAHRENHEIT, "356", 220, 428, "356"), + (METRIC_SYSTEM, "180", 220, 220, "180"), + (US_CUSTOMARY_SYSTEM, "356", 220, 428, "356"), ], ) async def test_temperature_control_water_heater_set_temperature( hass: HomeAssistant, - unit: UnitOfTemperature, + unit: UnitSystem, temp_init: str, temp_in: float, temp_out: float, current_init: str, ) -> None: """Test TemperatureControl trait support for water heater domain - SetTemperature.""" - hass.config.units.temperature_unit = unit + hass.config.units = unit min_temp = TemperatureConverter.convert( 40, UnitOfTemperature.CELSIUS, - unit, + unit.temperature_unit, ) max_temp = TemperatureConverter.convert( 230, UnitOfTemperature.CELSIUS, - unit, + unit.temperature_unit, ) trt = trait.TemperatureControlTrait( @@ -3633,17 +3629,17 @@ async def test_temperature_control_sensor(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("unit_in", "unit_out", "state", "ambient"), [ - (UnitOfTemperature.FAHRENHEIT, "F", "70", 21.1), - (UnitOfTemperature.CELSIUS, "C", "21.1", 21.1), - (UnitOfTemperature.FAHRENHEIT, "F", "unavailable", None), - (UnitOfTemperature.FAHRENHEIT, "F", "unknown", None), + (US_CUSTOMARY_SYSTEM, "F", "70", 21.1), + (METRIC_SYSTEM, "C", "21.1", 21.1), + (US_CUSTOMARY_SYSTEM, "F", "unavailable", None), + (US_CUSTOMARY_SYSTEM, "F", "unknown", None), ], ) async def test_temperature_control_sensor_data( - hass: HomeAssistant, unit_in, unit_out, state, ambient + hass: HomeAssistant, unit_in: UnitSystem, unit_out, state, ambient ) -> None: """Test TemperatureControl trait support for temperature sensor.""" - hass.config.units.temperature_unit = unit_in + hass.config.units = unit_in trt = trait.TemperatureControlTrait( hass, @@ -3668,7 +3664,6 @@ async def test_temperature_control_sensor_data( } else: assert trt.query_attributes() == {} - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS async def test_humidity_setting_sensor(hass: HomeAssistant) -> None: From 289e94f270e7e5ae8c0ba5aec57799402b867ca1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:38:44 +0100 Subject: [PATCH 1563/1941] Migrate gree tests to use unit system (#140358) --- tests/components/gree/test_climate.py | 49 ++++++++++++--------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index d7c011a4c25..e6bfc43252f 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -67,6 +67,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .common import async_setup_gree, build_device_mock @@ -411,19 +416,19 @@ async def test_send_power_off_device_timeout( @pytest.mark.parametrize( ("units", "temperature"), - [(UnitOfTemperature.CELSIUS, 26), (UnitOfTemperature.FAHRENHEIT, 73)], + [(METRIC_SYSTEM, 26), (US_CUSTOMARY_SYSTEM, 73)], ) async def test_send_target_temperature( - hass: HomeAssistant, discovery, device, units, temperature + hass: HomeAssistant, discovery, device, units: UnitSystem, temperature ) -> None: """Test for sending target temperature command to the device.""" - hass.config.units.temperature_unit = units + hass.config.units = units device().power = True device().mode = HVAC_MODES_REVERSE.get(HVACMode.AUTO) fake_device = device() - if units == UnitOfTemperature.FAHRENHEIT: + if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: fake_device.temperature_units = 1 await async_setup_gree(hass) @@ -435,7 +440,7 @@ async def test_send_target_temperature( ENTITY_ID, "off", { - ATTR_UNIT_OF_MEASUREMENT: units, + ATTR_UNIT_OF_MEASUREMENT: units.temperature_unit, }, ) @@ -451,10 +456,6 @@ async def test_send_target_temperature( assert state.attributes.get(ATTR_TEMPERATURE) == temperature assert state.state == HVAC_MODES.get(fake_device.mode) - # Reset config temperature_unit back to CELSIUS, required for - # additional tests outside this component. - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - @pytest.mark.parametrize( ("temperature", "hvac_mode"), @@ -493,17 +494,17 @@ async def test_send_target_temperature_with_hvac_mode( @pytest.mark.parametrize( ("units", "temperature"), [ - (UnitOfTemperature.CELSIUS, 25), - (UnitOfTemperature.FAHRENHEIT, 73), - (UnitOfTemperature.FAHRENHEIT, 74), + (METRIC_SYSTEM, 25), + (US_CUSTOMARY_SYSTEM, 73), + (US_CUSTOMARY_SYSTEM, 74), ], ) async def test_send_target_temperature_device_timeout( - hass: HomeAssistant, discovery, device, units, temperature + hass: HomeAssistant, discovery, device, units: UnitSystem, temperature ) -> None: """Test for sending target temperature command to the device with a device timeout.""" - hass.config.units.temperature_unit = units - if units == UnitOfTemperature.FAHRENHEIT: + hass.config.units = units + if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: device().temperature_units = 1 device().push_state_update.side_effect = DeviceTimeoutError @@ -520,24 +521,21 @@ async def test_send_target_temperature_device_timeout( assert state is not None assert state.attributes.get(ATTR_TEMPERATURE) == temperature - # Reset config temperature_unit back to CELSIUS, required for additional tests outside this component. - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - @pytest.mark.parametrize( ("units", "temperature"), [ - (UnitOfTemperature.CELSIUS, 25), - (UnitOfTemperature.FAHRENHEIT, 73), - (UnitOfTemperature.FAHRENHEIT, 74), + (METRIC_SYSTEM, 25), + (US_CUSTOMARY_SYSTEM, 73), + (US_CUSTOMARY_SYSTEM, 74), ], ) async def test_update_target_temperature( - hass: HomeAssistant, discovery, device, units, temperature + hass: HomeAssistant, discovery, device, units: UnitSystem, temperature ) -> None: """Test for updating target temperature from the device.""" - hass.config.units.temperature_unit = units - if units == UnitOfTemperature.FAHRENHEIT: + hass.config.units = units + if units.temperature_unit == UnitOfTemperature.FAHRENHEIT: device().temperature_units = 1 device().target_temperature = temperature @@ -554,9 +552,6 @@ async def test_update_target_temperature( assert state is not None assert state.attributes.get(ATTR_TEMPERATURE) == temperature - # Reset config temperature_unit back to CELSIUS, required for additional tests outside this component. - hass.config.units.temperature_unit = UnitOfTemperature.CELSIUS - @pytest.mark.parametrize( "preset", [PRESET_AWAY, PRESET_ECO, PRESET_SLEEP, PRESET_BOOST, PRESET_NONE] From 7826bb9323acc23edd5161fe6c7bc7818e15e37e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:51:56 +0100 Subject: [PATCH 1564/1941] Migrate google_assistant tests to use unit system (#140366) --- .../google_assistant/test_google_assistant.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 2b0bfd82908..035a8d151c4 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -16,13 +16,9 @@ from homeassistant.components import ( light, media_player, ) -from homeassistant.const import ( - CLOUD_NEVER_EXPOSED_ENTITIES, - EntityCategory, - Platform, - UnitOfTemperature, -) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory, Platform from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import DEMO_DEVICES @@ -275,7 +271,7 @@ async def test_query_climate_request_f( ) -> None: """Test a query request.""" # Mock demo devices as fahrenheit to see if we convert to celsius - hass_fixture.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass_fixture.config.units = US_CUSTOMARY_SYSTEM for entity_id in ("climate.hvac", "climate.heatpump", "climate.ecobee"): state = hass_fixture.states.get(entity_id) attr = dict(state.attributes) @@ -332,7 +328,6 @@ async def test_query_climate_request_f( "thermostatHumidityAmbient": 54.2, "currentFanSpeedSetting": "on_high", } - hass_fixture.config.units.temperature_unit = UnitOfTemperature.CELSIUS async def test_query_humidifier_request( From daaa1486fc22193243935a4a4631d2c6c7c09f92 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:54:21 +0100 Subject: [PATCH 1565/1941] Migrate lg_thinq tests to use unit system (#140365) --- tests/components/lg_thinq/test_climate.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py index 4ac2fa55a21..e53b1c5ff39 100644 --- a/tests/components/lg_thinq/test_climate.py +++ b/tests/components/lg_thinq/test_climate.py @@ -5,9 +5,10 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy import SnapshotAssertion -from homeassistant.const import Platform, UnitOfTemperature +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import setup_integration @@ -23,7 +24,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - hass.config.units.temperature_unit = UnitOfTemperature.FAHRENHEIT + hass.config.units = US_CUSTOMARY_SYSTEM with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): await setup_integration(hass, mock_config_entry) From bc6d342919dff9663f34d74116285c8dc47e10fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 14:03:15 +0100 Subject: [PATCH 1566/1941] Fix no temperature unit in SmartThings (#140363) --- .../components/smartthings/climate.py | 12 +- tests/components/smartthings/conftest.py | 1 + .../ecobee_thermostat_offline.json | 81 ++++++++++++++ .../devices/ecobee_thermostat_offline.json | 82 ++++++++++++++ .../smartthings/snapshots/test_climate.ambr | 64 +++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++ .../smartthings/snapshots/test_sensor.ambr | 103 ++++++++++++++++++ 7 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json create mode 100644 tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 650b0c5540a..a95105efaa6 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -321,10 +321,14 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ - Attribute.TEMPERATURE - ].unit - assert unit + # Offline third party thermostats may not have a unit + # Since climate always requires a unit, default to Celsius + if ( + unit := self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + ) is None: + return UnitOfTemperature.CELSIUS return UNIT_MAP[unit] diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index db6e49b2135..3b39fc921d7 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -120,6 +120,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "sensibo_airconditioner_1", "ecobee_sensor", "ecobee_thermostat", + "ecobee_thermostat_offline", "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", diff --git a/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json new file mode 100644 index 00000000000..fdda31783f6 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ecobee_thermostat_offline.json @@ -0,0 +1,81 @@ +{ + "components": { + "main": { + "relativeHumidityMeasurement": { + "humidity": { + "value": null + } + }, + "thermostatOperatingState": { + "thermostatOperatingState": { + "value": null + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2025-03-10T00:57:26.866Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "offline", + "data": { + "reason": "DEVICE-OFFLINE" + }, + "timestamp": "2025-03-11T10:22:17.013Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "thermostatHeatingSetpoint": { + "heatingSetpoint": { + "value": null + }, + "heatingSetpointRange": { + "value": null + } + }, + "thermostatFanMode": { + "thermostatFanMode": { + "value": null + }, + "supportedThermostatFanModes": { + "value": null + } + }, + "refresh": {}, + "thermostatMode": { + "thermostatMode": { + "value": null + }, + "supportedThermostatModes": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json b/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json new file mode 100644 index 00000000000..5fe8d8d28be --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ecobee_thermostat_offline.json @@ -0,0 +1,82 @@ +{ + "items": [ + { + "deviceId": "1888b38f-6246-4f1e-911b-bfcfb66999db", + "name": "v4 - ecobee Thermostat - Heat and Cool (F)", + "label": "Downstairs", + "manufacturerName": "0A0b", + "presentationId": "ST_5334da38-8076-4b40-9f6c-ac3fccaa5d24", + "deviceManufacturerCode": "ecobee", + "locationId": "1030449a-22c1-4a80-9781-0bd4ab7f0f2f", + "ownerId": "e7dbb793-4351-4cdc-b037-e6e0b4f9df67", + "roomId": "d22e6f98-78fe-4a76-b904-6cad8628da59", + "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-03-10T00:57:26.760Z", + "profile": { + "id": "234d537d-d388-497f-b0f4-2e25025119ba" + }, + "viper": { + "manufacturerName": "ecobee", + "modelName": "nikeSmart-thermostat", + "swVersion": "250308073247", + "hwVersion": "250308073247", + "endpointAppId": "viper_92ccdcc0-4184-11eb-b9c5-036180216747" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 6b512f93d39..20389f38a46 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -432,6 +432,70 @@ 'state': 'heat', }) # --- +# name: test_all_entities[ecobee_thermostat_offline][climate.downstairs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': None, + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.downstairs', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1888b38f-6246-4f1e-911b-bfcfb66999db', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][climate.downstairs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'fan_mode': None, + 'fan_modes': None, + 'friendly_name': 'Downstairs', + 'hvac_modes': list([ + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.downstairs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[generic_ef00_v1][climate.thermostat_kuche-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 2c45c466fa2..dad6c523a55 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -662,6 +662,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[ecobee_thermostat_offline] + 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': '250308073247', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '1888b38f-6246-4f1e-911b-bfcfb66999db', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'ecobee', + 'model': 'nikeSmart-thermostat', + 'model_id': None, + 'name': 'Downstairs', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '250308073247', + 'via_device_id': None, + }) +# --- # name: test_devices[fake_fan] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index e7b36e7d028..94fe1924fd2 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5093,6 +5093,109 @@ 'state': '22', }) # --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_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.downstairs_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': '1888b38f-6246-4f1e-911b-bfcfb66999db.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Downstairs Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.downstairs_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_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.downstairs_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': '1888b38f-6246-4f1e-911b-bfcfb66999db.temperature', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ecobee_thermostat_offline][sensor.downstairs_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Downstairs Temperature', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.downstairs_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[generic_ef00_v1][sensor.thermostat_kuche_link_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From d2124db3ece929060127a6fc2a1d9b0299c7446f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 11 Mar 2025 14:06:44 +0100 Subject: [PATCH 1567/1941] Fix double space quoting in WebDAV (#140364) --- homeassistant/components/webdav/__init__.py | 13 ++- homeassistant/components/webdav/helpers.py | 29 ++++++ homeassistant/components/webdav/manifest.json | 2 +- homeassistant/components/webdav/strings.json | 3 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webdav/__init__.py | 13 +++ tests/components/webdav/conftest.py | 1 + tests/components/webdav/test_init.py | 96 +++++++++++++++++++ 9 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 tests/components/webdav/test_init.py diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py index 952a68d829f..36a03dce4d7 100644 --- a/homeassistant/components/webdav/__init__.py +++ b/homeassistant/components/webdav/__init__.py @@ -13,7 +13,11 @@ 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 +from .helpers import ( + async_create_client, + async_ensure_path_exists, + async_migrate_wrong_folder_path, +) type WebDavConfigEntry = ConfigEntry[Client] @@ -46,10 +50,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo translation_key="cannot_connect", ) + path = entry.data.get(CONF_BACKUP_PATH, "/") + await async_migrate_wrong_folder_path(client, path) + # Ensure the backup directory exists - if not await async_ensure_path_exists( - client, entry.data.get(CONF_BACKUP_PATH, "/") - ): + if not await async_ensure_path_exists(client, path): raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="cannot_access_or_create_backup_path", diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py index 9f91ed3bdb3..5db15bba0f7 100644 --- a/homeassistant/components/webdav/helpers.py +++ b/homeassistant/components/webdav/helpers.py @@ -1,10 +1,18 @@ """Helper functions for the WebDAV component.""" +import logging + from aiowebdav2.client import Client, ClientOptions +from aiowebdav2.exceptions import WebDavError from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + @callback def async_create_client( @@ -36,3 +44,24 @@ async def async_ensure_path_exists(client: Client, path: str) -> bool: return False return True + + +async def async_migrate_wrong_folder_path(client: Client, path: str) -> None: + """Migrate the wrong encoded folder path to the correct one.""" + wrong_path = path.replace(" ", "%20") + if await client.check(wrong_path): + try: + await client.move(wrong_path, path) + except WebDavError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_migrate_folder", + translation_placeholders={ + "wrong_path": wrong_path, + "correct_path": path, + }, + ) from err + + _LOGGER.debug( + "Migrated wrong encoded folder path from %s to %s", wrong_path, path + ) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index fd3c749781e..30028cb28c9 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.4.1"] + "requirements": ["aiowebdav2==0.4.2"] } diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index 57117cdd9de..b03ffaf2a3d 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -36,6 +36,9 @@ }, "cannot_access_or_create_backup_path": { "message": "Cannot access or create backup path. Please check the path and permissions." + }, + "failed_to_migrate_folder": { + "message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"." } } } diff --git a/requirements_all.txt b/requirements_all.txt index 8a2aa375b3e..83833f3a665 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.4.1 +aiowebdav2==0.4.2 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfc9262316c..583df047cdd 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.4.1 +aiowebdav2==0.4.2 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/tests/components/webdav/__init__.py b/tests/components/webdav/__init__.py index 33e0222fb34..3b901bdd308 100644 --- a/tests/components/webdav/__init__.py +++ b/tests/components/webdav/__init__.py @@ -1 +1,14 @@ """Tests for the WebDAV integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the WebDAV 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/webdav/conftest.py b/tests/components/webdav/conftest.py index 4fdd6fb7870..645e2111364 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -62,4 +62,5 @@ def mock_webdav_client() -> Generator[AsyncMock]: mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None + mock.move.return_value = None yield mock diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py new file mode 100644 index 00000000000..c267f7c3251 --- /dev/null +++ b/tests/components/webdav/test_init.py @@ -0,0 +1,96 @@ +"""Test WebDAV component setup.""" + +from unittest.mock import AsyncMock + +from aiowebdav2.exceptions import WebDavError +import pytest + +from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_migrate_wrong_path( + hass: HomeAssistant, webdav_client: AsyncMock +) -> None: + """Test migration of wrong encoded folder path.""" + webdav_client.list_with_properties.return_value = [ + {"/wrong%20path": []}, + ] + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/wrong path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + webdav_client.move.assert_called_once_with("/wrong%20path", "/wrong path") + + +async def test_migrate_non_wrong_path( + hass: HomeAssistant, webdav_client: AsyncMock +) -> None: + """Test no migration of correct folder path.""" + webdav_client.list_with_properties.return_value = [ + {"/correct path": []}, + ] + webdav_client.check.side_effect = lambda path: path == "/correct path" + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/correct path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + + await setup_integration(hass, config_entry) + + webdav_client.move.assert_not_called() + + +async def test_migrate_error( + hass: HomeAssistant, + webdav_client: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test migration of wrong encoded folder path with error.""" + webdav_client.list_with_properties.return_value = [ + {"/wrong%20path": []}, + ] + webdav_client.move.side_effect = WebDavError("Failed to move") + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/wrong path", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert ( + 'Failed to migrate wrong encoded folder "/wrong%20path" to "/wrong path"' + in caplog.text + ) From 25d6974137b7b8d3f0afe58bc5ec8a55b79d0f8d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:09:50 +0100 Subject: [PATCH 1568/1941] Migrate balboa tests to use unit system (#140371) --- tests/components/balboa/test_climate.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 850184a7d71..9c23833518e 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -26,10 +26,11 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import client_update, init_integration @@ -97,11 +98,10 @@ async def test_spa_temperature_unit( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry ) -> None: """Test temperature unit conversions.""" - with patch.object( - hass.config.units, "temperature_unit", UnitOfTemperature.FAHRENHEIT - ): - state = await _patch_spa_settemp(hass, client, 0, 15.4) - assert state.attributes.get(ATTR_TEMPERATURE) == 15.0 + hass.config.units = US_CUSTOMARY_SYSTEM + + state = await _patch_spa_settemp(hass, client, 0, 15.4) + assert state.attributes.get(ATTR_TEMPERATURE) == 15.0 async def test_spa_hvac_modes( From 13e9906929885774e859b8bc753349ef91588e39 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:09:58 +0100 Subject: [PATCH 1569/1941] Remove redundant after dependencies in search (#140353) --- homeassistant/components/search/manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/search/manifest.json b/homeassistant/components/search/manifest.json index cd372139451..42a54fe8b55 100644 --- a/homeassistant/components/search/manifest.json +++ b/homeassistant/components/search/manifest.json @@ -1,7 +1,6 @@ { "domain": "search", "name": "Search", - "after_dependencies": ["scene", "group", "automation", "script"], "codeowners": ["@home-assistant/core"], "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/search", From 0e7a08384771ed34c9b75f3ffbab9377d6a92aff Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 14:10:06 +0100 Subject: [PATCH 1570/1941] Handle incomplete power consumption reports in SmartThings (#140370) --- .../components/smartthings/__init__.py | 26 ----- .../components/smartthings/sensor.py | 29 ++++- tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/tplink_p110.json | 46 ++++++++ .../fixtures/devices/tplink_p110.json | 73 ++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++ .../smartthings/snapshots/test_sensor.ambr | 110 ++++++++++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++++++++ 8 files changed, 337 insertions(+), 28 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/tplink_p110.json create mode 100644 tests/components/smartthings/fixtures/devices/tplink_p110.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 9d8881bc1c1..9b9494dd9c5 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -228,28 +228,6 @@ KEEP_CAPABILITY_QUIRK: dict[ Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True, } -POWER_CONSUMPTION_FIELDS = { - "energy", - "power", - "deltaEnergy", - "powerEnergy", - "energySaved", -} - -CAPABILITY_VALIDATION: dict[ - Capability | str, Callable[[dict[Attribute | str, Status]], bool] -] = { - Capability.POWER_CONSUMPTION_REPORT: ( - lambda status: ( - (power_consumption := status[Attribute.POWER_CONSUMPTION].value) is not None - and all( - field in cast(dict, power_consumption) - for field in POWER_CONSUMPTION_FIELDS - ) - ) - ) -} - def process_status( status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]], @@ -273,8 +251,4 @@ def process_status( or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability]) ): del main_component[capability] - for capability in list(main_component): - if capability in CAPABILITY_VALIDATION: - if not CAPABILITY_VALIDATION[capability](main_component[capability]): - del main_component[capability] return status diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 5a2fdcf3854..ce0f30a1f1a 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,9 +5,9 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, cast -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import Attribute, Capability, SmartThings, Status from homeassistant.components.sensor import ( SensorDeviceClass, @@ -131,6 +131,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): unique_id_separator: str = "." capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None + exists_fn: Callable[[Status], bool] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -583,6 +584,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energy"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "energy" in value + ), ), SmartThingsSensorEntityDescription( key="power_meter", @@ -592,6 +597,10 @@ CAPABILITY_TO_SENSORS: dict[ value_fn=lambda value: value["power"], extra_state_attributes_fn=power_attributes, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "power" in value + ), ), SmartThingsSensorEntityDescription( key="deltaEnergy_meter", @@ -601,6 +610,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["deltaEnergy"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "deltaEnergy" in value + ), ), SmartThingsSensorEntityDescription( key="powerEnergy_meter", @@ -610,6 +623,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["powerEnergy"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "powerEnergy" in value + ), ), SmartThingsSensorEntityDescription( key="energySaved_meter", @@ -619,6 +636,10 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda value: value["energySaved"] / 1000, suggested_display_precision=2, + exists_fn=lambda status: ( + (value := cast(dict | None, status.value)) is not None + and "energySaved" in value + ), ), ] }, @@ -980,6 +1001,10 @@ async def async_setup_entry( for capability_list in description.capability_ignore_list ) ) + and ( + not description.exists_fn + or description.exists_fn(device.status[MAIN][capability][attribute]) + ) ) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 3b39fc921d7..d9c31d44a7a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -127,6 +127,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", + "tplink_p110", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/tplink_p110.json b/tests/components/smartthings/fixtures/device_status/tplink_p110.json new file mode 100644 index 00000000000..9e1d41ed66e --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/tplink_p110.json @@ -0,0 +1,46 @@ +{ + "components": { + "main": { + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "start": "2025-03-10T14:43:42.500Z", + "end": "2025-03-10T14:59:42.500Z", + "energy": 15720, + "deltaEnergy": 0 + }, + "timestamp": "2025-03-10T14:59:50.010Z" + } + }, + "healthCheck": { + "checkInterval": { + "value": 60, + "unit": "s", + "data": { + "deviceScheme": "UNTRACKED", + "protocol": "cloud" + }, + "timestamp": "2024-03-07T21:14:59.839Z" + }, + "healthStatus": { + "value": null + }, + "DeviceWatch-Enroll": { + "value": null + }, + "DeviceWatch-DeviceStatus": { + "value": "online", + "data": {}, + "timestamp": "2025-03-10T14:14:37.232Z" + } + }, + "refresh": {}, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-03-10T14:14:37.232Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/tplink_p110.json b/tests/components/smartthings/fixtures/devices/tplink_p110.json new file mode 100644 index 00000000000..ffe7de5ff68 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/tplink_p110.json @@ -0,0 +1,73 @@ +{ + "items": [ + { + "deviceId": "6602696a-1e48-49e4-919f-69406f5b5da1", + "name": "plug-energy-usage-report", + "label": "Sp\u00fclmaschine", + "manufacturerName": "0AI2", + "presentationId": "ST_8f2be0ec-1113-46e0-ad56-3e92eb27410f", + "deviceManufacturerCode": "TP-Link", + "locationId": "70da36b0-bd25-410c-beed-7f0dbf658448", + "ownerId": "be5d4173-dd49-1eee-56f5-f98306ee872c", + "roomId": "bd13616d-b7e2-44ff-914c-eb38ea18c4b4", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "healthCheck", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + } + ], + "categories": [ + { + "name": "SmartPlug", + "categoryType": "manufacturer" + }, + { + "name": "SmartPlug", + "categoryType": "user" + } + ] + } + ], + "createTime": "2024-03-07T21:14:59.762Z", + "profile": { + "id": "a25b207e-cbb9-40ae-8a88-906637c22ab6" + }, + "viper": { + "uniqueIdentifier": "8022F7F6FE0A6EACA52B5D89C0D667352136D8C6", + "manufacturerName": "TP-Link", + "modelName": "P110", + "swVersion": "1.3.1 Build 240621 Rel.162048", + "hwVersion": "1.0", + "endpointAppId": "viper_7ea6bb80-b876-11eb-be42-952f31ab3f7b" + }, + "type": "VIPER", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [0.0, 0.0, 0.0], + "rotation": [0.0, 180.0, 0.0], + "visible": false, + "data": null + }, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index dad6c523a55..473b9cb580a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1124,6 +1124,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[tplink_p110] + 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', + '6602696a-1e48-49e4-919f-69406f5b5da1', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'P110', + 'model_id': None, + 'name': 'Spülmaschine', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.3.1 Build 240621 Rel.162048', + 'via_device_id': None, + }) +# --- # name: test_devices[vd_network_audio_002s] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 94fe1924fd2..52df02f55b8 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -6212,6 +6212,116 @@ 'state': '15', }) # --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_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.spulmaschine_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Spülmaschine Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spulmaschine_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.72', + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy_difference-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.spulmaschine_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[tplink_p110][sensor.spulmaschine_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Spülmaschine Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.spulmaschine_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_all_entities[vd_network_audio_002s][sensor.soundbar_living_media_playback_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index e119428c183..f1b5ce8412e 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -516,6 +516,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[tplink_p110][switch.spulmaschine-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.spulmaschine', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '6602696a-1e48-49e4-919f-69406f5b5da1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[tplink_p110][switch.spulmaschine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spülmaschine', + }), + 'context': , + 'entity_id': 'switch.spulmaschine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[vd_network_audio_002s][switch.soundbar_living-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1c242a6602446aac01a32a3ff55dbede7a0386c2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:10:20 +0100 Subject: [PATCH 1571/1941] Migrate homekit tests to use unit system (#140372) --- tests/components/homekit/test_type_thermostats.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index fc4cfa78ca4..69c347ef55a 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -69,7 +69,6 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, - CONF_TEMPERATURE_UNIT, EVENT_HOMEASSISTANT_START, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -77,6 +76,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, Event, HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from tests.common import async_mock_service @@ -858,6 +858,7 @@ async def test_thermostat_fahrenheit( ) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" + hass.config.units = US_CUSTOMARY_SYSTEM # support_ = True hass.states.async_set( @@ -869,10 +870,7 @@ async def test_thermostat_fahrenheit( }, ) await hass.async_block_till_done() - with patch.object( - hass.config.units, CONF_TEMPERATURE_UNIT, new=UnitOfTemperature.FAHRENHEIT - ): - acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) acc.run() await hass.async_block_till_done() @@ -1786,13 +1784,11 @@ async def test_water_heater_fahrenheit( ) -> None: """Test if accessory and HA are update accordingly.""" entity_id = "water_heater.test" + hass.config.units = US_CUSTOMARY_SYSTEM hass.states.async_set(entity_id, HVACMode.HEAT) await hass.async_block_till_done() - with patch.object( - hass.config.units, CONF_TEMPERATURE_UNIT, new=UnitOfTemperature.FAHRENHEIT - ): - acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 2, None) acc.run() await hass.async_block_till_done() From ca5ce74740416b4b6813a2392329840ffa26b5bf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 14:10:54 +0100 Subject: [PATCH 1572/1941] Improve user-facing strings of `hassio` component (#140355) - capitalize "Internet" - remove excessive space character - add "the" and trailing period in description of `homeassistant_exclude_database` field - replace duplicate strings in `backup_partial` with references to `backup_full` action --- homeassistant/components/hassio/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 799067b8215..a543dbc7f89 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -152,7 +152,7 @@ }, "unsupported_connectivity_check": { "title": "Unsupported system - Connectivity check disabled", - "description": "System is unsupported because Home Assistant cannot determine when an internet connection is available. Use the link to learn more and how to fix this." + "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. Use the link to learn more and how to fix this." }, "unsupported_content_trust": { "title": "Unsupported system - Content-trust check disabled", @@ -216,7 +216,7 @@ }, "unsupported_systemd_journal": { "title": "Unsupported system - Systemd Journal issues", - "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured . Use the link to learn more and how to fix this." + "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. Use the link to learn more and how to fix this." }, "unsupported_systemd_resolved": { "title": "Unsupported system - Systemd-Resolved issues", @@ -348,7 +348,7 @@ }, "homeassistant_exclude_database": { "name": "Home Assistant exclude database", - "description": "Exclude the Home Assistant database file from backup" + "description": "Exclude the Home Assistant database file from the backup." } } }, @@ -385,8 +385,8 @@ "description": "[%key:component::hassio::services::backup_full::fields::location::description%]" }, "homeassistant_exclude_database": { - "name": "Home Assistant exclude database", - "description": "Exclude the Home Assistant database file from backup" + "name": "[%key:component::hassio::services::backup_full::fields::homeassistant_exclude_database::name%]", + "description": "[%key:component::hassio::services::backup_full::fields::homeassistant_exclude_database::description%]" } } }, From d82c30364a86a4f21a7e3454185de7f05b67f57b Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:12:30 +0100 Subject: [PATCH 1573/1941] Remove redundant after dependencies in person (#140354) --- homeassistant/components/person/manifest.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json index 7f370be6fbe..0c1792e9277 100644 --- a/homeassistant/components/person/manifest.json +++ b/homeassistant/components/person/manifest.json @@ -1,7 +1,6 @@ { "domain": "person", "name": "Person", - "after_dependencies": ["device_tracker"], "codeowners": [], "dependencies": ["image_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/person", From 536109251e4286b77a151de565ca090e243f9ed4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 14:47:13 +0100 Subject: [PATCH 1574/1941] Make sure SmartThings light can deal with unknown states (#140190) * Fix * add comment * Make light unknown * Make light unknown --- homeassistant/components/smartthings/light.py | 54 +++++++++----- tests/components/smartthings/conftest.py | 1 + .../device_status/abl_light_b_001.json | 27 +++++++ .../fixtures/devices/abl_light_b_001.json | 59 ++++++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 +++++++++ .../smartthings/snapshots/test_light.ambr | 70 +++++++++++++++++++ 6 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/abl_light_b_001.json create mode 100644 tests/components/smartthings/fixtures/devices/abl_light_b_001.json diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index eee333f131f..12c7f7ebbcb 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -150,14 +150,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): """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.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL), - 100, - 255, - 0, + if ( + brightness := self.get_attribute_value( + Capability.SWITCH_LEVEL, Attribute.LEVEL + ) + ) is None: + self._attr_brightness = None + else: + self._attr_brightness = int( + convert_scale( + brightness, + 100, + 255, + 0, + ) ) - ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: self._attr_color_temp_kelvin = self.get_attribute_value( @@ -165,16 +172,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): ) # Color if ColorMode.HS in self._attr_supported_color_modes: - self._attr_hs_color = ( - convert_scale( - self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE), - 100, - 360, - ), - self.get_attribute_value( - Capability.COLOR_CONTROL, Attribute.SATURATION - ), - ) + if ( + hue := self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE) + ) is None: + self._attr_hs_color = None + else: + self._attr_hs_color = ( + convert_scale( + hue, + 100, + 360, + ), + self.get_attribute_value( + Capability.COLOR_CONTROL, Attribute.SATURATION + ), + ) async def async_set_color(self, hs_color): """Set the color of the device.""" @@ -220,6 +232,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): super()._update_handler(event) @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if light is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" + if ( + state := self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + ) is None: + return None + return state == "on" diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index d9c31d44a7a..6de472a59a8 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -127,6 +127,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", + "abl_light_b_001", "tplink_p110", ] ) diff --git a/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json b/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json new file mode 100644 index 00000000000..6dba85d7dc4 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/abl_light_b_001.json @@ -0,0 +1,27 @@ +{ + "components": { + "main": { + "switchLevel": { + "levelRange": { + "value": null + }, + "level": { + "value": null + } + }, + "switch": { + "switch": { + "value": null + } + }, + "colorTemperature": { + "colorTemperatureRange": { + "value": null + }, + "colorTemperature": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/abl_light_b_001.json b/tests/components/smartthings/fixtures/devices/abl_light_b_001.json new file mode 100644 index 00000000000..bb4970b6d5a --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/abl_light_b_001.json @@ -0,0 +1,59 @@ +{ + "items": [ + { + "deviceId": "7c16163e-c94e-482f-95f6-139ae0cd9d5e", + "name": "ABL Wafer Down Light(BLE)", + "label": "Kitchen Light 5", + "manufacturerName": "Samsung Electronics", + "presentationId": "ABL-LIGHT-B-001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "6c314222-8baf-48a0-9442-5b1102a8757f", + "ownerId": "f24ff388-700c-7d1e-91f2-1c37ae68ce2b", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "colorTemperature", + "version": 1 + }, + { + "id": "switchLevel", + "version": 1 + } + ], + "categories": [ + { + "name": "Light", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-03-08T22:40:25.073Z", + "profile": { + "id": "65f5db53-9a78-4b19-8e40-d32187cd59ab" + }, + "bleD2D": { + "encryptionKey": "f593369dcea915f6352a4a42cd4b2ea6", + "cipher": "AES_128-CBC-PKCS7Padding", + "advertisingId": "b13d7192", + "identifier": "88-57-1d-7c-cb-cf", + "configurationUrl": "https://apis.samsungiotcloud.com/v1/miniature/profile/65f5db53-9a78-4b19-8e40-d32187cd59ab", + "bleDeviceType": "BLE", + "metadata": null + }, + "type": "BLE_D2D", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 473b9cb580a..0276873384a 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -2,6 +2,39 @@ # name: test_button_event[button] # --- +# name: test_devices[abl_light_b_001] + 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', + '7c16163e-c94e-482f-95f6-139ae0cd9d5e', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Kitchen Light 5', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[aeotec_home_energy_meter_gen5] DeviceRegistryEntrySnapshot({ 'area_id': 'toilet', diff --git a/tests/components/smartthings/snapshots/test_light.ambr b/tests/components/smartthings/snapshots/test_light.ambr index 8766811c443..f1f2b92de77 100644 --- a/tests/components/smartthings/snapshots/test_light.ambr +++ b/tests/components/smartthings/snapshots/test_light.ambr @@ -1,4 +1,74 @@ # serializer version: 1 +# name: test_all_entities[abl_light_b_001][light.kitchen_light_5-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.kitchen_light_5', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '7c16163e-c94e-482f-95f6-139ae0cd9d5e', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[abl_light_b_001][light.kitchen_light_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Kitchen Light 5', + '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.kitchen_light_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[centralite][light.dimmer_debian-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 8edecd8671b033edf44d9cf99700397a9b66a717 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 09:47:30 -0400 Subject: [PATCH 1575/1941] Bump python-roborock to 2.12.2 (#140368) bump python roboorck to 2.12.2 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index db2654d4baa..1b143591203 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.11.1", + "python-roborock==2.12.2", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 83833f3a665..7d2ca933235 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2461,7 +2461,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.11.1 +python-roborock==2.12.2 # homeassistant.components.smarttub python-smarttub==0.0.39 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 583df047cdd..9f30c342c95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1994,7 +1994,7 @@ python-picnic-api2==1.2.2 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.11.1 +python-roborock==2.12.2 # homeassistant.components.smarttub python-smarttub==0.0.39 From 7bdec5f19f3e83649035e3535b91279f2e1a0089 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 11 Mar 2025 14:54:02 +0100 Subject: [PATCH 1576/1941] Bump reolink-aio to 0.12.2 (#140369) --- 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 f923efdbbf2..c07d63c184c 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.1"] + "requirements": ["reolink-aio==0.12.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7d2ca933235..f22db6ffd7b 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.1 +reolink-aio==0.12.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f30c342c95..e8e9c477fff 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.1 +reolink-aio==0.12.2 # homeassistant.components.rflink rflink==0.0.66 From 6c54f8dff2edd39b4803ec3c88f1f72846bf045c Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:56:41 -0400 Subject: [PATCH 1577/1941] Fix browsing Audible Favorites in Sonos (#140378) * initial commit * updates * update test data --- homeassistant/components/sonos/const.py | 4 ++ .../sonos/fixtures/sonos_favorites.json | 18 +++++ .../sonos/snapshots/test_media_browser.ambr | 70 +++++++++++++++++++ tests/components/sonos/test_media_browser.py | 37 ++++++++++ 4 files changed, 129 insertions(+) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 8fb704cbfbc..cda40729dbc 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -32,6 +32,7 @@ SONOS_TRACKS = "tracks" SONOS_COMPOSER = "composers" SONOS_RADIO = "radio" SONOS_OTHER_ITEM = "other items" +SONOS_AUDIO_BOOK = "audio book" SONOS_STATE_PLAYING = "PLAYING" SONOS_STATE_TRANSITIONING = "TRANSITIONING" @@ -67,6 +68,7 @@ SONOS_TO_MEDIA_CLASSES = { "object.item": MediaClass.TRACK, "object.item.audioItem.musicTrack": MediaClass.TRACK, "object.item.audioItem.audioBroadcast": MediaClass.GENRE, + "object.item.audioItem.audioBook": MediaClass.TRACK, } SONOS_TO_MEDIA_TYPES = { @@ -84,6 +86,7 @@ SONOS_TO_MEDIA_TYPES = { "object.container.playlistContainer.sameArtist": MediaType.ARTIST, "object.container.playlistContainer": MediaType.PLAYLIST, "object.item.audioItem.musicTrack": MediaType.TRACK, + "object.item.audioItem.audioBook": MediaType.TRACK, } MEDIA_TYPES_TO_SONOS: dict[MediaType | str, str] = { @@ -113,6 +116,7 @@ SONOS_TYPES_MAPPING = { "object.item": SONOS_OTHER_ITEM, "object.item.audioItem.musicTrack": SONOS_TRACKS, "object.item.audioItem.audioBroadcast": SONOS_RADIO, + "object.item.audioItem.audioBook": SONOS_AUDIO_BOOK, } LIBRARY_TITLES_MAPPING = { diff --git a/tests/components/sonos/fixtures/sonos_favorites.json b/tests/components/sonos/fixtures/sonos_favorites.json index 21ee68f4872..d5463c3d02b 100644 --- a/tests/components/sonos/fixtures/sonos_favorites.json +++ b/tests/components/sonos/fixtures/sonos_favorites.json @@ -34,5 +34,23 @@ "protocol_info": "a:b:c:d" } ] + }, + { + "title": "American Tall Tales", + "parent_id": "FV:2", + "item_id": "FV:2/66", + "restricted": false, + "resource_meta_data": "American Tall Talesobject.item.audioItem.audioBookSA_RINCON61191_X_#Svc6-0-Token", + "resources": [ + { + "uri": "x-rincon-cpcontainer:101340c8reftitle%C9F27_com?sid=239&flags=16584&sn=5", + "protocol_info": "x-rincon-cpcontainer:*:*:*" + } + ], + "desc": null, + "album_art_uri": "https://m.media-amazon.com/images/I/810lqLo5m0L._SL600_.jpg", + "type": "instantPlay", + "description": "Audible", + "favorite_nr": "0" } ] diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index ae8e813ae5d..9f6560c0f75 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -1,4 +1,74 @@ # serializer version: 1 +# name: test_browse_media_favorites[-favorites] + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'object.container.album.musicAlbum', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Albums', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'object.item.audioItem.audioBook', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Audio Book', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'genre', + 'media_content_id': 'object.item.audioItem.audioBroadcast', + 'media_content_type': 'favorites_folder', + 'thumbnail': None, + 'title': 'Radio', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Favorites', + }) +# --- +# name: test_browse_media_favorites[object.item.audioItem.audioBook-favorites_folder] + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'FV:2/66', + 'media_content_type': 'favorite_item_id', + 'thumbnail': 'https://m.media-amazon.com/images/I/810lqLo5m0L._SL600_.jpg', + 'title': 'American Tall Tales', + }), + ]), + 'children_media_class': 'track', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Audio Book', + }) +# --- # name: test_browse_media_library list([ dict({ diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index 6e03935f7f6..323140e285d 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -2,6 +2,7 @@ from functools import partial +import pytest from syrupy import SnapshotAssertion from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType @@ -176,3 +177,39 @@ async def test_browse_media_library_albums( assert response["success"] assert response["result"]["children"] == snapshot assert soco_mock.music_library.browse_by_idstring.call_count == 1 + + +@pytest.mark.parametrize( + ("media_content_id", "media_content_type"), + [ + ( + "", + "favorites", + ), + ( + "object.item.audioItem.audioBook", + "favorites_folder", + ), + ], +) +async def test_browse_media_favorites( + async_autosetup_sonos, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + media_content_id, + media_content_type, +) -> None: + """Test the async_browse_media method.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.zone_a", + "media_content_id": media_content_id, + "media_content_type": media_content_type, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot From ca33d7263f93bcd3a817c4e93f0883f29d021754 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Mar 2025 07:12:19 -0700 Subject: [PATCH 1578/1941] Improve roborock map image (#140379) --- homeassistant/components/roborock/const.py | 1 + homeassistant/components/roborock/image.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index cc8d34fbadc..5a725ff5586 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -49,6 +49,7 @@ IMAGE_CACHE_INTERVAL = 90 MAP_SLEEP = 3 GET_MAPS_SERVICE_NAME = "get_maps" +MAP_SCALE = 4 MAP_FILE_FORMAT = "PNG" MAP_FILENAME_SUFFIX = ".png" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 66088d6453c..70f06dd4b92 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -26,6 +26,7 @@ from .const import ( DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_FILE_FORMAT, + MAP_SCALE, MAP_SLEEP, ) from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator @@ -47,7 +48,11 @@ async def async_setup_entry( if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) ] parser = RoborockMapDataParser( - ColorsPalette(), Sizes(), drawables, ImageConfig(), [] + ColorsPalette(), + Sizes({k: v * MAP_SCALE for k, v in Sizes.SIZES.items()}), + drawables, + ImageConfig(scale=MAP_SCALE), + [], ) def parse_image(map_bytes: bytes) -> bytes | None: From 3c57b12cd1daef98bb5287b255a2ba48b28b89cd Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 10:31:20 -0400 Subject: [PATCH 1579/1941] Fix bug with all Roborock maps being set to the wrong map when empty (#138493) * Fix bug with all maps being set to the same when empty * fix parens * fix other parens * rework some of the logic * few small updates * Remove test that is no longer relevant * remove updated time bump --- homeassistant/components/roborock/image.py | 28 +++++++----------- tests/components/roborock/test_image.py | 34 ---------------------- 2 files changed, 11 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 70f06dd4b92..2fb5d644826 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -117,19 +117,6 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): """Return if this map is the currently selected map.""" return self.map_flag == self.coordinator.current_map - def is_map_valid(self) -> bool: - """Update the map if it is valid. - - Update this map if it is the currently active map, and the - vacuum is cleaning, or if it has never been set at all. - """ - return self.cached_map == b"" or ( - self.is_selected - and self.image_last_updated is not None - and self.coordinator.roborock_device_info.props.status is not None - and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) - ) - async def async_added_to_hass(self) -> None: """When entity is added to hass load any previously cached maps from disk.""" await super().async_added_to_hass() @@ -142,15 +129,22 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): # Bump last updated every third time the coordinator runs, so that async_image # will be called and we will evaluate on the new coordinator data if we should # update the cache. - if ( - dt_util.utcnow() - self.image_last_updated - ).total_seconds() > IMAGE_CACHE_INTERVAL and self.is_map_valid(): + if self.is_selected and ( + ( + (dt_util.utcnow() - self.image_last_updated).total_seconds() + > IMAGE_CACHE_INTERVAL + and self.coordinator.roborock_device_info.props.status is not None + and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) + ) + or self.cached_map == b"" + ): + # This will tell async_image it should update. self._attr_image_last_updated = dt_util.utcnow() super()._handle_coordinator_update() async def async_image(self) -> bytes | None: """Update the image if it is not cached.""" - if self.is_map_valid(): + if self.is_selected: response = await asyncio.gather( *( self.cloud_api.get_map_v1(), diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index fd6c8b2796a..7d79cf4f6ab 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -3,7 +3,6 @@ import copy from datetime import timedelta from http import HTTPStatus -import io from unittest.mock import patch from PIL import Image @@ -111,39 +110,6 @@ async def test_floorplan_image_failed_parse( assert not resp.ok -async def test_load_stored_image( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - setup_entry: MockConfigEntry, -) -> None: - """Test that we correctly load an image from storage when it already exists.""" - img_byte_arr = io.BytesIO() - MAP_DATA.image.data.save(img_byte_arr, format="PNG") - img_bytes = img_byte_arr.getvalue() - - # 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 - - with patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", - ) as parse_map: - # Reload the config entry so that the map is saved in storage and entities exist. - await hass.config_entries.async_reload(setup_entry.entry_id) - await hass.async_block_till_done() - 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 - body = await resp.read() - assert body == img_bytes - - # Ensure that we never tried to update the map, and only used the cached image. - assert parse_map.call_count == 0 - - async def test_fail_to_save_image( hass: HomeAssistant, hass_client: ClientSessionGenerator, From 71159c755f2151cb0b15dca7c37e36c05ba5cfae Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 11 Mar 2025 15:33:32 +0100 Subject: [PATCH 1580/1941] Delete subscription on shutdown of SmartThings (#140135) * Cache subscription url in SmartThings * Cache subscription url in SmartThings * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Fix * Bump pysmartthings to 2.7.1 * 2.7.2 --------- Co-authored-by: Martin Hjelmare --- .../components/smartthings/__init__.py | 74 ++++++- homeassistant/components/smartthings/const.py | 1 + .../components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/conftest.py | 4 + .../smartthings/fixtures/subscription.json | 16 ++ .../smartthings/test_config_flow.py | 2 + tests/components/smartthings/test_init.py | 185 +++++++++++++++++- 9 files changed, 275 insertions(+), 13 deletions(-) create mode 100644 tests/components/smartthings/fixtures/subscription.json diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 9b9494dd9c5..f95719a8d02 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -16,12 +16,18 @@ from pysmartthings import ( Scene, SmartThings, SmartThingsAuthenticationFailedError, + SmartThingsSinkError, Status, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -33,6 +39,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from .const import ( CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, + CONF_SUBSCRIPTION_ID, DOMAIN, EVENT_BUTTON, MAIN, @@ -100,6 +107,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) client.refresh_token_function = _refresh_token + def _handle_max_connections() -> None: + _LOGGER.debug("We hit the limit of max connections") + hass.config_entries.async_schedule_reload(entry.entry_id) + + client.max_connections_reached_callback = _handle_max_connections + + def _handle_new_subscription_identifier(identifier: str | None) -> None: + """Handle a new subscription identifier.""" + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_SUBSCRIPTION_ID: identifier, + }, + ) + if identifier is not None: + _LOGGER.debug("Updating subscription ID to %s", identifier) + else: + _LOGGER.debug("Removing subscription ID") + + client.new_subscription_id_callback = _handle_new_subscription_identifier + + if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: + _LOGGER.debug("Trying to delete old subscription %s", old_identifier) + await client.delete_subscription(old_identifier) + + _LOGGER.debug("Trying to create a new subscription") + try: + subscription = await client.create_subscription( + entry.data[CONF_LOCATION_ID], + entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], + ) + except SmartThingsSinkError as err: + _LOGGER.debug("Couldn't create a new subscription: %s", err) + raise ConfigEntryNotReady from err + subscription_id = subscription.subscription_id + _handle_new_subscription_identifier(subscription_id) + + entry.async_create_background_task( + hass, + client.subscribe( + entry.data[CONF_LOCATION_ID], + entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], + subscription, + ), + "smartthings_socket", + ) + device_status: dict[str, FullDevice] = {} try: rooms = { @@ -171,12 +226,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) client.add_unspecified_device_event_listener(handle_button_press) ) - entry.async_create_background_task( - hass, - client.subscribe( - entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID] - ), - "smartthings_webhook", + async def _handle_shutdown(_: Event) -> None: + """Handle shutdown.""" + await client.delete_subscription(subscription_id) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_shutdown) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -201,6 +256,9 @@ async def async_unload_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry ) -> bool: """Unload a config entry.""" + client = entry.runtime_data.client + if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None: + await client.delete_subscription(subscription_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index a6d028aed06..2ba59ade4e8 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -33,4 +33,5 @@ CONF_REFRESH_TOKEN = "refresh_token" MAIN = "main" OLD_DATA = "old_data" +CONF_SUBSCRIPTION_ID = "subscription_id" EVENT_BUTTON = "smartthings.button" diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 2a4e79bff58..74f0e4bae83 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.7.0"] + "requirements": ["pysmartthings==2.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f22db6ffd7b..10e305cc47e 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.7.0 +pysmartthings==2.7.2 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8e9c477fff..c2043684a80 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.7.0 +pysmartthings==2.7.2 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 6de472a59a8..2deef344b5e 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -10,6 +10,7 @@ from pysmartthings.models import ( LocationResponse, RoomResponse, SceneResponse, + Subscription, ) import pytest @@ -82,6 +83,9 @@ def mock_smartthings() -> Generator[AsyncMock]: client.get_rooms.return_value = RoomResponse.from_json( load_fixture("rooms.json", DOMAIN) ).items + client.create_subscription.return_value = Subscription.from_json( + load_fixture("subscription.json", DOMAIN) + ) yield client diff --git a/tests/components/smartthings/fixtures/subscription.json b/tests/components/smartthings/fixtures/subscription.json new file mode 100644 index 00000000000..80f37445524 --- /dev/null +++ b/tests/components/smartthings/fixtures/subscription.json @@ -0,0 +1,16 @@ +{ + "subscriptionId": "f5768ce8-c9e5-4507-9020-912c0c60e0ab", + "registrationUrl": "https://spigot-regional.api.smartthings.com/filters/f5768ce8-c9e5-4507-9020-912c0c60e0ab/activate?filterRegion=eu-west-1", + "name": "My Home Assistant sub", + "version": 20250122, + "subscriptionFilters": [ + { + "type": "LOCATIONIDS", + "value": ["88a3a314-f0c8-40b4-bb44-44ba06c9c42e"], + "eventType": ["DEVICE_EVENT"], + "attribute": null, + "capability": null, + "component": null + } + ] +} diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 7472d7d6b71..4069c201225 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.smartthings.const import ( CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, + CONF_SUBSCRIPTION_ID, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntryState @@ -508,6 +509,7 @@ async def test_migration( "installed_app_id": "123123123-2be1-4e40-b257-e4ef59083324", }, CONF_LOCATION_ID: "397678e5-9995-4a39-9d9f-ae6ba310236c", + CONF_SUBSCRIPTION_ID: "f5768ce8-c9e5-4507-9020-912c0c60e0ab", } assert mock_old_config_entry.unique_id == "397678e5-9995-4a39-9d9f-ae6ba310236c" assert mock_old_config_entry.version == 3 diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index e3d865fc5c8..2083bb7ea24 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -2,12 +2,21 @@ from unittest.mock import AsyncMock -from pysmartthings import Attribute, Capability, DeviceResponse, DeviceStatus +from pysmartthings import ( + Attribute, + Capability, + DeviceResponse, + DeviceStatus, + SmartThingsSinkError, +) +from pysmartthings.models import Subscription import pytest from syrupy import SnapshotAssertion from homeassistant.components.smartthings import EVENT_BUTTON -from homeassistant.components.smartthings.const import DOMAIN +from homeassistant.components.smartthings.const import CONF_SUBSCRIPTION_ID, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr @@ -63,6 +72,178 @@ async def test_button_event( assert events[0] == snapshot +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_create_subscription( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a subscription.""" + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + await setup_integration(hass, mock_config_entry) + + devices.create_subscription.assert_called_once() + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.subscribe.assert_called_once_with( + "397678e5-9995-4a39-9d9f-ae6ba310236c", + "5aaaa925-2be1-4e40-b257-e4ef59083324", + Subscription.from_json(load_fixture("subscription.json", DOMAIN)), + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_create_subscription_sink_error( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test handling an error when creating a subscription.""" + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + devices.create_subscription.side_effect = SmartThingsSinkError("Sink error") + + await setup_integration(hass, mock_config_entry) + + devices.subscribe.assert_not_called() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert CONF_SUBSCRIPTION_ID not in mock_config_entry.data + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_update_subscription_identifier( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating the subscription identifier.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.new_subscription_id_callback("abc") + + await hass.async_block_till_done() + + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] == "abc" + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_stale_subscription_id( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating the subscription identifier.""" + mock_config_entry.add_to_hass(hass) + + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_SUBSCRIPTION_ID: "test"}, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + devices.delete_subscription.assert_called_once_with("test") + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_remove_subscription_identifier( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing the subscription identifier.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.new_subscription_id_callback(None) + + await hass.async_block_till_done() + + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_max_connections_handling( + hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test handling reaching max connections.""" + await setup_integration(hass, mock_config_entry) + + assert ( + mock_config_entry.data[CONF_SUBSCRIPTION_ID] + == "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + + devices.create_subscription.side_effect = SmartThingsSinkError("Sink error") + + devices.max_connections_reached_callback() + + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_unloading( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading the integration.""" + await setup_integration(hass, mock_config_entry) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + devices.delete_subscription.assert_called_once_with( + "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + # Deleting the subscription automatically deletes the subscription ID + devices.new_subscription_id_callback(None) + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_shutdown( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test shutting down Home Assistant.""" + await setup_integration(hass, mock_config_entry) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + devices.delete_subscription.assert_called_once_with( + "f5768ce8-c9e5-4507-9020-912c0c60e0ab" + ) + # Deleting the subscription automatically deletes the subscription ID + devices.new_subscription_id_callback(None) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.data[CONF_SUBSCRIPTION_ID] is None + + @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_removing_stale_devices( hass: HomeAssistant, From 490dd3b525a57f1081e9e8856652cde0994dfeb0 Mon Sep 17 00:00:00 2001 From: victorclaessen Date: Tue, 11 Mar 2025 15:52:55 +0100 Subject: [PATCH 1581/1941] Add microseconds as unit for device class duration (#140307) * Add microseconds as unit for device class duration. Add microseconds as unit for device class duration. The converter already supports it. * Update const.py Also update number component --- homeassistant/components/number/const.py | 3 ++- homeassistant/components/sensor/const.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 61a4fa644b0..a7493194847 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -159,7 +159,7 @@ class NumberDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s`, `ms` + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs` """ ENERGY = "energy" @@ -462,6 +462,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MINUTES, UnitOfTime.SECONDS, UnitOfTime.MILLISECONDS, + UnitOfTime.MICROSECONDS, }, NumberDeviceClass.ENERGY: set(UnitOfEnergy), NumberDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8eccb758756..774f2a9cff2 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -186,7 +186,7 @@ class SensorDeviceClass(StrEnum): DURATION = "duration" """Fixed duration. - Unit of measurement: `d`, `h`, `min`, `s`, `ms` + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs` """ ENERGY = "energy" @@ -558,6 +558,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.MINUTES, UnitOfTime.SECONDS, UnitOfTime.MILLISECONDS, + UnitOfTime.MICROSECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), SensorDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance), From ad126a745a59dbc124e61a2585196339ee2157ba Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 15:58:16 +0100 Subject: [PATCH 1582/1941] Fix sentence-casing in `hive` integration (#140382) Use sentence-casing for all strings following the HA standard. Capitalize "Internet" as a name. --- homeassistant/components/hive/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 219776ad7e6..064ced42d54 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -2,27 +2,27 @@ "config": { "step": { "user": { - "title": "Hive Login", + "title": "Hive login", "description": "Enter your Hive login information.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "scan_interval": "Scan Interval (seconds)" + "scan_interval": "Scan interval (seconds)" } }, "2fa": { - "title": "Hive Two-factor Authentication.", - "description": "Enter your Hive authentication code. \n \n Please enter code 0000 to request another code.", + "title": "Hive two-factor authentication.", + "description": "Enter your Hive authentication code.\n\nPlease enter code 0000 to request another code.", "data": { "2fa": "Two-factor code" } }, "configuration": { "data": { - "device_name": "Device Name" + "device_name": "Device name" }, "description": "Enter your Hive configuration", - "title": "Hive Configuration." + "title": "Hive configuration." }, "reauth": { "title": "[%key:component::hive::config::step::user::title%]", @@ -37,7 +37,7 @@ "invalid_username": "Failed to sign into Hive. Your email address is not recognised.", "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.", "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.", - "no_internet_available": "An internet connection is required to connect to Hive.", + "no_internet_available": "An Internet connection is required to connect to Hive.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { From 95afebceb49f85c80d8e7356373b30b59908e348 Mon Sep 17 00:00:00 2001 From: Lincoln Kirchoff Date: Tue, 11 Mar 2025 10:27:47 -0500 Subject: [PATCH 1583/1941] Add modbus climate hvac action (#139864) * Added the hvac action attribute for modbus climate entities. * Fixed issue in hvac action unit test, was incorrectly referencing the hvac mode attribute. * Fixed the modbus climate test for hvac action, it now correctly checks that hvac actions in the config match HVACActions. * Made changes recommended by @crug80 to remove dead code and to add ability to use input or holding register for hvac action. * Moved action test case in test_climate.py * Updated comment for `test_service_climate_action_update` * Fixed ruff formatting error. * Addressed request to update labels from `state_*` to `action_*` --- homeassistant/components/modbus/__init__.py | 49 +++++++ homeassistant/components/modbus/climate.py | 55 +++++++- homeassistant/components/modbus/const.py | 10 ++ tests/components/modbus/test_climate.py | 138 ++++++++++++++++++++ 4 files changed, 251 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 61df7206402..52642cc32e3 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -79,6 +79,16 @@ from .const import ( CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, CONF_FANS, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -297,6 +307,45 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_HVAC_ACTION_REGISTER): vol.Maybe( + { + CONF_ADDRESS: cv.positive_int, + CONF_HVAC_ACTION_VALUES: { + vol.Optional(CONF_HVAC_ACTION_COOLING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_DEFROSTING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_DRYING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_FAN): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_HEATING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_IDLE): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_OFF): vol.Any( + cv.positive_int, [cv.positive_int] + ), + vol.Optional(CONF_HVAC_ACTION_PREHEATING): vol.Any( + cv.positive_int, [cv.positive_int] + ), + }, + vol.Optional( + CONF_INPUT_TYPE, default=CALL_TYPE_REGISTER_HOLDING + ): vol.In( + [ + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_REGISTER_INPUT, + ] + ), + } + ), vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( vol.All( { diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index fca1b94611a..be10a9495c6 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -24,6 +24,7 @@ from homeassistant.components.climate import ( SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.const import ( @@ -61,6 +62,16 @@ from .const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -74,6 +85,7 @@ from .const import ( CONF_HVAC_ON_VALUE, CONF_HVAC_ONOFF_COIL, CONF_HVAC_ONOFF_REGISTER, + CONF_INPUT_TYPE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_STEP, @@ -188,6 +200,34 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_HVAC_ACTION_REGISTER in config: + action_config = config[CONF_HVAC_ACTION_REGISTER] + self._hvac_action_register = action_config[CONF_ADDRESS] + self._hvac_action_type = action_config[CONF_INPUT_TYPE] + + self._attr_hvac_action = None + self._hvac_action_mapping: list[tuple[int, HVACAction]] = [] + action_value_config = action_config[CONF_HVAC_ACTION_VALUES] + + for hvac_action_kw, hvac_action in ( + (CONF_HVAC_ACTION_COOLING, HVACAction.COOLING), + (CONF_HVAC_ACTION_DEFROSTING, HVACAction.DEFROSTING), + (CONF_HVAC_ACTION_DRYING, HVACAction.DRYING), + (CONF_HVAC_ACTION_FAN, HVACAction.FAN), + (CONF_HVAC_ACTION_HEATING, HVACAction.HEATING), + (CONF_HVAC_ACTION_IDLE, HVACAction.IDLE), + (CONF_HVAC_ACTION_OFF, HVACAction.OFF), + (CONF_HVAC_ACTION_PREHEATING, HVACAction.PREHEATING), + ): + if hvac_action_kw in action_value_config: + values = action_value_config[hvac_action_kw] + if not isinstance(values, list): + values = [values] + for value in values: + self._hvac_action_mapping.append((value, hvac_action)) + else: + self._hvac_action_register = None + if CONF_FAN_MODE_REGISTER in config: self._attr_supported_features = ( self._attr_supported_features | ClimateEntityFeature.FAN_MODE @@ -216,7 +256,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._fan_mode_mapping_from_modbus[value] = fan_mode self._fan_mode_mapping_to_modbus[fan_mode] = value self._attr_fan_modes.append(fan_mode) - else: # No FAN modes defined self._fan_mode_register = None @@ -457,6 +496,20 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = mode break + # Read the HVAC action register if defined + if self._hvac_action_register is not None: + hvac_action = await self._async_read_register( + self._hvac_action_type, self._hvac_action_register, raw=True + ) + + # Translate the value received + if hvac_action is not None: + self._attr_hvac_action = None + for value, action in self._hvac_action_mapping: + if hvac_action == value: + self._attr_hvac_action = action + break + # Read the Fan mode register if defined if self._fan_mode_register is not None: fan_mode = await self._async_read_register( diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 5926569040d..634637a6b08 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -63,6 +63,16 @@ CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_ON_VALUE = "hvac_on_value" CONF_HVAC_OFF_VALUE = "hvac_off_value" CONF_HVAC_ONOFF_COIL = "hvac_onoff_coil" +CONF_HVAC_ACTION_REGISTER = "hvac_action_register" +CONF_HVAC_ACTION_COOLING = "action_cooling" +CONF_HVAC_ACTION_DEFROSTING = "action_defrosting" +CONF_HVAC_ACTION_DRYING = "action_drying" +CONF_HVAC_ACTION_FAN = "action_fan" +CONF_HVAC_ACTION_HEATING = "action_heating" +CONF_HVAC_ACTION_IDLE = "action_idle" +CONF_HVAC_ACTION_OFF = "action_off" +CONF_HVAC_ACTION_PREHEATING = "action_preheating" +CONF_HVAC_ACTION_VALUES = "values" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" CONF_HVAC_MODE_COOL = "state_cool" diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 3c30efe9dce..54d4c5f6666 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_FAN_MODES, + ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, ATTR_SWING_MODE, @@ -31,6 +32,7 @@ from homeassistant.components.climate import ( SWING_OFF, SWING_ON, SWING_VERTICAL, + HVACAction, HVACMode, ) from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY @@ -47,6 +49,16 @@ from homeassistant.components.modbus.const import ( CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_TOP, CONF_FAN_MODE_VALUES, + CONF_HVAC_ACTION_COOLING, + CONF_HVAC_ACTION_DEFROSTING, + CONF_HVAC_ACTION_DRYING, + CONF_HVAC_ACTION_FAN, + CONF_HVAC_ACTION_HEATING, + CONF_HVAC_ACTION_IDLE, + CONF_HVAC_ACTION_OFF, + CONF_HVAC_ACTION_PREHEATING, + CONF_HVAC_ACTION_REGISTER, + CONF_HVAC_ACTION_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -224,6 +236,43 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_HVAC_ONOFF_REGISTER: 12, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_WRITE_REGISTERS: True, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_OFF: 0, + CONF_HVAC_MODE_HEAT: 1, + CONF_HVAC_MODE_COOL: 2, + CONF_HVAC_MODE_HEAT_COOL: 3, + CONF_HVAC_MODE_DRY: 4, + CONF_HVAC_MODE_FAN_ONLY: 5, + CONF_HVAC_MODE_AUTO: 6, + }, + }, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 14, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_COOLING: 0, + CONF_HVAC_ACTION_DEFROSTING: 1, + CONF_HVAC_ACTION_DRYING: 2, + CONF_HVAC_ACTION_FAN: 3, + CONF_HVAC_ACTION_HEATING: 4, + CONF_HVAC_ACTION_IDLE: 5, + CONF_HVAC_ACTION_OFF: 6, + CONF_HVAC_ACTION_PREHEATING: 7, + }, + }, + } + ], + }, ], ) async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: @@ -745,6 +794,95 @@ async def test_hvac_onoff_coil_update( assert state.state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_IDLE: 0, + CONF_HVAC_ACTION_HEATING: 1, + }, + }, + }, + ] + }, + HVACAction.HEATING, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_COOLING: 0, + CONF_HVAC_ACTION_HEATING: 1, + }, + }, + }, + ] + }, + HVACAction.COOLING, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 116, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_HVAC_ACTION_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_ACTION_VALUES: { + CONF_HVAC_ACTION_OFF: 0, + CONF_HVAC_ACTION_DRYING: 1, + }, + }, + }, + ] + }, + HVACAction.DRYING, + [0x01], + ), + ], +) +async def test_service_climate_action_update( + hass: HomeAssistant, mock_modbus_ha, result, register_words +) -> None: + """Test HVAC action updates.""" + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_HVAC_ACTION] == result + + @pytest.mark.parametrize( ("do_config", "result", "register_words"), [ From 7b7483b254789ff5defe973250d3b17294b9212f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 16:44:52 +0100 Subject: [PATCH 1584/1941] Fix wrong punctuation in `hive` integration (#140390) --- homeassistant/components/hive/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 064ced42d54..6323a2eecbf 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -11,18 +11,18 @@ } }, "2fa": { - "title": "Hive two-factor authentication.", + "title": "Hive two-factor authentication", "description": "Enter your Hive authentication code.\n\nPlease enter code 0000 to request another code.", "data": { "2fa": "Two-factor code" } }, "configuration": { + "title": "Hive configuration", + "description": "Enter your Hive configuration.", "data": { "device_name": "Device name" - }, - "description": "Enter your Hive configuration", - "title": "Hive configuration." + } }, "reauth": { "title": "[%key:component::hive::config::step::user::title%]", From 36cbd28d9d4e6e6df15882bca2e732cac0e0e929 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 11 Mar 2025 17:41:19 +0100 Subject: [PATCH 1585/1941] Add platinum quality scale to incomfort integration (#136387) * Add platinum quality scale to incomfort integration * Add platinum quality scale to incomfort integration * Exempt actions attributes * Comment on known limitations --- .../components/incomfort/manifest.json | 1 + .../components/incomfort/quality_scale.yaml | 77 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/incomfort/quality_scale.yaml diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index d02b1d27554..825f198dd30 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -10,5 +10,6 @@ "documentation": "https://www.home-assistant.io/integrations/incomfort", "iot_class": "local_polling", "loggers": ["incomfortclient"], + "quality_scale": "platinum", "requirements": ["incomfort-client==0.6.7"] } diff --git a/homeassistant/components/incomfort/quality_scale.yaml b/homeassistant/components/incomfort/quality_scale.yaml new file mode 100644 index 00000000000..f5af3c9d061 --- /dev/null +++ b/homeassistant/components/incomfort/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No actions implemented. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No actions implemented. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: | + Entities are set up dand updated through the datacoordimator. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: done + reauthentication-flow: done + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: done + + # Gold + entity-translations: done + entity-device-class: done + devices: done + entity-category: done + entity-disabled-by-default: done + discovery: done + stale-devices: + status: exempt + comment: > + There is a maximum of 3 heaters that can be discovered by the gateway. + The user must remove manually any heeater devices that have been replaced. + diagnostics: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + dynamic-devices: done + discovery-update-info: done + repair-issues: + status: exempt + comment: | + No current issues to repair. + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: + status: done + comment: There are no known limmitations, + docs-troubleshooting: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 65e9d4ed9cc..e1898afc79b 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -515,7 +515,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "ihc", "imgw_pib", "improv_ble", - "incomfort", "influxdb", "inkbird", "insteon", @@ -1579,7 +1578,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "imap", "imgw_pib", "improv_ble", - "incomfort", "influxdb", "inkbird", "insteon", From 0ba571160391591d6851f50b08769594edae05d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Mar 2025 12:54:39 -0400 Subject: [PATCH 1586/1941] Add TTS token to TTS-END event (#140333) --- .../components/assist_pipeline/pipeline.py | 2 ++ homeassistant/components/tts/__init__.py | 6 +++++ .../assist_pipeline/snapshots/test_init.ambr | 10 ++++++++ .../snapshots/test_websocket.ambr | 21 ++++++++++++++++ tests/components/tts/common.py | 25 +++++++++++++++++++ tests/components/tts/test_init.py | 17 +++++++++++++ 6 files changed, 81 insertions(+) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a028fa638df..42bb2d4ced8 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -649,6 +649,7 @@ class PipelineRun: data["runner_data"] = self.runner_data if self.tts_stream: data["tts_output"] = { + "token": self.tts_stream.token, "url": self.tts_stream.url, "mime_type": self.tts_stream.content_type, } @@ -1295,6 +1296,7 @@ class PipelineRun: tts_output = { "media_id": tts_media_id, + "token": self.tts_stream.token, "url": self.tts_stream.url, "mime_type": self.tts_stream.content_type, } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 31a92c62258..6fc25e32091 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -182,6 +182,12 @@ def async_create_stream( ) +@callback +def async_get_stream(hass: HomeAssistant, token: str) -> ResultStream | None: + """Return a result stream given a token.""" + return hass.data[DATA_TTS_MANAGER].token_to_stream.get(token) + + async def async_get_media_source_audio( hass: HomeAssistant, media_source_id: str, diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 2375d48fcf9..f772f877d3a 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -8,6 +8,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -85,6 +86,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -105,6 +107,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -182,6 +185,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -202,6 +206,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -279,6 +284,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -299,6 +305,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -400,6 +407,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }), @@ -420,6 +428,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }), @@ -620,6 +629,7 @@ 'pipeline': , 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index d937b5396d1..57ae0095236 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -10,6 +10,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -81,6 +82,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -99,6 +101,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -170,6 +173,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -200,6 +204,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -271,6 +276,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -289,6 +295,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -382,6 +389,7 @@ 'tts_output': dict({ 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -400,6 +408,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'test_token.mp3', 'url': '/api/tts_proxy/test_token.mp3', }), }) @@ -607,6 +616,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -660,6 +670,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -675,6 +686,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -690,6 +702,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -705,6 +718,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -720,6 +734,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -853,6 +868,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -868,6 +884,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -924,6 +941,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -939,6 +957,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -998,6 +1017,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) @@ -1013,6 +1033,7 @@ }), 'tts_output': dict({ 'mime_type': 'audio/mpeg', + 'token': 'mocked-token.mp3', 'url': '/api/tts_proxy/mocked-token.mp3', }), }) diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 921cab4cba2..9ae83cb2bb5 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -14,9 +14,11 @@ import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.tts import ( CONF_LANG, + DATA_TTS_MANAGER, DOMAIN as TTS_DOMAIN, PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, + ResultStream, TextToSpeechEntity, TtsAudioType, Voice, @@ -263,3 +265,26 @@ async def mock_config_entry_setup( await hass.async_block_till_done() return config_entry + + +class MockResultStream(ResultStream): + """Mock result stream.""" + + def __init__(self, hass: HomeAssistant, extension: str, data: bytes) -> None: + """Initialize the result stream.""" + super().__init__( + token="test-token", + extension=extension, + content_type=f"audio/mock-{extension}", + engine="test-engine", + use_file_cache=True, + language="en", + options={}, + _manager=hass.data[DATA_TTS_MANAGER], + ) + hass.data[DATA_TTS_MANAGER].token_to_stream[self.token] = self + self._mock_data = data + + async def async_stream_result(self): + """Stream the result.""" + yield self._mock_data diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 1b9692cc70c..8bdd17cf3e9 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -28,6 +28,7 @@ from homeassistant.util import dt as dt_util from .common import ( DEFAULT_LANG, TEST_DOMAIN, + MockResultStream, MockTTS, MockTTSEntity, MockTTSProvider, @@ -1829,3 +1830,19 @@ async def test_default_engine_prefer_cloud_entity( provider_engine = tts.async_resolve_engine(hass, "test") assert provider_engine == "test" assert tts.async_default_engine(hass) == "tts.cloud_tts_entity" + + +async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> None: + """Test creating streams.""" + await mock_config_entry_setup(hass, mock_tts_entity) + stream = tts.async_create_stream(hass, mock_tts_entity.entity_id) + assert stream.language == mock_tts_entity.default_language + assert stream.options == (mock_tts_entity.default_options or {}) + assert tts.async_get_stream(hass, stream.token) is stream + + data = b"beer" + stream2 = MockResultStream(hass, "wav", data) + assert tts.async_get_stream(hass, stream2.token) is stream2 + assert stream2.extension == "wav" + result_data = b"".join([chunk async for chunk in stream2.async_stream_result()]) + assert result_data == data From a13911e00ecf492fe1ca8ddec3602a7fee6a6cef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 11 Mar 2025 18:00:51 +0100 Subject: [PATCH 1587/1941] Rename test helpers module in mqtt (#140375) * Rename test helpers module in mqtt * missed a file --- tests/components/mqtt/{test_common.py => common.py} | 0 tests/components/mqtt/test_alarm_control_panel.py | 2 +- tests/components/mqtt/test_binary_sensor.py | 2 +- tests/components/mqtt/test_button.py | 2 +- tests/components/mqtt/test_camera.py | 2 +- tests/components/mqtt/test_client.py | 2 +- tests/components/mqtt/test_climate.py | 2 +- tests/components/mqtt/test_cover.py | 2 +- tests/components/mqtt/test_device_tracker.py | 2 +- tests/components/mqtt/test_device_trigger.py | 2 +- tests/components/mqtt/test_discovery.py | 2 +- tests/components/mqtt/test_event.py | 2 +- tests/components/mqtt/test_fan.py | 2 +- tests/components/mqtt/test_humidifier.py | 2 +- tests/components/mqtt/test_image.py | 2 +- tests/components/mqtt/test_lawn_mower.py | 2 +- tests/components/mqtt/test_light.py | 2 +- tests/components/mqtt/test_light_json.py | 2 +- tests/components/mqtt/test_light_template.py | 2 +- tests/components/mqtt/test_lock.py | 2 +- tests/components/mqtt/test_notify.py | 2 +- tests/components/mqtt/test_number.py | 2 +- tests/components/mqtt/test_scene.py | 2 +- tests/components/mqtt/test_select.py | 2 +- tests/components/mqtt/test_sensor.py | 2 +- tests/components/mqtt/test_siren.py | 2 +- tests/components/mqtt/test_switch.py | 2 +- tests/components/mqtt/test_tag.py | 2 +- tests/components/mqtt/test_text.py | 2 +- tests/components/mqtt/test_update.py | 2 +- tests/components/mqtt/test_vacuum.py | 2 +- tests/components/mqtt/test_valve.py | 2 +- tests/components/mqtt/test_water_heater.py | 2 +- 33 files changed, 32 insertions(+), 32 deletions(-) rename tests/components/mqtt/{test_common.py => common.py} (100%) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/common.py similarity index 100% rename from tests/components/mqtt/test_common.py rename to tests/components/mqtt/common.py diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index b46829650f6..9241106496b 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 8809f2201f2..169e1ab4c6b 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index f147b33c88b..f99c48a440f 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -10,7 +10,7 @@ from homeassistant.components import button, mqtt from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index cda536dc19e..b5971adcb92 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -11,7 +11,7 @@ from homeassistant.components import camera, mqtt from homeassistant.components.mqtt.camera import MQTT_CAMERA_ATTRIBUTES_BLOCKED from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 0dbbff58026..c2cce3d1344 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -27,8 +27,8 @@ from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.util.dt import utcnow +from .common import help_all_subscribe_calls from .conftest import ENTRY_DEFAULT_BIRTH_MESSAGE -from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 3760b0226f5..5279dfe93f7 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -37,7 +37,7 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, UnitOfTemperatu from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index ee74b78be81..1e45853026a 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 00e88860299..02289c8e476 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -12,7 +12,7 @@ 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 .test_common import ( +from .common import ( help_custom_config, help_test_reloadable, help_test_setting_blocked_attribute_via_mqtt_json_message, diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 5cdfb14a5cf..ecf922e54a1 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component -from .test_common import help_test_unload_config_entry +from .common import help_test_unload_config_entry from tests.common import async_fire_mqtt_message, async_get_device_automations from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 47c3a1e1988..ee33cbcbaa1 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -46,8 +46,8 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util.signal_type import SignalTypeFormat +from .common import help_all_subscribe_calls, help_test_unload_config_entry from .conftest import ENTRY_DEFAULT_BIRTH_MESSAGE -from .test_common import help_all_subscribe_calls, help_test_unload_config_entry from .test_tag import DEFAULT_TAG_ID, DEFAULT_TAG_SCAN from tests.common import ( diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index 41049ed0887..a7f00a1d1a8 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 6c8afe8c1b4..36b5032e282 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 20ca89181eb..435531182ed 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -36,7 +36,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index 6f0eb8edf49..9b64a8836a0 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -14,7 +14,7 @@ from homeassistant.components import image, mqtt from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_lawn_mower.py b/tests/components/mqtt/test_lawn_mower.py index 0bef4196ef2..c58402c4f5c 100644 --- a/tests/components/mqtt/test_lawn_mower.py +++ b/tests/components/mqtt/test_lawn_mower.py @@ -19,7 +19,7 @@ from homeassistant.components.mqtt.lawn_mower import MQTT_LAWN_MOWER_ATTRIBUTES_ from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index f8c66a3de1d..a8be259c1c9 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -210,7 +210,7 @@ from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 67d382826ae..f3264858095 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -102,7 +102,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.util.json import json_loads -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 568d86f8bd9..b3a1c11c2b6 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -45,7 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 034f9b5ff6e..4aa6ecd03ef 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_notify.py b/tests/components/mqtt/test_notify.py index 4837ee214c4..56da809d1b6 100644 --- a/tests/components/mqtt/test_notify.py +++ b/tests/components/mqtt/test_notify.py @@ -11,7 +11,7 @@ from homeassistant.components.notify import ATTR_MESSAGE from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 7bdd39e81a7..f391236aca4 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -31,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index d78dbe5c003..1650fe74601 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -10,7 +10,7 @@ from homeassistant.components import mqtt, scene from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 8d79a3ce609..a880368fa51 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOW from homeassistant.core import HomeAssistant, State from homeassistant.helpers.typing import ConfigType -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 1fcd70a0b10..74dc94de21e 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -25,7 +25,7 @@ from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 58a5cb735f9..5d82708e242 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index dceeff07377..d834595afe0 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 41c417fe3e9..95326382dcc 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .test_common import help_test_unload_config_entry +from .common import help_test_unload_config_entry from tests.common import ( MockConfigEntry, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 96924030279..050b2b59590 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -11,7 +11,7 @@ from homeassistant.components import mqtt, text from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 4ca10cbe8b2..d70d7dd792b 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -10,7 +10,7 @@ from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INS from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index c1c662048d7..ba404e2dff0 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -33,7 +33,7 @@ from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py index 6dd0102b8a3..10387a5b19e 100644 --- a/tests/components/mqtt/test_valve.py +++ b/tests/components/mqtt/test_valve.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index 02ae54c1a85..bd688af6f21 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -34,7 +34,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.util.unit_conversion import TemperatureConverter -from .test_common import ( +from .common import ( help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, From d309239bcc46614b0d08b38f7d5250256b166b3b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 11 Mar 2025 18:18:34 +0100 Subject: [PATCH 1588/1941] Fix typo in Google Generative AI conversation: intead -> instead (#140398) --- .../components/google_generative_ai_conversation/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 772fadb089c..7bf1831a34b 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -70,7 +70,7 @@ "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." + "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' instead." } } } From d8bcba9ef0a31383537c87d47ea8d58d12b2e18f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Tue, 11 Mar 2025 13:00:43 -0500 Subject: [PATCH 1589/1941] Enable HEOS automatic failover (#140394) Failover --- homeassistant/components/heos/coordinator.py | 18 +++++++++++++++--- tests/components/heos/__init__.py | 4 ++++ tests/components/heos/test_init.py | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 93fe069d9be..0333c60ec21 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -43,7 +43,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]): def __init__(self, hass: HomeAssistant, config_entry: HeosConfigEntry) -> None: """Set up the coordinator and set in config_entry.""" - self.host: str = config_entry.data[CONF_HOST] credentials: Credentials | None = None if config_entry.options: credentials = Credentials( @@ -53,9 +52,10 @@ class HeosCoordinator(DataUpdateCoordinator[None]): # media position update upon start of playback or when media changes self.heos = Heos( HeosOptions( - self.host, + config_entry.data[CONF_HOST], all_progress_events=False, auto_reconnect=True, + auto_failover=True, credentials=credentials, ) ) @@ -66,6 +66,11 @@ class HeosCoordinator(DataUpdateCoordinator[None]): self._inputs: Sequence[MediaItem] = [] super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN) + @property + def host(self) -> str: + """Get the host address of the device.""" + return self.heos.current_host + @property def inputs(self) -> Sequence[MediaItem]: """Get input sources across all devices.""" @@ -159,8 +164,15 @@ class HeosCoordinator(DataUpdateCoordinator[None]): async def _async_on_reconnected(self) -> None: """Handle when reconnected so resources are updated and entities marked available.""" + assert self.config_entry is not None + if self.host != self.config_entry.data[CONF_HOST]: + self.hass.config_entries.async_update_entry( + self.config_entry, data={CONF_HOST: self.host} + ) + _LOGGER.warning("Successfully failed over to HEOS host %s", self.host) + else: + _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) await self._async_update_sources() - _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host) self.async_update_listeners() async def _async_on_controller_event( diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 016cc7b3580..862b1e5ffab 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -64,3 +64,7 @@ class MockHeos(Heos): def mock_set_connection_state(self, connection_state: ConnectionState) -> None: """Set the connection state on the mock instance.""" self._connection._state = connection_state + + def mock_set_current_host(self, host: str) -> None: + """Set the current host on the mock instance.""" + self._connection._host = host diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index b155abaf0e9..7bc232ad5a6 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -297,6 +297,25 @@ async def test_reconnected_new_entities_created( assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3") +async def test_reconnected_failover_updates_host( + hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos +) -> None: + """Test the config entry host is updated after failover.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + # Simulate reconnection + controller.mock_set_current_host("127.0.0.2") + await controller.dispatcher.wait_send( + SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED + ) + await hass.async_block_till_done() + + # Assert config entry host updated + assert config_entry.data[CONF_HOST] == "127.0.0.2" + + async def test_players_changed_new_entities_created( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 0b41d056d3a68bfdf7beb9ae715c2706b57ea903 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 11 Mar 2025 20:05:02 +0100 Subject: [PATCH 1590/1941] Only do WebDAV path migration when path differs (#140402) --- homeassistant/components/webdav/helpers.py | 3 ++- tests/components/webdav/test_init.py | 24 ++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py index 5db15bba0f7..442f69b4d3c 100644 --- a/homeassistant/components/webdav/helpers.py +++ b/homeassistant/components/webdav/helpers.py @@ -49,7 +49,8 @@ async def async_ensure_path_exists(client: Client, path: str) -> bool: async def async_migrate_wrong_folder_path(client: Client, path: str) -> None: """Migrate the wrong encoded folder path to the correct one.""" wrong_path = path.replace(" ", "%20") - if await client.check(wrong_path): + # migrate folder when the old folder exists + if wrong_path != path and await client.check(wrong_path): try: await client.move(wrong_path, path) except WebDavError as err: diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py index c267f7c3251..124a644fa93 100644 --- a/tests/components/webdav/test_init.py +++ b/tests/components/webdav/test_init.py @@ -39,14 +39,30 @@ async def test_migrate_wrong_path( webdav_client.move.assert_called_once_with("/wrong%20path", "/wrong path") +@pytest.mark.parametrize( + ("expected_path", "remote_path_check"), + [ + ( + "/correct path", + False, + ), # remote_path_check is False as /correct%20path is not there + ("/", True), + ("/folder_with_underscores", True), + ], +) async def test_migrate_non_wrong_path( - hass: HomeAssistant, webdav_client: AsyncMock + hass: HomeAssistant, + webdav_client: AsyncMock, + expected_path: str, + remote_path_check: bool, ) -> None: """Test no migration of correct folder path.""" webdav_client.list_with_properties.return_value = [ - {"/correct path": []}, + {expected_path: []}, ] - webdav_client.check.side_effect = lambda path: path == "/correct path" + # first return is used to check the connectivity + # second is used in the migration to determine if wrong quoted path is there + webdav_client.check.side_effect = [True, remote_path_check] config_entry = MockConfigEntry( title="user@webdav.demo", @@ -55,7 +71,7 @@ async def test_migrate_non_wrong_path( CONF_URL: "https://webdav.demo", CONF_USERNAME: "user", CONF_PASSWORD: "supersecretpassword", - CONF_BACKUP_PATH: "/correct path", + CONF_BACKUP_PATH: expected_path, }, entry_id="01JKXV07ASC62D620DGYNG2R8H", ) From f50325fc7df7f37cde1159643b6fb2d9827f4647 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 15:21:28 -0400 Subject: [PATCH 1591/1941] Add dock dryer control to Roborock (#138495) * Add a dock dryer select * change import * Change name to match app --- homeassistant/components/roborock/select.py | 47 ++++++++++++------- .../components/roborock/strings.json | 9 ++++ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 6133eed0652..b76c90b44f5 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -4,9 +4,9 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from roborock.containers import Status +from roborock.code_mappings import RoborockDockDustCollectionModeCode from roborock.roborock_message import RoborockDataProtocol -from roborock.roborock_typing import RoborockCommand +from roborock.roborock_typing import DeviceProp, RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -25,11 +25,11 @@ class RoborockSelectDescription(SelectEntityDescription): # The command that the select entity will send to the api. api_command: RoborockCommand # Gets the current value of the select entity. - value_fn: Callable[[Status], str | None] + value_fn: Callable[[DeviceProp], str | None] # Gets all options of the select entity. - options_lambda: Callable[[Status], list[str] | None] + options_lambda: Callable[[DeviceProp], list[str] | None] # Takes the value from the select entity and converts it for the api. - parameter_lambda: Callable[[str, Status], list[int]] + parameter_lambda: Callable[[str, DeviceProp], list[int]] protocol_listener: RoborockDataProtocol | None = None @@ -39,24 +39,37 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ key="water_box_mode", translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda data: data.water_box_mode_name, + value_fn=lambda data: data.status.water_box_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.water_box_mode.keys() - if data.water_box_mode is not None + options_lambda=lambda data: data.status.water_box_mode.keys() + if data.status.water_box_mode is not None else None, - parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)], + parameter_lambda=lambda key, prop: [prop.status.get_mop_intensity_code(key)], protocol_listener=RoborockDataProtocol.WATER_BOX_MODE, ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda data: data.mop_mode_name, + value_fn=lambda data: data.status.mop_mode_name, entity_category=EntityCategory.CONFIG, - options_lambda=lambda data: data.mop_mode.keys() - if data.mop_mode is not None + options_lambda=lambda data: data.status.mop_mode.keys() + if data.status.mop_mode is not None else None, - parameter_lambda=lambda key, status: [status.get_mop_mode_code(key)], + parameter_lambda=lambda key, prop: [prop.status.get_mop_mode_code(key)], + ), + RoborockSelectDescription( + key="dust_collection_mode", + translation_key="dust_collection_mode", + api_command=RoborockCommand.SET_DUST_COLLECTION_MODE, + value_fn=lambda data: data.dust_collection_mode_name, + entity_category=EntityCategory.CONFIG, + options_lambda=lambda data: RoborockDockDustCollectionModeCode.keys() + if data.dust_collection_mode_name is not None + else None, + parameter_lambda=lambda key, _: [ + RoborockDockDustCollectionModeCode.as_dict().get(key) + ], ), ] @@ -74,7 +87,7 @@ async def async_setup_entry( for description in SELECT_DESCRIPTIONS if ( options := description.options_lambda( - coordinator.roborock_device_info.props.status + coordinator.roborock_device_info.props ) ) is not None @@ -111,13 +124,13 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """Set the option.""" await self.send( self.entity_description.api_command, - self.entity_description.parameter_lambda(option, self._device_status), + self.entity_description.parameter_lambda(option, self.coordinator.data), ) @property def current_option(self) -> str | None: - """Get the current status of the select entity from device_status.""" - return self.entity_description.value_fn(self._device_status) + """Get the current status of the select entity from device props.""" + return self.entity_description.value_fn(self.coordinator.data) class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index eb058ea74e3..efb17ef407e 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -353,6 +353,15 @@ }, "selected_map": { "name": "Selected map" + }, + "dust_collection_mode": { + "name": "Empty mode", + "state": { + "smart": "Smart", + "light": "Light", + "balanced": "[%key:component::roborock::entity::vacuum::roborock::state_attributes::fan_speed::state::balanced%]", + "max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]" + } } }, "switch": { From 6fb6f9298543aecaa5f954b67feb4f1304380c04 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 11 Mar 2025 20:23:41 +0100 Subject: [PATCH 1592/1941] Improve descriptions of `lifx.effect_sky` action (#140400) * Improve descriptions of `lifx.effect_sky` action The 'Sky Effect' action of the LIFX integration allows three types of sky types to choose from: - "Clouds" - "Sunrise" - "Sunset" This commit fixes the wrong naming of the "Clouds" effect as "Cloud" and adds details about it to the descriptions of the `cloud_saturation_min`and `cloud_saturation_max` fields (from the online docs). In addition the inconsistent capitalization of their `name` strings is fixed, too. * Improve action description as well --- homeassistant/components/lifx/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index c407489d52d..97cd007ef22 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -223,23 +223,23 @@ }, "effect_sky": { "name": "Sky effect", - "description": "Starts the firmware-based Sky effect on LIFX Ceiling.", + "description": "Starts a firmware-based effect on LIFX Ceiling lights that animates a sky scene across the device.", "fields": { "speed": { "name": "Speed", - "description": "How long the Sunrise and Sunset sky types will take to complete. For the Cloud sky type, it is the speed of the clouds across the device." + "description": "How long the Sunrise and Sunset sky types will take to complete. For the Clouds sky type, it is the speed of the clouds across the device." }, "sky_type": { "name": "Sky type", "description": "The style of sky that will be animated by the effect." }, "cloud_saturation_min": { - "name": "Cloud saturation Minimum", - "description": "Minimum cloud saturation." + "name": "Cloud saturation minimum", + "description": "The minimum cloud saturation for the Clouds sky type." }, "cloud_saturation_max": { - "name": "Cloud Saturation maximum", - "description": "Maximum cloud saturation." + "name": "Cloud saturation maximum", + "description": "The maximum cloud saturation for the Clouds sky type." }, "palette": { "name": "Palette", From 7aeefa1400e956384b5da144ce270d5457d918bc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Mar 2025 15:28:13 -0400 Subject: [PATCH 1593/1941] Only store strings in cloud TTS default options (#140332) * Only store strings in cloud TTS default options * more type check * Don't stringify strenum --- homeassistant/components/cloud/tts.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 3ac3f3d1c2d..f901adfa99e 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -286,7 +286,7 @@ class CloudTTSEntity(TextToSpeechEntity): return self._language @property - def default_options(self) -> dict[str, Any]: + def default_options(self) -> dict[str, str]: """Return a dict include default options.""" return { ATTR_AUDIO_OUTPUT: AudioOutput.MP3, @@ -363,7 +363,7 @@ class CloudTTSEntity(TextToSpeechEntity): _LOGGER.error("Voice error: %s", err) return (None, None) - return (str(options[ATTR_AUDIO_OUTPUT].value), data) + return (options[ATTR_AUDIO_OUTPUT], data) class CloudProvider(Provider): @@ -404,7 +404,7 @@ class CloudProvider(Provider): return [Voice(voice, voice) for voice in voices] @property - def default_options(self) -> dict[str, Any]: + def default_options(self) -> dict[str, str]: """Return a dict include default options.""" return { ATTR_AUDIO_OUTPUT: AudioOutput.MP3, @@ -444,7 +444,7 @@ class CloudProvider(Provider): _LOGGER.error("Voice error: %s", err) return (None, None) - return (str(options[ATTR_AUDIO_OUTPUT].value), data) + return options[ATTR_AUDIO_OUTPUT], data @callback From b88d662677e6e1986a4494650a377916c78388b9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 11 Mar 2025 13:02:56 -0700 Subject: [PATCH 1594/1941] Add Roborock data_description for config flow and options flow (#140384) * Add Roborock data_description for config flow and options flow * Remove the drawables logging --- .../components/roborock/quality_scale.yaml | 4 +-- .../components/roborock/strings.json | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 845d77d0fbe..fa5e1f4ceeb 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -9,9 +9,7 @@ rules: separate cloud vs local intervals. brands: done common-modules: done - config-flow: - status: todo - comment: Not all fields have a data_description. + config-flow: done config-flow-test-coverage: done dependency-transparency: done docs-actions: diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index efb17ef407e..c115ec33851 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -5,12 +5,18 @@ "description": "Enter your Roborock email address.", "data": { "username": "[%key:common::config_flow::data::email%]" + }, + "data_description": { + "username": "The email address used to sign in to the Roborock app." } }, "code": { "description": "Type the verification code sent to your email", "data": { "code": "Verification code" + }, + "data_description": { + "code": "The verification code sent to your email." } }, "reauth_confirm": { @@ -54,6 +60,25 @@ "vacuum_position": "Vacuum position", "virtual_walls": "Virtual walls", "zones": "Zones" + }, + "data_description": { + "charger": "Show the charger on the map.", + "cleaned_area": "Show the area cleaned on the map.", + "goto_path": "Show the go-to path on the map.", + "ignored_obstacles": "Show ignored obstacles on the map.", + "ignored_obstacles_with_photo": "Show ignored obstacles with photos on the map.", + "mop_path": "Show the mop path on the map.", + "no_carpet_zones": "Show the no carpet zones on the map.", + "no_go_zones": "Show the no-go zones on the map.", + "no_mopping_zones": "Show the no-mop zones on the map.", + "obstacles": "Show obstacles on the map.", + "obstacles_with_photo": "Show obstacles with photos on the map.", + "path": "Show the path on the map.", + "predicted_path": "Show the predicted path on the map.", + "room_names": "Show room names on the map.", + "vacuum_position": "Show the vacuum position on the map.", + "virtual_walls": "Show virtual walls on the map.", + "zones": "Show zones on the map." } } } From 2f44e300138c5497d19ab128ace224f81eba53a7 Mon Sep 17 00:00:00 2001 From: Tiddly Widdly Date: Tue, 11 Mar 2025 16:39:31 -0400 Subject: [PATCH 1595/1941] Add lutron caseta model Caseta Shade SerenaEssentialsRollerShade (#139800) * Update cover.py Add support for new model roller shade SerenaEssentialsRollerShade, SYERX-B-X * update requirements modified: homeassistant/components/lutron_caseta/cover.py modified: homeassistant/components/lutron_caseta/manifest.json modified: requirements_all.txt modified: requirements_test_all.txt --------- Co-authored-by: J. Nick Koston --- homeassistant/components/lutron_caseta/cover.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 3727dbf17ba..e05fddb996f 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -108,6 +108,7 @@ PYLUTRON_TYPE_TO_CLASSES = { "QsWirelessHorizontalSheerBlind": LutronCasetaShade, "Shade": LutronCasetaShade, "PalladiomWireFreeShade": LutronCasetaShade, + "SerenaEssentialsRollerShade": LutronCasetaShade, } From e858e21a402889c5052cd4a24a2499125f3b1649 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Mar 2025 12:57:16 -1000 Subject: [PATCH 1596/1941] Add Bluetooth discovery support for InkBird ITH-11-B (#140423) Add support for InkBird ITH-11-B --- homeassistant/components/inkbird/manifest.json | 4 ++++ homeassistant/generated/bluetooth.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json index 6b570b27fe2..aaa9c4b3473 100644 --- a/homeassistant/components/inkbird/manifest.json +++ b/homeassistant/components/inkbird/manifest.json @@ -22,6 +22,10 @@ "local_name": "tps", "connectable": false }, + { + "local_name": "ITH-11-B", + "connectable": false + }, { "local_name": "ITH-13-B", "connectable": false diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index be75c675a91..1ff444ca25f 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -356,6 +356,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "domain": "inkbird", "local_name": "tps", }, + { + "connectable": False, + "domain": "inkbird", + "local_name": "ITH-11-B", + }, { "connectable": False, "domain": "inkbird", From 7b736908fa1a128c0b775e9fa264594e95bb401c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 12 Mar 2025 00:15:25 +0100 Subject: [PATCH 1597/1941] Fix typo in description of `lifx.effect_morph` action (#140416) --- homeassistant/components/lifx/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 97cd007ef22..be0485c6dff 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -201,7 +201,7 @@ }, "effect_morph": { "name": "Morph effect", - "description": "Starts the firmware-based Morph effect on LIFX Tiles on Candle.", + "description": "Starts the firmware-based Morph effect on LIFX Tiles or Candle.", "fields": { "speed": { "name": "Speed", From 7197b8ebffe901714b4e4d9d9908e523bcf5a6f7 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 11 Mar 2025 22:22:36 -0400 Subject: [PATCH 1598/1941] Set Roborock current map to config instead of select (#140429) Set current map to config instead of select --- homeassistant/components/roborock/select.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index b76c90b44f5..c22a4deed3b 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -136,7 +136,7 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """A class to let you set the selected map on Roborock vacuum.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_category = EntityCategory.CONFIG _attr_translation_key = "selected_map" async def async_select_option(self, option: str) -> None: From 25cfd6ceda30547b2b46034285d23846236efd8b Mon Sep 17 00:00:00 2001 From: Tobias Perschon Date: Wed, 12 Mar 2025 07:31:58 +0100 Subject: [PATCH 1599/1941] bump pydaikin to 2.14.1 (#140424) Signed-off-by: Tobias Perschon --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index f794d97a9ba..86fc804ec92 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.13.8"], + "requirements": ["pydaikin==2.14.1"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 10e305cc47e..6830c3880e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1882,7 +1882,7 @@ pycsspeechtts==1.0.8 # pycups==2.0.4 # homeassistant.components.daikin -pydaikin==2.13.8 +pydaikin==2.14.1 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2043684a80..29425a177c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1541,7 +1541,7 @@ pycountry==24.6.1 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.13.8 +pydaikin==2.14.1 # homeassistant.components.deako pydeako==0.6.0 From 593ae48aa2a75d9bfd89ab1845ff54d87ae2a95c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 12 Mar 2025 08:47:34 +0100 Subject: [PATCH 1600/1941] Migrate mqtt tests to use unit system (#140376) * Migrate mqtt tests to use unit system * Fix param list * Missed one --------- Co-authored-by: jbouwh --- tests/components/mqtt/test_climate.py | 52 +++++++++------------- tests/components/mqtt/test_water_heater.py | 48 ++++++++------------ 2 files changed, 38 insertions(+), 62 deletions(-) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 5279dfe93f7..fd0b95f2b13 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -33,9 +33,14 @@ from homeassistant.components.mqtt.climate import ( MQTT_CLIMATE_ATTRIBUTES_BLOCKED, VALUE_TEMPLATE_KEYS, ) -from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .common import ( help_custom_config, @@ -1823,7 +1828,7 @@ async def test_temperature_unit( @pytest.mark.parametrize( - ("hass_config", "temperature_unit", "initial", "min", "max", "current"), + ("hass_config", "units", "initial", "min", "max", "current"), [ ( help_custom_config( @@ -1836,7 +1841,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.CELSIUS, + METRIC_SYSTEM, DEFAULT_INITIAL_TEMPERATURE, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, @@ -1854,7 +1859,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.CELSIUS, + METRIC_SYSTEM, 20.5, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, @@ -1871,24 +1876,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.KELVIN, - 294, - 280, - 308, - 298, - ), - ( - help_custom_config( - climate.DOMAIN, - DEFAULT_CONFIG, - ( - { - "temperature_unit": "F", - "current_temperature_topic": "current_temperature", - }, - ), - ), - UnitOfTemperature.FAHRENHEIT, + US_CUSTOMARY_SYSTEM, 70, 45, 95, @@ -1899,25 +1887,25 @@ async def test_temperature_unit( async def test_alt_temperature_unit( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - temperature_unit: UnitOfTemperature, + units: UnitSystem, initial: float, min: float, max: float, current: float, ) -> None: """Test deriving the systems temperature unit.""" - with patch.object(hass.config.units, "temperature_unit", temperature_unit): - await mqtt_mock_entry() + hass.config.units = units + await mqtt_mock_entry() - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("temperature") == initial - assert state.attributes.get("min_temp") == min - assert state.attributes.get("max_temp") == max + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("temperature") == initial + assert state.attributes.get("min_temp") == min + assert state.attributes.get("max_temp") == max - async_fire_mqtt_message(hass, "current_temperature", "77") + async_fire_mqtt_message(hass, "current_temperature", "77") - state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get("current_temperature") == current + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get("current_temperature") == current async def test_setting_attribute_via_mqtt_json_message( diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index bd688af6f21..21969ad7788 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -33,6 +33,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.util.unit_conversion import TemperatureConverter +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .common import ( help_custom_config, @@ -714,7 +719,7 @@ async def test_temperature_unit( @pytest.mark.parametrize( - ("hass_config", "temperature_unit", "initial", "min_temp", "max_temp", "current"), + ("hass_config", "units", "initial", "min_temp", "max_temp", "current"), [ ( help_custom_config( @@ -727,7 +732,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.CELSIUS, + METRIC_SYSTEM, _DEFAULT_MIN_TEMP_CELSIUS, _DEFAULT_MIN_TEMP_CELSIUS, _DEFAULT_MAX_TEMP_CELSIUS, @@ -744,24 +749,7 @@ async def test_temperature_unit( }, ), ), - UnitOfTemperature.KELVIN, - 316, - 316, - 333, - 322, - ), - ( - help_custom_config( - water_heater.DOMAIN, - DEFAULT_CONFIG, - ( - { - "temperature_unit": "F", - "current_temperature_topic": "current_temperature", - }, - ), - ), - UnitOfTemperature.FAHRENHEIT, + US_CUSTOMARY_SYSTEM, DEFAULT_MIN_TEMP, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, @@ -772,25 +760,25 @@ async def test_temperature_unit( async def test_alt_temperature_unit( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - temperature_unit: UnitOfTemperature, + units: UnitSystem, initial: float, min_temp: float, max_temp: float, current: float, ) -> None: """Test deriving the systems temperature unit.""" - with patch.object(hass.config.units, "temperature_unit", temperature_unit): - await mqtt_mock_entry() + hass.config.units = units + await mqtt_mock_entry() - state = hass.states.get(ENTITY_WATER_HEATER) - assert state.attributes.get("temperature") == initial - assert state.attributes.get("min_temp") == min_temp - assert state.attributes.get("max_temp") == max_temp + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == initial + assert state.attributes.get("min_temp") == min_temp + assert state.attributes.get("max_temp") == max_temp - async_fire_mqtt_message(hass, "current_temperature", "120") + async_fire_mqtt_message(hass, "current_temperature", "120") - state = hass.states.get(ENTITY_WATER_HEATER) - assert state.attributes.get("current_temperature") == current + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("current_temperature") == current async def test_setting_attribute_via_mqtt_json_message( From 2f1ff5ab95b060d678c1ec0f027460f27c332a02 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Wed, 12 Mar 2025 00:52:28 -0700 Subject: [PATCH 1601/1941] TotalConnect refactor tests (#140240) * refactor button * refactor test_options_flow --- tests/components/totalconnect/test_button.py | 26 ++++++---- .../totalconnect/test_config_flow.py | 50 ++++++------------- 2 files changed, 30 insertions(+), 46 deletions(-) diff --git a/tests/components/totalconnect/test_button.py b/tests/components/totalconnect/test_button.py index 80de004be1d..87764e55186 100644 --- a/tests/components/totalconnect/test_button.py +++ b/tests/components/totalconnect/test_button.py @@ -11,12 +11,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import ( - RESPONSE_ZONE_BYPASS_FAILURE, - RESPONSE_ZONE_BYPASS_SUCCESS, - TOTALCONNECT_REQUEST, - setup_platform, -) +from .common import setup_platform from tests.common import snapshot_platform @@ -34,12 +29,23 @@ async def test_entity_registry( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -@pytest.mark.parametrize("entity_id", [ZONE_BYPASS_ID, PANEL_BYPASS_ID]) -async def test_bypass_button(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("entity_id", "tcc_request"), + [ + (ZONE_BYPASS_ID, "total_connect_client.zone.TotalConnectZone.bypass"), + ( + PANEL_BYPASS_ID, + "total_connect_client.location.TotalConnectLocation.zone_bypass_all", + ), + ], +) +async def test_bypass_button( + hass: HomeAssistant, entity_id: str, tcc_request: str +) -> None: """Test pushing a bypass button.""" - responses = [RESPONSE_ZONE_BYPASS_FAILURE, RESPONSE_ZONE_BYPASS_SUCCESS] + responses = [FailedToBypassZone, None] await setup_platform(hass, BUTTON) - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + with patch(tcc_request, side_effect=responses) as mock_request: # try to bypass, but fails with pytest.raises(FailedToBypassZone): await hass.services.async_call( diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index f5020394bce..b7ac42c84b5 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -28,6 +28,7 @@ from .common import ( TOTALCONNECT_REQUEST, TOTALCONNECT_REQUEST_TOKEN, USERNAME, + init_integration, ) from tests.common import MockConfigEntry @@ -219,42 +220,19 @@ async def test_no_locations(hass: HomeAssistant) -> None: async def test_options_flow(hass: HomeAssistant) -> None: """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG_DATA, - unique_id=USERNAME, + config_entry = await init_integration(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={AUTO_BYPASS: True, CODE_REQUIRED: False} ) - config_entry.add_to_hass(hass) - responses = [ - RESPONSE_SESSION_DETAILS, - RESPONSE_PARTITION_DETAILS, - RESPONSE_GET_ZONE_DETAILS_SUCCESS, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - RESPONSE_DISARMED, - ] + assert result["type"] is FlowResultType.CREATE_ENTRY + assert config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} + await hass.async_block_till_done() - 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() - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={AUTO_BYPASS: True, CODE_REQUIRED: False} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} - await hass.async_block_till_done() - - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() From 06019e7995edb0ac3e8743c5eb6d0fdb72f65cd2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Mar 2025 00:59:36 -1000 Subject: [PATCH 1602/1941] Split timeout in lutron_caseta to increase configure timeout (#138875) --- .../components/lutron_caseta/__init__.py | 42 ++++++++----- .../components/lutron_caseta/config_flow.py | 5 +- .../components/lutron_caseta/const.py | 3 +- tests/components/lutron_caseta/__init__.py | 62 ++++++++++++++----- .../lutron_caseta/test_device_trigger.py | 15 +---- .../lutron_caseta/test_diagnostics.py | 11 +--- tests/components/lutron_caseta/test_init.py | 54 ++++++++++++++++ .../components/lutron_caseta/test_logbook.py | 21 ++----- 8 files changed, 143 insertions(+), 70 deletions(-) create mode 100644 tests/components/lutron_caseta/test_init.py diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index d697d6244b5..b489fe9dba7 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import contextlib from itertools import chain import logging import ssl @@ -37,11 +36,12 @@ from .const import ( ATTR_SERIAL, ATTR_TYPE, BRIDGE_DEVICE_ID, - BRIDGE_TIMEOUT, CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE, CONF_SUBTYPE, + CONFIGURE_TIMEOUT, + CONNECT_TIMEOUT, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, MANUFACTURER, @@ -161,28 +161,40 @@ async def async_setup_entry( keyfile = hass.config.path(entry.data[CONF_KEYFILE]) certfile = hass.config.path(entry.data[CONF_CERTFILE]) ca_certs = hass.config.path(entry.data[CONF_CA_CERTS]) - bridge = None + connected_future: asyncio.Future[None] = hass.loop.create_future() + + def _on_connect() -> None: + nonlocal connected_future + if not connected_future.done(): + connected_future.set_result(None) try: bridge = Smartbridge.create_tls( - hostname=host, keyfile=keyfile, certfile=certfile, ca_certs=ca_certs + hostname=host, + keyfile=keyfile, + certfile=certfile, + ca_certs=ca_certs, + on_connect_callback=_on_connect, ) except ssl.SSLError: _LOGGER.error("Invalid certificate used to connect to bridge at %s", host) return False - timed_out = True - with contextlib.suppress(TimeoutError): - async with asyncio.timeout(BRIDGE_TIMEOUT): - await bridge.connect() - timed_out = False + connect_task = hass.async_create_task(bridge.connect()) + for future, name, timeout in ( + (connected_future, "connect", CONNECT_TIMEOUT), + (connect_task, "configure", CONFIGURE_TIMEOUT), + ): + try: + async with asyncio.timeout(timeout): + await future + except TimeoutError as ex: + connect_task.cancel() + await bridge.close() + raise ConfigEntryNotReady(f"Timed out on {name} for {host}") from ex - if timed_out or not bridge.is_connected(): - await bridge.close() - if timed_out: - raise ConfigEntryNotReady(f"Timed out while trying to connect to {host}") - if not bridge.is_connected(): - raise ConfigEntryNotReady(f"Cannot connect to {host}") + if not bridge.is_connected(): + raise ConfigEntryNotReady(f"Connection failed to {host}") _LOGGER.debug("Connected to Lutron Caseta bridge via LEAP at %s", host) await _async_migrate_unique_ids(hass, entry) diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 767c3d2f2b7..45e7a04bdc9 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -20,10 +20,11 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import ( ABORT_REASON_CANNOT_CONNECT, BRIDGE_DEVICE_ID, - BRIDGE_TIMEOUT, CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE, + CONFIGURE_TIMEOUT, + CONNECT_TIMEOUT, DOMAIN, ERROR_CANNOT_CONNECT, STEP_IMPORT_FAILED, @@ -232,7 +233,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): return None try: - async with asyncio.timeout(BRIDGE_TIMEOUT): + async with asyncio.timeout(CONNECT_TIMEOUT + CONFIGURE_TIMEOUT): await bridge.connect() except TimeoutError: _LOGGER.error( diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index 809b9e8d007..26a83de6f4b 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -34,7 +34,8 @@ ACTION_RELEASE = "release" CONF_SUBTYPE = "subtype" -BRIDGE_TIMEOUT = 35 +CONNECT_TIMEOUT = 9 +CONFIGURE_TIMEOUT = 50 UNASSIGNED_AREA = "Unassigned" diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index b27d30ac31f..5f146cd988a 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -1,5 +1,8 @@ """Tests for the Lutron Caseta integration.""" +import asyncio +from collections.abc import Callable +from typing import Any from unittest.mock import patch from homeassistant.components.lutron_caseta import DOMAIN @@ -84,25 +87,12 @@ _LEAP_DEVICE_TYPES = { } -async def async_setup_integration(hass: HomeAssistant, mock_bridge) -> MockConfigEntry: - """Set up a mock bridge.""" - mock_entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) - mock_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls" - ) as create_tls: - create_tls.return_value = mock_bridge(can_connect=True) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - return mock_entry - - class MockBridge: """Mock Lutron bridge that emulates configured connected status.""" - def __init__(self, can_connect=True) -> None: + def __init__(self, can_connect=True, timeout_on_connect=False) -> None: """Initialize MockBridge instance with configured mock connectivity.""" + self.timeout_on_connect = timeout_on_connect self.can_connect = can_connect self.is_currently_connected = False self.areas = self.load_areas() @@ -113,6 +103,8 @@ class MockBridge: async def connect(self): """Connect the mock bridge.""" + if self.timeout_on_connect: + await asyncio.Event().wait() # wait forever if self.can_connect: self.is_currently_connected = True @@ -320,3 +312,43 @@ class MockBridge: async def close(self): """Close the mock bridge connection.""" self.is_currently_connected = False + + +def make_mock_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) + + +async def async_setup_integration( + hass: HomeAssistant, + mock_bridge: MockBridge, + config_entry_id: str | None = None, + can_connect: bool = True, + timeout_during_connect: bool = False, + timeout_during_configure: bool = False, +) -> MockConfigEntry: + """Set up a mock bridge.""" + if config_entry_id is None: + mock_entry = make_mock_entry() + mock_entry.add_to_hass(hass) + config_entry_id = mock_entry.entry_id + else: + mock_entry = hass.config_entries.async_get_entry(config_entry_id) + + def create_tls_factory( + *args: Any, on_connect_callback: Callable[[], None], **kwargs: Any + ) -> None: + """Return a mock bridge.""" + if not timeout_during_connect: + on_connect_callback() + return mock_bridge( + can_connect=can_connect, timeout_on_connect=timeout_during_configure + ) + + with patch( + "homeassistant.components.lutron_caseta.Smartbridge.create_tls", + create_tls_factory, + ): + await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + return mock_entry diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index 1ab45bf7582..001bf86ad54 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -1,7 +1,5 @@ """The tests for Lutron Caséta device triggers.""" -from unittest.mock import patch - import pytest from pytest_unordered import unordered @@ -37,7 +35,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from . import MockBridge +from . import MockBridge, async_setup_integration from tests.common import MockConfigEntry, async_get_device_automations @@ -112,12 +110,7 @@ async def _async_setup_lutron_with_picos(hass: HomeAssistant) -> str: ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) return config_entry.entry_id @@ -487,9 +480,7 @@ async def test_if_fires_on_button_event_late_setup( }, ) - with patch("homeassistant.components.lutron_caseta.Smartbridge.create_tls"): - await hass.config_entries.async_setup(config_entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry_id) message = { ATTR_SERIAL: device.get("serial"), diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py index 5c7d20da208..45229918578 100644 --- a/tests/components/lutron_caseta/test_diagnostics.py +++ b/tests/components/lutron_caseta/test_diagnostics.py @@ -1,6 +1,6 @@ """Test the Lutron Caseta diagnostics.""" -from unittest.mock import ANY, patch +from unittest.mock import ANY from homeassistant.components.lutron_caseta import DOMAIN from homeassistant.components.lutron_caseta.const import ( @@ -11,7 +11,7 @@ from homeassistant.components.lutron_caseta.const import ( from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from . import MockBridge +from . import MockBridge, async_setup_integration from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -34,12 +34,7 @@ async def test_diagnostics( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == { diff --git a/tests/components/lutron_caseta/test_init.py b/tests/components/lutron_caseta/test_init.py new file mode 100644 index 00000000000..7e509acbf62 --- /dev/null +++ b/tests/components/lutron_caseta/test_init.py @@ -0,0 +1,54 @@ +"""Tests for the Lutron Caseta integration.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import lutron_caseta +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import MockBridge, async_setup_integration, make_mock_entry + + +@pytest.mark.parametrize( + ("constant", "message", "timeout_during_connect", "timeout_during_configure"), + [ + ("CONNECT_TIMEOUT", "Timed out on connect", True, False), + ("CONFIGURE_TIMEOUT", "Timed out on configure", False, True), + ], +) +async def test_timeout_during_setup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + constant: str, + message: str, + timeout_during_connect: bool, + timeout_during_configure: bool, +) -> None: + """Test a timeout during setup.""" + mock_entry = make_mock_entry() + mock_entry.add_to_hass(hass) + with patch.object(lutron_caseta, constant, 0.001): + await async_setup_integration( + hass, + MockBridge, + config_entry_id=mock_entry.entry_id, + timeout_during_connect=timeout_during_connect, + timeout_during_configure=timeout_during_configure, + ) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY + assert f"{message} for 1.1.1.1" in caplog.text + + +async def test_cannot_connect( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test failing to connect.""" + mock_entry = make_mock_entry() + mock_entry.add_to_hass(hass) + await async_setup_integration( + hass, MockBridge, config_entry_id=mock_entry.entry_id, can_connect=False + ) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY + assert "Connection failed to 1.1.1.1" in caplog.text diff --git a/tests/components/lutron_caseta/test_logbook.py b/tests/components/lutron_caseta/test_logbook.py index 9a58838d65c..8b4a3e00fa9 100644 --- a/tests/components/lutron_caseta/test_logbook.py +++ b/tests/components/lutron_caseta/test_logbook.py @@ -1,7 +1,5 @@ """The tests for lutron caseta logbook.""" -from unittest.mock import patch - from homeassistant.components.lutron_caseta.const import ( ATTR_ACTION, ATTR_AREA_NAME, @@ -43,13 +41,7 @@ async def test_humanify_lutron_caseta_button_event(hass: HomeAssistant) -> None: unique_id="abc", ) config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) await hass.async_block_till_done() @@ -104,15 +96,10 @@ async def test_humanify_lutron_caseta_button_event_integration_not_loaded( ) config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.lutron_caseta.Smartbridge.create_tls", - return_value=MockBridge(can_connect=True), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await async_setup_integration(hass, MockBridge, config_entry.entry_id) - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() for device in device_registry.devices.values(): if device.config_entries == {config_entry.entry_id}: From d3376f31d0382c80e468edd1ac23c9230dcd5c2d Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:29:43 +0100 Subject: [PATCH 1603/1941] Bump fyta_cli to 0.7.1 (#140452) --- homeassistant/components/fyta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../fyta/fixtures/plant_status1.json | 20 ++++++++++- .../fyta/fixtures/plant_status1_update.json | 20 ++++++++++- .../fyta/fixtures/plant_status2.json | 20 ++++++++++- .../fyta/fixtures/plant_status3.json | 20 ++++++++++- .../fyta/snapshots/test_diagnostics.ambr | 36 +++++++++++++++++++ 8 files changed, 115 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index ea628f55c6c..1c91807b711 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["fyta_cli"], "quality_scale": "platinum", - "requirements": ["fyta_cli==0.7.0"] + "requirements": ["fyta_cli==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6830c3880e3..f7183090743 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -968,7 +968,7 @@ freesms==0.2.0 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.0 +fyta_cli==0.7.1 # homeassistant.components.google_translate gTTS==2.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29425a177c5..3023294a095 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -821,7 +821,7 @@ freebox-api==1.2.2 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.0 +fyta_cli==0.7.1 # homeassistant.components.google_translate gTTS==2.5.3 diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index 21e1fcfb0ab..91157c57c3a 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -6,10 +6,18 @@ "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Gummibaum", "nutrients_status": 3, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": null, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture", "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Ficus elastica", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/fixtures/plant_status1_update.json b/tests/components/fyta/fixtures/plant_status1_update.json index 98a4c6a9d91..5363c5bd290 100644 --- a/tests/components/fyta/fixtures/plant_status1_update.json +++ b/tests/components/fyta/fixtures/plant_status1_update.json @@ -6,10 +6,18 @@ "low_battery": false, "last_updated": "2023-01-10 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Gummibaum", "nutrients_status": 3, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E2", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": null, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture1", "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": false, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Ficus elastica", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json index bf90ab1e50d..5a181bee576 100644 --- a/tests/components/fyta/fixtures/plant_status2.json +++ b/tests/components/fyta/fixtures/plant_status2.json @@ -6,10 +6,18 @@ "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Kakaobaum", "nutrients_status": 3, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E3", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": 7, "plant_id": 0, "plant_origin_path": "", "plant_thumb_path": "", "is_productive_plant": false, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Theobroma cacao", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json index 4bb4e0b81a7..ad34e01065e 100644 --- a/tests/components/fyta/fixtures/plant_status3.json +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -6,10 +6,18 @@ "low_battery": true, "last_updated": "2023-01-02 10:10:00", "light": 2, + "light_min_good": "20", + "light_max_good": "450", + "light_min_acceptable": "18", + "light_max_acceptable": "675", "light_status": 3, "nickname": "Tomatenpflanze", "nutrients_status": 0, "moisture": 61, + "moisture_min_good": "35", + "moisture_max_good": "70", + "moisture_min_acceptable": "25", + "moisture_max_acceptable": "80", "moisture_status": 3, "sensor_available": true, "sensor_id": "FD:1D:B7:E3:D0:E3", @@ -17,14 +25,24 @@ "sw_version": "1.0", "status": 1, "online": true, + "origin_path": "http://www.plant_picture.com/user_picture", "ph": 7, "plant_id": 0, "plant_origin_path": "http://www.plant_picture.com/picture", "plant_thumb_path": "http://www.plant_picture.com/picture_thumb", "is_productive_plant": true, "salinity": 1, + "salinity_min_good": "0.6", + "salinity_max_good": "1", + "salinity_min_acceptable": "0.4", + "salinity_max_acceptable": "1.2", "salinity_status": 4, "scientific_name": "Solanum lycopersicum", "temperature": 25.2, - "temperature_status": 3 + "temperature_min_good": "17", + "temperature_max_good": "36", + "temperature_min_acceptable": "10", + "temperature_max_acceptable": "42", + "temperature_status": 3, + "thumb_path": "http://www.plant_picture.com/user_picture_thumb" } diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index 24206fbb875..7bc6a6f7b5a 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -32,9 +32,17 @@ 'fertilise_next': None, 'last_updated': '2023-01-10T10:10:00', 'light': 2.0, + 'light_max_acceptable': 675.0, + 'light_max_good': 450.0, + 'light_min_acceptable': 18.0, + 'light_min_good': 20.0, 'light_status': 3, 'low_battery': False, 'moisture': 61.0, + 'moisture_max_acceptable': 80.0, + 'moisture_max_good': 70.0, + 'moisture_min_acceptable': 25.0, + 'moisture_min_good': 35.0, 'moisture_status': 3, 'name': 'Gummibaum', 'notification_light': False, @@ -50,6 +58,10 @@ 'productive_plant': False, 'repotted': True, 'salinity': 1.0, + 'salinity_max_acceptable': 1.2, + 'salinity_max_good': 1.0, + 'salinity_min_acceptable': 0.4, + 'salinity_min_good': 0.6, 'salinity_status': 4, 'scientific_name': 'Ficus elastica', 'sensor_available': True, @@ -59,7 +71,13 @@ 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, + 'temperature_max_acceptable': 42.0, + 'temperature_max_good': 36.0, + 'temperature_min_acceptable': 10.0, + 'temperature_min_good': 17.0, 'temperature_status': 3, + 'user_picture_path': 'http://www.plant_picture.com/user_picture', + 'user_thumb_path': 'http://www.plant_picture.com/user_picture_thumb', }), '1': dict({ 'battery_level': 80.0, @@ -67,9 +85,17 @@ 'fertilise_next': None, 'last_updated': '2023-01-02T10:10:00', 'light': 2.0, + 'light_max_acceptable': 675.0, + 'light_max_good': 450.0, + 'light_min_acceptable': 18.0, + 'light_min_good': 20.0, 'light_status': 3, 'low_battery': True, 'moisture': 61.0, + 'moisture_max_acceptable': 80.0, + 'moisture_max_good': 70.0, + 'moisture_min_acceptable': 25.0, + 'moisture_min_good': 35.0, 'moisture_status': 3, 'name': 'Kakaobaum', 'notification_light': False, @@ -85,6 +111,10 @@ 'productive_plant': False, 'repotted': True, 'salinity': 1.0, + 'salinity_max_acceptable': 1.2, + 'salinity_max_good': 1.0, + 'salinity_min_acceptable': 0.4, + 'salinity_min_good': 0.6, 'salinity_status': 4, 'scientific_name': 'Theobroma cacao', 'sensor_available': True, @@ -94,7 +124,13 @@ 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, + 'temperature_max_acceptable': 42.0, + 'temperature_max_good': 36.0, + 'temperature_min_acceptable': 10.0, + 'temperature_min_good': 17.0, 'temperature_status': 3, + 'user_picture_path': 'http://www.plant_picture.com/user_picture', + 'user_thumb_path': 'http://www.plant_picture.com/user_picture_thumb', }), }), }) From 70c355b52e55d4881f2a198cf014366a7014282b Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Wed, 12 Mar 2025 16:30:01 +0100 Subject: [PATCH 1604/1941] Bump velbusaio to 2025.3.1 (#140443) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index ff30ee14a8a..1cb540b22ec 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "bronze", - "requirements": ["velbus-aio==2025.3.0"], + "requirements": ["velbus-aio==2025.3.1"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index f7183090743..0c057e8f537 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3000,7 +3000,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.3.0 +velbus-aio==2025.3.1 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3023294a095..e2dca4383ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2416,7 +2416,7 @@ vallox-websocket-api==5.3.0 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2025.3.0 +velbus-aio==2025.3.1 # homeassistant.components.venstar venstarcolortouch==0.19 From 892b78a1f9ebe18c3ffc38c4f5b879fe1b1aae33 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 12 Mar 2025 17:12:27 +0100 Subject: [PATCH 1605/1941] Add exceptions translation for Vodafone Station (#140410) --- .../vodafone_station/coordinator.py | 6 +- .../components/vodafone_station/strings.json | 65 ++++++++++++++----- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index b7986d06c25..424abc4fafd 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -127,7 +127,11 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): exceptions.GenericLoginError, JSONDecodeError, ) as err: - raise UpdateFailed(f"Error fetching data: {err!r}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": repr(err)}, + ) from err except (ConfigEntryAuthFailed, UpdateFailed): await self.api.close() raise diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 8910d7178b7..dd847df4d6b 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -47,14 +47,26 @@ }, "entity": { "button": { - "dsl_reconnect": { "name": "DSL reconnect" }, - "fiber_reconnect": { "name": "Fiber reconnect" }, - "internet_key_reconnect": { "name": "Internet key reconnect" } + "dsl_reconnect": { + "name": "DSL reconnect" + }, + "fiber_reconnect": { + "name": "Fiber reconnect" + }, + "internet_key_reconnect": { + "name": "Internet key reconnect" + } }, "sensor": { - "external_ipv4": { "name": "WAN IPv4 address" }, - "external_ipv6": { "name": "WAN IPv6 address" }, - "external_ip_key": { "name": "WAN internet key address" }, + "external_ipv4": { + "name": "WAN IPv4 address" + }, + "external_ipv6": { + "name": "WAN IPv6 address" + }, + "external_ip_key": { + "name": "WAN internet key address" + }, "active_connection": { "name": "Active connection", "state": { @@ -64,15 +76,38 @@ "internet_key": "Internet key" } }, - "down_stream": { "name": "WAN download rate" }, - "up_stream": { "name": "WAN upload rate" }, - "fw_version": { "name": "Firmware version" }, - "phone_num1": { "name": "Phone number (1)" }, - "phone_num2": { "name": "Phone number (2)" }, - "sys_uptime": { "name": "Uptime" }, - "sys_cpu_usage": { "name": "CPU usage" }, - "sys_memory_usage": { "name": "Memory usage" }, - "sys_reboot_cause": { "name": "Reboot cause" } + "down_stream": { + "name": "WAN download rate" + }, + "up_stream": { + "name": "WAN upload rate" + }, + "fw_version": { + "name": "Firmware version" + }, + "phone_num1": { + "name": "Phone number (1)" + }, + "phone_num2": { + "name": "Phone number (2)" + }, + "sys_uptime": { + "name": "Uptime" + }, + "sys_cpu_usage": { + "name": "CPU usage" + }, + "sys_memory_usage": { + "name": "Memory usage" + }, + "sys_reboot_cause": { + "name": "Reboot cause" + } + } + }, + "exceptions": { + "update_failed": { + "message": "Error fetching data: {error}" } } } From bad109dec5afa1101c18ca42e15038dde51fdf2f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 12 Mar 2025 19:07:41 +0100 Subject: [PATCH 1606/1941] Mark value in number.set_value action as required (#140445) --- homeassistant/components/number/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/number/services.yaml b/homeassistant/components/number/services.yaml index dcbb955d739..6a7083a7613 100644 --- a/homeassistant/components/number/services.yaml +++ b/homeassistant/components/number/services.yaml @@ -7,5 +7,6 @@ set_value: fields: value: example: 42 + required: true selector: text: From 1f6658fca0ccfdd333c8bf62712e21bbe1560057 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:19:09 -0400 Subject: [PATCH 1607/1941] Prevent ipv6 discovery messages for Sonos (#139648) --- homeassistant/components/sonos/__init__.py | 9 ++++++ homeassistant/components/sonos/config_flow.py | 2 ++ homeassistant/components/sonos/strings.json | 3 +- tests/components/sonos/test_config_flow.py | 16 ++++++++++ tests/components/sonos/test_init.py | 29 +++++++++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index d530fa21e39..24580971ae2 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -7,6 +7,7 @@ from collections import OrderedDict from dataclasses import dataclass, field import datetime from functools import partial +from ipaddress import AddressValueError, IPv4Address import logging import socket from typing import Any, cast @@ -208,6 +209,14 @@ class SonosDiscoveryManager: async def async_subscribe_to_zone_updates(self, ip_address: str) -> None: """Test subscriptions and create SonosSpeakers based on results.""" + try: + _ = IPv4Address(ip_address) + except AddressValueError: + _LOGGER.debug( + "Sonos integration only supports IPv4 addresses, invalid ip_address received: %s", + ip_address, + ) + return soco = SoCo(ip_address) # Cache now to avoid household ID lookup during first ZoneGroupState processing await self.hass.async_add_executor_job( diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index 057cdb8ec08..b5e2c684281 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -31,6 +31,8 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DO hostname = discovery_info.hostname if hostname is None or not hostname.lower().startswith("sonos"): return self.async_abort(reason="not_sonos_device") + if discovery_info.ip_address.version != 4: + return self.async_abort(reason="not_ipv4_address") if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): host = discovery_info.host mdns_name = discovery_info.name diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 07d2e2db4e0..433bb3cc36a 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -8,7 +8,8 @@ "abort": { "not_sonos_device": "Discovered device is not a Sonos device", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "not_ipv4_address": "No IPv4 address in SSDP discovery information" } }, "issues": { diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 70605092da1..8454b4ad673 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -123,6 +123,22 @@ async def test_zeroconf_form( assert len(mock_manager.mock_calls) == 2 +async def test_zeroconf_form_not_ipv4( + hass: HomeAssistant, zeroconf_payload: ZeroconfServiceInfo +) -> None: + """Test we pass Zeroconf discoveries to the manager.""" + mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() + zeroconf_payload.ip_address = ip_address("2001:db8:3333:4444:5555:6666:7777:8888") + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_payload, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_ipv4_address" + assert mock_manager.call_count == 0 + + async def test_ssdp_discovery(hass: HomeAssistant, soco) -> None: """Test that SSDP discoveries create a config flow.""" diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index a7ad2f4cb82..c6be606eb20 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -455,3 +455,32 @@ async def test_async_poll_manual_hosts_8( assert "media_player.garage" in entity_registry.entities assert "media_player.studio" in entity_registry.entities await hass.async_block_till_done(wait_background_tasks=True) + + +async def _setup_hass_ipv6_address_not_supported(hass: HomeAssistant): + await async_setup_component( + hass, + sonos.DOMAIN, + { + "sonos": { + "media_player": { + "interface_addr": "127.0.0.1", + "hosts": ["2001:db8:3333:4444:5555:6666:7777:8888"], + } + } + }, + ) + await hass.async_block_till_done() + + +async def test_ipv6_not_supported( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Tests that invalid ipv4 addresses do not generate stack dump.""" + with caplog.at_level(logging.DEBUG): + caplog.clear() + await _setup_hass_ipv6_address_not_supported(hass) + await hass.async_block_till_done() + assert "invalid ip_address received" in caplog.text + assert "2001:db8:3333:4444:5555:6666:7777:8888" in caplog.text From e78dc486f7d6944dc56e513f2980ca71022bbcf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Mar 2025 13:09:41 -1000 Subject: [PATCH 1608/1941] Bump SQLAlchemy to 2.0.39 (#140473) * Bump SQLAlchemy to 2.0.39 changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.39 * fix typing --- homeassistant/components/recorder/db_schema.py | 4 ++-- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/recorder/migration.py | 17 +++++++++++------ 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 +- 9 files changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index d1a2405406e..bc8fcd1310e 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -203,11 +203,11 @@ UINT_32_TYPE = BigInteger().with_variant( "mariadb", ) JSON_VARIANT_CAST = Text().with_variant( - postgresql.JSON(none_as_null=True), # type: ignore[no-untyped-call] + postgresql.JSON(none_as_null=True), "postgresql", ) JSONB_VARIANT_CAST = Text().with_variant( - postgresql.JSONB(none_as_null=True), # type: ignore[no-untyped-call] + postgresql.JSONB(none_as_null=True), "postgresql", ) DATETIME_TYPE = ( diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 3ba36ab86c0..f5336e2a85b 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.38", + "SQLAlchemy==2.0.39", "fnv-hash-fast==1.4.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 3aa12f2b1f9..c5eea0f7088 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -9,7 +9,7 @@ from dataclasses import dataclass, replace as dataclass_replace from datetime import timedelta import logging from time import time -from typing import TYPE_CHECKING, Any, cast, final +from typing import TYPE_CHECKING, Any, TypedDict, cast, final from uuid import UUID import sqlalchemy @@ -712,6 +712,11 @@ def _modify_columns( raise +class _FKAlterDict(TypedDict): + old_fk: ForeignKeyConstraint + columns: list[str] + + def _update_states_table_with_foreign_key_options( session_maker: Callable[[], Session], engine: Engine ) -> None: @@ -729,7 +734,7 @@ def _update_states_table_with_foreign_key_options( inspector = sqlalchemy.inspect(engine) tmp_states_table = Table(TABLE_STATES, MetaData()) - alters = [ + alters: list[_FKAlterDict] = [ { "old_fk": ForeignKeyConstraint( (), (), name=foreign_key["name"], table=tmp_states_table @@ -755,14 +760,14 @@ def _update_states_table_with_foreign_key_options( with session_scope(session=session_maker()) as session: try: connection = session.connection() - connection.execute(DropConstraint(alter["old_fk"])) # type: ignore[no-untyped-call] + connection.execute(DropConstraint(alter["old_fk"])) for fkc in states_key_constraints: if fkc.column_keys == alter["columns"]: # AddConstraint mutates the constraint passed to it, we need to # undo that to avoid changing the behavior of the table schema. # https://github.com/sqlalchemy/sqlalchemy/blob/96f1172812f858fead45cdc7874abac76f45b339/lib/sqlalchemy/sql/ddl.py#L746-L748 create_rule = fkc._create_rule # noqa: SLF001 - add_constraint = AddConstraint(fkc) # type: ignore[no-untyped-call] + add_constraint = AddConstraint(fkc) fkc._create_rule = create_rule # noqa: SLF001 connection.execute(add_constraint) except (InternalError, OperationalError): @@ -800,7 +805,7 @@ def _drop_foreign_key_constraints( with session_scope(session=session_maker()) as session: try: connection = session.connection() - connection.execute(DropConstraint(drop)) # type: ignore[no-untyped-call] + connection.execute(DropConstraint(drop)) except (InternalError, OperationalError): _LOGGER.exception( "Could not drop foreign constraints in %s table on %s", @@ -845,7 +850,7 @@ def _restore_foreign_key_constraints( # undo that to avoid changing the behavior of the table schema. # https://github.com/sqlalchemy/sqlalchemy/blob/96f1172812f858fead45cdc7874abac76f45b339/lib/sqlalchemy/sql/ddl.py#L746-L748 create_rule = constraint._create_rule # noqa: SLF001 - add_constraint = AddConstraint(constraint) # type: ignore[no-untyped-call] + add_constraint = AddConstraint(constraint) constraint._create_rule = create_rule # noqa: SLF001 try: _add_constraint(session_maker, add_constraint, table, column) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 2b00a5b0d65..37b5dc2b647 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.38", "sqlparse==0.5.0"] + "requirements": ["SQLAlchemy==2.0.39", "sqlparse==0.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d9c761e6341..24ce6e23e86 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.2.1 -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.39 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index 09c14cbde69..8e3fe4e25a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "PyYAML==6.0.2", "requests==2.32.3", "securetar==2025.2.1", - "SQLAlchemy==2.0.38", + "SQLAlchemy==2.0.39", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", "typing-extensions>=4.12.2,<5.0", diff --git a/requirements.txt b/requirements.txt index 6ae428d5420..13c58f6cd71 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.2.1 -SQLAlchemy==2.0.38 +SQLAlchemy==2.0.39 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0c057e8f537..b40ab7110c4 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.38 +SQLAlchemy==2.0.39 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2dca4383ed..eef5fc03173 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.38 +SQLAlchemy==2.0.39 # homeassistant.components.tami4 Tami4EdgeAPI==3.0 From db9a805ff0720ccab64fd3b1af4c6d1fc9a09085 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 13 Mar 2025 00:32:55 +0100 Subject: [PATCH 1609/1941] Add rain state binary sensor to ecowitt (#140463) --- homeassistant/components/ecowitt/binary_sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/ecowitt/binary_sensor.py b/homeassistant/components/ecowitt/binary_sensor.py index a2ed279f601..1d36f5232db 100644 --- a/homeassistant/components/ecowitt/binary_sensor.py +++ b/homeassistant/components/ecowitt/binary_sensor.py @@ -26,6 +26,9 @@ ECOWITT_BINARYSENSORS_MAPPING: Final = { device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + EcoWittSensorTypes.RAIN_STATE: BinarySensorEntityDescription( + key="RAIN_STATE", device_class=BinarySensorDeviceClass.MOISTURE + ), } From ab56a4ca69d088a3a3c307bb1291be02dcda3467 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Mar 2025 16:15:28 -1000 Subject: [PATCH 1610/1941] Bump aioesphomeapi to 29.6.0 (#140481) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.5.1...v29.6.0 --- 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 f0eeecfdb1e..6783b05fa0f 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.5.1", + "aioesphomeapi==29.6.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.11.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index b40ab7110c4..afee136b7da 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.5.1 +aioesphomeapi==29.6.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eef5fc03173..f3c1dacff23 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.5.1 +aioesphomeapi==29.6.0 # homeassistant.components.flo aioflo==2021.11.0 From 6a743310bb5c66bfe46fcc5081c54ce715063f7c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 12 Mar 2025 19:38:50 -0700 Subject: [PATCH 1611/1941] Change the local to-do list creation button to 'Create' (#140484) --- homeassistant/components/local_todo/strings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/local_todo/strings.json b/homeassistant/components/local_todo/strings.json index 2403fae60a5..ebf7810494c 100644 --- a/homeassistant/components/local_todo/strings.json +++ b/homeassistant/components/local_todo/strings.json @@ -6,7 +6,8 @@ "description": "Please choose a name for your new To-do list", "data": { "todo_list_name": "To-do list name" - } + }, + "submit": "Create" } }, "abort": { From 6d58dd541ee79f22015de2884ab622508d7fcbbe Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 12 Mar 2025 19:50:42 -0700 Subject: [PATCH 1612/1941] Update roborock quality scale for docs items (#140483) --- .../components/roborock/quality_scale.yaml | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index fa5e1f4ceeb..1077888ed14 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -12,15 +12,10 @@ rules: config-flow: done config-flow-test-coverage: done dependency-transparency: done - docs-actions: - status: todo - comment: | - The documentation for `roborock.get_maps` should be updated so it is next - to the other actions rather than only an example. All actions should be - updated to use the simple table format. + docs-actions: done docs-high-level-description: done - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: done @@ -33,8 +28,8 @@ rules: # Silver action-exceptions: todo config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -50,20 +45,13 @@ rules: discovery-update-info: status: exempt comment: Devices do not support discovery. - docs-data-update: - status: todo - comment: | - The docs talk about device communication works (cloud vs local), but does - not yet describe data flow (e.g. polling). We should move into a separate - section. - docs-examples: todo + docs-data-update: done + docs-examples: done docs-known-limitations: status: todo comment: Documentation does not describe known limitations like rate limiting docs-supported-devices: todo - docs-supported-functions: - status: todo - comment: Mostly complete, but some documentation is outdated (e.g. maps/images) + docs-supported-functions: done docs-troubleshooting: status: todo comment: | From f5412dd2090e79bddc14f9b6b477efc1a8a3f6b2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 13 Mar 2025 17:23:26 +1000 Subject: [PATCH 1613/1941] Bump Tesla Fleet API to 0.9.13 (#140485) --- 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 53aff3d0a54..010197ccbd9 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.12"] + "requirements": ["tesla-fleet-api==0.9.13"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 7c27024d9f0..3d37ced8cff 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.12", "teslemetry-stream==0.6.12"] + "requirements": ["tesla-fleet-api==0.9.13", "teslemetry-stream==0.6.12"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index d4ac56883e8..4ddd63552f0 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.12"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index afee136b7da..d4081f1a968 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.12 +tesla-fleet-api==0.9.13 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3c1dacff23..9a6bf446cea 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.12 +tesla-fleet-api==0.9.13 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From ffa6f42c0e1355ea66c4529ac97f88c1ab06eee7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 13 Mar 2025 00:52:42 -0700 Subject: [PATCH 1614/1941] Use `runtime_data` to store coordinator state (#140486) Use runtime-data to save coordinator state --- .../components/purpleair/__init__.py | 35 +++++++++---------- homeassistant/components/purpleair/const.py | 11 +++--- .../components/purpleair/coordinator.py | 7 ++-- .../components/purpleair/diagnostics.py | 9 ++--- homeassistant/components/purpleair/entity.py | 8 ++--- homeassistant/components/purpleair/sensor.py | 15 ++++---- tests/components/purpleair/conftest.py | 4 +-- .../components/purpleair/test_config_flow.py | 3 +- 8 files changed, 44 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 2d4022946b2..78986b34351 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -2,37 +2,34 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator - -PLATFORMS = [Platform.SENSOR] +from .const import PLATFORMS +from .coordinator import PurpleAirConfigEntry, PurpleAirDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up PurpleAir from a config entry.""" - coordinator = PurpleAirDataUpdateCoordinator(hass, entry) +async def async_setup_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: + """Set up PurpleAir config entry.""" + coordinator = PurpleAirDataUpdateCoordinator( + hass, + entry, + ) + entry.runtime_data = coordinator + await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_handle_entry_update)) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_handle_entry_update(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle an options update.""" +async def async_reload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: + """Reload config entry.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok +async def async_unload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: + """Unload config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py index 5f1ec84d469..fcb928bd4f3 100644 --- a/homeassistant/components/purpleair/const.py +++ b/homeassistant/components/purpleair/const.py @@ -1,10 +1,13 @@ """Constants for the PurpleAir integration.""" import logging +from typing import Final -DOMAIN = "purpleair" +from homeassistant.const import Platform -LOGGER = logging.getLogger(__package__) +LOGGER: Final = logging.getLogger(__package__) +PLATFORMS: Final = [Platform.SENSOR] -CONF_READ_KEY = "read_key" -CONF_SENSOR_INDICES = "sensor_indices" +DOMAIN: Final[str] = "purpleair" + +CONF_SENSOR_INDICES: Final[str] = "sensor_indices" diff --git a/homeassistant/components/purpleair/coordinator.py b/homeassistant/components/purpleair/coordinator.py index f1511733cfa..4ed0c0340c6 100644 --- a/homeassistant/components/purpleair/coordinator.py +++ b/homeassistant/components/purpleair/coordinator.py @@ -46,12 +46,15 @@ SENSOR_FIELDS_TO_RETRIEVE = [ UPDATE_INTERVAL = timedelta(minutes=2) +type PurpleAirConfigEntry = ConfigEntry[PurpleAirDataUpdateCoordinator] + + class PurpleAirDataUpdateCoordinator(DataUpdateCoordinator[GetSensorsResponse]): """Define a PurpleAir-specific coordinator.""" - config_entry: ConfigEntry + config_entry: PurpleAirConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: """Initialize.""" self._api = API( entry.data[CONF_API_KEY], diff --git a/homeassistant/components/purpleair/diagnostics.py b/homeassistant/components/purpleair/diagnostics.py index f7c44b7e9b2..71b83e277d3 100644 --- a/homeassistant/components/purpleair/diagnostics.py +++ b/homeassistant/components/purpleair/diagnostics.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -14,8 +13,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator +from .coordinator import PurpleAirConfigEntry CONF_TITLE = "title" @@ -30,14 +28,13 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: PurpleAirConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return async_redact_data( { "entry": entry.as_dict(), - "data": coordinator.data.model_dump(), + "data": entry.runtime_data.data.model_dump(), }, TO_REDACT, ) diff --git a/homeassistant/components/purpleair/entity.py b/homeassistant/components/purpleair/entity.py index 4f7be1874ed..410fdd9b942 100644 --- a/homeassistant/components/purpleair/entity.py +++ b/homeassistant/components/purpleair/entity.py @@ -7,13 +7,12 @@ from typing import Any from aiopurpleair.models.sensors import SensorModel -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator +from .coordinator import PurpleAirConfigEntry, PurpleAirDataUpdateCoordinator class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): @@ -23,12 +22,11 @@ class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): def __init__( self, - coordinator: PurpleAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: PurpleAirConfigEntry, sensor_index: int, ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(entry.runtime_data) self._sensor_index = sensor_index diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index bed1d878557..a85a23b6144 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, @@ -27,8 +26,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_SENSOR_INDICES, DOMAIN -from .coordinator import PurpleAirDataUpdateCoordinator +from .const import CONF_SENSOR_INDICES +from .coordinator import PurpleAirConfigEntry from .entity import PurpleAirEntity CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" @@ -165,13 +164,12 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PurpleAirConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PurpleAir sensors based on a config entry.""" - coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - PurpleAirSensorEntity(coordinator, entry, sensor_index, description) + PurpleAirSensorEntity(entry, sensor_index, description) for sensor_index in entry.options[CONF_SENSOR_INDICES] for description in SENSOR_DESCRIPTIONS ) @@ -184,13 +182,12 @@ class PurpleAirSensorEntity(PurpleAirEntity, SensorEntity): def __init__( self, - coordinator: PurpleAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: PurpleAirConfigEntry, sensor_index: int, description: PurpleAirSensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator, entry, sensor_index) + super().__init__(entry, sensor_index) self._attr_unique_id = f"{self._sensor_index}-{description.key}" self.entity_description = description diff --git a/tests/components/purpleair/conftest.py b/tests/components/purpleair/conftest.py index 1809b16bd75..a9a51c22b7c 100644 --- a/tests/components/purpleair/conftest.py +++ b/tests/components/purpleair/conftest.py @@ -8,7 +8,7 @@ from aiopurpleair.endpoints.sensors import NearbySensorResult from aiopurpleair.models.sensors import GetSensorsResponse import pytest -from homeassistant.components.purpleair import DOMAIN +from homeassistant.components.purpleair.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -20,7 +20,7 @@ TEST_SENSOR_INDEX2 = 567890 @pytest.fixture(name="api") def api_fixture(get_sensors_response: GetSensorsResponse) -> Mock: - """Define a fixture to return a mocked aiopurple API object.""" + """Define a fixture to return a mocked aiopurpleair API object.""" return Mock( async_check_api_key=AsyncMock(), get_map_url=Mock(return_value="http://example.com"), diff --git a/tests/components/purpleair/test_config_flow.py b/tests/components/purpleair/test_config_flow.py index 998cb2b7878..5ee15de4e6b 100644 --- a/tests/components/purpleair/test_config_flow.py +++ b/tests/components/purpleair/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch from aiopurpleair.errors import InvalidApiKeyError, PurpleAirError import pytest -from homeassistant.components.purpleair import DOMAIN +from homeassistant.components.purpleair.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -288,6 +288,7 @@ async def test_options_remove_sensor( device_entry = device_registry.async_get_device( identifiers={(DOMAIN, str(TEST_SENSOR_INDEX1))} ) + assert device_entry is not None result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"sensor_device_id": device_entry.id}, From 427aa55789d172f7cfb9cdf6d6912d5616c34a2b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 13 Mar 2025 09:28:15 +0100 Subject: [PATCH 1615/1941] Correct fallback to state in state machine when processing statistics (#140396) --- homeassistant/components/sensor/recorder.py | 17 ++-- tests/components/sensor/test_recorder.py | 105 ++++++++++++++++++-- 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 675d24b9240..4e8e27e0c79 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -134,16 +134,7 @@ def _time_weighted_average( duration = end - old_start_time accumulated += old_fstate * duration.total_seconds() - period_seconds = (end - start).total_seconds() - if period_seconds == 0: - # If the only state changed that happened was at the exact moment - # at the end of the period, we can't calculate a meaningful average - # so we return 0.0 since it represents a time duration smaller than - # we can measure. This probably means the precision of statistics - # column schema in the database is incorrect but it is actually possible - # to happen if the state change event fired at the exact microsecond - return 0.0 - return accumulated / period_seconds + return accumulated / (end - start).total_seconds() def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: @@ -447,7 +438,11 @@ def compile_statistics( # noqa: C901 entity_id = _state.entity_id # If there are no recent state changes, the sensor's state may already be pruned # from the recorder. Get the state from the state machine instead. - if not (entity_history := history_list.get(entity_id, [_state])): + try: + entity_history = history_list[entity_id] + except KeyError: + entity_history = [_state] if _state.last_changed < end else [] + if not entity_history: continue if not (float_states := _entity_history_to_float_and_state(entity_history)): continue diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index a5b6a07dde5..1dd8fb4905a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -541,11 +541,11 @@ async def test_compile_hourly_statistics_with_all_same_last_updated( "max", ), [ - ("temperature", "°C", "°C", "°C", "temperature", 0, 60, 60), - ("temperature", "°F", "°F", "°F", "temperature", 0, 60, 60), + ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), + ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), ], ) -async def test_compile_hourly_statistics_only_state_is_and_end_of_period( +async def test_compile_hourly_statistics_only_state_is_at_end_of_period( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_class, @@ -557,7 +557,7 @@ async def test_compile_hourly_statistics_only_state_is_and_end_of_period( min, max, ) -> None: - """Test compiling hourly statistics when the only state at end of period.""" + """Test compiling hourly statistics when the only states are at end of period.""" zero = get_start_time(dt_util.utcnow()) await async_setup_component(hass, "sensor", {}) # Wait for the sensor recorder platform to be added @@ -604,6 +604,7 @@ async def test_compile_hourly_statistics_only_state_is_and_end_of_period( assert_dict_of_states_equal_without_context_and_last_changed(states, hist) do_adhoc_statistics(hass, start=zero) + do_adhoc_statistics(hass, start=zero + timedelta(minutes=5)) await async_wait_recording_done(hass) statistic_ids = await async_list_statistic_ids(hass) assert statistic_ids == [ @@ -622,8 +623,8 @@ async def test_compile_hourly_statistics_only_state_is_and_end_of_period( assert stats == { "sensor.test1": [ { - "start": process_timestamp(zero).timestamp(), - "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "start": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=10)).timestamp(), "mean": pytest.approx(mean), "min": pytest.approx(min), "max": pytest.approx(max), @@ -651,7 +652,10 @@ async def test_compile_hourly_statistics_purged_state_changes( statistics_unit, unit_class, ) -> None: - """Test compiling hourly statistics.""" + """Test compiling hourly statistics. + + This tests statistics falls back to the state machine when states are purged. + """ zero = get_start_time(dt_util.utcnow()) await async_setup_component(hass, "sensor", {}) # Wait for the sensor recorder platform to be added @@ -716,6 +720,93 @@ async def test_compile_hourly_statistics_purged_state_changes( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + (None, "%", "%", "%", "unitless", 13.050847, -10, 30), + ], +) +async def test_compile_hourly_statistics_ignore_future_state( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics. + + This tests statistics does not fall back to the state machine if the state + in the state machine is newer than the end of the statistics period. + """ + zero = get_start_time(dt_util.utcnow() + timedelta(minutes=5)) + previous_period = zero - timedelta(minutes=5) + await async_setup_component(hass, "sensor", {}) + # Wait for the sensor recorder platform to be added + await async_recorder_block_till_done(hass) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + with freeze_time(zero) as freezer: + four, states = await async_record_states( + hass, freezer, zero, "sensor.test1", attributes + ) + await async_wait_recording_done(hass) + hist = history.get_significant_states( + hass, zero, four, hass.states.async_entity_ids() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=previous_period) + do_adhoc_statistics(hass, start=zero) + await async_wait_recording_done(hass) + statistic_ids = await async_list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, previous_period, period="5minute") + # Check we get no stats from the previous period + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) async def test_compile_hourly_statistics_wrong_unit( hass: HomeAssistant, From 26e3624610114d1314ca12e003bf8d78990f2404 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 13:23:00 +0100 Subject: [PATCH 1616/1941] Update pipdeptree to 2.25.1 (#140507) --- requirements_test.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index f40ed46a82f..6a95b6dadb1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.4 pylint-per-file-ignores==1.4.0 -pipdeptree==2.25.0 +pipdeptree==2.25.1 pytest-asyncio==0.25.3 pytest-aiohttp==1.1.0 pytest-cov==6.0.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 104939c3808..e4e0c751d78 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -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.10 \ + stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.9.10 \ 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" From e710d3699c1b5a51147ba2e37a4fc10dd68215bb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 13:23:52 +0100 Subject: [PATCH 1617/1941] Improve frontend typing (#140503) --- homeassistant/components/frontend/__init__.py | 22 +++++++++---------- homeassistant/components/frontend/storage.py | 5 ++++- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6184d888004..9a0627f9f42 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -52,10 +52,9 @@ CONF_JS_VERSION = "javascript_version" DEFAULT_THEME_COLOR = "#03A9F4" -DATA_PANELS = "frontend_panels" -DATA_JS_VERSION = "frontend_js_version" -DATA_EXTRA_MODULE_URL = "frontend_extra_module_url" -DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5" +DATA_PANELS: HassKey[dict[str, Panel]] = HassKey("frontend_panels") +DATA_EXTRA_MODULE_URL: HassKey[UrlManager] = HassKey("frontend_extra_module_url") +DATA_EXTRA_JS_URL_ES5: HassKey[UrlManager] = HassKey("frontend_extra_js_url_es5") DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] = HassKey( "frontend_ws_subscribers" @@ -64,8 +63,8 @@ DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] = THEMES_STORAGE_KEY = f"{DOMAIN}_theme" THEMES_STORAGE_VERSION = 1 THEMES_SAVE_DELAY = 60 -DATA_THEMES_STORE = "frontend_themes_store" -DATA_THEMES = "frontend_themes" +DATA_THEMES_STORE: HassKey[Store] = HassKey("frontend_themes_store") +DATA_THEMES: HassKey[dict[str, Any]] = HassKey("frontend_themes") DATA_DEFAULT_THEME = "frontend_default_theme" DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme" DEFAULT_THEME = "default" @@ -242,7 +241,7 @@ class Panel: sidebar_title: str | None = None # Url to show the panel in the frontend - frontend_url_path: str | None = None + frontend_url_path: str # Config to pass to the webcomponent config: dict[str, Any] | None = None @@ -273,7 +272,7 @@ class Panel: self.config_panel_domain = config_panel_domain @callback - def to_response(self) -> PanelRespons: + def to_response(self) -> PanelResponse: """Panel as dictionary.""" return { "component_name": self.component_name, @@ -631,7 +630,8 @@ class IndexView(web_urldispatcher.AbstractResource): def get_info(self) -> dict[str, list[str]]: # type: ignore[override] """Return a dict with additional info useful for introspection.""" - return {"panels": list(self.hass.data[DATA_PANELS])} + panels = self.hass.data[DATA_PANELS] + return {"panels": list(panels)} def raw_match(self, path: str) -> bool: """Perform a raw match against path.""" @@ -841,13 +841,13 @@ def websocket_subscribe_extra_js( connection.send_message(websocket_api.result_message(msg["id"])) -class PanelRespons(TypedDict): +class PanelResponse(TypedDict): """Represent the panel response type.""" component_name: str icon: str | None title: str | None config: dict[str, Any] | None - url_path: str | None + url_path: str require_admin: bool config_panel_domain: str | None diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index cbcc3024aa7..a33a9de7ac5 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -12,8 +12,11 @@ from homeassistant.components import websocket_api from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store +from homeassistant.util.hass_dict import HassKey -DATA_STORAGE = "frontend_storage" +DATA_STORAGE: HassKey[tuple[dict[str, Store], dict[str, dict]]] = HassKey( + "frontend_storage" +) STORAGE_VERSION_USER_DATA = 1 From f32bb1a318cd2cf912d0900f923dc4d80f12c849 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Mar 2025 09:36:38 -0400 Subject: [PATCH 1618/1941] Assist satellite to use TTS tokens for announcements (#140336) * Migrate Assist Satellite to use token * Fix tests * Fix tests --- .../components/assist_satellite/entity.py | 34 ++++++++++---- .../assist_satellite/test_entity.py | 32 ++++++++++++-- .../assist_satellite/test_intent.py | 44 +++++++++++-------- .../esphome/test_assist_satellite.py | 18 ++++---- tests/components/tts/common.py | 7 +++ tests/components/voip/test_voip.py | 18 +++++++- 6 files changed, 113 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 8c63525294c..3db38a23889 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -23,9 +23,6 @@ from homeassistant.components.assist_pipeline import ( vad, ) from homeassistant.components.media_player import async_process_play_media_url -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 chat_session, entity @@ -98,6 +95,9 @@ class AssistSatelliteAnnouncement: original_media_id: str """The raw media ID before processing.""" + tts_token: str | None + """The TTS token of the media.""" + media_id_source: Literal["url", "media_id", "tts"] """Source of the media ID.""" @@ -474,6 +474,7 @@ class AssistSatelliteEntity(entity.Entity): ) -> AssistSatelliteAnnouncement: """Resolve the media ID.""" media_id_source: Literal["url", "media_id", "tts"] | None = None + tts_token: str | None = None if media_id: original_media_id = media_id @@ -484,6 +485,10 @@ class AssistSatelliteEntity(entity.Entity): pipeline_id = self._resolve_pipeline() pipeline = async_get_pipeline(self.hass, pipeline_id) + engine = tts.async_resolve_engine(self.hass, pipeline.tts_engine) + if engine is None: + raise HomeAssistantError(f"TTS engine {pipeline.tts_engine} not found") + tts_options: dict[str, Any] = {} if pipeline.tts_voice is not None: tts_options[tts.ATTR_VOICE] = pipeline.tts_voice @@ -491,14 +496,23 @@ class AssistSatelliteEntity(entity.Entity): if self.tts_options is not None: tts_options.update(self.tts_options) - media_id = tts_generate_media_source_id( + stream = tts.async_create_stream( self.hass, - message, - engine=pipeline.tts_engine, + engine=engine, + language=pipeline.tts_language, + options=tts_options, + ) + stream.async_set_message(message) + + tts_token = stream.token + media_id = stream.url + original_media_id = tts.generate_media_source_id( + self.hass, + message, + engine=engine, language=pipeline.tts_language, options=tts_options, ) - original_media_id = media_id if media_source.is_media_source_id(media_id): if not media_id_source: @@ -517,5 +531,9 @@ class AssistSatelliteEntity(entity.Entity): media_id = async_process_play_media_url(self.hass, media_id) return AssistSatelliteAnnouncement( - message, media_id, original_media_id, media_id_source + message=message, + media_id=media_id, + original_media_id=original_media_id, + tts_token=tts_token, + media_id_source=media_id_source, ) diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 42b4adf742c..6604fdc3f25 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -31,6 +31,8 @@ from homeassistant.exceptions import HomeAssistantError from . import ENTITY_ID from .conftest import MockAssistSatellite +from tests.components.tts.common import MockResultStream + @pytest.fixture def mock_chat_session_conversation_id() -> Generator[Mock]: @@ -186,8 +188,9 @@ async def test_new_pipeline_cancels_pipeline( {"message": "Hello"}, AssistSatelliteAnnouncement( message="Hello", - media_id="https://www.home-assistant.io/resolved.mp3", + media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", original_media_id="media-source://bla", + tts_token="test-token", media_id_source="tts", ), ), @@ -200,6 +203,7 @@ async def test_new_pipeline_cancels_pipeline( message="Hello", media_id="https://www.home-assistant.io/resolved.mp3", original_media_id="media-source://given", + tts_token=None, media_id_source="media_id", ), ), @@ -209,6 +213,7 @@ async def test_new_pipeline_cancels_pipeline( message="", media_id="http://example.com/bla.mp3", original_media_id="http://example.com/bla.mp3", + tts_token=None, media_id_source="url", ), ), @@ -243,9 +248,17 @@ async def test_announce( with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", new=tts_generate_media_source_id, ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), patch( "homeassistant.components.media_source.async_resolve_media", return_value=PlayMedia( @@ -500,7 +513,8 @@ async def test_vad_sensitivity_entity_not_found( "Better system prompt", AssistSatelliteAnnouncement( message="Hello", - media_id="https://www.home-assistant.io/resolved.mp3", + media_id="http://10.10.10.10:8123/api/tts_proxy/test-token", + tts_token="test-token", original_media_id="media-source://generated", media_id_source="tts", ), @@ -517,6 +531,7 @@ async def test_vad_sensitivity_entity_not_found( AssistSatelliteAnnouncement( message="Hello", media_id="https://www.home-assistant.io/resolved.mp3", + tts_token=None, original_media_id="media-source://given", media_id_source="media_id", ), @@ -530,6 +545,7 @@ async def test_vad_sensitivity_entity_not_found( AssistSatelliteAnnouncement( message="", media_id="http://example.com/given.mp3", + tts_token=None, original_media_id="http://example.com/given.mp3", media_id_source="url", ), @@ -554,9 +570,17 @@ async def test_start_conversation( with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", return_value="media-source://generated", ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), patch( "homeassistant.components.media_source.async_resolve_media", return_value=PlayMedia( diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py index 9304229dbe3..0e531811adc 100644 --- a/tests/components/assist_satellite/test_intent.py +++ b/tests/components/assist_satellite/test_intent.py @@ -4,28 +4,28 @@ from unittest.mock import patch import pytest -from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import intent +from homeassistant.setup import async_setup_component from .conftest import TEST_DOMAIN, MockAssistSatellite +from tests.components.tts.common import MockResultStream + @pytest.fixture -def mock_tts(): +async def mock_tts(hass: HomeAssistant): """Mock TTS service.""" + assert await async_setup_component(hass, "tts", {}) with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", return_value="media-source://bla", ), patch( - "homeassistant.components.media_source.async_resolve_media", - return_value=PlayMedia( - url="https://www.home-assistant.io/resolved.mp3", - mime_type="audio/mp3", - ), + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), ), ): yield @@ -41,9 +41,13 @@ async def test_broadcast_intent( ) -> None: """Test we can invoke a broadcast intent.""" - result = await intent.async_handle( - hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} - ) + with patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ): + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) assert result.as_dict() == { "card": {}, @@ -71,13 +75,17 @@ async def test_broadcast_intent( assert len(entity2.announcements) == 1 assert len(entity_no_features.announcements) == 0 - result = await intent.async_handle( - hass, - "test", - intent.INTENT_BROADCAST, - {"message": {"value": "Hello"}}, - device_id=entity.device_entry.id, - ) + with patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ): + result = await intent.async_handle( + hass, + "test", + intent.INTENT_BROADCAST, + {"message": {"value": "Hello"}}, + device_id=entity.device_entry.id, + ) # Broadcast doesn't targets device that triggered it. assert result.as_dict() == { "card": {}, diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 3281a760c39..329a7b5179a 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -41,7 +41,6 @@ from homeassistant.components.esphome.assist_satellite import ( EsphomeAssistSatellite, VoiceAssistantUDPServer, ) -from homeassistant.components.media_source import PlayMedia from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -57,6 +56,8 @@ from homeassistant.helpers.entity_component import EntityComponent from .conftest import MockESPHomeDevice +from tests.components.tts.common import MockResultStream + def get_satellite_entity( hass: HomeAssistant, mac_address: str @@ -1209,22 +1210,23 @@ async def test_announce_message( media_id: str, timeout: float, text: str ): assert satellite.state == AssistSatelliteState.RESPONDING - assert media_id == "https://www.home-assistant.io/resolved.mp3" + assert media_id == "http://10.10.10.10:8123/api/tts_proxy/test-token" assert text == "test-text" done.set() with ( patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + "homeassistant.components.tts.generate_media_source_id", return_value="media-source://bla", ), patch( - "homeassistant.components.media_source.async_resolve_media", - return_value=PlayMedia( - url="https://www.home-assistant.io/resolved.mp3", - mime_type="audio/mp3", - ), + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud_tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), ), patch.object( mock_client, diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 9ae83cb2bb5..99c698771f7 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -270,6 +270,8 @@ async def mock_config_entry_setup( class MockResultStream(ResultStream): """Mock result stream.""" + test_set_message: str | None = None + def __init__(self, hass: HomeAssistant, extension: str, data: bytes) -> None: """Initialize the result stream.""" super().__init__( @@ -285,6 +287,11 @@ class MockResultStream(ResultStream): hass.data[DATA_TTS_MANAGER].token_to_stream[self.token] = self self._mock_data = data + @callback + def async_set_message(self, message: str) -> None: + """Set message to be generated.""" + self.test_set_message = message + async def async_stream_result(self): """Stream the result.""" yield self._mock_data diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index d971591c79a..459ab020336 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -27,6 +27,8 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import EntityComponent from homeassistant.setup import async_setup_component +from tests.components.tts.common import MockResultStream + _ONE_SECOND = 16000 * 2 # 16Khz 16-bit _MEDIA_ID = "12345" @@ -879,6 +881,7 @@ async def test_announce( announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token="test-token", original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -926,6 +929,7 @@ async def test_voip_id_is_ip_address( announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token="test-token", original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -978,6 +982,7 @@ async def test_announce_timeout( announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token="test-token", original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -1018,6 +1023,7 @@ async def test_start_conversation( announcement = assist_satellite.AssistSatelliteAnnouncement( message="test announcement", media_id=_MEDIA_ID, + tts_token="test-token", original_media_id=_MEDIA_ID, media_id_source="tts", ) @@ -1162,8 +1168,16 @@ async def test_start_conversation_user_doesnt_pick_up( new=async_pipeline_from_audio_stream, ), patch( - "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", - return_value="test media id", + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="test tts", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), ), ): satellite.transport = Mock() From 5526585eeb3e19e37dc648ad7cdf01df7d42570c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Mar 2025 15:35:40 +0100 Subject: [PATCH 1619/1941] Fix spelling of "ID" and excessive colon in `bang_olufsen` integration (#140518) --- homeassistant/components/bang_olufsen/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 278e9b6d47c..422dc4be567 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -274,7 +274,7 @@ "message": "An error occurred while attempting to play {media_type}: {error_message}." }, "invalid_grouping_entity": { - "message": "Entity with id: {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?" + "message": "Entity with ID {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?" }, "invalid_sound_mode": { "message": "{invalid_sound_mode} is an invalid sound mode. Valid values are: {valid_sound_modes}." From bc6eb94c0db65705f0b2a37eed5c6e92b250b2a1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Mar 2025 15:36:12 +0100 Subject: [PATCH 1620/1941] Fix sentence-casing and spelling of "ID" in `system_bridge` integration (#140516) --- homeassistant/components/system_bridge/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index ef7495ef74f..1c079c1ef0c 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -109,7 +109,7 @@ "message": "No data received from {host}" }, "process_not_found": { - "message": "Could not find process with id {id}." + "message": "Could not find process with ID {id}." }, "timeout": { "message": "A timeout occurred for {title} ({host})" @@ -120,7 +120,7 @@ }, "issues": { "unsupported_version": { - "title": "System Bridge Upgrade Required", + "title": "System Bridge upgrade required", "description": "Your version of System Bridge for host {host} is not supported.\n\nPlease upgrade to the latest version." } }, From 3bba781554948a22b445632174fd133de880558c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 13 Mar 2025 15:53:01 +0100 Subject: [PATCH 1621/1941] Use runtime data in Vodafone Station (#140464) * Use runtime data in Vodafone Station * specialize config entry * revert unwanted change --- .../components/vodafone_station/__init__.py | 13 ++++++------- homeassistant/components/vodafone_station/button.py | 9 ++++----- .../components/vodafone_station/coordinator.py | 6 ++++-- .../components/vodafone_station/device_tracker.py | 13 ++++++++----- .../components/vodafone_station/diagnostics.py | 8 +++----- homeassistant/components/vodafone_station/sensor.py | 9 ++++----- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 871afe09a2e..9f118fe4fbd 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -1,16 +1,15 @@ """Vodafone Station integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -from .coordinator import VodafoneStationRouter +from .coordinator import VodafoneConfigEntry, VodafoneStationRouter PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Set up Vodafone Station platform.""" coordinator = VodafoneStationRouter( hass, @@ -22,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -31,10 +30,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data await coordinator.api.logout() await coordinator.api.close() hass.data[DOMAIN].pop(entry.entry_id) @@ -42,7 +41,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: VodafoneConfigEntry) -> None: """Update when config_entry options update.""" if entry.options: await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 9812cef48d6..9227611ce22 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -11,14 +11,13 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN -from .coordinator import VodafoneStationRouter +from .const import _LOGGER +from .coordinator import VodafoneConfigEntry, VodafoneStationRouter @dataclass(frozen=True, kw_only=True) @@ -68,13 +67,13 @@ BUTTON_TYPES: Final = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VodafoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station buttons") - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors_data = coordinator.data.sensors diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 424abc4fafd..55643cd2778 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -21,6 +21,8 @@ from .helpers import cleanup_device_tracker CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() +type VodafoneConfigEntry = ConfigEntry[VodafoneStationRouter] + @dataclass(slots=True) class VodafoneStationDeviceInfo: @@ -42,7 +44,7 @@ class UpdateCoordinatorDataType: class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Queries router running Vodafone Station firmware.""" - config_entry: ConfigEntry + config_entry: VodafoneConfigEntry def __init__( self, @@ -50,7 +52,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): host: str, username: str, password: str, - config_entry: ConfigEntry, + config_entry: VodafoneConfigEntry, ) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index ece4bd05a02..984355287a4 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -3,25 +3,28 @@ from __future__ import annotations from homeassistant.components.device_tracker import ScannerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN -from .coordinator import VodafoneStationDeviceInfo, VodafoneStationRouter +from .const import _LOGGER +from .coordinator import ( + VodafoneConfigEntry, + VodafoneStationDeviceInfo, + VodafoneStationRouter, +) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VodafoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Vodafone Station component.""" _LOGGER.debug("Start device trackers setup") - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data tracked: set = set() diff --git a/homeassistant/components/vodafone_station/diagnostics.py b/homeassistant/components/vodafone_station/diagnostics.py index e306d6caca2..4778e7d5a4e 100644 --- a/homeassistant/components/vodafone_station/diagnostics.py +++ b/homeassistant/components/vodafone_station/diagnostics.py @@ -5,22 +5,20 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import VodafoneStationRouter +from .coordinator import VodafoneConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: VodafoneConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors_data = coordinator.data.sensors return { diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index d29fb7f21e9..bdb429aa6dd 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -12,14 +12,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER, DOMAIN, LINE_TYPES -from .coordinator import VodafoneStationRouter +from .const import _LOGGER, LINE_TYPES +from .coordinator import VodafoneConfigEntry, VodafoneStationRouter NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] UPTIME_DEVIATION = 60 @@ -166,13 +165,13 @@ SENSOR_TYPES: Final = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: VodafoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" _LOGGER.debug("Setting up Vodafone Station sensors") - coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data sensors_data = coordinator.data.sensors From c92ee120b609876a18d814da37e60af7580761b9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Mar 2025 16:39:12 +0100 Subject: [PATCH 1622/1941] Make actions in `flo` integration UI-friendly (#140522) Makes actions in `flo` integration UI-friendly - replace key name `sleep_minutes` with its friendly name to match the UI (in translations) - replace "time" with "duration" to reduce the ambiguity - use third-person singular for `run_health_test` description for consistency (in translations) --- homeassistant/components/flo/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index 3444911fbd4..64e22bedec3 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -60,11 +60,11 @@ "fields": { "sleep_minutes": { "name": "Sleep minutes", - "description": "The time to sleep in minutes." + "description": "The duration to sleep in minutes." }, "revert_to_mode": { "name": "Revert to mode", - "description": "The mode to revert to after sleep_minutes has elapsed." + "description": "The mode to revert to after the 'Sleep minutes' duration has elapsed." } } }, @@ -78,7 +78,7 @@ }, "run_health_test": { "name": "Run health test", - "description": "Have the Flo device run a health test." + "description": "Requests the Flo device to run a health test." } } } From 473a5559cc8597275ff44cf329194d9d0b5e4c38 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 16:48:04 +0100 Subject: [PATCH 1623/1941] Improve tado typing (#140505) --- homeassistant/components/tado/climate.py | 4 ++-- homeassistant/components/tado/helper.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index e6aa921d428..6a2067ffff1 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -157,8 +157,8 @@ async def create_climate_entity( TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF], TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE], ] - supported_fan_modes = None - supported_swing_modes = None + supported_fan_modes: list[str] | None = None + supported_swing_modes: list[str] | None = None heat_temperatures = None cool_temperatures = None diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py index 571a757a3e8..5c515e00cf0 100644 --- a/homeassistant/components/tado/helper.py +++ b/homeassistant/components/tado/helper.py @@ -53,13 +53,13 @@ def decide_duration( return duration -def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]): +def generate_supported_fanmodes( + tado_to_ha_mapping: dict[str, str], options: list[str] +) -> list[str] | None: """Return correct list of fan modes or None.""" supported_fanmodes = [ - tado_to_ha_mapping.get(option) - for option in options - if tado_to_ha_mapping.get(option) is not None + val for option in options if (val := tado_to_ha_mapping.get(option)) is not None ] if not supported_fanmodes: return None From b07ac301b9beedb6f9fb51079272d64f73f17ace Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 13 Mar 2025 16:57:22 +0100 Subject: [PATCH 1624/1941] Update xknxproject to 3.8.2 (#140499) --- 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 8cfb034a793..98e3a6a5242 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "requirements": [ "xknx==3.6.0", - "xknxproject==3.8.1", + "xknxproject==3.8.2", "knx-frontend==2025.1.30.194235" ], "single_config_entry": true diff --git a/requirements_all.txt b/requirements_all.txt index d4081f1a968..11a9df4ba16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3091,7 +3091,7 @@ xiaomi-ble==0.33.0 xknx==3.6.0 # homeassistant.components.knx -xknxproject==3.8.1 +xknxproject==3.8.2 # homeassistant.components.fritz # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a6bf446cea..7769e8e824f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2492,7 +2492,7 @@ xiaomi-ble==0.33.0 xknx==3.6.0 # homeassistant.components.knx -xknxproject==3.8.1 +xknxproject==3.8.2 # homeassistant.components.fritz # homeassistant.components.rest From 55895df54dba28802e1f0abc0953f37b18e09793 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Mar 2025 13:24:44 -0400 Subject: [PATCH 1625/1941] Switch more TTS core to async generators (#140432) * Switch more TTS core to async generators * Document a design choice * robust * Add more tests * Update comment * Clarify and document TTSCache variables --- homeassistant/components/tts/__init__.py | 369 ++++++++++++++--------- tests/components/tts/test_init.py | 109 ++++++- tests/components/wyoming/test_tts.py | 18 +- 3 files changed, 330 insertions(+), 166 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 6fc25e32091..350b03a2e80 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -17,7 +17,7 @@ import secrets import subprocess import tempfile from time import monotonic -from typing import Any, Final, TypedDict +from typing import Any, Final from aiohttp import web import mutagen @@ -123,13 +123,94 @@ KEY_PATTERN = "{0}_{1}_{2}_{3}" SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) -class TTSCache(TypedDict): - """Cached TTS file.""" +class TTSCache: + """Cached bytes of a TTS result.""" - extension: str - voice: bytes - pending: asyncio.Task | None - last_used: float + _result_data: bytes | None = None + """When fully loaded, contains the result data.""" + + _partial_data: list[bytes] | None = None + """While loading, contains the data already received from the generator.""" + + _loading_error: Exception | None = None + """If an error occurred while loading, contains the error.""" + + _consumers: list[asyncio.Queue[bytes | None]] | None = None + """A queue for each current consumer to notify of new data while the generator is loading.""" + + def __init__( + self, + cache_key: str, + extension: str, + data_gen: AsyncGenerator[bytes], + ) -> None: + """Initialize the TTS cache.""" + self.cache_key = cache_key + self.extension = extension + self.last_used = monotonic() + self._data_gen = data_gen + + async def async_load_data(self) -> bytes: + """Load the data from the generator.""" + if self._result_data is not None or self._partial_data is not None: + raise RuntimeError("Data already being loaded") + + self._partial_data = [] + self._consumers = [] + + try: + async for chunk in self._data_gen: + self._partial_data.append(chunk) + for queue in self._consumers: + queue.put_nowait(chunk) + except Exception as err: # pylint: disable=broad-except + self._loading_error = err + raise + finally: + for queue in self._consumers: + queue.put_nowait(None) + self._consumers = None + + self._result_data = b"".join(self._partial_data) + self._partial_data = None + return self._result_data + + async def async_stream_data(self) -> AsyncGenerator[bytes]: + """Stream the data. + + Will return all data already returned from the generator. + Will listen for future data returned from the generator. + Raises error if one occurred. + """ + if self._result_data is not None: + yield self._result_data + return + if self._loading_error: + raise self._loading_error + + if self._partial_data is None: + raise RuntimeError("Data not being loaded") + + queue: asyncio.Queue[bytes | None] | None = None + # Check if generator is still feeding data + if self._consumers is not None: + queue = asyncio.Queue() + self._consumers.append(queue) + + for chunk in list(self._partial_data): + yield chunk + + if self._loading_error: + raise self._loading_error + + if queue is not None: + while (chunk2 := await queue.get()) is not None: + yield chunk2 + + if self._loading_error: + raise self._loading_error + + self.last_used = monotonic() @callback @@ -194,10 +275,11 @@ async def async_get_media_source_audio( ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" manager = hass.data[DATA_TTS_MANAGER] - cache_key = manager.async_cache_message_in_memory( + cache = manager.async_cache_message_in_memory( **media_source_id_to_kwargs(media_source_id) ) - return await manager.async_get_tts_audio(cache_key) + data = b"".join([chunk async for chunk in cache.async_stream_data()]) + return cache.extension, data @callback @@ -216,18 +298,19 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: return languages -async def async_convert_audio( +async def _async_convert_audio( hass: HomeAssistant, from_extension: str, - audio_bytes: bytes, + audio_bytes_gen: AsyncGenerator[bytes], to_extension: str, to_sample_rate: int | None = None, to_sample_channels: int | None = None, to_sample_bytes: int | None = None, -) -> bytes: +) -> AsyncGenerator[bytes]: """Convert audio to a preferred format using ffmpeg.""" ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) - return await hass.async_add_executor_job( + audio_bytes = b"".join([chunk async for chunk in audio_bytes_gen]) + data = await hass.async_add_executor_job( lambda: _convert_audio( ffmpeg_manager.binary, from_extension, @@ -238,6 +321,7 @@ async def async_convert_audio( to_sample_bytes=to_sample_bytes, ) ) + yield data def _convert_audio( @@ -401,32 +485,33 @@ class ResultStream: return f"/api/tts_proxy/{self.token}" @cached_property - def _result_cache_key(self) -> asyncio.Future[str]: - """Get the future that returns the cache key.""" + def _result_cache(self) -> asyncio.Future[TTSCache]: + """Get the future that returns the cache.""" return asyncio.Future() @callback - def async_set_message_cache_key(self, cache_key: str) -> None: - """Set cache key for message to be streamed.""" - self._result_cache_key.set_result(cache_key) + def async_set_message_cache(self, cache: TTSCache) -> None: + """Set cache containing message audio to be streamed.""" + self._result_cache.set_result(cache) @callback def async_set_message(self, message: str) -> None: """Set message to be generated.""" - cache_key = self._manager.async_cache_message_in_memory( - engine=self.engine, - message=message, - use_file_cache=self.use_file_cache, - language=self.language, - options=self.options, + self._result_cache.set_result( + self._manager.async_cache_message_in_memory( + engine=self.engine, + message=message, + use_file_cache=self.use_file_cache, + language=self.language, + options=self.options, + ) ) - self._result_cache_key.set_result(cache_key) async def async_stream_result(self) -> AsyncGenerator[bytes]: """Get the stream of this result.""" - cache_key = await self._result_cache_key - _extension, data = await self._manager.async_get_tts_audio(cache_key) - yield data + cache = await self._result_cache + async for chunk in cache.async_stream_data(): + yield chunk def _hash_options(options: dict) -> str: @@ -483,7 +568,7 @@ class MemcacheCleanup: now = monotonic() for cache_key, info in list(memcache.items()): - if info["last_used"] + maxage < now: + if info.last_used + maxage < now: _LOGGER.debug("Cleaning up %s", cache_key) del memcache[cache_key] @@ -638,15 +723,18 @@ class SpeechManager: if message is None: return result_stream - cache_key = self._async_ensure_cached_in_memory( - engine=engine, - engine_instance=engine_instance, - message=message, - use_file_cache=use_file_cache, - language=language, - options=options, + # We added this method as an alternative to stream.async_set_message + # to avoid the options being processed twice + result_stream.async_set_message_cache( + self._async_ensure_cached_in_memory( + engine=engine, + engine_instance=engine_instance, + message=message, + use_file_cache=use_file_cache, + language=language, + options=options, + ) ) - result_stream.async_set_message_cache_key(cache_key) return result_stream @@ -658,7 +746,7 @@ class SpeechManager: use_file_cache: bool | None = None, language: str | None = None, options: dict | None = None, - ) -> str: + ) -> TTSCache: """Make sure a message is cached in memory and returns cache key.""" if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") @@ -685,7 +773,7 @@ class SpeechManager: use_file_cache: bool, language: str, options: dict, - ) -> str: + ) -> TTSCache: """Ensure a message is cached. Requires options, language to be processed. @@ -697,62 +785,101 @@ class SpeechManager: ).lower() # Is speech already in memory - if cache_key in self.mem_cache: - return cache_key + if cache := self.mem_cache.get(cache_key): + _LOGGER.debug("Found audio in cache for %s", message[0:32]) + return cache - if use_file_cache and cache_key in self.file_cache: - coro = self._async_load_file_to_mem(cache_key) + store_to_disk = use_file_cache + + if use_file_cache and (filename := self.file_cache.get(cache_key)): + _LOGGER.debug("Loading audio from disk for %s", message[0:32]) + extension = os.path.splitext(filename)[1][1:] + data_gen = self._async_load_file(cache_key) + store_to_disk = False else: - coro = self._async_generate_tts_audio( - engine_instance, cache_key, message, use_file_cache, language, options + _LOGGER.debug("Generating audio for %s", message[0:32]) + extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT) + data_gen = self._async_generate_tts_audio( + engine_instance, message, language, options ) - task = self.hass.async_create_task(coro, eager_start=False) + cache = TTSCache( + cache_key=cache_key, + extension=extension, + data_gen=data_gen, + ) - def handle_error(future: asyncio.Future) -> None: - """Handle error.""" - if not (err := future.exception()): - return + self.mem_cache[cache_key] = cache + self.hass.async_create_background_task( + self._load_data_into_cache( + cache, engine_instance, message, store_to_disk, language, options + ), + f"tts_load_data_into_cache_{engine_instance.name}", + ) + self.memcache_cleanup.schedule() + return cache + + async def _load_data_into_cache( + self, + cache: TTSCache, + engine_instance: TextToSpeechEntity | Provider, + message: str, + store_to_disk: bool, + language: str, + options: dict, + ) -> None: + """Load and process a finished loading TTS Cache.""" + try: + data = await cache.async_load_data() + except Exception as err: # pylint: disable=broad-except # noqa: BLE001 # Truncate message so we don't flood the logs. Cutting off at 32 chars # but since we add 3 dots to truncated message, we cut off at 35. trunc_msg = message if len(message) < 35 else f"{message[0:32]}…" - _LOGGER.error("Error generating audio for %s: %s", trunc_msg, err) - self.mem_cache.pop(cache_key, None) + _LOGGER.error("Error getting audio for %s: %s", trunc_msg, err) + self.mem_cache.pop(cache.cache_key, None) + return - task.add_done_callback(handle_error) + if not store_to_disk: + return - self.mem_cache[cache_key] = { - "extension": "", - "voice": b"", - "pending": task, - "last_used": monotonic(), - } - return cache_key + filename = f"{cache.cache_key}.{cache.extension}".lower() - async def async_get_tts_audio(self, cache_key: str) -> tuple[str, bytes]: - """Fetch TTS audio.""" - cached = self.mem_cache.get(cache_key) - if cached is None: - raise HomeAssistantError("Audio not cached") - if pending := cached.get("pending"): - await pending - cached = self.mem_cache[cache_key] - cached["last_used"] = monotonic() - return cached["extension"], cached["voice"] + # Validate filename + if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( + filename + ): + raise HomeAssistantError( + f"TTS filename '{filename}' from {engine_instance.name} is invalid!" + ) + + if cache.extension == "mp3": + name = ( + engine_instance.name if isinstance(engine_instance.name, str) else "-" + ) + data = self.write_tags(filename, data, name, message, language, options) + + voice_file = os.path.join(self.cache_dir, filename) + + def save_speech() -> None: + """Store speech to filesystem.""" + with open(voice_file, "wb") as speech: + speech.write(data) + + try: + await self.hass.async_add_executor_job(save_speech) + except OSError as err: + _LOGGER.error("Can't write %s: %s", filename, err) + else: + self.file_cache[cache.cache_key] = filename async def _async_generate_tts_audio( self, engine_instance: TextToSpeechEntity | Provider, - cache_key: str, message: str, - cache_to_disk: bool, language: str, options: dict[str, Any], - ) -> None: - """Start loading of the TTS audio. - - This method is a coroutine. - """ + ) -> AsyncGenerator[bytes]: + """Generate TTS audio from an engine.""" options = dict(options or {}) supported_options = engine_instance.supported_options or [] @@ -800,6 +927,17 @@ class SpeechManager: extension, data = await engine_instance.async_get_tts_audio( message, language, options ) + + if data is None or extension is None: + raise HomeAssistantError( + f"No TTS from {engine_instance.name} for '{message}'" + ) + + async def make_data_generator(data: bytes) -> AsyncGenerator[bytes]: + yield data + + data_gen = make_data_generator(data) + else: async def message_gen() -> AsyncGenerator[str]: @@ -809,12 +947,7 @@ class SpeechManager: TTSAudioRequest(language, options, message_gen()) ) extension = tts_result.extension - data = b"".join([chunk async for chunk in tts_result.data_gen]) - - if data is None or extension is None: - raise HomeAssistantError( - f"No TTS from {engine_instance.name} for '{message}'" - ) + data_gen = tts_result.data_gen # Only convert if we have a preferred format different than the # expected format from the TTS system, or if a specific sample @@ -827,62 +960,21 @@ class SpeechManager: ) if needs_conversion: - data = await async_convert_audio( + data_gen = _async_convert_audio( self.hass, extension, - data, + data_gen, to_extension=final_extension, to_sample_rate=sample_rate, to_sample_channels=sample_channels, to_sample_bytes=sample_bytes, ) - # Create file infos - filename = f"{cache_key}.{final_extension}".lower() + async for chunk in data_gen: + yield chunk - # Validate filename - if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match( - filename - ): - raise HomeAssistantError( - f"TTS filename '{filename}' from {engine_instance.name} is invalid!" - ) - - # Save to memory - if final_extension == "mp3": - data = self.write_tags( - filename, data, engine_instance.name, message, language, options - ) - - self._async_store_to_memcache(cache_key, final_extension, data) - - if not cache_to_disk: - return - - voice_file = os.path.join(self.cache_dir, filename) - - def save_speech() -> None: - """Store speech to filesystem.""" - with open(voice_file, "wb") as speech: - speech.write(data) - - # Don't await, we're going to do this in the background - task = self.hass.async_add_executor_job(save_speech) - - def write_done(future: asyncio.Future) -> None: - """Write is done task.""" - if err := future.exception(): - _LOGGER.error("Can't write %s: %s", filename, err) - else: - self.file_cache[cache_key] = filename - - task.add_done_callback(write_done) - - async def _async_load_file_to_mem(self, cache_key: str) -> None: - """Load voice from file cache into memory. - - This method is a coroutine. - """ + async def _async_load_file(self, cache_key: str) -> AsyncGenerator[bytes]: + """Load TTS audio from disk.""" if not (filename := self.file_cache.get(cache_key)): raise HomeAssistantError(f"Key {cache_key} not in file cache!") @@ -899,22 +991,7 @@ class SpeechManager: del self.file_cache[cache_key] raise HomeAssistantError(f"Can't read {voice_file}") from err - extension = os.path.splitext(filename)[1][1:] - - self._async_store_to_memcache(cache_key, extension, data) - - @callback - def _async_store_to_memcache( - self, cache_key: str, extension: str, data: bytes - ) -> None: - """Store data to memcache and set timer to remove it.""" - self.mem_cache[cache_key] = { - "extension": extension, - "voice": data, - "pending": None, - "last_used": monotonic(), - } - self.memcache_cleanup.schedule() + yield data @staticmethod def write_tags( diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 8bdd17cf3e9..be14e006610 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -168,7 +168,7 @@ async def test_service( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" @@ -230,7 +230,7 @@ async def test_service_default_language( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -294,7 +294,7 @@ async def test_service_default_special_language( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" @@ -354,7 +354,7 @@ async def test_service_language( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" @@ -470,7 +470,7 @@ async def test_service_options( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -554,7 +554,7 @@ async def test_service_default_options( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -628,7 +628,7 @@ async def test_merge_default_service_options( assert await get_media_source_url( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) == ("/api/tts_proxy/test_token.mp3") - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / ( @@ -743,7 +743,7 @@ async def test_service_clear_cache( # To make sure the file is persisted assert len(calls) == 1 await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert ( mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" @@ -1769,9 +1769,15 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: """Test that ffmpeg failing during audio conversion will raise an error.""" assert await async_setup_component(hass, ffmpeg.DOMAIN, {}) - with pytest.raises(RuntimeError): + async def bad_data_gen(): + yield bytes(0) + + with pytest.raises(RuntimeError): # noqa: PT012 # Simulate a bad WAV file - await tts.async_convert_audio(hass, "wav", bytes(0), "mp3") + async for _chunk in tts._async_convert_audio( + hass, "wav", bad_data_gen(), "mp3" + ): + pass async def test_default_engine_prefer_entity( @@ -1846,3 +1852,86 @@ async def test_stream(hass: HomeAssistant, mock_tts_entity: MockTTSEntity) -> No assert stream2.extension == "wav" result_data = b"".join([chunk async for chunk in stream2.async_stream_result()]) assert result_data == data + + +async def test_tts_cache() -> None: + """Test TTSCache.""" + + async def data_gen(queue: asyncio.Queue[bytes | None | Exception]): + while chunk := await queue.get(): + if isinstance(chunk, Exception): + raise chunk + yield chunk + + queue = asyncio.Queue() + cache = tts.TTSCache("test-key", "mp3", data_gen(queue)) + assert cache.cache_key == "test-key" + assert cache.extension == "mp3" + + for i in range(10): + queue.put_nowait(f"{i}".encode()) + queue.put_nowait(None) + + assert await cache.async_load_data() == b"0123456789" + + with pytest.raises(RuntimeError): + await cache.async_load_data() + + # When data is loaded, we get it all in 1 chunk + cur = 0 + async for chunk in cache.async_stream_data(): + assert chunk == b"0123456789" + cur += 1 + assert cur == 1 + + # Show we can stream the data while it's still being generated + async def consume_cache(cache: tts.TTSCache): + return b"".join([chunk async for chunk in cache.async_stream_data()]) + + queue = asyncio.Queue() + cache = tts.TTSCache("test-key", "mp3", data_gen(queue)) + + load_data_task = asyncio.create_task(cache.async_load_data()) + consume_pre_data_loaded_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(b"0") + await asyncio.sleep(0) + queue.put_nowait(b"1") + await asyncio.sleep(0) + consume_mid_data_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(b"2") + await asyncio.sleep(0) + queue.put_nowait(None) + consume_post_data_loaded_task = asyncio.create_task(consume_cache(cache)) + await asyncio.sleep(0) + assert await load_data_task == b"012" + assert await consume_post_data_loaded_task == b"012" + assert await consume_mid_data_task == b"012" + assert await consume_pre_data_loaded_task == b"012" + + # Now with errors + async def consume_cache(cache: tts.TTSCache): + return b"".join([chunk async for chunk in cache.async_stream_data()]) + + queue = asyncio.Queue() + cache = tts.TTSCache("test-key", "mp3", data_gen(queue)) + + load_data_task = asyncio.create_task(cache.async_load_data()) + consume_pre_data_loaded_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(b"0") + await asyncio.sleep(0) + queue.put_nowait(b"1") + await asyncio.sleep(0) + consume_mid_data_task = asyncio.create_task(consume_cache(cache)) + queue.put_nowait(ValueError("Boom!")) + await asyncio.sleep(0) + queue.put_nowait(None) + consume_post_data_loaded_task = asyncio.create_task(consume_cache(cache)) + await asyncio.sleep(0) + with pytest.raises(ValueError): + assert await load_data_task == b"012" + with pytest.raises(ValueError): + assert await consume_post_data_loaded_task == b"012" + with pytest.raises(ValueError): + assert await consume_mid_data_task == b"012" + with pytest.raises(ValueError): + assert await consume_pre_data_loaded_task == b"012" diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 263804787b1..73fb68b44e5 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -150,17 +150,15 @@ async def test_get_tts_audio_connection_lost( hass: HomeAssistant, init_wyoming_tts ) -> None: """Test streaming audio and losing connection.""" - with ( - patch( - "homeassistant.components.wyoming.tts.AsyncTcpClient", - MockAsyncTcpClient([None]), - ), - pytest.raises(HomeAssistantError), + stream = tts.async_create_stream(hass, "tts.test_tts", "en-US") + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient([None]), ): - await tts.async_get_media_source_audio( - hass, - tts.generate_media_source_id(hass, "Hello world", "tts.test_tts", "en-US"), - ) + stream.async_set_message("Hello world") + with pytest.raises(HomeAssistantError): # noqa: PT012 + async for _chunk in stream.async_stream_result(): + pass async def test_get_tts_audio_audio_oserror( From d5af542dd1fa4cda2b7e6870e205a17b79c845bb Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 13 Mar 2025 18:32:45 +0100 Subject: [PATCH 1626/1941] Add parallel updates to Vodafone Station (#140532) --- homeassistant/components/vodafone_station/button.py | 3 +++ homeassistant/components/vodafone_station/device_tracker.py | 3 +++ homeassistant/components/vodafone_station/sensor.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 9227611ce22..5c98c3241e9 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -19,6 +19,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER from .coordinator import VodafoneConfigEntry, VodafoneStationRouter +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class VodafoneStationEntityDescription(ButtonEntityDescription): diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index 984355287a4..4efa26cda8c 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -15,6 +15,9 @@ from .coordinator import ( VodafoneStationRouter, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index bdb429aa6dd..2573864330d 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -20,6 +20,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, LINE_TYPES from .coordinator import VodafoneConfigEntry, VodafoneStationRouter +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] UPTIME_DEVIATION = 60 From 8ea2d40467c4667fef32941765b8f5334520c923 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Thu, 13 Mar 2025 18:57:05 +0000 Subject: [PATCH 1627/1941] Bump ohmepy to 1.4.1 (#140535) --- 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 f31af213387..f0021808d92 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.4.0"] + "requirements": ["ohme==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11a9df4ba16..947e025115c 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.4.0 +ohme==1.4.1 # homeassistant.components.ollama ollama==0.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7769e8e824f..6d9f549be38 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.4.0 +ohme==1.4.1 # homeassistant.components.ollama ollama==0.4.7 From fa57d572154f3c3c53e881e75c5370c59750db36 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 13 Mar 2025 19:58:09 +0100 Subject: [PATCH 1628/1941] Fix Shelly diagnostics for devices without WebSocket Outbound support (#140501) * Don't assume that `ws` is always in config * Fix device --- homeassistant/components/shelly/diagnostics.py | 14 ++++++++------ tests/components/shelly/test_diagnostics.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py index cac2bb2f16b..2a9699e0a08 100644 --- a/homeassistant/components/shelly/diagnostics.py +++ b/homeassistant/components/shelly/diagnostics.py @@ -79,12 +79,14 @@ async def async_get_config_entry_diagnostics( device_settings = { k: v for k, v in rpc_coordinator.device.config.items() if k in ["cloud"] } - ws_config = rpc_coordinator.device.config["ws"] - device_settings["ws_outbound_enabled"] = ws_config["enable"] - if ws_config["enable"]: - device_settings["ws_outbound_server_valid"] = bool( - ws_config["server"] == get_rpc_ws_url(hass) - ) + if not (ws_config := rpc_coordinator.device.config.get("ws", {})): + device_settings["ws_outbound"] = "not supported" + if (ws_outbound_enabled := ws_config.get("enable")) is not None: + device_settings["ws_outbound_enabled"] = ws_outbound_enabled + if ws_outbound_enabled: + device_settings["ws_outbound_server_valid"] = bool( + ws_config["server"] == get_rpc_ws_url(hass) + ) device_status = { k: v for k, v in rpc_coordinator.device.status.items() diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 3826631c580..d89f21f5992 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -194,3 +194,21 @@ async def test_rpc_config_entry_diagnostics_ws_outbound( result["device_settings"]["ws_outbound_server_valid"] == ws_outbound_server_valid ) + + +async def test_rpc_config_entry_diagnostics_no_ws( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test config entry diagnostics for rpc device which doesn't support ws outbound.""" + config = deepcopy(mock_rpc_device.config) + config.pop("ws") + monkeypatch.setattr(mock_rpc_device, "config", config) + + entry = await init_integration(hass, 3) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result["device_settings"]["ws_outbound"] == "not supported" From 87f726141a485704236e2def9ec9d99800b57d7c Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 13 Mar 2025 21:41:45 +0200 Subject: [PATCH 1629/1941] Fix ollama history trimming test (#140538) --- tests/components/ollama/test_conversation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index db641ba703b..c718aab1e81 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, Mock, patch +from freezegun.api import FrozenDateTimeFactory from ollama import Message, ResponseError import pytest from syrupy.assertion import SnapshotAssertion @@ -404,7 +405,10 @@ async def test_unknown_hass_api( async def test_message_history_trimming( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + freezer: FrozenDateTimeFactory, ) -> None: """Test that a single message history is trimmed according to the config.""" response_idx = 0 From 474d427b879d2a923433bc09f03f17f48f1b4cab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 10:01:41 -1000 Subject: [PATCH 1630/1941] Bump bleak-esphome to 2.12.0 (#140543) changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.11.0...v2.12.0 --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 4b65852d205..ab62c962982 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.11.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 6783b05fa0f..8d1cafee926 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.6.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.11.0" + "bleak-esphome==2.12.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 947e025115c..5331fdb6800 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.11.0 +bleak-esphome==2.12.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d9f549be38..31d99827de1 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.11.0 +bleak-esphome==2.12.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From cdead8661d7c0a8fbdfd51f3b5039af5b1d30a21 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 13 Mar 2025 21:27:00 +0100 Subject: [PATCH 1631/1941] Add lawn mower support to HomeKit (#140438) Add lawn mower support to homekit --- .../components/homekit/accessories.py | 8 ++ .../components/homekit/config_flow.py | 2 + .../components/homekit/type_switches.py | 29 +++++++ .../components/homekit/test_type_switches.py | 75 +++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 8d10387e239..0d810d6986d 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -15,6 +15,7 @@ from pyhap.service import Service from pyhap.util import callback as pyhap_callback from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature +from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.remote import RemoteEntityFeature from homeassistant.components.sensor import SensorDeviceClass @@ -250,6 +251,13 @@ def get_accessory( # noqa: C901 elif state.domain == "vacuum": a_type = "Vacuum" + elif ( + state.domain == "lawn_mower" + and features & LawnMowerEntityFeature.DOCK + and features & LawnMowerEntityFeature.START_MOWING + ): + a_type = "LawnMower" + elif state.domain == "remote" and features & RemoteEntityFeature.ACTIVITY: a_type = "ActivityRemote" diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 53db7774821..0ef2e8563bc 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -106,6 +106,7 @@ SUPPORTED_DOMAINS = [ "sensor", "switch", "vacuum", + "lawn_mower", "water_heater", VALVE_DOMAIN, ] @@ -123,6 +124,7 @@ DEFAULT_DOMAINS = [ REMOTE_DOMAIN, "switch", "vacuum", + "lawn_mower", "water_heater", ] diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 0482a5956ac..8c6fc1ed672 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -16,6 +16,12 @@ from pyhap.const import ( from homeassistant.components import button, input_button from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_START_MOWING, + LawnMowerActivity, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -218,6 +224,29 @@ class Vacuum(Switch): self.char_on.set_value(current_state) +@TYPES.register("LawnMower") +class LawnMower(Switch): + """Generate a Switch accessory.""" + + def set_state(self, value: bool) -> None: + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) + state = self.hass.states.get(self.entity_id) + assert state + + service = SERVICE_START_MOWING if value else SERVICE_DOCK + self.async_call_service( + LAWN_MOWER_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id} + ) + + @callback + def async_update_state(self, new_state: State) -> None: + """Update switch state after state changed.""" + current_state = new_state.state in (LawnMowerActivity.MOWING, STATE_ON) + _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) + self.char_on.set_value(current_state) + + class ValveBase(HomeAccessory): """Valve base class.""" diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 141141e7f15..6a30877a795 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -12,6 +12,7 @@ from homeassistant.components.homekit.const import ( TYPE_VALVE, ) from homeassistant.components.homekit.type_switches import ( + LawnMower, Outlet, SelectSwitch, Switch, @@ -19,6 +20,13 @@ from homeassistant.components.homekit.type_switches import ( Valve, ValveSwitch, ) +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_START_MOWING, + LawnMowerActivity, + LawnMowerEntityFeature, +) from homeassistant.components.select import ATTR_OPTIONS from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, @@ -383,6 +391,73 @@ async def test_vacuum_set_state_without_returnhome_and_start_support( assert events[-1].data[ATTR_VALUE] is None +async def test_lawn_mower_set_state( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test if Lawn mower accessory and HA are updated accordingly.""" + entity_id = "lawn_mower.mower" + + hass.states.async_set( + entity_id, + None, + { + ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.START_MOWING + }, + ) + await hass.async_block_till_done() + + acc = LawnMower(hass, hk_driver, "LawnMower", entity_id, 2, None) + acc.run() + await hass.async_block_till_done() + assert acc.aid == 2 + assert acc.category == 8 # Switch + + assert acc.char_on.value == 0 + + hass.states.async_set( + entity_id, + LawnMowerActivity.MOWING, + { + ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.START_MOWING + }, + ) + await hass.async_block_till_done() + assert acc.char_on.value == 1 + + hass.states.async_set( + entity_id, + LawnMowerActivity.DOCKED, + { + ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.START_MOWING + }, + ) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, LAWN_MOWER_DOMAIN, SERVICE_START_MOWING) + call_turn_off = async_mock_service(hass, LAWN_MOWER_DOMAIN, SERVICE_DOCK) + + acc.char_on.client_update_value(1) + await hass.async_block_till_done() + assert acc.char_on.value == 1 + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + acc.char_on.client_update_value(0) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None + + async def test_reset_switch( hass: HomeAssistant, hk_driver, events: list[Event] ) -> None: From b48ab77a38efc1df8df3680eaac373a02c2564a0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 23:02:26 +0100 Subject: [PATCH 1632/1941] Fix call on root logger (LOG015) (#140556) --- homeassistant/components/point/config_flow.py | 4 +++- homeassistant/components/sky_remote/config_flow.py | 8 +++++--- tests/components/stream/conftest.py | 6 ++++-- tests/components/stream/test_worker.py | 4 +++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index a0a51c7b9e6..b26ade8b725 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -11,6 +11,8 @@ from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHan from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle Minut Point OAuth2 authentication.""" @@ -56,7 +58,7 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): if reauth_entry.unique_id is not None: self._abort_if_unique_id_mismatch(reason="wrong_account") - logging.debug("user_id: %s", user_id) + _LOGGER.debug("user_id: %s", user_id) return self.async_update_reload_and_abort( reauth_entry, data_updates=data, unique_id=user_id ) diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py index 13cddf99332..51cf9c9bf64 100644 --- a/homeassistant/components/sky_remote/config_flow.py +++ b/homeassistant/components/sky_remote/config_flow.py @@ -12,6 +12,8 @@ from homeassistant.helpers import config_validation as cv from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, @@ -21,7 +23,7 @@ DATA_SCHEMA = vol.Schema( async def async_find_box_port(host: str) -> int: """Find port box uses for communication.""" - logging.debug("Attempting to find port to connect to %s on", host) + _LOGGER.debug("Attempting to find port to connect to %s on", host) remote = RemoteControl(host, DEFAULT_PORT) try: await remote.check_connectable() @@ -46,12 +48,12 @@ class SkyRemoteConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - logging.debug("user_input: %s", user_input) + _LOGGER.debug("user_input: %s", user_input) self._async_abort_entries_match(user_input) try: port = await async_find_box_port(user_input[CONF_HOST]) except SkyBoxConnectionError: - logging.exception("while finding port of skybox") + _LOGGER.exception("While finding port of skybox") errors["base"] = "cannot_connect" else: return self.async_create_entry( diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index 39e4de13fed..296505271c0 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -27,6 +27,8 @@ from homeassistant.components.stream.worker import StreamState from .common import generate_h264_video, stream_teardown +_LOGGER = logging.getLogger(__name__) + TEST_TIMEOUT = 7.0 # Lower than 9s home assistant timeout @@ -44,7 +46,7 @@ class WorkerSync: def resume(self): """Allow the worker thread to finalize the stream.""" - logging.debug("waking blocked worker") + _LOGGER.debug("waking blocked worker") self._event.set() def blocking_discontinuity(self, stream_state: StreamState): @@ -52,7 +54,7 @@ class WorkerSync: # Worker is ending the stream, which clears all output buffers. # Block the worker thread until the test has a chance to verify # the segments under test. - logging.debug("blocking worker") + _LOGGER.debug("blocking worker") if self._event: self._event.wait() diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 2be972cc6a2..276b4109652 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -56,6 +56,8 @@ from .test_ll_hls import TEST_PART_DURATION from tests.components.camera.common import EMPTY_8_6_JPEG, mock_turbo_jpeg +_LOGGER = logging.getLogger(__name__) + STREAM_SOURCE = "some-stream-source" # Formats here are arbitrary, not exercised by tests AUDIO_STREAM_FORMAT = "mp3" @@ -229,7 +231,7 @@ class FakePyAvBuffer: return def mux(self, packet): - logging.debug("Muxed packet: %s", packet) + _LOGGER.debug("Muxed packet: %s", packet) self.capture_packets.append(packet) def __str__(self) -> str: From 5cf3bea8fe79c89d2ec750535996ec08d1819b09 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 13 Mar 2025 23:32:00 +0100 Subject: [PATCH 1633/1941] Fix unnecessary-dict-comprehension-for-iterable (C420) (#140555) --- homeassistant/components/isy994/sensor.py | 6 +-- homeassistant/components/knx/__init__.py | 2 +- homeassistant/components/knx/services.py | 2 +- homeassistant/components/logger/helpers.py | 2 +- .../components/netatmo/config_flow.py | 2 +- .../components/onewire/config_flow.py | 7 +-- .../recorder/auto_repairs/schema.py | 4 +- .../components/recorder/statistics.py | 47 +++++++++---------- homeassistant/components/risco/const.py | 6 +-- .../components/solarlog/coordinator.py | 2 +- .../components/telegram_bot/__init__.py | 2 +- .../components/tesla_fleet/coordinator.py | 2 +- .../components/teslemetry/coordinator.py | 2 +- .../components/ukraine_alarm/coordinator.py | 2 +- .../components/xiaomi_miio/button.py | 2 +- tests/components/conftest.py | 2 +- tests/components/harmony/test_subscriber.py | 2 +- tests/components/nws/const.py | 4 +- tests/components/stream/test_ll_hls.py | 2 +- 19 files changed, 46 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 2655f4d3c4e..2d27f4602c6 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -97,9 +97,9 @@ ISY_CONTROL_TO_DEVICE_CLASS = { "WEIGHT": SensorDeviceClass.WEIGHT, "WINDCH": SensorDeviceClass.TEMPERATURE, } -ISY_CONTROL_TO_STATE_CLASS = { - control: SensorStateClass.MEASUREMENT for control in ISY_CONTROL_TO_DEVICE_CLASS -} +ISY_CONTROL_TO_STATE_CLASS = dict.fromkeys( + ISY_CONTROL_TO_DEVICE_CLASS, SensorStateClass.MEASUREMENT +) ISY_CONTROL_TO_ENTITY_CATEGORY = { PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC, PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index fa3439b02f4..8ad16642e45 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -486,7 +486,7 @@ class KNXModule: transcoder := DPTBase.parse_transcoder(dpt) ): self._address_filter_transcoder.update( - {_filter: transcoder for _filter in _filters} + dict.fromkeys(_filters, transcoder) ) return self.xknx.telegram_queue.register_telegram_received_cb( diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index f0f760180f4..fc28e0850ed 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -126,7 +126,7 @@ async def service_event_register_modify(call: ServiceCall) -> None: transcoder := DPTBase.parse_transcoder(dpt) ): knx_module.group_address_transcoder.update( - {_address: transcoder for _address in group_addresses} + dict.fromkeys(group_addresses, transcoder) ) for group_address in group_addresses: if group_address in knx_module.knx_event_callback.group_addresses: diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index 034266428a3..00cea7e8aa5 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -203,7 +203,7 @@ class LoggerSettings: else: loggers = {domain} - combined_logs = {logger: LOGSEVERITY[settings.level] for logger in loggers} + combined_logs = dict.fromkeys(loggers, LOGSEVERITY[settings.level]) # Don't override the log levels with the ones from YAML # since we want whatever the user is asking for to be honored. diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index d853694ffea..02d9c2fa3a6 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -135,7 +135,7 @@ class NetatmoOptionsFlowHandler(OptionsFlow): vol.Optional( CONF_WEATHER_AREAS, default=weather_areas, - ): cv.multi_select({wa: None for wa in weather_areas}), + ): cv.multi_select(dict.fromkeys(weather_areas)), vol.Optional(CONF_NEW_AREA): str, } ) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 8a5623772f7..2099d9aabb5 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -234,12 +234,7 @@ class OnewireOptionsFlowHandler(OptionsFlow): INPUT_ENTRY_DEVICE_SELECTION, default=self._get_current_configured_sensors(), description="Multiselect with list of devices to choose from", - ): cv.multi_select( - { - friendly_name: False - for friendly_name in self.configurable_devices - } - ), + ): cv.multi_select(dict.fromkeys(self.configurable_devices, False)), } ), errors=errors, diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index 1373f466bc2..cf3addd4f20 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -175,7 +175,7 @@ def _validate_db_schema_precision( # Mark the session as read_only to ensure that the test data is not committed # to the database and we always rollback when the scope is exited with session_scope(session=instance.get_session(), read_only=True) as session: - db_object = table_object(**{column: PRECISE_NUMBER for column in columns}) + db_object = table_object(**dict.fromkeys(columns, PRECISE_NUMBER)) table = table_object.__tablename__ try: session.add(db_object) @@ -184,7 +184,7 @@ def _validate_db_schema_precision( _check_columns( schema_errors=schema_errors, stored={column: getattr(db_object, column) for column in columns}, - expected={column: PRECISE_NUMBER for column in columns}, + expected=dict.fromkeys(columns, PRECISE_NUMBER), columns=columns, table_name=table, supports="double precision", diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 97fe73c54fe..e26a69c0db9 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -136,31 +136,28 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { - **{unit: AreaConverter for unit in AreaConverter.VALID_UNITS}, - **{ - unit: BloodGlucoseConcentrationConverter - for unit in BloodGlucoseConcentrationConverter.VALID_UNITS - }, - **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, - **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, - **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, - **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS}, - **{unit: ElectricCurrentConverter for unit in ElectricCurrentConverter.VALID_UNITS}, - **{ - unit: ElectricPotentialConverter - 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}, - **{unit: PressureConverter for unit in PressureConverter.VALID_UNITS}, - **{unit: SpeedConverter for unit in SpeedConverter.VALID_UNITS}, - **{unit: TemperatureConverter for unit in TemperatureConverter.VALID_UNITS}, - **{unit: UnitlessRatioConverter for unit in UnitlessRatioConverter.VALID_UNITS}, - **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS}, - **{unit: VolumeFlowRateConverter for unit in VolumeFlowRateConverter.VALID_UNITS}, + **dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter), + **dict.fromkeys( + BloodGlucoseConcentrationConverter.VALID_UNITS, + BloodGlucoseConcentrationConverter, + ), + **dict.fromkeys(ConductivityConverter.VALID_UNITS, ConductivityConverter), + **dict.fromkeys(DataRateConverter.VALID_UNITS, DataRateConverter), + **dict.fromkeys(DistanceConverter.VALID_UNITS, DistanceConverter), + **dict.fromkeys(DurationConverter.VALID_UNITS, DurationConverter), + **dict.fromkeys(ElectricCurrentConverter.VALID_UNITS, ElectricCurrentConverter), + **dict.fromkeys(ElectricPotentialConverter.VALID_UNITS, ElectricPotentialConverter), + **dict.fromkeys(EnergyConverter.VALID_UNITS, EnergyConverter), + **dict.fromkeys(EnergyDistanceConverter.VALID_UNITS, EnergyDistanceConverter), + **dict.fromkeys(InformationConverter.VALID_UNITS, InformationConverter), + **dict.fromkeys(MassConverter.VALID_UNITS, MassConverter), + **dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter), + **dict.fromkeys(PressureConverter.VALID_UNITS, PressureConverter), + **dict.fromkeys(SpeedConverter.VALID_UNITS, SpeedConverter), + **dict.fromkeys(TemperatureConverter.VALID_UNITS, TemperatureConverter), + **dict.fromkeys(UnitlessRatioConverter.VALID_UNITS, UnitlessRatioConverter), + **dict.fromkeys(VolumeConverter.VALID_UNITS, VolumeConverter), + **dict.fromkeys(VolumeFlowRateConverter.VALID_UNITS, VolumeFlowRateConverter), } diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py index 078e26c43b5..ef3280fe232 100644 --- a/homeassistant/components/risco/const.py +++ b/homeassistant/components/risco/const.py @@ -30,9 +30,9 @@ RISCO_ARM = "arm" RISCO_PARTIAL_ARM = "partial_arm" RISCO_STATES = [RISCO_ARM, RISCO_PARTIAL_ARM, *RISCO_GROUPS] -DEFAULT_RISCO_GROUPS_TO_HA = { - group: AlarmControlPanelState.ARMED_HOME for group in RISCO_GROUPS -} +DEFAULT_RISCO_GROUPS_TO_HA = dict.fromkeys( + RISCO_GROUPS, AlarmControlPanelState.ARMED_HOME +) DEFAULT_RISCO_STATES_TO_HA = { RISCO_ARM: AlarmControlPanelState.ARMED_AWAY, RISCO_PARTIAL_ARM: AlarmControlPanelState.ARMED_HOME, diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 6292b1332d7..48ebeece1ba 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -75,7 +75,7 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): await self.solarlog.test_extended_data_available() if logged_in or await self.solarlog.test_extended_data_available(): device_list = await self.solarlog.update_device_list() - self.solarlog.set_enabled_devices({key: True for key in device_list}) + self.solarlog.set_enabled_devices(dict.fromkeys(device_list, True)) async def _async_update_data(self) -> SolarlogData: """Update the data from the SolarLog device.""" diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index b3c09049ae5..15e1f7d4f0e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -548,7 +548,7 @@ class TelegramNotificationService: """Initialize the service.""" self.allowed_chat_ids = allowed_chat_ids self._default_user = self.allowed_chat_ids[0] - self._last_message_id = {user: None for user in self.allowed_chat_ids} + self._last_message_id = dict.fromkeys(self.allowed_chat_ids) self._parsers = { PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN, diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 128c15068f6..6f881d0feba 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -248,7 +248,7 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any self.updated_once = True # Add all time periods together - output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: output[key] += period.get(key, 0) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 0cd2a5a62d6..f902fb4cc1b 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -192,7 +192,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise UpdateFailed(e.message) from e # Add all time periods together - output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: output[key] += period.get(key, 0) diff --git a/homeassistant/components/ukraine_alarm/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py index 267358e4aa6..b4e1decb1a1 100644 --- a/homeassistant/components/ukraine_alarm/coordinator.py +++ b/homeassistant/components/ukraine_alarm/coordinator.py @@ -52,7 +52,7 @@ class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): except aiohttp.ClientError as error: raise UpdateFailed(f"Error fetching alerts from API: {error}") from error - current = {alert_type: False for alert_type in ALERT_TYPES} + current = dict.fromkeys(ALERT_TYPES, False) for alert in res[0]["activeAlerts"]: current[alert["type"]] = True diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index a5d1b4b69c6..a7bcb3a12fe 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -117,7 +117,7 @@ MODEL_TO_BUTTON_MAP: dict[str, tuple[str, ...]] = { ATTR_RESET_DUST_FILTER, ATTR_RESET_UPPER_FILTER, ), - **{model: BUTTONS_FOR_VACUUM for model in MODELS_VACUUM}, + **dict.fromkeys(MODELS_VACUUM, BUTTONS_FOR_VACUUM), } diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 6d6d0d4641f..e0db306cae9 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -929,7 +929,7 @@ async def check_translations( ignored_domains = set(ignore_translations_for_mock_domains) # Set all ignored translation keys to "unused" - translation_errors = {k: "unused" for k in ignore_missing_translations} + translation_errors = dict.fromkeys(ignore_missing_translations, "unused") translation_coros = set() diff --git a/tests/components/harmony/test_subscriber.py b/tests/components/harmony/test_subscriber.py index f1d1866a044..22957fc3f69 100644 --- a/tests/components/harmony/test_subscriber.py +++ b/tests/components/harmony/test_subscriber.py @@ -38,7 +38,7 @@ async def test_empty_callbacks(hass: HomeAssistant) -> None: """Ensure we handle a missing callback in a subscription.""" subscriber = HarmonySubscriberMixin(hass) - callbacks = {k: None for k in _ALL_CALLBACK_NAMES} + callbacks = dict.fromkeys(_ALL_CALLBACK_NAMES) subscriber.async_subscribe(HarmonyCallback(**callbacks)) _call_all_callbacks(subscriber) await hass.async_block_till_done() diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index 39e954af15a..1de8f67fbdb 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -176,7 +176,7 @@ WEATHER_EXPECTED_OBSERVATION_METRIC = { ATTR_WEATHER_HUMIDITY: 10, } -NONE_OBSERVATION = {key: None for key in DEFAULT_OBSERVATION} +NONE_OBSERVATION = dict.fromkeys(DEFAULT_OBSERVATION) DEFAULT_FORECAST = [ { @@ -235,4 +235,4 @@ EXPECTED_FORECAST_METRIC = { ATTR_FORECAST_HUMIDITY: 75, } -NONE_FORECAST = [{key: None for key in DEFAULT_FORECAST[0]}] +NONE_FORECAST = [dict.fromkeys(DEFAULT_FORECAST[0])] diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 443103fdf92..1eb638237af 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -202,7 +202,7 @@ async def test_ll_hls_stream( datetime_re = re.compile(r"#EXT-X-PROGRAM-DATE-TIME:(?P.+)") inf_re = re.compile(r"#EXTINF:(?P[0-9]{1,}.[0-9]{3,}),") # keep track of which tests were done (indexed by re) - tested = {regex: False for regex in (part_re, datetime_re, inf_re)} + tested = dict.fromkeys((part_re, datetime_re, inf_re), False) # keep track of times and durations along playlist for checking consistency part_durations = [] segment_duration = 0 From d56680e05e69fa800957efe99e794c294f5dc0db Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 14 Mar 2025 00:13:16 +0100 Subject: [PATCH 1634/1941] Update to version 1.6.0 of gardena library (#140559) --- homeassistant/components/gardena_bluetooth/config_flow.py | 2 ++ homeassistant/components/gardena_bluetooth/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py index c7631b62f47..613d0cf21db 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -41,6 +41,8 @@ def _is_supported(discovery_info: BluetoothServiceInfo): ProductType.PUMP, ProductType.VALVE, ProductType.WATER_COMPUTER, + ProductType.AUTOMATS, + ProductType.PRESSURE_TANKS, ): _LOGGER.debug("Unsupported device: %s", manufacturer_data) return False diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 28bba1015f5..8c9cda7d998 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], - "requirements": ["gardena-bluetooth==1.5.0"] + "requirements": ["gardena-bluetooth==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5331fdb6800..9fc11d08b32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -974,7 +974,7 @@ fyta_cli==0.7.1 gTTS==2.5.3 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.5.0 +gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 31d99827de1..fd6f2e9112a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -827,7 +827,7 @@ fyta_cli==0.7.1 gTTS==2.5.3 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.5.0 +gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 From b1285fcc4e28a8a9e960eb9c57f52e37364603bc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 00:28:01 +0100 Subject: [PATCH 1635/1941] Set unit of measurement for SmartThings oven setpoint (#140560) --- .../components/smartthings/sensor.py | 3 + tests/components/smartthings/conftest.py | 1 + .../device_status/da_ks_range_0101x.json | 688 ++++++++++++++++++ .../fixtures/devices/da_ks_range_0101x.json | 197 +++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 406 ++++++++++- 6 files changed, 1325 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ce0f30a1f1a..ec4d9ee6207 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -572,6 +572,9 @@ CAPABILITY_TO_SENSORS: dict[ SmartThingsSensorEntityDescription( key=Attribute.OVEN_SETPOINT, translation_key="oven_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + value_fn=lambda value: value if value != 0 else None, ) ] }, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 2deef344b5e..f0e2f76c112 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -113,6 +113,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_wm_000001_1", "da_rvc_normal_000001", "da_ks_microwave_0101x", + "da_ks_range_0101x", "hue_color_temperature_bulb", "hue_rgbw_color_bulb", "c2c_shade", diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json new file mode 100644 index 00000000000..6d15aa4696d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_range_0101x.json @@ -0,0 +1,688 @@ +{ + "components": { + "cavity-01": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 0, + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-09-07T22:35:34.197Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 175, + "unit": "F", + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2024-05-14T19:00:04.579Z", + "timestamp": "2024-05-14T19:00:04.584Z" + }, + "operatingState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "progress": { + "value": 1, + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2022-02-21T22:37:05.415Z" + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": null + }, + "defaultOvenMode": { + "value": "ConvectionBake", + "timestamp": "2022-02-21T22:37:06.983Z" + }, + "defaultOvenSetpoint": { + "value": 350, + "timestamp": "2022-02-21T22:37:06.976Z" + } + }, + "custom.ovenCavityStatus": { + "ovenCavityStatus": { + "value": "off", + "timestamp": "2025-03-12T20:38:01.259Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": ["Others"], + "timestamp": "2022-02-21T22:37:08.409Z" + }, + "ovenMode": { + "value": "Others", + "timestamp": "2022-02-21T22:37:06.983Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2024-05-14T19:00:04.579Z", + "timestamp": "2024-05-14T19:00:04.584Z" + }, + "machineState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "progress": { + "value": 1, + "unit": "%", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "ready", + "timestamp": "2022-02-21T22:37:05.415Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2022-02-21T22:37:05.415Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": ["SelfClean", "SteamClean", "NoOperation"], + "timestamp": "2022-02-21T22:37:08.409Z" + }, + "ovenMode": { + "value": "NoOperation", + "timestamp": "2022-02-21T22:37:06.983Z" + } + } + }, + "main": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 425, + "timestamp": "2025-03-13T21:42:23.492Z" + } + }, + "samsungce.meatProbe": { + "temperatureSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2022-02-21T22:37:02.619Z" + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2022-02-21T22:37:02.619Z" + }, + "status": { + "value": "disconnected", + "timestamp": "2022-02-21T22:37:02.679Z" + } + }, + "refresh": {}, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-03-12T20:38:01.255Z" + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": 3600, + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "defaultOvenMode": { + "value": "ConvectionBake", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "defaultOvenSetpoint": { + "value": 350, + "timestamp": "2025-03-13T21:23:27.596Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.baseline", "oic.if.a"], + "x.com.samsung.da.modelNum": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E03151101020000000000000", + "x.com.samsung.da.description": "TP1X_DA-KS-OVEN-01011", + "x.com.samsung.da.serialNum": "0J4D7DARB03393K", + "x.com.samsung.da.otnDUID": "ZPCNQWBWXI47Q", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A221005", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20121600,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-11-28T22:49:09.333Z" + } + }, + "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": "TP1X_DA-KS-RANGE-0101X", + "timestamp": "2025-03-12T20:40:29.034Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP1-20-OVEN-3-CR_40240205", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "di": { + "value": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2022-12-19T22:33:09.710Z" + }, + "n": { + "value": "Samsung Range", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnmo": { + "value": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E031511010200000000000000", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "vid": { + "value": "DA-KS-RANGE-0101X", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2024-05-14T19:00:26.132Z" + }, + "pi": { + "value": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "timestamp": "2022-02-21T22:37:02.282Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2022-02-21T22:37:02.282Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-13T21:42:23.615Z" + } + }, + "samsungce.customRecipe": {}, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "US", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "modelCode": { + "value": "NE6516A-/AA0", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "range", + "timestamp": "2022-02-21T22:37:02.487Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "Bake", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 175, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 350, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "Broil", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 61441, + "max": 61442, + "default": 61441, + "supportedValues": [61441, 61442] + }, + "F": { + "min": 61441, + "max": 61442, + "default": 61441, + "supportedValues": [61441, 61442] + } + } + } + }, + { + "mode": "ConvectionBake", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 160, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 325, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "ConvectionRoast", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 285, + "default": 160, + "resolution": 0 + }, + "F": { + "min": 175, + "max": 550, + "default": 325, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "KeepWarm", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 80, + "max": 80, + "default": 80, + "supportedValues": [80] + }, + "F": { + "min": 175, + "max": 175, + "default": 175, + "supportedValues": [175] + } + } + } + }, + { + "mode": "BreadProof", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 35, + "max": 35, + "default": 35, + "supportedValues": [35] + }, + "F": { + "min": 95, + "max": 95, + "default": 95, + "supportedValues": [95] + } + } + } + }, + { + "mode": "AirFryer", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 175, + "max": 260, + "default": 220, + "resolution": 0 + }, + "F": { + "min": 350, + "max": 500, + "default": 425, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "Dehydrate", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 105, + "default": 65, + "resolution": 0 + }, + "F": { + "min": 100, + "max": 225, + "default": 150, + "resolution": 0 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "09:59:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "SelfClean", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "SteamClean", + "supportedOperations": [], + "supportedOptions": {} + } + ] + }, + "timestamp": "2024-05-14T19:00:30.062Z" + } + }, + "custom.cooktopOperatingState": { + "supportedCooktopOperatingState": { + "value": ["run", "ready"], + "timestamp": "2022-02-21T22:37:05.293Z" + }, + "cooktopOperatingState": { + "value": "ready", + "timestamp": "2025-03-12T20:38:01.402Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2022-11-01T21:37:51.304Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "ZPCNQWBWXI47Q", + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-12T20:38:01.262Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 425, + "unit": "F", + "timestamp": "2025-03-13T21:46:35.545Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-03-14T03:23:28.048Z", + "timestamp": "2025-03-13T22:09:29.052Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "progress": { + "value": 13, + "timestamp": "2025-03-13T22:06:35.591Z" + }, + "ovenJobState": { + "value": "cooking", + "timestamp": "2025-03-13T21:46:34.327Z" + }, + "operationTime": { + "value": "06:00:00", + "timestamp": "2025-03-13T21:23:24.771Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "warming", + "Others", + "Dehydrate" + ], + "timestamp": "2025-03-12T20:38:01.259Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-03-14T03:23:28.048Z", + "timestamp": "2025-03-13T22:09:29.052Z" + }, + "machineState": { + "value": "running", + "timestamp": "2025-03-13T21:23:24.771Z" + }, + "progress": { + "value": 13, + "unit": "%", + "timestamp": "2025-03-13T22:06:35.591Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "cooking", + "timestamp": "2025-03-13T21:46:34.327Z" + }, + "operationTime": { + "value": 21600, + "timestamp": "2025-03-13T21:23:24.771Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "KeepWarm", + "BreadProof", + "AirFryer", + "Dehydrate", + "SelfClean", + "SteamClean" + ], + "timestamp": "2025-03-12T20:38:01.259Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "off", + "timestamp": "2025-03-13T21:23:27.659Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "high"], + "timestamp": "2025-03-13T21:23:27.659Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-12T20:38:01.400Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json b/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json new file mode 100644 index 00000000000..e918e2d77ca --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_range_0101x.json @@ -0,0 +1,197 @@ +{ + "items": [ + { + "deviceId": "2c3cbaa0-1899-5ddc-7b58-9d657bd48f18", + "name": "Samsung Range", + "label": "Vulcan", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-RANGE-0101X", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "597a4912-13c9-47ab-9956-7ebc38b61abd", + "ownerId": "c4478c70-9014-e5c9-993c-f62707fa1e61", + "roomId": "fc407cd9-3b32-4fc0-bf23-e0d4995101e9", + "deviceTypeName": "Samsung OCF Range", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.customRecipe", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.meatProbe", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.cooktopOperatingState", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Range", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cavity-01", + "label": "cavity-01", + "capabilities": [ + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "custom.ovenCavityStatus", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2022-02-21T22:37:01.648Z", + "profile": { + "id": "8e479dd0-9719-337a-9fbe-2c4572f95c71" + }, + "ocf": { + "ocfDeviceType": "oic.d.range", + "name": "Samsung Range", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA-KS-RANGE-0101X|40445041|5001011E031511010200000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AKS-WW-TP1-20-OVEN-3-CR_40240205", + "vendorId": "DA-KS-RANGE-0101X", + "vendorResourceClientServerVersion": "Realtek Release 3.1.220727", + "lastSignupTime": "2023-11-28T22:49:01.876575Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0276873384a..74297ac6a0b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -398,6 +398,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ks_range_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': 'Realtek', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA-KS-RANGE-0101X', + 'model_id': None, + 'name': 'Vulcan', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP1-20-OVEN-3-CR_40240205', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ref_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 52df02f55b8..43d26f4f987 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2007,7 +2007,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Set point', 'platform': 'smartthings', @@ -2015,20 +2015,22 @@ 'supported_features': 0, 'translation_key': 'oven_setpoint', 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.ovenSetpoint', - 'unit_of_measurement': None, + 'unit_of_measurement': , }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_set_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', 'friendly_name': 'Microwave Set point', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.microwave_set_point', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'unknown', }) # --- # name: test_all_entities[da_ks_microwave_0101x][sensor.microwave_temperature-entry] @@ -2083,6 +2085,404 @@ 'state': '-17', }) # --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_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.vulcan_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Vulcan Completion time', + }), + 'context': , + 'entity_id': 'sensor.vulcan_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-14T03:23:28+00:00', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_job_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan 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.vulcan_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cooking', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vulcan_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_machine_state', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.vulcan_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vulcan_oven_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oven mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_mode', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Vulcan 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.vulcan_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_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.vulcan_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': 'Set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_setpoint', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.ovenSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vulcan Set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vulcan_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_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.vulcan_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': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_range_0101x][sensor.vulcan_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vulcan Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vulcan_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218', + }) +# --- # name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 058aed96d24e9129de34949bb02b257b862d5322 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 00:28:08 +0100 Subject: [PATCH 1636/1941] Fix windowShadeLevel capability in SmartThings (#140552) --- homeassistant/components/smartthings/cover.py | 4 + tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/ikea_kadrilj.json | 68 ++++++++++++++++ .../fixtures/devices/ikea_kadrilj.json | 78 +++++++++++++++++++ .../smartthings/snapshots/test_cover.ambr | 51 ++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++++ .../smartthings/snapshots/test_sensor.ambr | 49 ++++++++++++ 7 files changed, 284 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json create mode 100644 tests/components/smartthings/fixtures/devices/ikea_kadrilj.json diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 564de8443b1..0b0817d7c56 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -125,6 +125,10 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): self._attr_current_cover_position = self.get_attribute_value( Capability.SWITCH_LEVEL, Attribute.LEVEL ) + elif self.supports_capability(Capability.WINDOW_SHADE_LEVEL): + self._attr_current_cover_position = self.get_attribute_value( + Capability.WINDOW_SHADE_LEVEL, Attribute.SHADE_LEVEL + ) self._attr_extra_state_attributes = {} if self.supports_capability(Capability.BATTERY): diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index f0e2f76c112..c10668210e0 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -134,6 +134,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "im_speaker_ai_0001", "abl_light_b_001", "tplink_p110", + "ikea_kadrilj", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json b/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json new file mode 100644 index 00000000000..56a2d9e762d --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/ikea_kadrilj.json @@ -0,0 +1,68 @@ +{ + "components": { + "main": { + "windowShadeLevel": { + "shadeLevel": { + "value": 32, + "unit": "%", + "timestamp": "2025-03-13T10:40:25.613Z" + } + }, + "refresh": {}, + "windowShadePreset": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 37, + "unit": "%", + "timestamp": "2025-03-13T07:09:05.149Z" + }, + "type": { + "value": null + } + }, + "firmwareUpdate": { + "lastUpdateStatusReason": { + "value": null + }, + "availableVersion": { + "value": "22007631", + "timestamp": "2025-03-12T20:35:04.576Z" + }, + "lastUpdateStatus": { + "value": null + }, + "supportedCommands": { + "value": null + }, + "state": { + "value": "updateRequested", + "timestamp": "2025-03-12T20:35:03.879Z" + }, + "updateAvailable": { + "value": false, + "timestamp": "2025-03-12T20:35:04.577Z" + }, + "currentVersion": { + "value": "22007631", + "timestamp": "2025-03-12T20:35:04.508Z" + }, + "lastUpdateTime": { + "value": null + } + }, + "windowShade": { + "supportedWindowShadeCommands": { + "value": ["open", "close", "pause"], + "timestamp": "2025-03-13T10:33:48.402Z" + }, + "windowShade": { + "value": "partially open", + "timestamp": "2025-03-13T10:55:58.205Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json b/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json new file mode 100644 index 00000000000..36f9d40f7e4 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/ikea_kadrilj.json @@ -0,0 +1,78 @@ +{ + "items": [ + { + "deviceId": "71afed1c-006d-4e48-b16e-e7f88f9fd638", + "name": "window-treatment-battery", + "label": "Kitchen IKEA KADRILJ Window blind", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "fa41d7d3-4c03-327f-b0ce-2edc829f0e34", + "deviceManufacturerCode": "IKEA of Sweden", + "locationId": "5b5f96b5-0286-4f4a-86ef-d5d5c1a78cb8", + "ownerId": "f43fd9e5-2ecd-4aae-aeac-73a8e5cb04da", + "roomId": "89f675a1-1f16-451c-8ab1-a7fdacc5852d", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "windowShade", + "version": 1 + }, + { + "id": "windowShadePreset", + "version": 1 + }, + { + "id": "windowShadeLevel", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "firmwareUpdate", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Blind", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-04-26T18:19:06.792Z", + "parentDeviceId": "3ffe04c4-a12c-41f5-b83d-c1b28eca2b5f", + "profile": { + "id": "6d9804bc-9e56-3823-95be-4b315669c481" + }, + "zigbee": { + "eui": "000D6FFFFE2AD0E7", + "networkId": "3009", + "driverId": "46b8bada-1a55-4f84-8915-47ce2cad3621", + "executingLocally": true, + "hubId": "3ffe04c4-a12c-41f5-b83d-c1b28eca2b5f", + "provisioningState": "NONFUNCTIONAL" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [10.0, 36.0, 98.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_cover.ambr b/tests/components/smartthings/snapshots/test_cover.ambr index aa928c09b7a..6877a8ccc01 100644 --- a/tests/components/smartthings/snapshots/test_cover.ambr +++ b/tests/components/smartthings/snapshots/test_cover.ambr @@ -49,3 +49,54 @@ 'state': 'open', }) # --- +# name: test_all_entities[ikea_kadrilj][cover.kitchen_ikea_kadrilj_window_blind-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.kitchen_ikea_kadrilj_window_blind', + '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': '71afed1c-006d-4e48-b16e-e7f88f9fd638', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ikea_kadrilj][cover.kitchen_ikea_kadrilj_window_blind-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'battery_level': 37, + 'current_position': 32, + 'device_class': 'shade', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_ikea_kadrilj_window_blind', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 74297ac6a0b..825ab49e814 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -992,6 +992,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[ikea_kadrilj] + 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', + '71afed1c-006d-4e48-b16e-e7f88f9fd638', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Kitchen IKEA KADRILJ Window blind', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[im_speaker_ai_0001] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 43d26f4f987..98e619596fd 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5906,6 +5906,55 @@ 'state': '19.0', }) # --- +# name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_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.kitchen_ikea_kadrilj_window_blind_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[ikea_kadrilj][sensor.kitchen_ikea_kadrilj_window_blind_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.kitchen_ikea_kadrilj_window_blind_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- # name: test_all_entities[im_speaker_ai_0001][sensor.galaxy_home_mini_media_input_source-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 3383e8b70d56e5255163ea55123882a327e59723 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Mar 2025 00:47:11 +0100 Subject: [PATCH 1637/1941] Fix missing RGBW field description reference in Lokalise - step 1 (#140526) Empties the string to trigger an export to Lokalise. Will be followed up by a second PR to restore the reference. --- homeassistant/components/light/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index c0f658c3a44..0a9686b601e 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -297,7 +297,7 @@ }, "rgbw_color": { "name": "[%key:component::light::common::field_rgbw_color_name%]", - "description": "[%key:component::light::common::field_rgbw_color_description%]" + "description": "" }, "rgbww_color": { "name": "[%key:component::light::common::field_rgbww_color_name%]", From f0b86c512dab12d60dd191e45c63dbaaf333f9d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 14:06:24 -1000 Subject: [PATCH 1638/1941] Bump habluetooth to 3.25.1 and bluetooth-auto-recovery to 1.4.5 (#140561) habluetooth: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.25.0...v3.25.1 bluetooth-auto-recovery: https://github.com/Bluetooth-Devices/bluetooth-auto-recovery/compare/v1.4.4...v1.4.5 --- homeassistant/components/bluetooth/manifest.json | 4 ++-- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f6fb4f68e91..45a424c48b2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,9 +18,9 @@ "bleak==0.22.3", "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", - "bluetooth-auto-recovery==1.4.4", + "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.0", "dbus-fast==2.39.3", - "habluetooth==3.25.0" + "habluetooth==3.25.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 24ce6e23e86..b7cd4227715 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ bcrypt==4.2.0 bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 -bluetooth-auto-recovery==1.4.4 +bluetooth-auto-recovery==1.4.5 bluetooth-data-tools==1.26.0 cached-ipaddress==0.10.0 certifi>=2021.5.30 @@ -33,7 +33,7 @@ dbus-fast==2.39.3 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.25.0 +habluetooth==3.25.1 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9fc11d08b32..d6b5ba1c359 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.4 +bluetooth-auto-recovery==1.4.5 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.25.0 +habluetooth==3.25.1 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd6f2e9112a..7907e9474f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -558,7 +558,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.21.4 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.4 +bluetooth-auto-recovery==1.4.5 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -950,7 +950,7 @@ ha-philipsjs==3.2.2 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.25.0 +habluetooth==3.25.1 # homeassistant.components.cloud hass-nabucasa==0.94.0 From 9f801e77859fb2497a9f09084e664162edc0ef93 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 14:49:37 -1000 Subject: [PATCH 1639/1941] Bump dbus-fast to 2.39.5 (#140565) --- 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 45a424c48b2..50d115dc89b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.0", - "dbus-fast==2.39.3", + "dbus-fast==2.39.5", "habluetooth==3.25.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b7cd4227715..b4823d1a549 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.39.3 +dbus-fast==2.39.5 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index d6b5ba1c359..445d89ec651 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.3 +dbus-fast==2.39.5 # homeassistant.components.debugpy debugpy==1.8.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7907e9474f2..12001c6a121 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.3 +dbus-fast==2.39.5 # homeassistant.components.debugpy debugpy==1.8.13 From 6f926d0a66e72332ea7d5aa42800365b096620d8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 14 Mar 2025 08:28:56 +0100 Subject: [PATCH 1640/1941] Add missing typing to Vodafone Station (#140562) --- .../components/vodafone_station/config_flow.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 7a80244f8d6..fd0683bdacc 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -12,16 +12,12 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN +from .coordinator import VodafoneConfigEntry def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: @@ -63,7 +59,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: VodafoneConfigEntry, ) -> VodafoneStationOptionsFlowHandler: """Get the options flow for this handler.""" return VodafoneStationOptionsFlowHandler() From e42a6c5d4f9da680e68502f97f40be77ec136c3f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Mar 2025 08:51:49 +0100 Subject: [PATCH 1641/1941] Fix missing RGBW field description reference in Lokalise - step 2 (#140576) Reverts step 1, re-adding the field reference. --- homeassistant/components/light/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 0a9686b601e..c0f658c3a44 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -297,7 +297,7 @@ }, "rgbw_color": { "name": "[%key:component::light::common::field_rgbw_color_name%]", - "description": "" + "description": "[%key:component::light::common::field_rgbw_color_description%]" }, "rgbww_color": { "name": "[%key:component::light::common::field_rgbww_color_name%]", From 84667fd32dcfba52ce347d9e2c79f37aebcce495 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 14 Mar 2025 04:00:46 -0400 Subject: [PATCH 1642/1941] Migrate template light to new style (#140326) * Migrate template light to new style * add modern templates to tests * fix comments --- homeassistant/components/template/config.py | 7 +- homeassistant/components/template/light.py | 216 ++- tests/components/template/conftest.py | 9 + tests/components/template/test_light.py | 1599 ++++++++++++------- 4 files changed, 1177 insertions(+), 654 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 9963731c784..07c3c1b437f 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -13,6 +13,7 @@ from homeassistant.components.blueprint import ( ) from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -36,6 +37,7 @@ from . import ( binary_sensor as binary_sensor_platform, button as button_platform, image as image_platform, + light as light_platform, number as number_platform, select as select_platform, sensor as sensor_platform, @@ -104,11 +106,14 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(IMAGE_DOMAIN): vol.All( cv.ensure_list, [image_platform.IMAGE_SCHEMA] ), + vol.Optional(LIGHT_DOMAIN): vol.All( + cv.ensure_list, [light_platform.LIGHT_SCHEMA] + ), vol.Optional(WEATHER_DOMAIN): vol.All( cv.ensure_list, [weather_platform.WEATHER_SCHEMA] ), }, - ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN), + ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN, LIGHT_DOMAIN), ) ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 352f571078a..1cc47c74aa0 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -26,9 +26,13 @@ from homeassistant.components.light import ( filter_supported_color_modes, ) from homeassistant.const import ( + CONF_EFFECT, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_LIGHTS, + CONF_NAME, + CONF_RGB, + CONF_STATE, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, STATE_OFF, @@ -36,15 +40,18 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util -from .const import DOMAIN +from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) @@ -56,33 +63,96 @@ _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] CONF_COLOR_ACTION = "set_color" CONF_COLOR_TEMPLATE = "color_template" +CONF_HS = "hs" CONF_HS_ACTION = "set_hs" CONF_HS_TEMPLATE = "hs_template" CONF_RGB_ACTION = "set_rgb" CONF_RGB_TEMPLATE = "rgb_template" +CONF_RGBW = "rgbw" CONF_RGBW_ACTION = "set_rgbw" CONF_RGBW_TEMPLATE = "rgbw_template" +CONF_RGBWW = "rgbww" CONF_RGBWW_ACTION = "set_rgbww" CONF_RGBWW_TEMPLATE = "rgbww_template" CONF_EFFECT_ACTION = "set_effect" +CONF_EFFECT_LIST = "effect_list" CONF_EFFECT_LIST_TEMPLATE = "effect_list_template" CONF_EFFECT_TEMPLATE = "effect_template" +CONF_LEVEL = "level" CONF_LEVEL_ACTION = "set_level" CONF_LEVEL_TEMPLATE = "level_template" +CONF_MAX_MIREDS = "max_mireds" CONF_MAX_MIREDS_TEMPLATE = "max_mireds_template" +CONF_MIN_MIREDS = "min_mireds" CONF_MIN_MIREDS_TEMPLATE = "min_mireds_template" CONF_OFF_ACTION = "turn_off" CONF_ON_ACTION = "turn_on" -CONF_SUPPORTS_TRANSITION = "supports_transition_template" +CONF_SUPPORTS_TRANSITION = "supports_transition" +CONF_SUPPORTS_TRANSITION_TEMPLATE = "supports_transition_template" CONF_TEMPERATURE_ACTION = "set_temperature" +CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE_TEMPLATE = "temperature_template" CONF_WHITE_VALUE_ACTION = "set_white_value" +CONF_WHITE_VALUE = "white_value" CONF_WHITE_VALUE_TEMPLATE = "white_value_template" DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 -LIGHT_SCHEMA = vol.All( +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_COLOR_ACTION: CONF_HS_ACTION, + CONF_COLOR_TEMPLATE: CONF_HS, + CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST, + CONF_EFFECT_TEMPLATE: CONF_EFFECT, + CONF_HS_TEMPLATE: CONF_HS, + CONF_LEVEL_TEMPLATE: CONF_LEVEL, + CONF_MAX_MIREDS_TEMPLATE: CONF_MAX_MIREDS, + CONF_MIN_MIREDS_TEMPLATE: CONF_MIN_MIREDS, + CONF_RGB_TEMPLATE: CONF_RGB, + CONF_RGBW_TEMPLATE: CONF_RGBW, + CONF_RGBWW_TEMPLATE: CONF_RGBWW, + CONF_SUPPORTS_TRANSITION_TEMPLATE: CONF_SUPPORTS_TRANSITION, + CONF_TEMPERATURE_TEMPLATE: CONF_TEMPERATURE, + CONF_VALUE_TEMPLATE: CONF_STATE, + CONF_WHITE_VALUE_TEMPLATE: CONF_WHITE_VALUE, +} + +DEFAULT_NAME = "Template Light" + +LIGHT_SCHEMA = ( + vol.Schema( + { + vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, + vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, + vol.Inclusive(CONF_EFFECT, "effect"): cv.template, + vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_HS): cv.template, + vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL): cv.template, + vol.Optional(CONF_MAX_MIREDS): cv.template, + vol.Optional(CONF_MIN_MIREDS): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGB): cv.template, + vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBW): cv.template, + vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBWW): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, + vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TEMPERATURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) +) + +LEGACY_LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -107,7 +177,7 @@ LIGHT_SCHEMA = vol.All( vol.Optional(CONF_MIN_MIREDS_TEMPLATE): cv.template, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, + vol.Optional(CONF_SUPPORTS_TRANSITION_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, @@ -121,29 +191,50 @@ PLATFORM_SCHEMA = vol.All( cv.removed(CONF_WHITE_VALUE_ACTION), cv.removed(CONF_WHITE_VALUE_TEMPLATE), LIGHT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_SCHEMA)} + {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LEGACY_LIGHT_SCHEMA)} ), ) -async def _async_create_entities(hass: HomeAssistant, config): +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" + lights = [] + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + lights.append(entity_conf) + + return lights + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: """Create the Template Lights.""" lights = [] - for object_id, entity_config in config[CONF_LIGHTS].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) - lights.append( - LightTemplate( - hass, - object_id, - entity_config, - unique_id, - ) - ) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" - return lights + lights.append(LightTemplate(hass, entity_conf, unique_id)) + + async_add_entities(lights) async def async_setup_platform( @@ -153,7 +244,21 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template lights.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_LIGHTS]), + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) class LightTemplate(TemplateEntity, LightEntity): @@ -164,33 +269,30 @@ class LightTemplate(TemplateEntity, LightEntity): def __init__( self, hass: HomeAssistant, - object_id, config: dict[str, Any], - unique_id, + unique_id: str | None, ) -> None: """Initialize the light.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, object_id, hass=hass + ) name = self._attr_name if TYPE_CHECKING: assert name is not None - self._template = config.get(CONF_VALUE_TEMPLATE) - self._level_template = config.get(CONF_LEVEL_TEMPLATE) - self._temperature_template = config.get(CONF_TEMPERATURE_TEMPLATE) - self._color_template = config.get(CONF_COLOR_TEMPLATE) - self._hs_template = config.get(CONF_HS_TEMPLATE) - self._rgb_template = config.get(CONF_RGB_TEMPLATE) - self._rgbw_template = config.get(CONF_RGBW_TEMPLATE) - self._rgbww_template = config.get(CONF_RGBWW_TEMPLATE) - self._effect_list_template = config.get(CONF_EFFECT_LIST_TEMPLATE) - self._effect_template = config.get(CONF_EFFECT_TEMPLATE) - self._max_mireds_template = config.get(CONF_MAX_MIREDS_TEMPLATE) - self._min_mireds_template = config.get(CONF_MIN_MIREDS_TEMPLATE) + self._template = config.get(CONF_STATE) + self._level_template = config.get(CONF_LEVEL) + self._temperature_template = config.get(CONF_TEMPERATURE) + self._hs_template = config.get(CONF_HS) + self._rgb_template = config.get(CONF_RGB) + self._rgbw_template = config.get(CONF_RGBW) + self._rgbww_template = config.get(CONF_RGBWW) + self._effect_list_template = config.get(CONF_EFFECT_LIST) + self._effect_template = config.get(CONF_EFFECT) + self._max_mireds_template = config.get(CONF_MAX_MIREDS) + self._min_mireds_template = config.get(CONF_MIN_MIREDS) self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION) for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION): @@ -216,7 +318,6 @@ class LightTemplate(TemplateEntity, LightEntity): for action_id, color_mode in ( (CONF_TEMPERATURE_ACTION, ColorMode.COLOR_TEMP), (CONF_LEVEL_ACTION, ColorMode.BRIGHTNESS), - (CONF_COLOR_ACTION, ColorMode.HS), (CONF_HS_ACTION, ColorMode.HS), (CONF_RGB_ACTION, ColorMode.RGB), (CONF_RGBW_ACTION, ColorMode.RGBW), @@ -349,14 +450,6 @@ class LightTemplate(TemplateEntity, LightEntity): self._update_temperature, none_on_template_error=True, ) - if self._color_template: - self.add_template_attribute( - "_hs_color", - self._color_template, - None, - self._update_hs, - none_on_template_error=True, - ) if self._hs_template: self.add_template_attribute( "_hs_color", @@ -440,7 +533,7 @@ class LightTemplate(TemplateEntity, LightEntity): ) self._color_mode = ColorMode.COLOR_TEMP self._temperature = color_temp - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgb_template is None: self._rgb_color = None @@ -450,11 +543,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbww_color = None optimistic_set = True - if ( - self._hs_template is None - and self._color_template is None - and ATTR_HS_COLOR in kwargs - ): + if self._hs_template is None and ATTR_HS_COLOR in kwargs: _LOGGER.debug( "Optimistically setting hs color to %s", kwargs[ATTR_HS_COLOR], @@ -480,7 +569,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgb_color = kwargs[ATTR_RGB_COLOR] if self._temperature_template is None: self._temperature = None - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgbw_template is None: self._rgbw_color = None @@ -497,7 +586,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbw_color = kwargs[ATTR_RGBW_COLOR] if self._temperature_template is None: self._temperature = None - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgb_template is None: self._rgb_color = None @@ -514,7 +603,7 @@ class LightTemplate(TemplateEntity, LightEntity): self._rgbww_color = kwargs[ATTR_RGBWW_COLOR] if self._temperature_template is None: self._temperature = None - if self._hs_template is None and self._color_template is None: + if self._hs_template is None: self._hs_color = None if self._rgb_template is None: self._rgb_color = None @@ -561,17 +650,6 @@ class LightTemplate(TemplateEntity, LightEntity): await self.async_run_script( effect_script, run_variables=common_params, context=self._context ) - elif ATTR_HS_COLOR in kwargs and ( - color_script := self._action_scripts.get(CONF_COLOR_ACTION) - ): - hs_value = kwargs[ATTR_HS_COLOR] - common_params["hs"] = hs_value - common_params["h"] = int(hs_value[0]) - common_params["s"] = int(hs_value[1]) - - await self.async_run_script( - color_script, run_variables=common_params, context=self._context - ) elif ATTR_HS_COLOR in kwargs and ( hs_script := self._action_scripts.get(CONF_HS_ACTION) ): diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index bdca84ba071..86a30535e92 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -1,5 +1,7 @@ """template conftest.""" +from enum import Enum + import pytest from homeassistant.core import HomeAssistant, ServiceCall @@ -9,6 +11,13 @@ from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +class ConfigurationStyle(Enum): + """Configuration Styles for template testing.""" + + LEGACY = "Legacy" + MODERN = "Modern" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index a94ec233f81..1a739b4921e 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from homeassistant.components import light +from homeassistant.components import light, template from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -17,6 +17,7 @@ from homeassistant.components.light import ( ColorMode, LightEntityFeature, ) +from homeassistant.components.template.light import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -26,8 +27,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component +from .conftest import ConfigurationStyle + from tests.common import assert_setup_component # Represent for light's availability @@ -154,10 +159,245 @@ OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG = { } -async def async_setup_light( +TEST_MISSING_KEY_CONFIG = { + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + }, + }, +} + + +TEST_ON_ACTION_WITH_TRANSITION_CONFIG = { + "turn_on": { + "service": "test.automation", + "data_template": { + "transition": "{{transition}}", + }, + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + "transition": "{{transition}}", + }, + }, +} + + +TEST_OFF_ACTION_WITH_TRANSITION_CONFIG = { + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "test.automation", + "data_template": { + "transition": "{{transition}}", + }, + }, + "set_level": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "brightness": "{{brightness}}", + "transition": "{{transition}}", + }, + }, +} + + +TEST_ALL_COLORS_NO_TEMPLATE_CONFIG = { + "set_hs": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "h": "{{h}}", + "s": "{{s}}", + }, + }, + "set_temperature": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "color_temp": "{{color_temp}}", + }, + }, + "set_rgb": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + }, + }, + "set_rgbw": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "w": "{{w}}", + }, + }, + "set_rgbww": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "r": "{{r}}", + "g": "{{g}}", + "b": "{{b}}", + "cw": "{{cw}}", + "ww": "{{ww}}", + }, + }, +} + + +TEST_UNIQUE_ID_CONFIG = { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "unique_id": "not-so-unique-anymore", +} + + +@pytest.mark.parametrize( + ("old_attr", "new_attr", "attr_template"), + [ + ( + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + "rgb_template", + "rgb", + "{{ (255,255,255) }}", + ), + ( + "rgbw_template", + "rgbw", + "{{ (255,255,255,255) }}", + ), + ( + "rgbww_template", + "rgbww", + "{{ (255,255,255,255,255) }}", + ), + ( + "effect_list_template", + "effect_list", + "{{ ['a', 'b'] }}", + ), + ( + "effect_template", + "effect", + "{{ 'a' }}", + ), + ( + "level_template", + "level", + "{{ 255 }}", + ), + ( + "max_mireds_template", + "max_mireds", + "{{ 255 }}", + ), + ( + "min_mireds_template", + "min_mireds", + "{{ 255 }}", + ), + ( + "supports_transition_template", + "supports_transition", + "{{ True }}", + ), + ( + "temperature_template", + "temperature", + "{{ 255 }}", + ), + ( + "white_value_template", + "white_value", + "{{ 255 }}", + ), + ( + "hs_template", + "hs", + "{{ (255, 255) }}", + ), + ( + "color_template", + "hs", + "{{ (255, 255) }}", + ), + ], +) +async def test_legacy_to_modern_config( + hass: HomeAssistant, old_attr: str, new_attr: str, attr_template: str +) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "unique_id": "foo-bar-light", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + } + } + altered_configs = rewrite_legacy_to_modern_conf(hass, config) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "turn_off": { + "data_template": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", + }, + "service": "test.automation", + }, + "turn_on": { + "data_template": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", + }, + "service": "test.automation", + }, + "unique_id": "foo-bar-light", + new_attr: Template(attr_template, hass), + } + ] == altered_configs + + +async def async_setup_legacy_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] ) -> None: - """Do setup of light integration.""" + """Do setup of light integration via legacy format.""" config = {"light": {"platform": "template", "lights": light_config}} with assert_setup_component(count, light.DOMAIN): @@ -172,12 +412,291 @@ async def async_setup_light( await hass.async_block_till_done() -@pytest.fixture -async def setup_light( +async def async_setup_legacy_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy light that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **extra_config, + "value_template": "{{ 1 == 1 }}", + **extra, + } + }, + ) + + +async def async_setup_new_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] +) -> None: + """Do setup of light integration via new format.""" + config = {"template": {"light": light_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format_with_attribute( + hass: HomeAssistant, + count: int, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of a legacy light that has a single templated attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + **extra_config, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + +@pytest.fixture +async def setup_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + light_config: dict[str, Any], ) -> None: """Do setup of light integration.""" - await async_setup_light(hass, count, light_config) + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, light_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format(hass, count, light_config) + + +@pytest.fixture +async def setup_state_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "name": "test_template_light", + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format_with_attribute( + hass, count, attribute, attribute_template, extra_config + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format_with_attribute( + hass, count, attribute, attribute_template, extra_config + ) + + +@pytest.fixture +async def setup_single_action_light( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + extra_config: dict, +) -> None: + """Do setup of light integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format_with_attribute( + hass, count, "", "", extra_config + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format_with_attribute( + hass, count, "", "", extra_config + ) + + +@pytest.fixture +async def setup_light_with_effects( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + effect_list_template: str, + effect_template: str, +) -> None: + """Do setup of light with effects.""" + common = { + "set_effect": { + "service": "test.automation", + "data_template": { + "action": "set_effect", + "caller": "{{ this.entity_id }}", + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": "{{true}}", + **common, + "effect_list_template": effect_list_template, + "effect_template": effect_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "state": "{{true}}", + **common, + "effect_list": effect_list_template, + "effect": effect_template, + }, + ) + + +@pytest.fixture +async def setup_light_with_mireds( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of light that uses mireds.""" + common = { + "set_temperature": { + "service": "light.turn_on", + "data_template": { + "entity_id": "light.test_state", + "color_temp": "{{color_temp}}", + }, + }, + attribute: attribute_template, + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + **common, + "temperature_template": "{{200}}", + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "temperature": "{{200}}", + }, + ) + + +@pytest.fixture +async def setup_light_with_transition_template( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + transition_template: str, +) -> None: + """Do setup of light that uses mireds.""" + common = { + "set_effect": { + "service": "test.automation", + "data_template": { + "entity_id": "test.test_state", + "effect": "{{effect}}", + }, + }, + } + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + "test_template_light": { + **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, + "value_template": "{{ 1 == 1 }}", + **common, + "effect_list_template": "{{ ['Disco', 'Police'] }}", + "effect_template": "{{ None }}", + "supports_transition_template": transition_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_new_format( + hass, + count, + { + "name": "test_template_light", + **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, + "state": "{{ 1 == 1 }}", + **common, + "effect_list": "{{ ['Disco', 'Police'] }}", + "effect": "{{ None }}", + "supports_transition": transition_template, + }, + ) @pytest.mark.parametrize("count", [1]) @@ -186,18 +705,15 @@ async def setup_light( [(0, [ColorMode.BRIGHTNESS])], ) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{states.test['big.fat...']}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) +@pytest.mark.parametrize("state_template", ["{{states.test['big.fat...']}}"]) async def test_template_state_invalid( - hass: HomeAssistant, supported_features, supported_color_modes, setup_light + hass: HomeAssistant, supported_features, supported_color_modes, setup_state_light ) -> None: """Test template state with render error.""" state = hass.states.get("light.test_template_light") @@ -209,17 +725,14 @@ async def test_template_state_invalid( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{ states.light.test_state.state }}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) -async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: +@pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) +async def test_template_state_text(hass: HomeAssistant, setup_state_light) -> None: """Test the state text of a template.""" set_state = STATE_ON hass.states.async_set("light.test_state", set_state) @@ -242,7 +755,14 @@ async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("value_template", "expected_state", "expected_color_mode"), + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ], +) +@pytest.mark.parametrize( + ("state_template", "expected_state", "expected_color_mode"), [ ( "{{ 1 == 1 }}", @@ -256,21 +776,13 @@ async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: ), ], ) -async def test_templatex_state_boolean( +async def test_legacy_template_state_boolean( hass: HomeAssistant, expected_color_mode, expected_state, - count, - value_template, + setup_state_light, ) -> None: """Test the setting of the state with boolean on.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": value_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.state == expected_state assert state.attributes.get("color_mode") == expected_color_mode @@ -280,48 +792,56 @@ async def test_templatex_state_boolean( @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": "{%- if false -%}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "bad name here": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "value_template": "{{ 1== 1}}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + {"test_template_light": "Invalid"}, + ConfigurationStyle.LEGACY, + ), + ( + { **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{%- if false -%}", - } - }, - { - "bad name here": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{ 1== 1}}", - } - }, - {"test_template_light": "Invalid"}, + "name": "test_template_light", + "state": "{%- if false -%}", + }, + ConfigurationStyle.MODERN, + ), ], ) -async def test_template_syntax_error(hass: HomeAssistant, setup_light) -> None: - """Test templating syntax error.""" +async def test_template_config_errors(hass: HomeAssistant, setup_light) -> None: + """Test template light configuration errors.""" assert hass.states.async_all("light") == [] @pytest.mark.parametrize( - ("light_config", "count"), + ("light_config", "style", "count"), [ ( - { - "light_one": { - "value_template": "{{ 1== 1}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - }, - }, - } - }, + {"light_one": {"value_template": "{{ 1== 1}}", **TEST_MISSING_KEY_CONFIG}}, + ConfigurationStyle.LEGACY, + 0, + ), + ( + {"name": "light_one", "state": "{{ 1== 1}}", **TEST_MISSING_KEY_CONFIG}, + ConfigurationStyle.MODERN, 0, ), ], @@ -336,18 +856,15 @@ async def test_missing_key(hass: HomeAssistant, count, setup_light) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{states.light.test_state.state}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) +@pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) async def test_on_action( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] + hass: HomeAssistant, setup_state_light, calls: list[ServiceCall] ) -> None: """Test on action.""" hass.states.async_set("light.test_state", STATE_OFF) @@ -378,32 +895,26 @@ async def test_on_action( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { - "value_template": "{{states.light.test_state.state}}", - "turn_on": { - "service": "test.automation", - "data_template": { - "transition": "{{transition}}", - }, - }, - "turn_off": { - "service": "light.turn_off", - "entity_id": "light.test_state", - }, - "supports_transition_template": "{{true}}", - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - "transition": "{{transition}}", - }, - }, - } - }, + ( + { + "test_template_light": { + "value_template": "{{states.light.test_state.state}}", + **TEST_ON_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition_template": "{{true}}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", + **TEST_ON_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_on_action_with_transition( @@ -437,13 +948,23 @@ async def test_on_action_with_transition( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - } - }, + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_on_action_optimistic( @@ -497,18 +1018,15 @@ async def test_on_action_optimistic( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{states.light.test_state.state}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) +@pytest.mark.parametrize("state_template", ["{{ states.light.test_state.state }}"]) async def test_off_action( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] + hass: HomeAssistant, setup_state_light, calls: list[ServiceCall] ) -> None: """Test off action.""" hass.states.async_set("light.test_state", STATE_ON) @@ -538,32 +1056,27 @@ async def test_off_action( @pytest.mark.parametrize("count", [(1)]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { - "value_template": "{{states.light.test_state.state}}", - "turn_on": { - "service": "light.turn_on", - "entity_id": "light.test_state", - }, - "turn_off": { - "service": "test.automation", - "data_template": { - "transition": "{{transition}}", - }, - }, - "supports_transition_template": "{{true}}", - "set_level": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "brightness": "{{brightness}}", - "transition": "{{transition}}", - }, - }, - } - }, + ( + { + "test_template_light": { + "value_template": "{{states.light.test_state.state}}", + **TEST_OFF_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition_template": "{{true}}", + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", + "state": "{{states.light.test_state.state}}", + **TEST_OFF_ACTION_WITH_TRANSITION_CONFIG, + "supports_transition": "{{true}}", + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_off_action_with_transition( @@ -596,13 +1109,23 @@ async def test_off_action_with_transition( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - } - }, + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_off_action_optimistic( @@ -632,19 +1155,16 @@ async def test_off_action_optimistic( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) +@pytest.mark.parametrize("state_template", ["{{1 == 1}}"]) async def test_level_action_no_template( hass: HomeAssistant, - setup_light, + setup_state_light, calls: list[ServiceCall], ) -> None: """Test setting brightness with optimistic template.""" @@ -671,9 +1191,18 @@ async def test_level_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_level", "level_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "level_template"), + (ConfigurationStyle.MODERN, "level"), + ], +) +@pytest.mark.parametrize( + ("expected_level", "attribute_template", "expected_color_mode"), [ (255, "{{255}}", ColorMode.BRIGHTNESS), (None, "{{256}}", ColorMode.BRIGHTNESS), @@ -690,20 +1219,11 @@ async def test_level_action_no_template( ) async def test_level_template( hass: HomeAssistant, - expected_level, - expected_color_mode, - count, - level_template, + expected_level: Any, + expected_color_mode: ColorMode, + setup_single_attribute_light, ) -> None: """Test the template for the level.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "level_template": level_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("brightness") == expected_level assert state.state == STATE_ON @@ -712,9 +1232,18 @@ async def test_level_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_temp", "temperature_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "temperature_template"), + (ConfigurationStyle.MODERN, "temperature"), + ], +) +@pytest.mark.parametrize( + ("expected_temp", "attribute_template", "expected_color_mode"), [ (500, "{{500}}", ColorMode.COLOR_TEMP), (None, "{{501}}", ColorMode.COLOR_TEMP), @@ -727,20 +1256,11 @@ async def test_level_template( ) async def test_temperature_template( hass: HomeAssistant, - expected_temp, - expected_color_mode, - count, - temperature_template, + expected_temp: Any, + expected_color_mode: ColorMode, + setup_single_attribute_light, ) -> None: """Test the template for the temperature.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "temperature_template": temperature_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("color_temp") == expected_temp assert state.state == STATE_ON @@ -749,21 +1269,19 @@ async def test_temperature_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [(1, OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_temperature_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting temperature with optimistic template.""" @@ -793,43 +1311,53 @@ async def test_temperature_action_no_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style", "entity_id"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "friendly_name": "Template light", + "value_template": "{{ 1 == 1 }}", + } + }, + ConfigurationStyle.LEGACY, + "light.test_template_light", + ), + ( + { **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - } - }, + "name": "Template light", + "state": "{{ 1 == 1 }}", + }, + ConfigurationStyle.MODERN, + "light.template_light", + ), ], ) -async def test_friendly_name(hass: HomeAssistant, setup_light) -> None: +async def test_friendly_name(hass: HomeAssistant, entity_id: str, setup_light) -> None: """Test the accessibility of the friendly_name attribute.""" - state = hass.states.get("light.test_template_light") + state = hass.states.get(entity_id) assert state is not None assert state.attributes.get("friendly_name") == "Template light" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [(1, OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "icon_template": ( - "{% if states.light.test_state.state %}mdi:check{% endif %}" - ), - } - }, + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), ], ) -async def test_icon_template(hass: HomeAssistant, setup_light) -> None: +@pytest.mark.parametrize( + "attribute_template", ["{% if states.light.test_state.state %}mdi:check{% endif %}"] +) +async def test_icon_template(hass: HomeAssistant, setup_single_attribute_light) -> None: """Test icon template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("icon") == "" @@ -842,23 +1370,23 @@ async def test_icon_template(hass: HomeAssistant, setup_light) -> None: assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [(1, OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "friendly_name": "Template light", - "value_template": "{{ 1 == 1 }}", - "entity_picture_template": ( - "{% if states.light.test_state.state %}/local/light.png{% endif %}" - ), - } - }, + (ConfigurationStyle.LEGACY, "entity_picture_template"), + (ConfigurationStyle.MODERN, "picture"), ], ) -async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None: +@pytest.mark.parametrize( + "attribute_template", + ["{% if states.light.test_state.state %}/local/light.png{% endif %}"], +) +async def test_entity_picture_template( + hass: HomeAssistant, setup_single_attribute_light +) -> None: """Test entity_picture template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("entity_picture") == "" @@ -871,21 +1399,21 @@ async def test_entity_picture_template(hass: HomeAssistant, setup_light) -> None assert state.attributes["entity_picture"] == "/local/light.png" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [ - { - "test_template_light": { - **OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + (1, OPTIMISTIC_LEGACY_COLOR_LIGHT_CONFIG), + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, ], ) async def test_legacy_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting color with optimistic template.""" @@ -913,24 +1441,25 @@ async def test_legacy_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), [ - { - "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + (1, OPTIMISTIC_HS_COLOR_LIGHT_CONFIG), + ], +) +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_hs_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: - """Test setting hs color with optimistic template.""" + """Test setting color with optimistic template.""" state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") is None @@ -955,21 +1484,20 @@ async def test_hs_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), + [(1, OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG)], +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_rgb_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting rgb color with optimistic template.""" @@ -998,21 +1526,20 @@ async def test_rgb_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), + [(1, OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG)], +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_rgbw_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting rgbw color with optimistic template.""" @@ -1045,21 +1572,20 @@ async def test_rgbw_color_action_no_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config"), + [(1, OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG)], +) +@pytest.mark.parametrize( + "style", [ - { - "test_template_light": { - **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - } - }, + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, ], ) async def test_rgbww_color_action_no_template( hass: HomeAssistant, - setup_light, + setup_single_action_light, calls: list[ServiceCall], ) -> None: """Test setting rgbww color with optimistic template.""" @@ -1123,7 +1649,7 @@ async def test_legacy_color_template( "color_template": color_template, } } - await async_setup_light(hass, count, light_config) + await async_setup_legacy_format(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") == expected_hs assert state.state == STATE_ON @@ -1132,9 +1658,18 @@ async def test_legacy_color_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_hs", "hs_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_HS_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "hs_template"), + (ConfigurationStyle.MODERN, "hs"), + ], +) +@pytest.mark.parametrize( + ("expected_hs", "attribute_template", "expected_color_mode"), [ ((360, 100), "{{(360, 100)}}", ColorMode.HS), ((360, 100), "(360, 100)", ColorMode.HS), @@ -1152,18 +1687,9 @@ async def test_hs_template( hass: HomeAssistant, expected_hs, expected_color_mode, - count, - hs_template, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_HS_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "hs_template": hs_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("hs_color") == expected_hs assert state.state == STATE_ON @@ -1172,9 +1698,18 @@ async def test_hs_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_rgb", "rgb_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "rgb_template"), + (ConfigurationStyle.MODERN, "rgb"), + ], +) +@pytest.mark.parametrize( + ("expected_rgb", "attribute_template", "expected_color_mode"), [ ((160, 78, 192), "{{(160, 78, 192)}}", ColorMode.RGB), ((160, 78, 192), "{{[160, 78, 192]}}", ColorMode.RGB), @@ -1193,18 +1728,9 @@ async def test_rgb_template( hass: HomeAssistant, expected_rgb, expected_color_mode, - count, - rgb_template, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_RGB_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "rgb_template": rgb_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("rgb_color") == expected_rgb assert state.state == STATE_ON @@ -1213,9 +1739,18 @@ async def test_rgb_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_rgbw", "rgbw_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "rgbw_template"), + (ConfigurationStyle.MODERN, "rgbw"), + ], +) +@pytest.mark.parametrize( + ("expected_rgbw", "attribute_template", "expected_color_mode"), [ ((160, 78, 192, 25), "{{(160, 78, 192, 25)}}", ColorMode.RGBW), ((160, 78, 192, 25), "{{[160, 78, 192, 25]}}", ColorMode.RGBW), @@ -1235,18 +1770,9 @@ async def test_rgbw_template( hass: HomeAssistant, expected_rgbw, expected_color_mode, - count, - rgbw_template, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_RGBW_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "rgbw_template": rgbw_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("rgbw_color") == expected_rgbw assert state.state == STATE_ON @@ -1255,9 +1781,18 @@ async def test_rgbw_template( assert state.attributes["supported_features"] == 0 -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_rgbww", "rgbww_template", "expected_color_mode"), + ("count", "extra_config"), [(1, OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "rgbww_template"), + (ConfigurationStyle.MODERN, "rgbww"), + ], +) +@pytest.mark.parametrize( + ("expected_rgbww", "attribute_template", "expected_color_mode"), [ ((160, 78, 192, 25, 55), "{{(160, 78, 192, 25, 55)}}", ColorMode.RGBWW), ((160, 78, 192, 25, 55), "(160, 78, 192, 25, 55)", ColorMode.RGBWW), @@ -1282,18 +1817,9 @@ async def test_rgbww_template( hass: HomeAssistant, expected_rgbww, expected_color_mode, - count, - rgbww_template, + setup_single_attribute_light, ) -> None: """Test the template for the color.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_RGBWW_COLOR_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "rgbww_template": rgbww_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state.attributes.get("rgbww_color") == expected_rgbww assert state.state == STATE_ON @@ -1304,59 +1830,27 @@ async def test_rgbww_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light": { + ( + { + "test_template_light": { + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "value_template": "{{1 == 1}}", + **TEST_ALL_COLORS_NO_TEMPLATE_CONFIG, + } + }, + ConfigurationStyle.LEGACY, + ), + ( + { + "name": "test_template_light", **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{1 == 1}}", - "set_hs": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "h": "{{h}}", - "s": "{{s}}", - }, - }, - "set_temperature": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "set_rgb": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "r": "{{r}}", - "g": "{{g}}", - "b": "{{b}}", - }, - }, - "set_rgbw": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "r": "{{r}}", - "g": "{{g}}", - "b": "{{b}}", - "w": "{{w}}", - }, - }, - "set_rgbww": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "r": "{{r}}", - "g": "{{g}}", - "b": "{{b}}", - "cw": "{{cw}}", - "ww": "{{ww}}", - }, - }, - } - }, + "state": "{{1 == 1}}", + **TEST_ALL_COLORS_NO_TEMPLATE_CONFIG, + }, + ConfigurationStyle.MODERN, + ), ], ) async def test_all_colors_mode_no_template( @@ -1554,29 +2048,21 @@ async def test_all_colors_mode_no_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +@pytest.mark.parametrize( + ("effect_list_template", "effect_template", "effect", "expected"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{true}}", - "set_effect": { - "service": "test.automation", - "data_template": { - "action": "set_effect", - "caller": "{{ this.entity_id }}", - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Disco', 'Police'] }}", - "effect_template": "{{ 'Disco' }}", - } - }, + ("{{ ['Disco', 'Police'] }}", "{{ 'Disco' }}", "Disco", "Disco"), + ("{{ ['Disco', 'Police'] }}", "{{ 'None' }}", "RGB", None), ], ) -async def test_effect_action_valid_effect( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] +async def test_effect_action( + hass: HomeAssistant, + effect: str, + expected: Any, + setup_light_with_effects, + calls: list[ServiceCall], ) -> None: """Test setting valid effect with template.""" state = hass.states.get("light.test_template_light") @@ -1585,64 +2071,24 @@ async def test_effect_action_valid_effect( await hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_EFFECT: "Disco"}, + {ATTR_ENTITY_ID: "light.test_template_light", ATTR_EFFECT: effect}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "set_effect" assert calls[-1].data["caller"] == "light.test_template_light" - assert calls[-1].data["effect"] == "Disco" + assert calls[-1].data["effect"] == effect state = hass.states.get("light.test_template_light") assert state is not None - assert state.attributes.get("effect") == "Disco" + assert state.attributes.get("effect") == expected -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize(("count", "effect_template"), [(1, "{{ None }}")]) @pytest.mark.parametrize( - "light_config", - [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "value_template": "{{true}}", - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": "{{ ['Disco', 'Police'] }}", - "effect_template": "{{ None }}", - } - }, - ], + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] ) -async def test_effect_action_invalid_effect( - hass: HomeAssistant, setup_light, calls: list[ServiceCall] -) -> None: - """Test setting invalid effect with template.""" - state = hass.states.get("light.test_template_light") - assert state is not None - - await hass.services.async_call( - light.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_template_light", ATTR_EFFECT: "RGB"}, - blocking=True, - ) - - assert len(calls) == 1 - assert calls[0].data["effect"] == "RGB" - - state = hass.states.get("light.test_template_light") - assert state is not None - assert state.attributes.get("effect") is None - - -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("expected_effect_list", "effect_list_template"), [ @@ -1663,31 +2109,21 @@ async def test_effect_action_invalid_effect( ], ) async def test_effect_list_template( - hass: HomeAssistant, expected_effect_list, count, effect_list_template + hass: HomeAssistant, expected_effect_list, setup_light_with_effects ) -> None: """Test the template for the effect list.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_template": "{{ None }}", - "effect_list_template": effect_list_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect_list") == expected_effect_list -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("count", "effect_list_template"), + [(1, "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}")], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) @pytest.mark.parametrize( ("expected_effect", "effect_template"), [ @@ -1699,27 +2135,9 @@ async def test_effect_list_template( ], ) async def test_effect_template( - hass: HomeAssistant, expected_effect, count, effect_template + hass: HomeAssistant, expected_effect, setup_light_with_effects ) -> None: """Test the template for the effect.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_effect": { - "service": "test.automation", - "data_template": { - "entity_id": "test.test_state", - "effect": "{{effect}}", - }, - }, - "effect_list_template": ( - "{{ ['Strobe color', 'Police', 'Christmas', 'RGB', 'Random Loop'] }}" - ), - "effect_template": effect_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("effect") == expected_effect @@ -1727,7 +2145,14 @@ async def test_effect_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_min_mireds", "min_mireds_template"), + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "min_mireds_template"), + (ConfigurationStyle.MODERN, "min_mireds"), + ], +) +@pytest.mark.parametrize( + ("expected_min_mireds", "attribute_template"), [ (118, "{{118}}"), (153, "{{x - 12}}"), @@ -1738,25 +2163,9 @@ async def test_effect_template( ], ) async def test_min_mireds_template( - hass: HomeAssistant, expected_min_mireds, count, min_mireds_template + hass: HomeAssistant, expected_min_mireds, setup_light_with_mireds ) -> None: """Test the template for the min mireds.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "min_mireds_template": min_mireds_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("min_mireds") == expected_min_mireds @@ -1764,7 +2173,14 @@ async def test_min_mireds_template( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_max_mireds", "max_mireds_template"), + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "max_mireds_template"), + (ConfigurationStyle.MODERN, "max_mireds"), + ], +) +@pytest.mark.parametrize( + ("expected_max_mireds", "attribute_template"), [ (488, "{{488}}"), (500, "{{x - 12}}"), @@ -1775,33 +2191,26 @@ async def test_min_mireds_template( ], ) async def test_max_mireds_template( - hass: HomeAssistant, expected_max_mireds, count, max_mireds_template + hass: HomeAssistant, expected_max_mireds, setup_light_with_mireds ) -> None: """Test the template for the max mireds.""" - light_config = { - "test_template_light": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "value_template": "{{ 1 == 1 }}", - "set_temperature": { - "service": "light.turn_on", - "data_template": { - "entity_id": "light.test_state", - "color_temp": "{{color_temp}}", - }, - }, - "temperature_template": "{{200}}", - "max_mireds_template": max_mireds_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") assert state is not None assert state.attributes.get("max_mireds") == expected_max_mireds -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("expected_supports_transition", "supports_transition_template"), + ("count", "extra_config"), [(1, OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG)] +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "supports_transition_template"), + (ConfigurationStyle.MODERN, "supports_transition"), + ], +) +@pytest.mark.parametrize( + ("expected_supports_transition", "attribute_template"), [ (True, "{{true}}"), (True, "{{1 == 1}}"), @@ -1812,28 +2221,9 @@ async def test_max_mireds_template( ], ) async def test_supports_transition_template( - hass: HomeAssistant, - expected_supports_transition, - count, - supports_transition_template, + hass: HomeAssistant, expected_supports_transition, setup_single_attribute_light ) -> None: """Test the template for the supports transition.""" - 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}}", - }, - }, - "supports_transition_template": supports_transition_template, - } - } - await async_setup_light(hass, count, light_config) state = hass.states.get("light.test_template_light") expected_value = 1 @@ -1847,36 +2237,16 @@ async def test_supports_transition_template( ) != expected_value -@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("count", "transition_template"), [(1, "{{ states('sensor.test') }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) async def test_supports_transition_template_updates( - hass: HomeAssistant, count: int + hass: HomeAssistant, setup_light_with_transition_template ) -> 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 @@ -1901,22 +2271,25 @@ async def test_supports_transition_template_updates( assert supported_features == LightEntityFeature.EFFECT -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config", "attribute_template"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "availability_template": ( - "{{ is_state('availability_boolean.state', 'on') }}" - ), - } - }, + ( + 1, + OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "{{ is_state('availability_boolean.state', 'on') }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), ], ) async def test_available_template_with_entities( - hass: HomeAssistant, setup_light + hass: HomeAssistant, setup_single_attribute_light ) -> None: """Test availability templates with values from other entities.""" # When template returns true.. @@ -1934,20 +2307,25 @@ async def test_available_template_with_entities( assert hass.states.get("light.test_template_light").state == STATE_UNAVAILABLE -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("count", "extra_config", "attribute_template"), [ - { - "test_template_light": { - **OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, - "availability_template": "{{ x - 12 }}", - } - }, + ( + 1, + OPTIMISTIC_BRIGHTNESS_LIGHT_CONFIG, + "{{ x - 12 }}", + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), ], ) async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, setup_light, caplog_setup_text + hass: HomeAssistant, setup_single_attribute_light, caplog_setup_text ) -> None: """Test that an invalid availability keeps the device available.""" assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE @@ -1956,20 +2334,73 @@ async def test_invalid_availability_template_keeps_component_available( @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "light_config", + ("light_config", "style"), [ - { - "test_template_light_01": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "unique_id": "not-so-unique-anymore", + ( + { + "test_template_light_01": TEST_UNIQUE_ID_CONFIG, + "test_template_light_02": TEST_UNIQUE_ID_CONFIG, }, - "test_template_light_02": { - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - "unique_id": "not-so-unique-anymore", - }, - }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_light_01", + **TEST_UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_light_02", + **TEST_UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), ], ) async def test_unique_id(hass: HomeAssistant, setup_light) -> None: """Test unique_id option only creates one light per id.""" assert len(hass.states.async_all("light")) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test unique_id option creates one light per nested id.""" + + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "light": [ + { + "name": "test_a", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "unique_id": "a", + }, + { + "name": "test_b", + **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, + "unique_id": "b", + }, + ], + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all("light")) == 2 + + entry = entity_registry.async_get("light.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("light.test_b") + assert entry + assert entry.unique_id == "x-b" From 1e8f211725a66e4876c15fc9ff7d302255a1475a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 22:47:36 -1000 Subject: [PATCH 1643/1941] Bump aioshelly to 13.3.0 (#140571) changelog: https://github.com/home-assistant-libs/aioshelly/compare/13.2.0...13.3.0 --- 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 c8ac5520b13..c9cbd778e95 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.2.0"], + "requirements": ["aioshelly==13.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 445d89ec651..c29183c95b6 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.2.0 +aioshelly==13.3.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12001c6a121..05d6ed6390c 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.2.0 +aioshelly==13.3.0 # homeassistant.components.skybell aioskybell==22.7.0 From 23f4f97603e6721926abe3b55d4a9200680fff83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Mar 2025 22:57:24 -1000 Subject: [PATCH 1644/1941] Bump habluetooth to 3.27.0 (#140569) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.25.1...v3.27.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_manager.py | 6 ------ 5 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 50d115dc89b..3430787958e 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.0", "dbus-fast==2.39.5", - "habluetooth==3.25.1" + "habluetooth==3.27.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b4823d1a549..8f9a9670fee 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.39.5 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.25.1 +habluetooth==3.27.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index c29183c95b6..76926fd1001 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.25.1 +habluetooth==3.27.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05d6ed6390c..819d9756f85 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.25.1 +habluetooth==3.27.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index be23a536f49..48d1a38375d 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1019,8 +1019,6 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( def clear_all_devices(self) -> None: """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() self._previous_service_info.clear() connector = ( @@ -1446,8 +1444,6 @@ async def test_bluetooth_rediscover( def clear_all_devices(self) -> None: """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() self._previous_service_info.clear() connector = ( @@ -1625,8 +1621,6 @@ async def test_bluetooth_rediscover_no_match( def clear_all_devices(self) -> None: """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() self._previous_service_info.clear() connector = ( From 5daa3167ca93f703736efffaf43167ddf5a43072 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 14 Mar 2025 10:03:29 +0100 Subject: [PATCH 1645/1941] Add parallel updates to Comelit (#140527) --- homeassistant/components/comelit/alarm_control_panel.py | 3 +++ homeassistant/components/comelit/binary_sensor.py | 3 +++ homeassistant/components/comelit/climate.py | 3 +++ homeassistant/components/comelit/cover.py | 3 +++ homeassistant/components/comelit/humidifier.py | 3 +++ homeassistant/components/comelit/light.py | 3 +++ homeassistant/components/comelit/sensor.py | 3 +++ homeassistant/components/comelit/switch.py | 3 +++ 8 files changed, 24 insertions(+) diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index 0a01dd957a6..5ecc9a63599 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -20,6 +20,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitVedoSystem +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) AWAY = "away" diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index c17057d19d1..dfa6d3e97f3 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -16,6 +16,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitVedoSystem +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 3433d1bdf04..505c2b6b8e8 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -23,6 +23,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + class ClimaComelitMode(StrEnum): """Serial Bridge clima modes.""" diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 64412569f95..9bcf52ac111 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -15,6 +15,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index da6d44b1bbe..b28a9bf0036 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -24,6 +24,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + class HumidifierComelitMode(StrEnum): """Serial Bridge humidifier modes.""" diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 45f4146ece6..09180d628a6 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -14,6 +14,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 3d57d9dca9c..c93ccd30eb6 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -20,6 +20,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SENSOR_BRIDGE_TYPES: Final = ( SensorEntityDescription( key="power", diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index f6e5b192c38..db89bd082f6 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -14,6 +14,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From f48d94ce343be20c83ed6ef921ddac6ed789e206 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 14 Mar 2025 10:08:39 +0100 Subject: [PATCH 1646/1941] Use TypeVar default for Generator (#140506) --- tests/test_backup_restore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 4c6bc930667..7efe25c8428 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -15,7 +15,7 @@ from .common import get_test_config_dir @pytest.fixture(autouse=True) -def remove_restore_result_file() -> Generator[None, Any, Any]: +def remove_restore_result_file() -> Generator[None]: """Remove the restore result file.""" yield Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) From 9820cbb036f96cc40af36f6b9a90028daa0f3734 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 14 Mar 2025 10:17:10 +0100 Subject: [PATCH 1647/1941] Add exceptions translation for Comelit (#140404) * Add exceptions translation for Comelit * apply review comment * Add climate tests for Comelit * Revert "Add climate tests for Comelit" This reverts commit 6d76d312a064491be4dbfb960a28b00f742f4186. --- homeassistant/components/comelit/climate.py | 5 ++++- homeassistant/components/comelit/humidifier.py | 4 +++- homeassistant/components/comelit/strings.json | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 505c2b6b8e8..8064d478c32 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import ComelitConfigEntry, ComelitSerialBridge # Coordinator is used to centralize the data updates @@ -124,7 +125,9 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity """Handle updated data from the coordinator.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): - raise HomeAssistantError("Invalid clima data") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="invalid_clima_data" + ) # CLIMATE has a 2 item tuple: # - first for Clima diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index b28a9bf0036..c5edfb1c2de 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -130,7 +130,9 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier """Handle updated data from the coordinator.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): - raise HomeAssistantError("Invalid clima data") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="invalid_clima_data" + ) # CLIMATE has a 2 item tuple: # - first for Clima diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 14d947c7323..5ff4fa54688 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -58,6 +58,9 @@ "exceptions": { "humidity_while_off": { "message": "Cannot change humidity while off" + }, + "invalid_clima_data": { + "message": "Invalid 'clima' data" } } } From 2b0a2e76447525919041fcfe6a31f2edfeff2a3b Mon Sep 17 00:00:00 2001 From: ashionky <35916938+ashionky@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:19:43 +0800 Subject: [PATCH 1648/1941] Fix missing UnitOfPower.MILLIWATT in sensor and number allowed units (#140567) * MILLIWATT * MILLIWATT --- homeassistant/components/number/const.py | 1 + homeassistant/components/sensor/const.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index a7493194847..f44a510b1c0 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -487,6 +487,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, NumberDeviceClass.POWER: { + UnitOfPower.MILLIWATT, UnitOfPower.WATT, UnitOfPower.KILO_WATT, UnitOfPower.MEGA_WATT, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 774f2a9cff2..e1f7dd13d93 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -583,6 +583,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None}, SensorDeviceClass.POWER: { + UnitOfPower.MILLIWATT, UnitOfPower.WATT, UnitOfPower.KILO_WATT, UnitOfPower.MEGA_WATT, From d952e8186f32c9f53ef9edc29d379ccb094ebd3a Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 14 Mar 2025 10:20:16 +0100 Subject: [PATCH 1649/1941] Use only IPv4 for zeroconf in bluesound integration (#140226) * Use only ipv4 for zeroconf * Fix tests * Use only ip_address for ip version check * Add test * Reduce test --- .../components/bluesound/config_flow.py | 3 ++ .../components/bluesound/strings.json | 3 +- .../components/bluesound/test_config_flow.py | 33 +++++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py index 2f002b70e1d..cfb6646d829 100644 --- a/homeassistant/components/bluesound/config_flow.py +++ b/homeassistant/components/bluesound/config_flow.py @@ -75,6 +75,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" + # the player can have an ipv6 address, but the api is only available on ipv4 + if discovery_info.ip_address.version != 4: + return self.async_abort(reason="no_ipv4_address") if discovery_info.port is not None: self._port = discovery_info.port diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json index b50c01a11bf..1170e0b92e0 100644 --- a/homeassistant/components/bluesound/strings.json +++ b/homeassistant/components/bluesound/strings.json @@ -19,7 +19,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_ipv4_address": "No IPv4 address found." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py index d0e0f75991b..a4d5eecd744 100644 --- a/tests/components/bluesound/test_config_flow.py +++ b/tests/components/bluesound/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Bluesound config flow.""" +from ipaddress import IPv4Address, IPv6Address from unittest.mock import AsyncMock from pyblu.errors import PlayerUnreachableError @@ -121,8 +122,8 @@ async def test_zeroconf_flow_success( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.1", - ip_addresses=["1.1.1.1"], + ip_address=IPv4Address("1.1.1.1"), + ip_addresses=[IPv4Address("1.1.1.1")], port=11000, hostname="player-name1111", type="_musc._tcp.local.", @@ -160,8 +161,8 @@ async def test_zeroconf_flow_cannot_connect( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.1", - ip_addresses=["1.1.1.1"], + ip_address=IPv4Address("1.1.1.1"), + ip_addresses=[IPv4Address("1.1.1.1")], port=11000, hostname="player-name1111", type="_musc._tcp.local.", @@ -187,8 +188,8 @@ async def test_zeroconf_flow_already_configured( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - ip_address="1.1.1.2", - ip_addresses=["1.1.1.2"], + ip_address=IPv4Address("1.1.1.2"), + ip_addresses=[IPv4Address("1.1.1.2")], port=11000, hostname="player-name1112", type="_musc._tcp.local.", @@ -203,3 +204,23 @@ async def test_zeroconf_flow_already_configured( assert config_entry.data[CONF_HOST] == "1.1.1.2" player_mocks.player_data_for_already_configured.player.sync_status.assert_called_once() + + +async def test_zeroconf_flow_no_ipv4_address(hass: HomeAssistant) -> None: + """Test abort flow when no ipv4 address is found in zeroconf data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=IPv6Address("2001:db8::1"), + ip_addresses=[IPv6Address("2001:db8::1")], + port=11000, + hostname="player-name1112", + type="_musc._tcp.local.", + name="player-name._musc._tcp.local.", + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_ipv4_address" From 99b140f73f16fe8676e6164c6697b88469f3d7c0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 14 Mar 2025 10:21:16 +0100 Subject: [PATCH 1650/1941] Remove WebDAV properties and rely on metadata file (#140539) --- homeassistant/components/webdav/backup.py | 107 ++++++++-------------- tests/components/webdav/conftest.py | 4 +- tests/components/webdav/const.py | 21 +---- tests/components/webdav/test_backup.py | 39 +------- 4 files changed, 48 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index 321ed98bfa8..fb2927a58bb 100644 --- a/homeassistant/components/webdav/backup.py +++ b/homeassistant/components/webdav/backup.py @@ -5,10 +5,10 @@ from __future__ import annotations from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import logging +from time import time 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 @@ -28,9 +28,8 @@ 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) -NAMESPACE = "https://home-assistant.io" +CACHE_TTL = 300 async def async_get_backup_agents( @@ -96,23 +95,6 @@ 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 == 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 == NAMESPACE and prop.name == "backup_id": - return prop.value - return None - - class WebDavBackupAgent(BackupAgent): """Backup agent interface.""" @@ -126,6 +108,8 @@ class WebDavBackupAgent(BackupAgent): self._client = entry.runtime_data self.name = entry.title self.unique_id = entry.entry_id + self._cache_metadata_files: dict[str, AgentBackup] = {} + self._cache_expiration = time() @cached_property def _backup_path(self) -> str: @@ -182,27 +166,14 @@ class WebDavBackupAgent(BackupAgent): f"{self._backup_path}/{filename_meta}", ) - await self._client.set_property_batch( - f"{self._backup_path}/{filename_meta}", - [ - Property( - namespace=NAMESPACE, - name="backup_id", - value=backup.backup_id, - ), - Property( - namespace=NAMESPACE, - name="metadata_version", - value=METADATA_VERSION, - ), - ], - ) - _LOGGER.debug( "Uploaded metadata file for %s", f"{self._backup_path}/{filename_meta}", ) + # reset cache + self._cache_expiration = time() + @handle_backup_errors async def async_delete_backup( self, @@ -226,14 +197,13 @@ class WebDavBackupAgent(BackupAgent): backup_path, ) + # reset cache + self._cache_expiration = time() + @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.values() - ] + return list((await self._list_cached_metadata_files()).values()) @handle_backup_errors async def async_get_backup( @@ -244,38 +214,35 @@ class WebDavBackupAgent(BackupAgent): """Return a backup.""" return await self._find_backup_by_id(backup_id) - async def _list_metadata_files(self) -> dict[str, str]: - """List metadata files.""" - files = await self._client.list_with_properties( - self._backup_path, - [ - PropertyRequest( - namespace=NAMESPACE, - name="metadata_version", - ), - PropertyRequest( - namespace=NAMESPACE, - name="backup_id", - ), - ], - ) - 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 _list_cached_metadata_files(self) -> dict[str, AgentBackup]: + """List metadata files with a cache.""" + if time() <= self._cache_expiration: + return self._cache_metadata_files + + async def _download_metadata(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)) + + async def _list_metadata_files() -> dict[str, AgentBackup]: + """List metadata files.""" + files = await self._client.list_files(self._backup_path) + return { + metadata_content.backup_id: metadata_content + for file_name in files + if file_name.endswith(".json") + if (metadata_content := await _download_metadata(file_name)) + } + + self._cache_metadata_files = await _list_metadata_files() + self._cache_expiration = time() + CACHE_TTL + return self._cache_metadata_files async def _find_backup_by_id(self, backup_id: str) -> AgentBackup: """Find a backup by its backup ID on remote.""" - metadata_files = await self._list_metadata_files() + metadata_files = await self._list_cached_metadata_files() if metadata_file := metadata_files.get(backup_id): - return await self._download_metadata(metadata_file) + return metadata_file raise BackupNotFound(f"Backup {backup_id} not found") - - 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/tests/components/webdav/conftest.py b/tests/components/webdav/conftest.py index 645e2111364..5fa972e5fae 100644 --- a/tests/components/webdav/conftest.py +++ b/tests/components/webdav/conftest.py @@ -9,7 +9,7 @@ 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_LIST_WITH_PROPERTIES +from .const import BACKUP_METADATA, MOCK_LIST_FILES from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ 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_properties.return_value = MOCK_LIST_WITH_PROPERTIES + mock.list_files.return_value = MOCK_LIST_FILES mock.download_iter.side_effect = _download_mock mock.upload_iter.return_value = None mock.clean.return_value = None diff --git a/tests/components/webdav/const.py b/tests/components/webdav/const.py index 8d6b8ad67d7..0147826a777 100644 --- a/tests/components/webdav/const.py +++ b/tests/components/webdav/const.py @@ -1,7 +1,5 @@ """Constants for WebDAV tests.""" -from aiowebdav2 import Property - BACKUP_METADATA = { "addons": [], "backup_id": "23e64aec", @@ -16,18 +14,7 @@ BACKUP_METADATA = { "size": 34519040, } -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="https://home-assistant.io", - name="backup_id", - value="23e64aec", - ), - Property( - namespace="https://home-assistant.io", - name="metadata_version", - value="1", - ), - ], -} +MOCK_LIST_FILES = [ + "/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", +] diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index c20e73cc786..ca20467484f 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -6,7 +6,6 @@ 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 @@ -17,7 +16,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_PROPERTIES +from .const import BACKUP_METADATA from tests.common import AsyncMock, MockConfigEntry from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -184,7 +183,6 @@ async def test_agents_upload( 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( @@ -211,7 +209,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_properties.side_effect = [MOCK_LIST_WITH_PROPERTIES, {}] + webdav_client.list_files.return_value = [] resp = await client.get( f"/api/backup/download/{backup_id}?agent_id={DOMAIN}.{mock_config_entry.entry_id}" @@ -262,7 +260,7 @@ async def test_agents_delete_not_found_does_not_throw( webdav_client: AsyncMock, ) -> None: """Test agent delete backup.""" - webdav_client.list_with_properties.return_value = {} + webdav_client.list_files.return_value = {} client = await hass_ws_client(hass) await client.send_json_auto_id( @@ -283,7 +281,7 @@ async def test_agents_backup_not_found( webdav_client: AsyncMock, ) -> None: """Test backup not found.""" - webdav_client.list_with_properties.return_value = [] + webdav_client.list_files.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}) @@ -300,7 +298,7 @@ async def test_raises_on_403( mock_config_entry: MockConfigEntry, ) -> None: """Test we raise on 403.""" - webdav_client.list_with_properties.side_effect = UnauthorizedError( + webdav_client.list_files.side_effect = UnauthorizedError( "https://webdav.example.com" ) backup_id = BACKUP_METADATA["backup_id"] @@ -324,30 +322,3 @@ 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 8726be31ff52b40d43d7e912a26a348084e608c8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Mar 2025 10:28:37 +0100 Subject: [PATCH 1651/1941] Use correct unit symbol "min" for minutes in `webmin` integration (#140448) * Use correct unit symbol "min" for minutes in `webmin` integration Replace the unit symbol "m" which stands for meter with the correct SI uni symbol "min". * Update test_sensor.ambr * Update test_sensor.ambr (2) --- homeassistant/components/webmin/strings.json | 6 ++-- .../webmin/snapshots/test_sensor.ambr | 36 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/webmin/strings.json b/homeassistant/components/webmin/strings.json index 9a6d6d4fbe4..b92986f917a 100644 --- a/homeassistant/components/webmin/strings.json +++ b/homeassistant/components/webmin/strings.json @@ -29,13 +29,13 @@ "entity": { "sensor": { "load_1m": { - "name": "Load (1m)" + "name": "Load (1 min)" }, "load_5m": { - "name": "Load (5m)" + "name": "Load (5 min)" }, "load_15m": { - "name": "Load (15m)" + "name": "Load (15 min)" }, "mem_total": { "name": "Memory total" diff --git a/tests/components/webmin/snapshots/test_sensor.ambr b/tests/components/webmin/snapshots/test_sensor.ambr index a2068f662ba..1af5fe46b5c 100644 --- a/tests/components/webmin/snapshots/test_sensor.ambr +++ b/tests/components/webmin/snapshots/test_sensor.ambr @@ -1451,7 +1451,7 @@ 'state': '8794.3125', }) # --- -# name: test_sensor[sensor.192_168_1_1_load_15m-entry] +# name: test_sensor[sensor.192_168_1_1_load_15_min-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1466,7 +1466,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.192_168_1_1_load_15m', + 'entity_id': 'sensor.192_168_1_1_load_15_min', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1478,7 +1478,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Load (15m)', + 'original_name': 'Load (15 min)', 'platform': 'webmin', 'previous_unique_id': None, 'supported_features': 0, @@ -1487,21 +1487,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.192_168_1_1_load_15m-state] +# name: test_sensor[sensor.192_168_1_1_load_15_min-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '192.168.1.1 Load (15m)', + 'friendly_name': '192.168.1.1 Load (15 min)', 'state_class': , }), 'context': , - 'entity_id': 'sensor.192_168_1_1_load_15m', + 'entity_id': 'sensor.192_168_1_1_load_15_min', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1.37', }) # --- -# name: test_sensor[sensor.192_168_1_1_load_1m-entry] +# name: test_sensor[sensor.192_168_1_1_load_1_min-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1516,7 +1516,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.192_168_1_1_load_1m', + 'entity_id': 'sensor.192_168_1_1_load_1_min', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1528,7 +1528,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Load (1m)', + 'original_name': 'Load (1 min)', 'platform': 'webmin', 'previous_unique_id': None, 'supported_features': 0, @@ -1537,21 +1537,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.192_168_1_1_load_1m-state] +# name: test_sensor[sensor.192_168_1_1_load_1_min-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '192.168.1.1 Load (1m)', + 'friendly_name': '192.168.1.1 Load (1 min)', 'state_class': , }), 'context': , - 'entity_id': 'sensor.192_168_1_1_load_1m', + 'entity_id': 'sensor.192_168_1_1_load_1_min', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1.29', }) # --- -# name: test_sensor[sensor.192_168_1_1_load_5m-entry] +# name: test_sensor[sensor.192_168_1_1_load_5_min-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1566,7 +1566,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.192_168_1_1_load_5m', + 'entity_id': 'sensor.192_168_1_1_load_5_min', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1578,7 +1578,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Load (5m)', + 'original_name': 'Load (5 min)', 'platform': 'webmin', 'previous_unique_id': None, 'supported_features': 0, @@ -1587,14 +1587,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.192_168_1_1_load_5m-state] +# name: test_sensor[sensor.192_168_1_1_load_5_min-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': '192.168.1.1 Load (5m)', + 'friendly_name': '192.168.1.1 Load (5 min)', 'state_class': , }), 'context': , - 'entity_id': 'sensor.192_168_1_1_load_5m', + 'entity_id': 'sensor.192_168_1_1_load_5_min', 'last_changed': , 'last_reported': , 'last_updated': , From 5ea7c113b0b33ecb0784550f337179fedd34b741 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 14 Mar 2025 11:15:38 +0100 Subject: [PATCH 1652/1941] Use test snapshots for Shelly climate (#140582) --- tests/components/shelly/conftest.py | 1 + .../shelly/snapshots/test_climate.ambr | 276 ++++++++++++++++++ tests/components/shelly/test_climate.py | 32 +- 3 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 tests/components/shelly/snapshots/test_climate.ambr diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 0063c5c2697..8ea04ea3bfb 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -502,6 +502,7 @@ def _mock_blu_rtv_device(version: str | None = None): firmware_version="some fw string", initialized=True, connected=True, + xmod_info={}, ) type(device).name = PropertyMock(return_value="Test name") return device diff --git a/tests/components/shelly/snapshots/test_climate.ambr b/tests/components/shelly/snapshots/test_climate.ambr new file mode 100644 index 00000000000..991c570172e --- /dev/null +++ b/tests/components/shelly/snapshots/test_climate.ambr @@ -0,0 +1,276 @@ +# serializer version: 1 +# name: test_blu_trv_climate_set_temperature[climate.trv_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 4, + 'target_temp_step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.trv_name', + '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': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'f8:44:77:25:f0:dd-blutrv:200', + 'unit_of_measurement': None, + }) +# --- +# name: test_blu_trv_climate_set_temperature[climate.trv_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 15.2, + 'friendly_name': 'TRV-Name', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30, + 'min_temp': 4, + 'supported_features': , + 'target_temp_step': 0.1, + 'temperature': 17.1, + }), + 'context': , + 'entity_id': 'climate.trv_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_hvac_mode[climate.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 31, + 'min_temp': 4, + 'preset_modes': list([ + 'none', + 'Profile1', + 'Profile2', + ]), + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_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': 'Test name', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sensor_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_hvac_mode[climate.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.1, + 'friendly_name': 'Test name', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 31, + 'min_temp': 4, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'Profile1', + 'Profile2', + ]), + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_rpc_climate_hvac_mode[climate.test_name_thermostat_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name_thermostat_0', + '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': 'Test name Thermostat 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-thermostat:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_climate_hvac_mode[climate.test_name_thermostat_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 44.4, + 'current_temperature': 12.3, + 'friendly_name': 'Test name Thermostat 0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.test_name_thermostat_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_wall_display_thermostat_mode[climate.test_name_thermostat_0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_name_thermostat_0', + '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': 'Test name Thermostat 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-thermostat:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_wall_display_thermostat_mode[climate.test_name_thermostat_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 44.4, + 'current_temperature': 12.3, + 'friendly_name': 'Test name Thermostat 0', + 'hvac_action': , + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35, + 'min_temp': 5, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.test_name_thermostat_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index c78e87ebfce..fcfed090a66 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -11,6 +11,7 @@ from aioshelly.const import ( ) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, @@ -65,6 +66,7 @@ async def test_climate_hvac_mode( mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test climate hvac mode service.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") @@ -84,11 +86,10 @@ async def test_climate_hvac_mode( # Test initial hvac mode - off state = hass.states.get(ENTITY_ID) - assert state.state == HVACMode.OFF + assert state == snapshot(name=f"{ENTITY_ID}-state") entry = entity_registry.async_get(ENTITY_ID) - assert entry - assert entry.unique_id == "123456789ABC-sensor_0" + assert entry == snapshot(name=f"{ENTITY_ID}-entry") # Test set hvac mode heat await hass.services.async_call( @@ -603,6 +604,7 @@ async def test_rpc_climate_hvac_mode( entity_registry: EntityRegistry, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, ) -> None: """Test climate hvac mode service.""" entity_id = "climate.test_name_thermostat_0" @@ -610,15 +612,10 @@ async def test_rpc_climate_hvac_mode( await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) state = hass.states.get(entity_id) - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 23 - assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 + assert state == snapshot(name=f"{entity_id}-state") entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == "123456789ABC-thermostat:0" + assert entry == snapshot(name=f"{entity_id}-entry") monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "output", False) mock_rpc_device.mock_update() @@ -717,6 +714,7 @@ async def test_wall_display_thermostat_mode( mock_rpc_device: Mock, entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, ) -> None: """Test Wall Display in thermostat mode.""" climate_entity_id = "climate.test_name_thermostat_0" @@ -730,13 +728,11 @@ async def test_wall_display_thermostat_mode( # the climate entity should be created state = hass.states.get(climate_entity_id) - assert state - assert state.state == HVACMode.HEAT + assert state == snapshot(name=f"{climate_entity_id}-state") assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 entry = entity_registry.async_get(climate_entity_id) - assert entry - assert entry.unique_id == "123456789ABC-thermostat:0" + assert entry == snapshot(name=f"{climate_entity_id}-entry") async def test_wall_display_thermostat_mode_external_actuator( @@ -776,7 +772,9 @@ async def test_wall_display_thermostat_mode_external_actuator( async def test_blu_trv_climate_set_temperature( hass: HomeAssistant, mock_blu_trv: Mock, + entity_registry: EntityRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, ) -> None: """Test BLU TRV set target temperature.""" @@ -785,6 +783,12 @@ async def test_blu_trv_climate_set_temperature( await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + assert get_entity_attribute(hass, entity_id, ATTR_TEMPERATURE) == 17.1 monkeypatch.setitem( From ae8709be21f89375869cd0728e0f1b5f68b17f3d Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 14 Mar 2025 13:19:49 +0200 Subject: [PATCH 1653/1941] Expose ZWaveJS`supports_long_range` to the frontend (#140489) * Expose ZWaveJS`supports_long_range` to the frontend * update test --- homeassistant/components/zwave_js/api.py | 1 + tests/components/zwave_js/fixtures/controller_state.json | 1 + tests/components/zwave_js/test_api.py | 1 + 3 files changed, 3 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index aef23cb73ea..cc47339a6a6 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -518,6 +518,7 @@ async def websocket_network_status( "supported_function_types": controller.supported_function_types, "suc_node_id": controller.suc_node_id, "supports_timers": controller.supports_timers, + "supports_long_range": controller.supports_long_range, "is_rebuilding_routes": controller.is_rebuilding_routes, "inclusion_state": controller.inclusion_state, "rf_region": controller.rf_region, diff --git a/tests/components/zwave_js/fixtures/controller_state.json b/tests/components/zwave_js/fixtures/controller_state.json index d6d9dcacd9e..c3b9de4bdec 100644 --- a/tests/components/zwave_js/fixtures/controller_state.json +++ b/tests/components/zwave_js/fixtures/controller_state.json @@ -23,6 +23,7 @@ ], "sucNodeId": 1, "supportsTimers": false, + "supportsLongRange": true, "isHealNetworkActive": false, "inclusionState": 0, "status": 0 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 42c5d59d7ad..dcb8c8dafe4 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -168,6 +168,7 @@ async def test_network_status( assert result["client"]["server_version"] == "1.0.0" assert not result["client"]["server_logging_enabled"] assert result["controller"]["inclusion_state"] == InclusionState.IDLE + assert result["controller"]["supports_long_range"] # Try API call with device ID device = device_registry.async_get_device( From dcc63a6f2e495f92d703698203c5a495dc4378ac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 13:32:50 +0100 Subject: [PATCH 1654/1941] Bump ruff to 0.10.0 (#140541) * Bump ruff to 0.10.0 * Bump ruff to 0.10.0 * Bump ruff to 0.10.0 * Bump ruff to 0.10.0 * Update pyproject.toml Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Fix --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- homeassistant/helpers/deprecation.py | 2 +- homeassistant/helpers/entity.py | 2 +- homeassistant/util/frozen_dataclass_compat.py | 2 +- pyproject.toml | 9 ++++++--- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/test_chat_log.py | 4 ++-- tests/components/tts/test_init.py | 2 +- tests/components/wyoming/test_tts.py | 2 +- 10 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf6fe7030e9..1af73b2b5e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.10 + rev: v0.10.0 hooks: - id: ruff args: diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 375ec58c26f..101b9731caf 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -369,7 +369,7 @@ class EnumWithDeprecatedMembers(EnumType): """Enum with deprecated members.""" def __new__( - mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + mcs, cls: str, bases: tuple[type, ...], classdict: _EnumDict, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index bed5ce586c5..bdcda58c054 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -281,7 +281,7 @@ class CachedProperties(type): """ def __new__( - mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + mcs, name: str, bases: tuple[type, ...], namespace: dict[Any, Any], diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py index 81ce9961a0b..518515d4f85 100644 --- a/homeassistant/util/frozen_dataclass_compat.py +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -63,7 +63,7 @@ class FrozenOrThawed(type): ) def __new__( - mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + mcs, name: str, bases: tuple[type, ...], namespace: dict[Any, Any], diff --git a/pyproject.toml b/pyproject.toml index 8e3fe4e25a7..bcc657528a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -700,7 +700,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.9.1" +required-version = ">=0.10.0" [tool.ruff.lint] select = [ @@ -784,7 +784,6 @@ select = [ "S317", # suspicious-xml-sax-usage "S318", # suspicious-xml-mini-dom-usage "S319", # suspicious-xml-pull-dom-usage - "S320", # suspicious-xmle-tree-usage "S601", # paramiko-call "S602", # subprocess-popen-with-shell-equals-true "S604", # call-with-shell-equals-true @@ -836,6 +835,8 @@ ignore = [ "TC001", # Move application import {} into a type-checking block "TC002", # Move third-party import {} into a type-checking block "TC003", # Move standard library import {} into a type-checking block + # Quotes for typing.cast generally not necessary, only for performance critical paths + "TC006", # Add quotes to type expression in typing.cast() "TRY003", # Avoid specifying long messages outside the exception class "TRY400", # Use `logging.exception` instead of `logging.error` @@ -854,7 +855,9 @@ ignore = [ "COM819", # Disabled because ruff does not understand type of __all__ generated by a function - "PLE0605" + "PLE0605", + + "PLC1802", # disabled temporarily on ruff 0.10.0 update ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 1cf9ef3fcf5..a6ce0d38cb1 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.9.10 +ruff==0.10.0 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index e4e0c751d78..a9201bff6ce 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.9.10 \ + stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.10.0 \ 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" diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 97094740af0..d7b3531c658 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -591,7 +591,7 @@ async def test_add_delta_content_stream_errors( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): # Stream content without LLM API set - with pytest.raises(ValueError): # noqa: PT012 + with pytest.raises(ValueError): async for _tool_result_content in chat_log.async_add_delta_content_stream( "mock-agent-id", stream( @@ -613,7 +613,7 @@ async def test_add_delta_content_stream_errors( # Non assistant role for role in "system", "user": - with pytest.raises(ValueError): # noqa: PT012 + with pytest.raises(ValueError): async for ( _tool_result_content ) in chat_log.async_add_delta_content_stream( diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index be14e006610..4e17bc68a5e 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1772,7 +1772,7 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: async def bad_data_gen(): yield bytes(0) - with pytest.raises(RuntimeError): # noqa: PT012 + with pytest.raises(RuntimeError): # Simulate a bad WAV file async for _chunk in tts._async_convert_audio( hass, "wav", bad_data_gen(), "mp3" diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 73fb68b44e5..6e0edc022c0 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -156,7 +156,7 @@ async def test_get_tts_audio_connection_lost( MockAsyncTcpClient([None]), ): stream.async_set_message("Hello world") - with pytest.raises(HomeAssistantError): # noqa: PT012 + with pytest.raises(HomeAssistantError): async for _chunk in stream.async_stream_result(): pass From bd4d0ec4b84b1867493cb0d4d49d31ef0adc3a6a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 14 Mar 2025 14:00:07 +0100 Subject: [PATCH 1655/1941] Add initial MQTT subentry support for notify entities (#138461) * Add initial MQTT subentry support for notify entities * Fix componts assigment is reset on device config. Translation tweaks * Rephrase * Go to summary menu when components are set up already - add test * Fix suggested device info on config flow * Invert * Simplify subentry config flow and omit menu * Use constants instead of literals * More constants * Teak some translations * Only show save when the the entry is dirty * Do not trigger an entry reload twice * Remove encoding, entity_category * Remove icon from mqtt subentry flow * Separate entity settings and MQTT specific settings * Remove object_id and refactor * Migrate translations * Make subconfig flow test extensible * Make sub reconfig flow tests extensible * Rename entity_platform_config step to mqtt_platform_config * Make component unique ID independent from the name * Move code for update of component data to helper * Follow up on code review * Skip dirty stuff * Fix rebase issues #1 * Do not allow reconfig for entity platform/name, default QoS and refactor tests * Add entity platform and entity name label to basic entity config dialog * Rename to exclude_from_reconfig and make reconfig option not optional --- homeassistant/components/mqtt/__init__.py | 25 +- homeassistant/components/mqtt/config_flow.py | 439 +++++++++++- homeassistant/components/mqtt/entity.py | 45 +- homeassistant/components/mqtt/models.py | 19 + homeassistant/components/mqtt/strings.json | 109 +++ tests/components/mqtt/common.py | 112 +++ tests/components/mqtt/test_config_flow.py | 712 +++++++++++++++++++ tests/components/mqtt/test_mixins.py | 82 ++- tests/conftest.py | 23 +- 9 files changed, 1544 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 6656afe2c8a..ae010bf18c9 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DISCOVERY, SERVICE_RELOAD +from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, @@ -81,6 +81,7 @@ from .const import ( ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, TEMPLATE_ERRORS, + Platform, ) from .models import ( DATA_MQTT, @@ -293,6 +294,21 @@ async def async_check_config_schema( ) from exc +def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Platform]: + """Return a set of platforms in use.""" + domains: set[str | Platform] = { + entry.domain + for entry in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + } + # Update with domains from subentries + for subentry in entry.subentries.values(): + components = subentry.data["components"].values() + domains.update(component[CONF_PLATFORM] for component in components) + return domains + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the actions and websocket API for the MQTT component.""" @@ -434,12 +450,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mqtt_data, conf = await _setup_client() platforms_used = platforms_from_config(mqtt_data.config) - platforms_used.update( - entry.domain - for entry in er.async_entries_for_config_entry( - er.async_get(hass), entry.entry_id - ) - ) + platforms_used.update(_platforms_in_use(hass, entry)) integration = async_get_loaded_integration(hass, DOMAIN) # Preload platforms we know we are going to use so # discovery can setup each platform synchronously diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index ad188c50aa9..8922b059a23 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -5,12 +5,15 @@ from __future__ import annotations import asyncio from collections import OrderedDict from collections.abc import Callable, Mapping +from copy import deepcopy +from dataclasses import dataclass from enum import IntEnum import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast +from uuid import uuid4 from cryptography.hazmat.primitives.serialization import ( Encoding, @@ -29,21 +32,32 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, + ConfigSubentryFlow, OptionsFlow, + SubentryFlowResult, ) from homeassistant.const import ( + ATTR_CONFIGURATION_URL, + ATTR_HW_VERSION, + ATTR_MODEL, + ATTR_MODEL_ID, + ATTR_NAME, + ATTR_SW_VERSION, CONF_CLIENT_ID, + CONF_DEVICE, CONF_DISCOVERY, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PAYLOAD, + CONF_PLATFORM, CONF_PORT, CONF_PROTOCOL, CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.json import json_dumps from homeassistant.helpers.selector import ( @@ -54,9 +68,12 @@ from homeassistant.helpers.selector import ( NumberSelectorConfig, NumberSelectorMode, SelectOptionDict, + Selector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TemplateSelector, + TemplateSelectorConfig, TextSelector, TextSelectorConfig, TextSelectorType, @@ -76,8 +93,13 @@ from .const import ( CONF_CERTIFICATE, CONF_CLIENT_CERT, CONF_CLIENT_KEY, + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, CONF_DISCOVERY_PREFIX, + CONF_ENTITY_PICTURE, CONF_KEEPALIVE, + CONF_QOS, + CONF_RETAIN, CONF_TLS_INSECURE, CONF_TRANSPORT, CONF_WILL_MESSAGE, @@ -99,12 +121,15 @@ from .const import ( SUPPORTED_PROTOCOLS, TRANSPORT_TCP, TRANSPORT_WEBSOCKETS, + Platform, ) +from .models import MqttDeviceData, MqttSubentryData from .util import ( async_create_certificate_temp_files, get_file_path, valid_birth_will, valid_publish_topic, + valid_qos_schema, ) _LOGGER = logging.getLogger(__name__) @@ -128,10 +153,10 @@ PORT_SELECTOR = vol.All( vol.Coerce(int), ) PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)) -QOS_SELECTOR = vol.All( - NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2)), - vol.Coerce(int), +QOS_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2) ) +QOS_DATA_SCHEMA = vol.All(QOS_SELECTOR, valid_qos_schema) KEEPALIVE_SELECTOR = vol.All( NumberSelector( NumberSelectorConfig( @@ -183,6 +208,65 @@ KEY_UPLOAD_SELECTOR = FileSelector( FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8") ) +# Subentry selectors +SUBENTRY_PLATFORMS = [Platform.NOTIFY] +SUBENTRY_PLATFORM_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[platform.value for platform in SUBENTRY_PLATFORMS], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_PLATFORM, + ) +) + +TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) + + +@dataclass(frozen=True) +class PlatformField: + """Stores a platform config field schema, required flag and validator.""" + + selector: Selector + required: bool + validator: Callable[..., Any] + error: str | None = None + default: str | int | vol.Undefined = vol.UNDEFINED + exclude_from_reconfig: bool = False + + +COMMON_ENTITY_FIELDS = { + CONF_PLATFORM: PlatformField( + SUBENTRY_PLATFORM_SELECTOR, True, str, exclude_from_reconfig=True + ), + CONF_NAME: PlatformField(TEXT_SELECTOR, False, str, exclude_from_reconfig=True), + CONF_ENTITY_PICTURE: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"), +} + +COMMON_MQTT_FIELDS = { + CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0), + CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), +} +PLATFORM_MQTT_FIELDS = { + Platform.NOTIFY.value: { + CONF_COMMAND_TOPIC: PlatformField( + TEXT_SELECTOR, True, valid_publish_topic, "invalid_publish_topic" + ), + CONF_COMMAND_TEMPLATE: PlatformField( + TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + ), + }, +} + +MQTT_DEVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_NAME): TEXT_SELECTOR, + vol.Optional(ATTR_SW_VERSION): TEXT_SELECTOR, + vol.Optional(ATTR_HW_VERSION): TEXT_SELECTOR, + vol.Optional(ATTR_MODEL): TEXT_SELECTOR, + vol.Optional(ATTR_MODEL_ID): TEXT_SELECTOR, + vol.Optional(ATTR_CONFIGURATION_URL): TEXT_SELECTOR, + } +) + REAUTH_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): TEXT_SELECTOR, @@ -215,6 +299,57 @@ def update_password_from_user_input( return substituted_used_data +@callback +def validate_field( + field: str, + validator: Callable[..., Any], + user_input: dict[str, Any] | None, + errors: dict[str, str], + error: str, +) -> None: + """Validate a single field.""" + if user_input is None or field not in user_input: + return + try: + validator(user_input[field]) + except (ValueError, vol.Invalid): + errors[field] = error + + +@callback +def validate_user_input( + user_input: dict[str, Any], + data_schema_fields: dict[str, PlatformField], + errors: dict[str, str], +) -> None: + """Validate user input.""" + for field, value in user_input.items(): + validator = data_schema_fields[field].validator + try: + validator(value) + except (ValueError, vol.Invalid): + errors[field] = data_schema_fields[field].error or "invalid_input" + + +@callback +def data_schema_from_fields( + data_schema_fields: dict[str, PlatformField], + reconfig: bool, +) -> vol.Schema: + """Generate data schema from platform fields.""" + return vol.Schema( + { + vol.Required(field_name, default=field_details.default) + if field_details.required + else vol.Optional( + field_name, default=field_details.default + ): field_details.selector + for field_name, field_details in data_schema_fields.items() + if not field_details.exclude_from_reconfig or not reconfig + } + ) + + class FlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -230,6 +365,14 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self.install_task: asyncio.Task | None = None self.start_task: asyncio.Task | None = None + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {CONF_DEVICE: MQTTSubentryFlowHandler} + @staticmethod @callback def async_get_options_flow( @@ -685,7 +828,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): "birth_payload", description={"suggested_value": birth[CONF_PAYLOAD]} ) ] = TEXT_SELECTOR - fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_SELECTOR + fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_DATA_SCHEMA fields[vol.Optional("birth_retain", default=birth[ATTR_RETAIN])] = ( BOOLEAN_SELECTOR ) @@ -708,7 +851,7 @@ class MQTTOptionsFlowHandler(OptionsFlow): "will_payload", description={"suggested_value": will[CONF_PAYLOAD]} ) ] = TEXT_SELECTOR - fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_SELECTOR + fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_DATA_SCHEMA fields[vol.Optional("will_retain", default=will[ATTR_RETAIN])] = ( BOOLEAN_SELECTOR ) @@ -721,6 +864,288 @@ class MQTTOptionsFlowHandler(OptionsFlow): ) +class MQTTSubentryFlowHandler(ConfigSubentryFlow): + """Handle MQTT subentry flow.""" + + _subentry_data: MqttSubentryData + _component_id: str | None = None + + @callback + def update_component_fields( + self, data_schema: vol.Schema, user_input: dict[str, Any] + ) -> None: + """Update the componment fields.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + # Remove the fields from the component data if they are not in the user input + for field in [ + form_field + for form_field in data_schema.schema + if form_field in component_data and form_field not in user_input + ]: + component_data.pop(field) + component_data.update(user_input) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a subentry.""" + self._subentry_data = MqttSubentryData(device=MqttDeviceData(), components={}) + return await self.async_step_device() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Reconfigure a subentry.""" + reconfigure_subentry = self._get_reconfigure_subentry() + self._subentry_data = cast( + MqttSubentryData, deepcopy(dict(reconfigure_subentry.data)) + ) + return await self.async_step_summary_menu() + + async def async_step_device( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a new MQTT device.""" + errors: dict[str, str] = {} + validate_field("configuration_url", cv.url, user_input, errors, "invalid_url") + if not errors and user_input is not None: + self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_summary_menu() + return await self.async_step_entity() + + data_schema = self.add_suggested_values_to_schema( + MQTT_DEVICE_SCHEMA, + self._subentry_data[CONF_DEVICE] if user_input is None else user_input, + ) + return self.async_show_form( + step_id=CONF_DEVICE, + data_schema=data_schema, + errors=errors, + last_step=False, + ) + + async def async_step_entity( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add or edit an mqtt entity.""" + errors: dict[str, str] = {} + data_schema_fields = COMMON_ENTITY_FIELDS + entity_name_label: str = "" + platform_label: str = "" + if reconfig := (self._component_id is not None): + name: str | None = self._subentry_data["components"][ + self._component_id + ].get(CONF_NAME) + platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} " + entity_name_label = f" ({name})" if name is not None else "" + data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig) + if user_input is not None: + validate_user_input(user_input, data_schema_fields, errors) + if not errors: + if self._component_id is None: + self._component_id = uuid4().hex + self._subentry_data["components"].setdefault(self._component_id, {}) + self.update_component_fields(data_schema, user_input) + return await self.async_step_mqtt_platform_config() + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + elif self.source == SOURCE_RECONFIGURE and self._component_id is not None: + data_schema = self.add_suggested_values_to_schema( + data_schema, self._subentry_data["components"][self._component_id] + ) + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + return self.async_show_form( + step_id="entity", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + "entity_name_label": entity_name_label, + "platform_label": platform_label, + }, + errors=errors, + last_step=False, + ) + + def _show_update_or_delete_form(self, step_id: str) -> SubentryFlowResult: + """Help selecting an entity to update or delete.""" + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + entities = [ + SelectOptionDict( + value=key, label=f"{device_name} {component.get(CONF_NAME, '-')}" + ) + for key, component in self._subentry_data["components"].items() + ] + data_schema = vol.Schema( + { + vol.Required("component"): SelectSelector( + SelectSelectorConfig( + options=entities, + mode=SelectSelectorMode.LIST, + ) + ) + } + ) + return self.async_show_form( + step_id=step_id, data_schema=data_schema, last_step=False + ) + + async def async_step_update_entity( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Select the entity to update.""" + if user_input: + self._component_id = user_input["component"] + return await self.async_step_entity() + if len(self._subentry_data["components"]) == 1: + # Return first key + self._component_id = next(iter(self._subentry_data["components"])) + return await self.async_step_entity() + return self._show_update_or_delete_form("update_entity") + + async def async_step_delete_entity( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Select the entity to delete.""" + if user_input: + del self._subentry_data["components"][user_input["component"]] + return await self.async_step_summary_menu() + return self._show_update_or_delete_form("delete_entity") + + async def async_step_mqtt_platform_config( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure entity platform MQTT details.""" + errors: dict[str, str] = {} + if TYPE_CHECKING: + assert self._component_id is not None + platform = self._subentry_data["components"][self._component_id][CONF_PLATFORM] + data_schema_fields = PLATFORM_MQTT_FIELDS[platform] | COMMON_MQTT_FIELDS + data_schema = data_schema_from_fields( + data_schema_fields, reconfig=self._component_id is not None + ) + if user_input is not None: + # Test entity fields against the validator + validate_user_input(user_input, data_schema_fields, errors) + if not errors: + self.update_component_fields(data_schema, user_input) + self._component_id = None + if self.source == SOURCE_RECONFIGURE: + return await self.async_step_summary_menu() + return self._async_create_subentry() + + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + else: + data_schema = self.add_suggested_values_to_schema( + data_schema, self._subentry_data["components"][self._component_id] + ) + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + entity_name: str | None + if entity_name := self._subentry_data["components"][self._component_id].get( + CONF_NAME + ): + full_entity_name: str = f"{device_name} {entity_name}" + else: + full_entity_name = device_name + return self.async_show_form( + step_id="mqtt_platform_config", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + CONF_PLATFORM: platform, + "entity": full_entity_name, + }, + errors=errors, + last_step=False, + ) + + @callback + def _async_create_subentry( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Create a subentry for a new MQTT device.""" + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + component: dict[str, Any] = next( + iter(self._subentry_data["components"].values()) + ) + platform = component[CONF_PLATFORM] + entity_name: str | None + if entity_name := component.get(CONF_NAME): + full_entity_name: str = f"{device_name} {entity_name}" + else: + full_entity_name = device_name + + return self.async_create_entry( + data=self._subentry_data, + title=self._subentry_data[CONF_DEVICE][CONF_NAME], + description_placeholders={ + "entity": full_entity_name, + CONF_PLATFORM: platform, + }, + ) + + async def async_step_summary_menu( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Show summary menu and decide to add more entities or to finish the flow.""" + self._component_id = None + mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME] + mqtt_items = ", ".join( + f"{mqtt_device} {component.get(CONF_NAME, '-')}" + for component in self._subentry_data["components"].values() + ) + menu_options = [ + "entity", + "update_entity", + ] + if len(self._subentry_data["components"]) > 1: + menu_options.append("delete_entity") + menu_options.append("device") + if self._subentry_data != self._get_reconfigure_subentry().data: + menu_options.append("save_changes") + return self.async_show_menu( + step_id="summary_menu", + menu_options=menu_options, + description_placeholders={ + "mqtt_device": mqtt_device, + "mqtt_items": mqtt_items, + }, + ) + + async def async_step_save_changes( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Save the changes made to the subentry.""" + entry = self._get_reconfigure_entry() + subentry = self._get_reconfigure_subentry() + entity_registry = er.async_get(self.hass) + + # When a component is removed from the MQTT device, + # And we save the changes to the subentry, + # we need to clean up stale entity registry entries. + # The component id is used as a part of the unique id of the entity. + for unique_id, platform in [ + ( + f"{subentry.subentry_id}_{component_id}", + subentry.data["components"][component_id][CONF_PLATFORM], + ) + for component_id in subentry.data["components"] + if component_id not in self._subentry_data["components"] + ]: + if entity_id := entity_registry.async_get_entity_id( + platform, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + + return self.async_update_and_abort( + entry, + subentry, + data=self._subentry_data, + title=self._subentry_data[CONF_DEVICE][CONF_NAME], + ) + + @callback def async_is_pem_data(data: bytes) -> bool: """Return True if data is in PEM format.""" diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index fb047cc8d5e..df6a904fab2 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -43,7 +43,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity, async_generate_entity_id -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, @@ -111,6 +111,7 @@ from .discovery import ( from .models import ( DATA_MQTT, MessageCallbackType, + MqttSubentryData, MqttValueTemplate, MqttValueTemplateException, PublishPayloadType, @@ -238,7 +239,7 @@ def async_setup_entity_entry_helper( entry: ConfigEntry, entity_class: type[MqttEntity] | None, domain: str, - async_add_entities: AddEntitiesCallback, + async_add_entities: AddConfigEntryEntitiesCallback, discovery_schema: VolSchemaType, platform_schema_modern: VolSchemaType, schema_class_mapping: dict[str, type[MqttEntity]] | None = None, @@ -282,11 +283,10 @@ def async_setup_entity_entry_helper( @callback def _async_setup_entities() -> None: - """Set up MQTT items from configuration.yaml.""" + """Set up MQTT items from subentries and configuration.yaml.""" nonlocal entity_class mqtt_data = hass.data[DATA_MQTT] - if not (config_yaml := mqtt_data.config): - return + config_yaml = mqtt_data.config yaml_configs: list[ConfigType] = [ config for config_item in config_yaml @@ -294,6 +294,41 @@ def async_setup_entity_entry_helper( for config in configs if config_domain == domain ] + # process subentry entity setup + for config_subentry_id, subentry in entry.subentries.items(): + subentry_data = cast(MqttSubentryData, subentry.data) + subentry_entities: list[Entity] = [] + device_config = subentry_data["device"].copy() + device_config["identifiers"] = config_subentry_id + for component_id, component_data in subentry_data["components"].items(): + if component_data["platform"] != domain: + continue + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = ( + f"{config_subentry_id}_{component_id}" + ) + component_config[CONF_DEVICE] = device_config + component_config.pop("platform") + + try: + config = platform_schema_modern(component_config) + if schema_class_mapping is not None: + entity_class = schema_class_mapping[config[CONF_SCHEMA]] + if TYPE_CHECKING: + assert entity_class is not None + subentry_entities.append(entity_class(hass, config, entry, None)) + except vol.Invalid as exc: + _LOGGER.error( + "Schema violation occurred when trying to set up " + "entity from subentry %s %s %s: %s", + config_subentry_id, + subentry.title, + subentry.data, + exc, + ) + + async_add_entities(subentry_entities, config_subentry_id=config_subentry_id) + entities: list[Entity] = [] for yaml_config in yaml_configs: try: diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 34c1f304944..5bbd7967ad8 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -420,5 +420,24 @@ class MqttComponentConfig: discovery_payload: MQTTDiscoveryPayload +class MqttDeviceData(TypedDict, total=False): + """Hold the data for an MQTT device.""" + + name: str + identifiers: str + configuration_url: str + sw_version: str + hw_version: str + model: str + model_id: str + + +class MqttSubentryData(TypedDict): + """Hold the data for a MQTT subentry.""" + + device: MqttDeviceData + components: dict[str, dict[str, Any]] + + DATA_MQTT: HassKey[MqttData] = HassKey("mqtt") DATA_MQTT_AVAILABLE: HassKey[asyncio.Future[bool]] = HassKey("mqtt_client_available") diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 4eb41b9e39a..13595c2d462 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -108,6 +108,110 @@ "invalid_inclusion": "The client certificate and private key must be configured together" } }, + "config_subentries": { + "device": { + "initiate_flow": { + "user": "Add MQTT Device", + "reconfigure": "Reconfigure MQTT Device" + }, + "entry_type": "MQTT Device", + "step": { + "device": { + "title": "Configure MQTT device details", + "description": "Enter the MQTT device details:", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "configuration_url": "Configuration URL", + "sw_version": "Software version", + "hw_version": "Hardware version", + "model": "Model", + "model_id": "Model ID" + }, + "data_description": { + "name": "The name of the manually added MQTT device.", + "configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.", + "sw_version": "The software version of the device. E.g. '2025.1.0'.", + "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.", + "model": "E.g. 'Cleanmaster Pro'.", + "model_id": "E.g. '123NK2PRO'." + } + }, + "summary_menu": { + "title": "Reconfigure \"{mqtt_device}\"", + "description": "Entities set up:\n{mqtt_items}\n\nDecide what to do next:", + "menu_options": { + "entity": "Add another entity to \"{mqtt_device}\"", + "update_entity": "Update entity properties", + "delete_entity": "Delete an entity", + "device": "Update device properties", + "save_changes": "Save changes" + } + }, + "entity": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Configure the basic {platform_label}entity settings{entity_name_label}", + "data": { + "platform": "Type of entity", + "name": "Entity name", + "entity_picture": "Entity picture" + }, + "data_description": { + "platform": "The type of the entity to configure.", + "name": "The name of the entity. Leave empty to set it to `None` to [mark it as main feature of the MQTT device](https://www.home-assistant.io/integrations/mqtt/#naming-of-mqtt-entities).", + "entity_picture": "An URL to a picture to be assigned." + } + }, + "delete_entity": { + "title": "Delete entity", + "description": "Delete an entity. The entity will be removed from the device. Removing an entity will break any automations or scripts that depend on it.", + "data": { + "component": "Entity" + }, + "data_description": { + "component": "Select the entity you want to delete. Minimal one entity is required." + } + }, + "update_entity": { + "title": "Select entity", + "description": "Select the entity you want to update", + "data": { + "component": "Entity" + }, + "data_description": { + "component": "Select the entity you want to update." + } + }, + "mqtt_platform_config": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", + "data": { + "command_topic": "Command topic", + "command_template": "Command template", + "retain": "Retain", + "qos": "QoS" + }, + "data_description": { + "command_topic": "The publishing topic that will be used to control the {platform} entity.", + "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.", + "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", + "qos": "The QoS value {platform} entity should use." + } + } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "create_entry": { + "default": "MQTT device with {platform} entity \"{entity}\" was set up successfully.\n\nNote that you can reconfigure the MQTT device at any time, e.g. to add more entities." + }, + "error": { + "invalid_input": "Invalid value", + "invalid_subscribe_topic": "Invalid subscribe topic", + "invalid_template": "Invalid template", + "invalid_url": "Invalid URL" + } + } + }, "device_automation": { "trigger_type": { "button_short_press": "\"{subtype}\" pressed", @@ -221,6 +325,11 @@ } }, "selector": { + "platform": { + "options": { + "notify": "Notify" + } + }, "set_ca_cert": { "options": { "off": "[%key:common::state::off%]", diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 3bb8657e2f2..55458b9e4c8 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -66,6 +66,118 @@ DEFAULT_CONFIG_DEVICE_INFO_MAC = { "configuration_url": "http://example.com", } +MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { + "363a7ecad6be4a19b939a016ea93e994": { + "platform": "notify", + "name": "Milkman alert", + "qos": 0, + "command_topic": "test-topic", + "command_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", + "retain": False, + }, +} +MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { + "6494827dac294fa0827c54b02459d309": { + "platform": "notify", + "name": "The second notifier", + "qos": 0, + "command_topic": "test-topic2", + "entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309", + }, +} +MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { + "5269352dd9534c908d22812ea5d714cd": { + "platform": "notify", + "qos": 0, + "command_topic": "test-topic", + "command_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", + "retain": False, + }, +} + +# Bogus light component just for code coverage +# Note that light cannot be setup through the UI yet +# The test is for code coverage +MOCK_SUBENTRY_LIGHT_COMPONENT = { + "8131babc5e8d4f44b82e0761d39091a2": { + "platform": "light", + "name": "Test light", + "qos": 1, + "command_topic": "test-topic4", + "schema": "basic", + "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", + }, +} +MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { + "b10b531e15244425a74bb0abb1e9d2c6": { + "platform": "notify", + "name": "Test", + "qos": 1, + "command_topic": "bad#topic", + }, +} + +MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, +} + +MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, +} +MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, +} + +MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA, +} +MOCK_SUBENTRY_DATA_SET_MIX = { + "device": { + "name": "Milk notifier", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 + | MOCK_SUBENTRY_NOTIFY_COMPONENT2 + | MOCK_SUBENTRY_LIGHT_COMPONENT, +} _SENTINEL = object() DISCOVERY_COUNT = sum(len(discovery_topic) for discovery_topic in MQTT.values()) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index f39e32a0d8b..9007c49635b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2,6 +2,7 @@ from collections.abc import Generator, Iterator from contextlib import contextmanager +from copy import deepcopy from pathlib import Path from ssl import SSLError from typing import Any @@ -17,6 +18,7 @@ from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( CONF_CLIENT_ID, CONF_PASSWORD, @@ -26,8 +28,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.hassio import HassioServiceInfo +from .common import ( + MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, +) + from tests.common import MockConfigEntry, MockMqttReasonCode from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -2598,3 +2607,706 @@ async def test_migrate_of_incompatible_config_entry( await mqtt_mock_entry() assert config_entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + + +@pytest.mark.parametrize( + ( + "config_subentries_data", + "mock_entity_user_input", + "mock_mqtt_user_input", + "mock_failed_mqtt_user_input", + "mock_failed_mqtt_user_input_errors", + "entity_name", + ), + [ + ( + MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + {"name": "Milkman alert"}, + { + "command_topic": "test-topic", + "command_template": "{{ value_json.value }}", + "qos": 0, + "retain": False, + }, + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + "Milk notifier Milkman alert", + ), + ( + MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, + {}, + { + "command_topic": "test-topic", + "command_template": "{{ value_json.value }}", + "qos": 0, + "retain": False, + }, + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + "Milk notifier", + ), + ], + ids=["notify_with_entity_name", "notify_no_entity_name"], +) +async def test_subentry_configflow( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + config_subentries_data: dict[str, Any], + mock_entity_user_input: dict[str, Any], + mock_mqtt_user_input: dict[str, Any], + mock_failed_mqtt_user_input: dict[str, Any], + mock_failed_mqtt_user_input_errors: dict[str, Any], + entity_name: str, +) -> None: + """Test the subentry ConfigFlow.""" + device_name = config_subentries_data["device"]["name"] + component = next(iter(config_subentries_data["components"].values())) + + await mqtt_mock_entry() + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "device"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + + # Test the URL validation + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "name": device_name, + "configuration_url": "http:/badurl.example.com", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + assert result["errors"]["configuration_url"] == "invalid_url" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "name": device_name, + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + assert result["errors"] == {} + + # Process entity flow (initial step) + + # Test the entity picture URL validation + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "platform": component["platform"], + "entity_picture": "invalid url", + } + | mock_entity_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # Try again with valid data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "platform": component["platform"], + "entity_picture": component["entity_picture"], + } + | mock_entity_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + assert result["errors"] == {} + assert result["description_placeholders"] == { + "mqtt_device": "Milk notifier", + "platform": "notify", + "entity": entity_name, + } + + # Process entity platform config flow + + # Test an invalid mqtt user_input case + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=mock_failed_mqtt_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == mock_failed_mqtt_user_input_errors + + # Try again with a valid configuration + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], user_input=mock_mqtt_user_input + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == device_name + + subentry_component = next( + iter(next(iter(config_entry.subentries.values())).data["components"].values()) + ) + assert subentry_component == next( + iter(config_subentries_data["components"].values()) + ) + + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], + ids=["notify"], +) +async def test_subentry_reconfigure_remove_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the subentry ConfigFlow reconfigure removing an entity.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + object_list = list(components) + component_list = list(components.values()) + entity_name_0 = f"{device.name} {component_list[0]['name']}" + entity_name_1 = f"{device.name} {component_list[1]['name']}" + + for key, component in components.items(): + unique_entity_id = f"{subentry_id}_{key}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], + platform=mqtt.DOMAIN, + unique_id=unique_entity_id, + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we have the option to delete one entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + ] + + # assert we can delete an entity + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "delete_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "delete_entity" + assert result["data_schema"].schema["component"].config["options"] == [ + {"value": object_list[0], "label": entity_name_0}, + {"value": object_list[1], "label": entity_name_1}, + ] + # remove notify_the_second_notifier + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "component": object_list[1], + }, + ) + + # assert menu options, we have only one item left, we cannot delete it + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + "save_changes", + ] + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # check if the second entity was removed from the subentry and entity registry + unique_entity_id = f"{subentry_id}_{object_list[1]}" + entity_id = entity_registry.async_get_entity_id( + domain=components[object_list[1]]["platform"], + platform=mqtt.DOMAIN, + unique_id=unique_entity_id, + ) + assert entity_id is None + new_components = deepcopy(dict(subentry.data))["components"] + assert object_list[0] in new_components + assert object_list[1] not in new_components + + +@pytest.mark.parametrize( + ("mqtt_config_subentries_data", "user_input_mqtt"), + [ + ( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ), + {"command_topic": "test-topic2-updated"}, + ) + ], + ids=["notify"], +) +async def test_subentry_reconfigure_edit_entity_multi_entitites( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_mqtt: dict[str, Any], +) -> None: + """Test the subentry ConfigFlow reconfigure with multi entities.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + object_list = list(components) + component_list = list(components.values()) + entity_name_0 = f"{device.name} {component_list[0]['name']}" + entity_name_1 = f"{device.name} {component_list[1]['name']}" + + for key in components: + unique_entity_id = f"{subentry_id}_{key}" + entity_id = entity_registry.async_get_entity_id( + domain="notify", platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we have the option to delete one entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + ] + + # assert we can update an entity + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "update_entity" + assert result["data_schema"].schema["component"].config["options"] == [ + {"value": object_list[0], "label": entity_name_0}, + {"value": object_list[1], "label": entity_name_1}, + ] + # select second entity + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "component": object_list[1], + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the common entity data with changed entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "entity_picture": "https://example.com", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific entity data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have our components + new_components = deepcopy(dict(subentry.data))["components"] + + # Check the second component was updated + assert new_components[object_list[0]] == components[object_list[0]] + for key, value in user_input_mqtt.items(): + assert new_components[object_list[1]][key] == value + + +@pytest.mark.parametrize( + ("mqtt_config_subentries_data", "user_input_mqtt"), + [ + ( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value_json.value }}", + "retain": True, + }, + ) + ], + ids=["notify"], +) +async def test_subentry_reconfigure_edit_entity_single_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_mqtt: dict[str, Any], +) -> None: + """Test the subentry ConfigFlow reconfigure with single entity.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + # Check we have "notify_milkman_alert" in our mock data + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + + component_id, component = next(iter(components.items())) + + unique_entity_id = f"{subentry_id}_{component_id}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data, reset entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific entity data, + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 1 + + # Check our update was successful + assert "entity_picture" not in new_components[component_id] + + # Check the second component was updated + for key, value in user_input_mqtt.items(): + assert new_components[component_id][key] == value + + +@pytest.mark.parametrize( + ("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"), + [ + ( + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + { + "platform": "notify", + "name": "The second notifier", + "entity_picture": "https://example.com", + }, + { + "command_topic": "test-topic2", + "qos": 0, + }, + ) + ], + ids=["notify_notify"], +) +async def test_subentry_reconfigure_add_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_entity: dict[str, Any], + user_input_mqtt: dict[str, Any], +) -> None: + """Test the subentry ConfigFlow reconfigure and add an entity.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + component_id_1, component1 = next(iter(components.items())) + unique_entity_id = f"{subentry_id}_{component_id_1}" + entity_id = entity_registry.async_get_entity_id( + domain=component1["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_entity, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific entity data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # Finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 2 + + component_id_2 = next(iter(set(new_components) - {component_id_1})) + + # Check our new entity was added correctly + expected_component_config = user_input_entity | user_input_mqtt + for key, value in expected_component_config.items(): + assert new_components[component_id_2][key] == value + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_subentry_reconfigure_update_device_properties( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the subentry ConfigFlow reconfigure and update device properties.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # Assert initial data + device = deepcopy(dict(subentry.data))["device"] + assert device["name"] == "Milk notifier" + assert device["sw_version"] == "1.0" + assert device["hw_version"] == "2.1 rev a" + assert device["model"] == "Model XL" + assert device["model_id"] == "mn002" + + # assert menu options, we have the option to delete one entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + ] + + # assert we can update the device properties + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "device"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + + # Update the device details + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "name": "Beer notifier", + "sw_version": "1.1", + "model": "Beer bottle XL", + "model_id": "bn003", + "configuration_url": "https://example.com", + }, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check our device was updated + device = deepcopy(dict(subentry.data))["device"] + assert device["name"] == "Beer notifier" + assert "hw_version" not in device + assert device["model"] == "Beer bottle XL" + assert device["model_id"] == "bn003" diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index d65f1a4d661..ecc045b3871 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -1,18 +1,27 @@ """The tests for shared code of the MQTT platform.""" +from typing import Any from unittest.mock import patch import pytest from homeassistant.components import mqtt, sensor from homeassistant.components.mqtt.sensor import DEFAULT_NAME as DEFAULT_SENSOR_NAME +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import ( ATTR_FRIENDLY_NAME, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, ) from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.util import slugify + +from .common import MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, MOCK_SUBENTRY_DATA_SET_MIX from tests.common import MockConfigEntry, async_capture_events, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator @@ -453,3 +462,74 @@ async def test_value_template_fails( "TypeError: unsupported operand type(s) for *: 'NoneType' and 'int' rendering template" in caplog.text ) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_SUBENTRY_DATA_SET_MIX, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_loading_subentries( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_config_subentries_data: tuple[dict[str, Any]], + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test loading subentries.""" + await mqtt_mock_entry() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id = next(iter(entry.subentries)) + # Each subentry has one device + device = device_registry.async_get_device({("mqtt", subentry_id)}) + assert device is not None + for object_id, component in mqtt_config_subentries_data[0]["data"][ + "components" + ].items(): + platform = component["platform"] + entity_id = f"{platform}.{slugify(device.name)}_{slugify(component['name'])}" + entity_entry_entity_id = entity_registry.async_get_entity_id( + platform, mqtt.DOMAIN, f"{subentry_id}_{object_id}" + ) + assert entity_entry_entity_id == entity_id + state = hass.states.get(entity_id) + assert state is not None + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_loading_subentry_with_bad_component_schema( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_config_subentries_data: tuple[dict[str, Any]], + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test loading subentries.""" + await mqtt_mock_entry() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id = next(iter(entry.subentries)) + # Each subentry has one device + device = device_registry.async_get_device({("mqtt", subentry_id)}) + assert device is None + assert ( + "Schema violation occurred when trying to set up entity from subentry" + in caplog.text + ) diff --git a/tests/conftest.py b/tests/conftest.py index 7725189aa53..65e3518956e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,7 +67,12 @@ from homeassistant.components.websocket_api.auth import ( # pylint: disable-next=hass-component-root-import from homeassistant.components.websocket_api.http import URL from homeassistant.config import YAML_CONFIG_FILE -from homeassistant.config_entries import ConfigEntries, ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ( + ConfigEntries, + ConfigEntry, + ConfigEntryState, + ConfigSubentryData, +) from homeassistant.const import BASE_PLATFORMS, HASSIO_USER_NAME from homeassistant.core import ( Context, @@ -946,6 +951,12 @@ def mqtt_config_entry_data() -> dict[str, Any] | None: return None +@pytest.fixture +def mqtt_config_subentries_data() -> tuple[ConfigSubentryData] | None: + """Fixture to allow overriding MQTT subentries data.""" + return None + + @pytest.fixture def mqtt_config_entry_options() -> dict[str, Any] | None: """Fixture to allow overriding MQTT entry options.""" @@ -1032,6 +1043,7 @@ async def mqtt_mock( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_config_entry_options: dict[str, Any] | None, + mqtt_config_subentries_data: tuple[ConfigSubentryData] | None, mqtt_mock_entry: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient]: """Fixture to mock MQTT component.""" @@ -1044,6 +1056,7 @@ async def _mqtt_mock_entry( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_config_entry_options: dict[str, Any] | None, + mqtt_config_subentries_data: tuple[ConfigSubentryData] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase @@ -1060,6 +1073,7 @@ async def _mqtt_mock_entry( entry = MockConfigEntry( data=mqtt_config_entry_data, options=mqtt_config_entry_options, + subentries_data=mqtt_config_subentries_data, domain=mqtt.DOMAIN, title="MQTT", version=1, @@ -1174,6 +1188,7 @@ async def mqtt_mock_entry( mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_config_entry_options: dict[str, Any] | None, + mqtt_config_subentries_data: tuple[ConfigSubentryData] | None, ) -> AsyncGenerator[MqttMockHAClientGenerator]: """Set up an MQTT config entry.""" @@ -1190,7 +1205,11 @@ async def mqtt_mock_entry( return await mqtt_mock_entry(_async_setup_config_entry) async with _mqtt_mock_entry( - hass, mqtt_client_mock, mqtt_config_entry_data, mqtt_config_entry_options + hass, + mqtt_client_mock, + mqtt_config_entry_data, + mqtt_config_entry_options, + mqtt_config_subentries_data, ) as mqtt_mock_entry: yield _setup_mqtt_entry From 4e759e59a42da9548ac6f22851d68d7adee201b1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 14 Mar 2025 23:41:09 +1000 Subject: [PATCH 1656/1941] Add streaming switches to Teslemetry (#137145) * Add streaming switches * Add switch tests * Update snapshot * Fix sentry * update test docstring --- homeassistant/components/teslemetry/switch.py | 133 ++++++++++++++---- .../teslemetry/snapshots/test_switch.ambr | 18 +++ tests/components/teslemetry/test_switch.py | 49 ++++++- 3 files changed, 170 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 83441e6c4f6..4098a050fd9 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -8,6 +8,7 @@ from itertools import chain from typing import Any from tesla_fleet_api.const import Scope, Seat +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.switch import ( SwitchDeviceClass, @@ -16,10 +17,16 @@ from homeassistant.components.switch import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from . import TeslemetryConfigEntry -from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .entity import ( + TeslemetryEnergyInfoEntity, + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData @@ -34,18 +41,27 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription): off_func: Callable scopes: list[Scope] value_func: Callable[[StateType], bool] = bool + streaming_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[StateType], None]], + Callable[[], None], + ] + streaming_value_fn: Callable[[StateType], bool] = bool + streaming_firmware: str = "2024.26" unique_id: str | None = None VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="vehicle_state_sentry_mode", + streaming_listener=lambda x, y: x.listen_SentryMode(y), + streaming_value_fn=lambda x: x != "Off", on_func=lambda api: api.set_sentry_mode(on=True), off_func=lambda api: api.set_sentry_mode(on=False), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", + streaming_listener=lambda x, y: x.listen_AutoSeatClimateLeft(y), on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), off_func=lambda api: api.remote_auto_seat_climate_request( Seat.FRONT_LEFT, False @@ -54,6 +70,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", + streaming_listener=lambda x, y: x.listen_AutoSeatClimateRight(y), on_func=lambda api: api.remote_auto_seat_climate_request( Seat.FRONT_RIGHT, True ), @@ -64,6 +81,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_auto_steering_wheel_heat", + streaming_listener=lambda x, y: x.listen_HvacSteeringWheelHeatAuto(y), on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( on=True ), @@ -74,6 +92,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( ), TeslemetrySwitchEntityDescription( key="climate_state_defrost_mode", + streaming_listener=lambda x, y: x.listen_DefrostMode(y), + streaming_value_fn=lambda x: x != "Off", on_func=lambda api: api.set_preconditioning_max(on=True, manual_override=False), off_func=lambda api: api.set_preconditioning_max( on=False, manual_override=False @@ -83,9 +103,11 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="charge_state_charging_state", unique_id="charge_state_user_charge_enable_request", + value_func=lambda state: state in {"Starting", "Charging"}, + streaming_listener=lambda x, y: x.listen_DetailedChargeState(y), + streaming_value_fn=lambda x: x in {"Starting", "Charging"}, on_func=lambda api: api.charge_start(), off_func=lambda api: api.charge_stop(), - value_func=lambda state: state in {"Starting", "Charging"}, scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS], ), ) @@ -101,12 +123,16 @@ async def async_setup_entry( async_add_entities( chain( ( - TeslemetryVehicleSwitchEntity( + TeslemetryPollingVehicleSwitchEntity( + vehicle, description, entry.runtime_data.scopes + ) + if vehicle.api.pre2021 + or vehicle.firmware < description.streaming_firmware + else TeslemetryStreamingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) for vehicle in entry.runtime_data.vehicles for description in VEHICLE_DESCRIPTIONS - if description.key in vehicle.coordinator.data ), ( TeslemetryChargeFromGridSwitchEntity( @@ -126,15 +152,31 @@ async def async_setup_entry( ) -class TeslemetrySwitchEntity(SwitchEntity): +class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity): """Base class for all Teslemetry switch entities.""" _attr_device_class = SwitchDeviceClass.SWITCH entity_description: TeslemetrySwitchEntityDescription + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.on_func(self.api)) + self._attr_is_on = True + self.async_write_ha_state() -class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEntity): - """Base class for Teslemetry vehicle switch entities.""" + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + self.raise_for_scope(self.entity_description.scopes[0]) + await handle_vehicle_command(self.entity_description.off_func(self.api)) + self._attr_is_on = False + self.async_write_ha_state() + + +class TeslemetryPollingVehicleSwitchEntity( + TeslemetryVehicleEntity, TeslemetryVehicleSwitchEntity +): + """Base class for Teslemetry polling vehicle switch entities.""" def __init__( self, @@ -151,30 +193,63 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_is_on = self.entity_description.value_func(self._value) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the Switch.""" - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.on_func(self.api)) - self._attr_is_on = True - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the Switch.""" - self.raise_for_scope(self.entity_description.scopes[0]) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.entity_description.off_func(self.api)) - self._attr_is_on = False - self.async_write_ha_state() + self._attr_is_on = ( + None + if self._value is None + else self.entity_description.value_func(self._value) + ) -class TeslemetryChargeFromGridSwitchEntity( - TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity +class TeslemetryStreamingVehicleSwitchEntity( + TeslemetryVehicleStreamEntity, TeslemetryVehicleSwitchEntity, RestoreEntity ): + """Base class for Teslemetry streaming vehicle switch entities.""" + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetrySwitchEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the Switch.""" + + self.entity_description = description + self.scoped = any(scope in scopes for scope in description.scopes) + super().__init__(data, description.key) + if description.unique_id: + self._attr_unique_id = f"{data.vin}-{description.unique_id}" + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore previous state + if (state := await self.async_get_last_state()) is not None: + if state.state == "on": + self._attr_is_on = True + elif state.state == "off": + self._attr_is_on = False + + # Add listener + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) + + def _value_callback(self, value: StateType) -> None: + """Update the value of the entity.""" + self._attr_is_on = ( + None if value is None else self.entity_description.streaming_value_fn(value) + ) + self.async_write_ha_state() + + +class TeslemetryChargeFromGridSwitchEntity(TeslemetryEnergyInfoEntity, SwitchEntity): """Entity class for Charge From Grid switch.""" + _attr_device_class = SwitchDeviceClass.SWITCH + def __init__( self, data: TeslemetryEnergyData, @@ -215,11 +290,11 @@ class TeslemetryChargeFromGridSwitchEntity( self.async_write_ha_state() -class TeslemetryStormModeSwitchEntity( - TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity -): +class TeslemetryStormModeSwitchEntity(TeslemetryEnergyInfoEntity, SwitchEntity): """Entity class for Storm Mode switch.""" + _attr_device_class = SwitchDeviceClass.SWITCH + def __init__( self, data: TeslemetryEnergyData, diff --git a/tests/components/teslemetry/snapshots/test_switch.ambr b/tests/components/teslemetry/snapshots/test_switch.ambr index f9997133044..0586b454a91 100644 --- a/tests/components/teslemetry/snapshots/test_switch.ambr +++ b/tests/components/teslemetry/snapshots/test_switch.ambr @@ -495,3 +495,21 @@ 'state': 'off', }) # --- +# name: test_switch_streaming[switch.test_auto_seat_climate_left] + 'on' +# --- +# name: test_switch_streaming[switch.test_auto_seat_climate_right] + 'off' +# --- +# name: test_switch_streaming[switch.test_auto_steering_wheel_heater] + 'on' +# --- +# name: test_switch_streaming[switch.test_charge] + 'on' +# --- +# name: test_switch_streaming[switch.test_defrost] + 'off' +# --- +# name: test_switch_streaming[switch.test_sentry_mode] + 'on' +# --- diff --git a/tests/components/teslemetry/test_switch.py b/tests/components/teslemetry/test_switch.py index 6a1ddb430ce..17522f0ce2a 100644 --- a/tests/components/teslemetry/test_switch.py +++ b/tests/components/teslemetry/test_switch.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, @@ -14,7 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, assert_entities_alt, setup_platform +from . import assert_entities, assert_entities_alt, reload_platform, setup_platform from .const import COMMAND_OK, VEHICLE_DATA_ALT @@ -22,6 +23,7 @@ async def test_switch( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the switch entities are correct.""" @@ -34,6 +36,7 @@ async def test_switch_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the switch entities are correct.""" @@ -119,3 +122,47 @@ async def test_switch_services( state = hass.states.get(entity_id) assert state.state == STATE_OFF call.assert_called_once() + + +async def test_switch_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the switch entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.SWITCH]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.SENTRY_MODE: "SentryModeStateIdle", + Signal.AUTO_SEAT_CLIMATE_LEFT: True, + Signal.AUTO_SEAT_CLIMATE_RIGHT: False, + Signal.HVAC_STEERING_WHEEL_HEAT_AUTO: True, + Signal.DEFROST_MODE: "DefrostModeStateOff", + Signal.DETAILED_CHARGE_STATE: "DetailedChargeStateCharging", + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + # Reload the entry + await reload_platform(hass, entry, [Platform.SWITCH]) + + # Assert the entities restored their values + for entity_id in ( + "switch.test_sentry_mode", + "switch.test_auto_seat_climate_left", + "switch.test_auto_seat_climate_right", + "switch.test_auto_steering_wheel_heater", + "switch.test_defrost", + "switch.test_charge", + ): + state = hass.states.get(entity_id) + assert state.state == snapshot(name=entity_id) From 220bd5a27fef98fd5490393a5c0086b27e2eb4b9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 14 Mar 2025 23:48:17 +1000 Subject: [PATCH 1657/1941] Fix time to full charge in Teslemetry (#137996) * Fix streaming full charge * ruff --- homeassistant/components/teslemetry/sensor.py | 34 ++++++++++++------- tests/components/teslemetry/test_sensor.py | 2 +- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index f1859ad39de..b1c6b487bf9 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from propcache.api import cached_property -from teslemetry_stream import Signal +from teslemetry_stream import Signal, TeslemetryStreamVehicle from teslemetry_stream.const import ShiftState from homeassistant.components.sensor import ( @@ -50,6 +50,7 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 + CHARGE_STATES = { "Starting": "starting", "Charging": "charging", @@ -350,21 +351,26 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): """Describes Teslemetry Sensor entity.""" variance: int - streaming_key: Signal + streaming_listener: Callable[ + [TeslemetryStreamVehicle, Callable[[float | None], None]], + Callable[[], None], + ] streaming_firmware: str = "2024.26" + streaming_value_fn: Callable[[float], float] = lambda x: x VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="charge_state_minutes_to_full_charge", - streaming_key=Signal.TIME_TO_FULL_CHARGE, + streaming_value_fn=lambda x: x * 60, + streaming_listener=lambda x, y: x.listen_TimeToFullCharge(y), device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, variance=4, ), TeslemetryTimeEntityDescription( key="drive_state_active_route_minutes_to_arrival", - streaming_key=Signal.MINUTES_TO_ARRIVAL, + streaming_listener=lambda x, y: x.listen_MinutesToArrival(y), device_class=SensorDeviceClass.TIMESTAMP, variance=1, ), @@ -667,18 +673,22 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti """Initialize the sensor.""" self.entity_description = description self._get_timestamp = ignore_variance( - func=lambda value: dt_util.now() + timedelta(minutes=value), + func=lambda value: dt_util.now() + + timedelta(minutes=description.streaming_value_fn(value)), ignored_variance=timedelta(minutes=description.variance), ) - assert description.streaming_key - super().__init__(data, description.key, description.streaming_key) + super().__init__(data, description.key) - @cached_property - def available(self) -> bool: - """Return True if entity is available.""" - return self.stream.connected + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._value_callback + ) + ) - def _async_value_from_stream(self, value) -> None: + def _value_callback(self, value: float | None) -> None: """Update the value of the entity.""" if value is None: self._attr_native_value = None diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index a488ebc8a06..c3c2252ab89 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -72,7 +72,7 @@ async def test_sensors_streaming( Signal.AC_CHARGING_ENERGY_IN: 10, Signal.AC_CHARGING_POWER: 2, Signal.CHARGING_CABLE_TYPE: None, - Signal.TIME_TO_FULL_CHARGE: 10, + Signal.TIME_TO_FULL_CHARGE: 0.166666667, Signal.MINUTES_TO_ARRIVAL: None, }, "createdAt": "2024-10-04T10:45:17.537Z", From 7ff842fc372400074984f92f0fb1cf558dd1415d Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 14 Mar 2025 09:55:18 -0400 Subject: [PATCH 1658/1941] Add dynamic update interval to Roborock (#140563) * Add dynamic update interval to Roborock * mr comments * update time intervals * Set A01 to 1 minute * set interval to 30 --- homeassistant/components/roborock/const.py | 13 ++- .../components/roborock/coordinator.py | 26 ++++- .../components/roborock/quality_scale.yaml | 7 +- tests/components/roborock/test_coordinator.py | 107 ++++++++++++++++++ 4 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 tests/components/roborock/test_coordinator.py diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 5a725ff5586..4e2588c9478 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -1,5 +1,7 @@ """Constants for Roborock.""" +from datetime import timedelta + from vacuum_map_parser_base.config.drawable import Drawable from homeassistant.const import Platform @@ -43,8 +45,8 @@ PLATFORMS = [ Platform.VACUUM, ] - -IMAGE_CACHE_INTERVAL = 90 +# This can be lowered in the future if we do not receive rate limiting issues. +IMAGE_CACHE_INTERVAL = 30 MAP_SLEEP = 3 @@ -54,3 +56,10 @@ MAP_FILE_FORMAT = "PNG" MAP_FILENAME_SUFFIX = ".png" SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position" GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position" + + +A01_UPDATE_INTERVAL = timedelta(minutes=1) +V1_CLOUD_IN_CLEANING_INTERVAL = timedelta(seconds=30) +V1_CLOUD_NOT_CLEANING_INTERVAL = timedelta(minutes=1) +V1_LOCAL_IN_CLEANING_INTERVAL = timedelta(seconds=15) +V1_LOCAL_NOT_CLEANING_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 1ab23fc927a..c94fb785079 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -36,7 +36,14 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify -from .const import DOMAIN +from .const import ( + A01_UPDATE_INTERVAL, + DOMAIN, + V1_CLOUD_IN_CLEANING_INTERVAL, + V1_CLOUD_NOT_CLEANING_INTERVAL, + V1_LOCAL_IN_CLEANING_INTERVAL, + V1_LOCAL_NOT_CLEANING_INTERVAL, +) from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo from .roborock_storage import RoborockMapStorage @@ -85,7 +92,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=SCAN_INTERVAL, + # Assume we can use the local api. + update_interval=V1_LOCAL_NOT_CLEANING_INTERVAL, ) self.roborock_device_info = RoborockHassDeviceInfo( device, @@ -118,6 +126,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ) self._user_data = user_data self._api_client = api_client + self._is_cloud_api = False async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -152,6 +161,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): await self.api.async_disconnect() # We use the cloud api if the local api fails to connect. self.api = self.cloud_api + self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL + self._is_cloud_api = True # 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. @@ -181,6 +192,15 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): except RoborockException as ex: _LOGGER.debug("Failed to update data: %s", ex) raise UpdateFailed(ex) from ex + if self.roborock_device_info.props.status.in_cleaning: + if self._is_cloud_api: + self.update_interval = V1_CLOUD_IN_CLEANING_INTERVAL + else: + self.update_interval = V1_LOCAL_IN_CLEANING_INTERVAL + elif self._is_cloud_api: + self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL + else: + self.update_interval = V1_LOCAL_NOT_CLEANING_INTERVAL return self.roborock_device_info.props def _set_current_map(self) -> None: @@ -269,7 +289,7 @@ class RoborockDataUpdateCoordinatorA01( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=SCAN_INTERVAL, + update_interval=A01_UPDATE_INTERVAL, ) self.api = api self.device_info = DeviceInfo( diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 1077888ed14..2cf664beb40 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -1,12 +1,7 @@ rules: # Bronze action-setup: done - appropriate-polling: - status: todo - comment: | - The device currently polls every 30 seconds, which is a bit high when idle. - We should consider dynamic polling intervals (e.g. when cleaning) and - separate cloud vs local intervals. + appropriate-polling: done brands: done common-modules: done config-flow: done diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py new file mode 100644 index 00000000000..94976ba92f5 --- /dev/null +++ b/tests/components/roborock/test_coordinator.py @@ -0,0 +1,107 @@ +"""Test Roborock Coordinator specific logic.""" + +import copy +from datetime import timedelta +from unittest.mock import patch + +import pytest +from roborock.exceptions import RoborockException + +from homeassistant.components.roborock.const import ( + V1_CLOUD_IN_CLEANING_INTERVAL, + V1_CLOUD_NOT_CLEANING_INTERVAL, + V1_LOCAL_IN_CLEANING_INTERVAL, + V1_LOCAL_NOT_CLEANING_INTERVAL, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .mock_data import PROP + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.parametrize( + ("interval", "in_cleaning"), + [ + (V1_CLOUD_IN_CLEANING_INTERVAL, 1), + (V1_CLOUD_NOT_CLEANING_INTERVAL, 0), + ], +) +async def test_dynamic_cloud_scan_interval( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, + interval: timedelta, + in_cleaning: int, +) -> None: + """Test dynamic scan interval.""" + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = in_cleaning + with ( + # Force the system to use the cloud api. + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.ping", + side_effect=RoborockException(), + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_prop", + return_value=prop, + ), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + prop = copy.deepcopy(prop) + prop.status.battery = 20 + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_prop", + return_value=prop, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + interval - timedelta(seconds=5) + ) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + async_fire_time_changed(hass, dt_util.utcnow() + interval) + + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" + + +@pytest.mark.parametrize( + ("interval", "in_cleaning"), + [ + (V1_LOCAL_IN_CLEANING_INTERVAL, 1), + (V1_LOCAL_NOT_CLEANING_INTERVAL, 0), + ], +) +async def test_dynamic_local_scan_interval( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture_v1_only, + interval: timedelta, + in_cleaning: int, +) -> None: + """Test dynamic scan interval.""" + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = in_cleaning + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + prop = copy.deepcopy(prop) + prop.status.battery = 20 + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + interval - timedelta(seconds=5) + ) + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + + async_fire_time_changed(hass, dt_util.utcnow() + interval) + + assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20" From a8f1df3e55441969d32d7c1d71a6b727df929a37 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 14 Mar 2025 14:56:27 +0100 Subject: [PATCH 1659/1941] Add availability support for MQTT subentries (#138673) * Add availability support for MQTT subentries * Update homeassistant/components/mqtt/config_flow.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/config_flow.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/config_flow.py Co-authored-by: Erik Montnemery * Update homeassistant/components/mqtt/strings.json Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/config_flow.py | 63 +++++++++- homeassistant/components/mqtt/entity.py | 2 + homeassistant/components/mqtt/models.py | 12 +- homeassistant/components/mqtt/strings.json | 17 +++ tests/components/mqtt/common.py | 13 +- tests/components/mqtt/test_config_flow.py | 122 +++++++++++++++++++ tests/components/mqtt/test_mixins.py | 14 +++ 7 files changed, 238 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 8922b059a23..8dfccbb6b2a 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -88,6 +88,8 @@ from .const import ( ATTR_QOS, ATTR_RETAIN, ATTR_TOPIC, + CONF_AVAILABILITY_TEMPLATE, + CONF_AVAILABILITY_TOPIC, CONF_BIRTH_MESSAGE, CONF_BROKER, CONF_CERTIFICATE, @@ -98,6 +100,8 @@ from .const import ( CONF_DISCOVERY_PREFIX, CONF_ENTITY_PICTURE, CONF_KEEPALIVE, + CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_TLS_INSECURE, @@ -111,6 +115,8 @@ from .const import ( DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_KEEPALIVE, + DEFAULT_PAYLOAD_AVAILABLE, + DEFAULT_PAYLOAD_NOT_AVAILABLE, DEFAULT_PORT, DEFAULT_PREFIX, DEFAULT_PROTOCOL, @@ -123,13 +129,15 @@ from .const import ( TRANSPORT_WEBSOCKETS, Platform, ) -from .models import MqttDeviceData, MqttSubentryData +from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData from .util import ( async_create_certificate_temp_files, get_file_path, valid_birth_will, valid_publish_topic, valid_qos_schema, + valid_subscribe_topic, + valid_subscribe_topic_template, ) _LOGGER = logging.getLogger(__name__) @@ -220,6 +228,19 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector( TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) +SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_AVAILABILITY_TOPIC): TEXT_SELECTOR, + vol.Optional(CONF_AVAILABILITY_TEMPLATE): TEMPLATE_SELECTOR, + vol.Optional( + CONF_PAYLOAD_AVAILABLE, default=DEFAULT_PAYLOAD_AVAILABLE + ): TEXT_SELECTOR, + vol.Optional( + CONF_PAYLOAD_NOT_AVAILABLE, default=DEFAULT_PAYLOAD_NOT_AVAILABLE + ): TEXT_SELECTOR, + } +) + @dataclass(frozen=True) class PlatformField: @@ -1085,6 +1106,44 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): }, ) + async def async_step_availability( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure availability options.""" + errors: dict[str, str] = {} + validate_field( + "availability_topic", + valid_subscribe_topic, + user_input, + errors, + "invalid_subscribe_topic", + ) + validate_field( + "availability_template", + valid_subscribe_topic_template, + user_input, + errors, + "invalid_template", + ) + if not errors and user_input is not None: + self._subentry_data.setdefault("availability", MqttAvailabilityData()) + self._subentry_data["availability"] = cast(MqttAvailabilityData, user_input) + return await self.async_step_summary_menu() + + data_schema = SUBENTRY_AVAILABILITY_SCHEMA + data_schema = self.add_suggested_values_to_schema( + data_schema, + dict(self._subentry_data.setdefault("availability", {})) + if self.source == SOURCE_RECONFIGURE + else user_input, + ) + return self.async_show_form( + step_id="availability", + data_schema=data_schema, + errors=errors, + last_step=False, + ) + async def async_step_summary_menu( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -1101,7 +1160,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): ] if len(self._subentry_data["components"]) > 1: menu_options.append("delete_entity") - menu_options.append("device") + menu_options.extend(["device", "availability"]) if self._subentry_data != self._get_reconfigure_subentry().data: menu_options.append("save_changes") return self.async_show_menu( diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index df6a904fab2..0b4f65fab47 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -297,6 +297,7 @@ def async_setup_entity_entry_helper( # process subentry entity setup for config_subentry_id, subentry in entry.subentries.items(): subentry_data = cast(MqttSubentryData, subentry.data) + availability_config = subentry_data.get("availability", {}) subentry_entities: list[Entity] = [] device_config = subentry_data["device"].copy() device_config["identifiers"] = config_subentry_id @@ -309,6 +310,7 @@ def async_setup_entity_entry_helper( ) component_config[CONF_DEVICE] = device_config component_config.pop("platform") + component_config.update(availability_config) try: config = platform_schema_modern(component_config) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 5bbd7967ad8..bcfe94bbd58 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -432,11 +432,21 @@ class MqttDeviceData(TypedDict, total=False): model_id: str -class MqttSubentryData(TypedDict): +class MqttAvailabilityData(TypedDict, total=False): + """Hold the availability configuration for a device.""" + + availability_topic: str + availability_template: str + payload_available: str + payload_not_available: str + + +class MqttSubentryData(TypedDict, total=False): """Hold the data for a MQTT subentry.""" device: MqttDeviceData components: dict[str, dict[str, Any]] + availability: MqttAvailabilityData DATA_MQTT: HassKey[MqttData] = HassKey("mqtt") diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 13595c2d462..c3338948ff5 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -116,6 +116,22 @@ }, "entry_type": "MQTT Device", "step": { + "availability": { + "title": "Availability options", + "description": "The availability feature allows a device to report it's availability.", + "data": { + "availability_topic": "Availability topic", + "availability_template": "Availability template", + "payload_available": "Payload available", + "payload_not_available": "Payload not available" + }, + "data_description": { + "availability_topic": "Topic to receive the availabillity payload on", + "availability_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-templates-with-the-mqtt-integration) to render the availability payload received on the availability topic", + "payload_available": "The payload that indicates the device is available (defaults to 'online')", + "payload_not_available": "The payload that indicates the device is not available (defaults to 'offline')" + } + }, "device": { "title": "Configure MQTT device details", "description": "Enter the MQTT device details:", @@ -143,6 +159,7 @@ "entity": "Add another entity to \"{mqtt_device}\"", "update_entity": "Update entity properties", "delete_entity": "Delete an entity", + "availability": "Configure availability", "device": "Update device properties", "save_changes": "Save changes" } diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 55458b9e4c8..f000c4e0b9b 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -119,6 +119,15 @@ MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { }, } +MOCK_SUBENTRY_AVAILABILITY_DATA = { + "availability": { + "availability_topic": "test/availability", + "availability_template": "{{ value_json.availability }}", + "payload_available": "online", + "payload_not_available": "offline", + } +} + MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { "device": { "name": "Milk notifier", @@ -129,7 +138,7 @@ MOCK_NOTIFY_SUBENTRY_DATA_MULTI = { "configuration_url": "https://example.com", }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, -} +} | MOCK_SUBENTRY_AVAILABILITY_DATA MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { "device": { @@ -177,7 +186,7 @@ MOCK_SUBENTRY_DATA_SET_MIX = { "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2 | MOCK_SUBENTRY_LIGHT_COMPONENT, -} +} | MOCK_SUBENTRY_AVAILABILITY_DATA _SENTINEL = object() DISCOVERY_COUNT = sum(len(discovery_topic) for discovery_topic in MQTT.values()) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 9007c49635b..354cb33ba39 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2821,6 +2821,7 @@ async def test_subentry_reconfigure_remove_entity( "update_entity", "delete_entity", "device", + "availability", ] # assert we can delete an entity @@ -2849,6 +2850,7 @@ async def test_subentry_reconfigure_remove_entity( "entity", "update_entity", "device", + "availability", "save_changes", ] @@ -2938,6 +2940,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "update_entity", "delete_entity", "device", + "availability", ] # assert we can update an entity @@ -3061,6 +3064,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( "entity", "update_entity", "device", + "availability", ] # assert we can update the entity, there is no select step @@ -3174,6 +3178,7 @@ async def test_subentry_reconfigure_add_entity( "entity", "update_entity", "device", + "availability", ] # assert we can update the entity, there is no select step @@ -3272,6 +3277,7 @@ async def test_subentry_reconfigure_update_device_properties( "update_entity", "delete_entity", "device", + "availability", ] # assert we can update the device properties @@ -3310,3 +3316,119 @@ async def test_subentry_reconfigure_update_device_properties( assert "hw_version" not in device assert device["model"] == "Beer bottle XL" assert device["model_id"] == "bn003" + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +async def test_subentry_reconfigure_availablity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the subentry ConfigFlow reconfigure and update device properties.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + + expected_availability = { + "availability_topic": "test/availability", + "availability_template": "{{ value_json.availability }}", + "payload_available": "online", + "payload_not_available": "offline", + } + assert subentry.data.get("availability") == expected_availability + + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we can set the availability config + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "availability"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "availability" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "availability_topic": "test/new_availability#invalid_topic", + "payload_available": "1", + "payload_not_available": "0", + }, + ) + assert result["errors"] == {"availability_topic": "invalid_subscribe_topic"} + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "availability_topic": "test/new_availability", + "payload_available": "1", + "payload_not_available": "0", + }, + ) + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check the availability was updated + expected_availability = { + "availability_topic": "test/new_availability", + "payload_available": "1", + "payload_not_available": "0", + } + assert subentry.data.get("availability") == expected_availability + + # Assert we can reset the availability config + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "availability"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "availability" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "payload_available": "1", + "payload_not_available": "0", + }, + ) + + # Finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check the availability was updated + assert subentry.data.get("availability") == { + "payload_available": "1", + "payload_not_available": "0", + } diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index ecc045b3871..2049dec0437 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -501,6 +501,20 @@ async def test_loading_subentries( assert entity_entry_entity_id == entity_id state = hass.states.get(entity_id) assert state is not None + assert ( + state.attributes.get("entity_picture") == f"https://example.com/{object_id}" + ) + # Availability was configured, so entities are unavailable + assert state.state == "unavailable" + + # Make entities available + async_fire_mqtt_message(hass, "test/availability", '{"availability": "online"}') + for component in mqtt_config_subentries_data[0]["data"]["components"].values(): + platform = component["platform"] + entity_id = f"{platform}.{slugify(device.name)}_{slugify(component['name'])}" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "unknown" @pytest.mark.parametrize( From 1bd8ff884e07129ccd2befe3bc2c1d87af377405 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 14 Mar 2025 09:58:55 -0400 Subject: [PATCH 1660/1941] Improve Snoo testing (#139302) * improve snoo testing * change to asyncMock method of testing * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * address comments * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * adress comments --------- Co-authored-by: Joost Lekkerkerker --- tests/components/snoo/__init__.py | 16 +++++++ tests/components/snoo/conftest.py | 58 ++++------------------- tests/components/snoo/const.py | 37 +++++++++++++++ tests/components/snoo/test_config_flow.py | 13 ++--- tests/components/snoo/test_init.py | 22 ++++++++- tests/components/snoo/test_sensor.py | 22 +++++++++ 6 files changed, 108 insertions(+), 60 deletions(-) create mode 100644 tests/components/snoo/test_sensor.py diff --git a/tests/components/snoo/__init__.py b/tests/components/snoo/__init__.py index f8529251720..b4692e6f08b 100644 --- a/tests/components/snoo/__init__.py +++ b/tests/components/snoo/__init__.py @@ -1,5 +1,11 @@ """Tests for the Happiest Baby Snoo integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest +from python_snoo.containers import SnooData + from homeassistant.components.snoo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -36,3 +42,13 @@ async def async_init_integration(hass: HomeAssistant) -> ConfigEntry: await hass.async_block_till_done() return entry + + +def find_update_callback( + mock: AsyncMock, serial_number: str +) -> Callable[[SnooData], Awaitable[None]]: + """Find the update callback for a specific identifier.""" + for call in mock.subscribe.call_args_list: + if call[0][0].serialNumber == serial_number: + return call[0][1] + pytest.fail(f"Callback for identifier {serial_number} not found") diff --git a/tests/components/snoo/conftest.py b/tests/components/snoo/conftest.py index 33642e67ff5..6163fa56b7f 100644 --- a/tests/components/snoo/conftest.py +++ b/tests/components/snoo/conftest.py @@ -5,9 +5,8 @@ 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 +from .const import MOCK_SNOO_DEVICES, MOCKED_AUTH @pytest.fixture @@ -19,55 +18,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: 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: +def bypass_api() -> Generator[AsyncMock]: """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), + patch("homeassistant.components.snoo.Snoo", autospec=True) as mock_client, + patch("homeassistant.components.snoo.config_flow.Snoo", new=mock_client), ): - yield api + client = mock_client.return_value + client.get_devices.return_value = [SnooDevice.from_dict(MOCK_SNOO_DEVICES[0])] + client.authorize.return_value = MOCKED_AUTH + yield client diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py index c5d53780fa1..2657048afb8 100644 --- a/tests/components/snoo/const.py +++ b/tests/components/snoo/const.py @@ -1,5 +1,9 @@ """Snoo constants for testing.""" +import time + +from python_snoo.containers import AuthorizationInfo, SnooData + MOCK_AMAZON_AUTH = { # This is a JWT with random values. "AccessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2" @@ -32,3 +36,36 @@ MOCK_SNOO_DEVICES = [ "provisionedAt": "random_time", } ] + +MOCK_SNOO_DATA = SnooData.from_dict( + { + "system_state": "normal", + "sw_version": "v1.14.27", + "state_machine": { + "session_id": "0", + "state": "ONLINE", + "is_active_session": "false", + "since_session_start_ms": -1, + "time_left": -1, + "hold": "off", + "weaning": "off", + "audio": "on", + "up_transition": "NONE", + "down_transition": "NONE", + "sticky_white_noise": "off", + }, + "left_safety_clip": 1, + "right_safety_clip": 1, + "event": "status_requested", + "event_time_ms": int(time.time()), + "rx_signal": {"rssi": -45, "strength": 100}, + } +) + + +MOCKED_AUTH = AuthorizationInfo( + snoo=MOCK_SNOO_AUTH, + aws_access=MOCK_AMAZON_AUTH["AccessToken"], + aws_id=MOCK_AMAZON_AUTH["IdToken"], + aws_refresh=MOCK_AMAZON_AUTH["RefreshToken"], +) diff --git a/tests/components/snoo/test_config_flow.py b/tests/components/snoo/test_config_flow.py index ffdfb22142d..9e07f011cd4 100644 --- a/tests/components/snoo/test_config_flow.py +++ b/tests/components/snoo/test_config_flow.py @@ -13,11 +13,10 @@ 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 + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: AsyncMock ) -> None: """Test we create the entry successfully.""" result = await hass.config_entries.flow.async_init( @@ -55,7 +54,7 @@ async def test_config_flow_success( async def test_form_auth_issues( hass: HomeAssistant, mock_setup_entry: AsyncMock, - bypass_api: MockedSnoo, + bypass_api: AsyncMock, exception, error_msg, ) -> None: @@ -64,7 +63,7 @@ async def test_form_auth_issues( DOMAIN, context={"source": config_entries.SOURCE_USER} ) # Set Authorize to fail. - bypass_api.set_auth_error(exception) + bypass_api.authorize.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -73,10 +72,9 @@ async def test_form_auth_issues( }, ) # Reset auth back to the original - bypass_api.set_auth_error(None) assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": error_msg} - + bypass_api.authorize.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -84,7 +82,6 @@ async def test_form_auth_issues( CONF_PASSWORD: "test-password", }, ) - await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" @@ -96,7 +93,7 @@ async def test_form_auth_issues( async def test_account_already_configured( - hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api + hass: HomeAssistant, mock_setup_entry: AsyncMock, bypass_api: AsyncMock ) -> None: """Ensure we abort if the config flow already exists.""" create_entry(hass) diff --git a/tests/components/snoo/test_init.py b/tests/components/snoo/test_init.py index 06f420b6518..72c4b6fb8ab 100644 --- a/tests/components/snoo/test_init.py +++ b/tests/components/snoo/test_init.py @@ -1,14 +1,32 @@ """Test init for Snoo.""" +from unittest.mock import AsyncMock + +from python_snoo.exceptions import SnooAuthException + +from homeassistant.components.snoo import SnooDeviceError 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: +async def test_async_setup_entry(hass: HomeAssistant, bypass_api: AsyncMock) -> 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 + + +async def test_cannot_auth(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test that we are put into retry when we fail to auth.""" + bypass_api.authorize.side_effect = SnooAuthException + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_failed_devices(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test that we are put into retry when we fail to get devices.""" + bypass_api.get_devices.side_effect = SnooDeviceError + entry = await async_init_integration(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/snoo/test_sensor.py b/tests/components/snoo/test_sensor.py new file mode 100644 index 00000000000..96a22e548b8 --- /dev/null +++ b/tests/components/snoo/test_sensor.py @@ -0,0 +1,22 @@ +"""Test Snoo Sensors.""" + +from unittest.mock import AsyncMock + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_sensors(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test sensors and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("sensor")) == 2 + assert hass.states.get("sensor.test_snoo_state").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test_snoo_time_left").state == STATE_UNAVAILABLE + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 2 + assert hass.states.get("sensor.test_snoo_state").state == "stop" + assert hass.states.get("sensor.test_snoo_time_left").state == STATE_UNKNOWN From 96a6d88dca306deb29b09aa94c0c6dab4978ac2c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Mar 2025 15:01:43 +0100 Subject: [PATCH 1661/1941] Allow configuring ignored devices from dormakaba_dkey user flow (#140596) --- .../components/dormakaba_dkey/config_flow.py | 2 +- .../dormakaba_dkey/test_config_flow.py | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 0d23b822231..369accb83d8 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -57,7 +57,7 @@ class DormkabaConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = self._discovered_devices[address] return await self.async_step_associate() - current_addresses = self._async_current_ids() + current_addresses = self._async_current_ids(include_ignore=False) for discovery in async_discovered_service_info(self.hass): if ( discovery.address in current_addresses diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index 8d8140d609a..b3657810006 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -9,6 +9,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.dormakaba_dkey.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -143,6 +144,43 @@ async def test_async_step_user_takes_precedence_over_discovery( assert not hass.config_entries.flow.async_progress(DOMAIN) +async def test_user_setup_removes_ignored_entry(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DKEY_DISCOVERY_INFO.address, + source=SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + assert hass.config_entries.async_entries(DOMAIN) == [ignored_entry] + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_discovered_service_info", + return_value=[NOT_DKEY_DISCOVERY_INFO, DKEY_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + await _test_common_success(hass, result) + + # Check the ignored entry is removed + assert ignored_entry not in hass.config_entries.async_entries(DOMAIN) + + async def test_bluetooth_step_success(hass: HomeAssistant) -> None: """Test bluetooth step success path.""" result = await hass.config_entries.flow.async_init( From 08fc6dcff643973a044c64c397ba3b995d500457 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Mar 2025 15:05:58 +0100 Subject: [PATCH 1662/1941] Allow configuring ignored devices from improve_ble user flow (#140595) --- .../components/improv_ble/config_flow.py | 19 ++++++++---- .../components/improv_ble/test_config_flow.py | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 22f2bf3623c..0dcefba6428 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -83,12 +83,9 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = self._discovered_devices[address] return await self.async_step_start_improv() - current_addresses = self._async_current_ids() for discovery in bluetooth.async_discovered_service_info(self.hass): - if ( - discovery.address in current_addresses - or discovery.address in self._discovered_devices - or not device_filter(discovery.advertisement) + if discovery.address in self._discovered_devices or not device_filter( + discovery.advertisement ): continue self._discovered_devices[discovery.address] = discovery @@ -364,6 +361,18 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): assert self._provision_result is not None result = self._provision_result + if result["type"] == "abort" and result["reason"] in ( + "provision_successful", + "provision_successful_url", + ): + # Delete ignored config entry, if it exists + address = self.context["unique_id"] + current_entries = self._async_current_entries(include_ignore=True) + for entry in current_entries: + if entry.unique_id == address: + _LOGGER.debug("Removing ignored entry: %s", entry) + await self.hass.config_entries.async_remove(entry.entry_id) + break self._provision_result = None return result diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 4536c64349c..9d883502d28 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -10,6 +10,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.bluetooth import BluetoothChange from homeassistant.components.improv_ble.const import DOMAIN +from homeassistant.config_entries import SOURCE_IGNORE from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType @@ -21,6 +22,8 @@ from . import ( PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, ) +from tests.common import MockConfigEntry + IMPROV_BLE = "homeassistant.components.improv_ble" @@ -118,6 +121,32 @@ async def test_async_step_user_takes_precedence_over_discovery( assert not hass.config_entries.flow.async_progress(DOMAIN) +async def test_user_setup_removes_ignored_entry(hass: HomeAssistant) -> None: + """Test the user initiated form can replace an ignored device.""" + ignored_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=IMPROV_BLE_DISCOVERY_INFO.address, + source=SOURCE_IGNORE, + ) + ignored_entry.add_to_hass(hass) + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + # Check the ignored entry is removed + assert not hass.config_entries.async_entries(DOMAIN) + + async def test_bluetooth_step_provisioned_device(hass: HomeAssistant) -> None: """Test bluetooth step when device is already provisioned.""" result = await hass.config_entries.flow.async_init( From e9c8b3acfc56e5d436cb4a4d8a27a892621c939f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Mar 2025 04:07:32 -1000 Subject: [PATCH 1663/1941] Bump aioharmony to 0.5.2 (#140589) mostly logging fixes (some format stings were missing values) related issue #139126 --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index aab4f51b09a..f67eb4db5aa 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.4.1"], + "requirements": ["aioharmony==0.5.2"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/requirements_all.txt b/requirements_all.txt index 76926fd1001..cadf1be7645 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -258,7 +258,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.4.1 +aioharmony==0.5.2 # homeassistant.components.hassio aiohasupervisor==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 819d9756f85..0637d2a737a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -243,7 +243,7 @@ aiogithubapi==24.6.0 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.4.1 +aioharmony==0.5.2 # homeassistant.components.hassio aiohasupervisor==0.3.0 From de0efd61d177877f35bb31e557e29e448d8929db Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 14 Mar 2025 16:17:23 +0200 Subject: [PATCH 1664/1941] Add Z-Wave JS NVM backup and restore API (#139233) * ZWaveJS: NVM backup and restore API * remove unused const * test fix * switch to WS commands * Backup & restore MVP * Use base64 data directly * update tests * fix mistake * Apply suggestions from code review Co-authored-by: Martin Hjelmare * PR comments * update tests * more tests --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 125 ++++++++++++ tests/components/zwave_js/test_api.py | 236 +++++++++++++++++++++++ 2 files changed, 361 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index cc47339a6a6..a3d1416962e 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -454,6 +454,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_node_capabilities) websocket_api.async_register_command(hass, websocket_invoke_cc_api) websocket_api.async_register_command(hass, websocket_get_integration_settings) + websocket_api.async_register_command(hass, websocket_backup_nvm) + websocket_api.async_register_command(hass, websocket_restore_nvm) hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @@ -2780,3 +2782,126 @@ def websocket_get_integration_settings( CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False), }, ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/backup_nvm", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_backup_nvm( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Backup NVM data.""" + controller = driver.controller + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "bytesRead": event["bytesRead"], + "total": event["total"], + }, + ) + ) + + # Set up subscription for progress events + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + controller.on("nvm backup progress", forward_progress), + ] + + result = await controller.async_backup_nvm_raw_base64() + # Send the finished event with the backup data + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "finished", + "data": result, + }, + ) + ) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/restore_nvm", + vol.Required(ENTRY_ID): str, + vol.Required("data"): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_restore_nvm( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Restore NVM data.""" + controller = driver.controller + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + @callback + def forward_progress(event: dict) -> None: + """Forward progress events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "bytesRead": event.get("bytesRead"), + "bytesWritten": event.get("bytesWritten"), + "total": event["total"], + }, + ) + ) + + # Set up subscription for progress events + connection.subscriptions[msg["id"]] = async_cleanup + msg[DATA_UNSUBSCRIBE] = unsubs = [ + controller.on("nvm convert progress", forward_progress), + controller.on("nvm restore progress", forward_progress), + ] + + await controller.async_restore_nvm_base64(msg["data"]) + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "finished", + }, + ) + ) + connection.send_result(msg[ID]) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index dcb8c8dafe4..07c874197b6 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5201,6 +5201,242 @@ async def test_get_integration_settings( } +async def test_backup_nvm( + hass: HomeAssistant, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the backup NVM websocket command.""" + ws_client = await hass_ws_client(hass) + + # Set up mocks for the controller events + controller = client.driver.controller + + # Test subscription and events + with patch.object( + controller, "async_backup_nvm_raw_base64", return_value="test" + ) as mock_backup: + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": integration.entry_id, + } + ) + + # Verify the finished event with data first + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["event"] == "finished" + assert msg["event"]["data"] == "test" + + # Verify subscription success + msg = await ws_client.receive_json() + assert msg["type"] == "result" + assert msg["success"] is True + + # Simulate progress events + event = Event( + "nvm backup progress", + { + "source": "controller", + "event": "nvm backup progress", + "bytesRead": 25, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm backup progress" + assert msg["event"]["bytesRead"] == 25 + assert msg["event"]["total"] == 100 + + event = Event( + "nvm backup progress", + { + "source": "controller", + "event": "nvm backup progress", + "bytesRead": 50, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm backup progress" + assert msg["event"]["bytesRead"] == 50 + assert msg["event"]["total"] == 100 + + # Wait for the backup to complete + await hass.async_block_till_done() + + # Verify the backup was called + assert mock_backup.called + + # Test backup failure + with patch.object( + controller, + "async_backup_nvm_raw_base64", + side_effect=FailedCommand("failed_command", "Backup failed"), + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": integration.entry_id, + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "Backup failed" + + # Test config entry not found + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": "invalid_entry_id", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + + # Test config entry not loaded + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + "type": "zwave_js/backup_nvm", + "entry_id": integration.entry_id, + } + ) + msg = await ws_client.receive_json() + assert msg["error"]["code"] == "not_loaded" + + +async def test_restore_nvm( + hass: HomeAssistant, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the restore NVM websocket command.""" + ws_client = await hass_ws_client(hass) + + # Set up mocks for the controller events + controller = client.driver.controller + + # Test restore success + with patch.object( + controller, "async_restore_nvm_base64", return_value=None + ) as mock_restore: + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify the finished event first + msg = await ws_client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["event"] == "finished" + + # Verify subscription success + msg = await ws_client.receive_json() + assert msg["type"] == "result" + assert msg["success"] is True + + # Simulate progress events + event = Event( + "nvm restore progress", + { + "source": "controller", + "event": "nvm restore progress", + "bytesWritten": 25, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 25 + assert msg["event"]["total"] == 100 + + event = Event( + "nvm restore progress", + { + "source": "controller", + "event": "nvm restore progress", + "bytesWritten": 50, + "total": 100, + }, + ) + controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"]["event"] == "nvm restore progress" + assert msg["event"]["bytesWritten"] == 50 + assert msg["event"]["total"] == 100 + + # Wait for the restore to complete + await hass.async_block_till_done() + + # Verify the restore was called + assert mock_restore.called + + # Test restore failure + with patch.object( + controller, + "async_restore_nvm_base64", + side_effect=FailedCommand("failed_command", "Restore failed"), + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "Restore failed" + + # Test entry_id not found + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": "invalid_entry_id", + "data": "dGVzdA==", # base64 encoded "test" + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + + # Test config entry not loaded + await hass.config_entries.async_unload(integration.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + "type": "zwave_js/restore_nvm", + "entry_id": integration.entry_id, + "data": "dGVzdA==", # base64 encoded "test" + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + async def test_cancel_secure_bootstrap_s2( hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator ) -> None: From 251bb30dc7bb2ea0c17ca41f9800be4f275c9a59 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 15 Mar 2025 00:27:18 +1000 Subject: [PATCH 1665/1941] Add streaming media platform to Teslemetry (#140482) * Update media player * Add media player platform with tests and bump firmware --- .../components/teslemetry/media_player.py | 312 +++++++++++++----- tests/components/teslemetry/const.py | 3 +- .../teslemetry/fixtures/metadata.json | 22 -- .../teslemetry/fixtures/vehicle_data.json | 2 +- .../snapshots/test_binary_sensor.ambr | 180 ++++++++++ .../snapshots/test_diagnostics.ambr | 2 +- .../snapshots/test_media_player.ambr | 42 ++- .../teslemetry/snapshots/test_update.ambr | 2 +- .../teslemetry/test_media_player.py | 67 +++- 9 files changed, 523 insertions(+), 109 deletions(-) delete mode 100644 tests/components/teslemetry/fixtures/metadata.json diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index 1bfc9bf66dc..409b409e325 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.const import Scope from homeassistant.components.media_player import ( @@ -12,9 +13,14 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from . import TeslemetryConfigEntry -from .entity import TeslemetryVehicleEntity +from .entity import ( + TeslemetryRootEntity, + TeslemetryVehicleEntity, + TeslemetryVehicleStreamEntity, +) from .helpers import handle_vehicle_command from .models import TeslemetryVehicleData @@ -24,8 +30,16 @@ STATES = { "Stopped": MediaPlayerState.IDLE, "Off": MediaPlayerState.OFF, } -VOLUME_MAX = 11.0 -VOLUME_STEP = 1.0 / 3 +DISPLAY_STATES = { + "On": MediaPlayerState.IDLE, + "Accessory": MediaPlayerState.IDLE, + "Charging": MediaPlayerState.OFF, + "Sentry": MediaPlayerState.OFF, + "Off": MediaPlayerState.OFF, +} +# Tesla uses 31 steps, in 0.333 increments up to 10.333 +VOLUME_STEP = 1 / 31 +VOLUME_FACTOR = 31 / 3 # 10.333 PARALLEL_UPDATES = 0 @@ -38,68 +52,99 @@ async def async_setup_entry( """Set up the Teslemetry Media platform from a config entry.""" async_add_entities( - TeslemetryMediaEntity(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) + TeslemetryPollingMediaEntity(vehicle, entry.runtime_data.scopes) + if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6" + else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) -class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity): - """Vehicle media player class.""" +class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity): + """Base vehicle media player class.""" + + api: VehicleSpecific _attr_device_class = MediaPlayerDeviceClass.SPEAKER - _attr_supported_features = ( - MediaPlayerEntityFeature.NEXT_TRACK - | MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.PREVIOUS_TRACK - | MediaPlayerEntityFeature.VOLUME_SET - ) - _volume_max: float = VOLUME_MAX + _attr_volume_step = VOLUME_STEP + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.adjust_volume(volume * VOLUME_FACTOR)) + self._attr_volume_level = volume + self.async_write_ha_state() + + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() + + async def async_media_pause(self) -> None: + """Send pause command.""" + + if self.state == MediaPlayerState.PLAYING: + self.raise_for_scope(Scope.VEHICLE_CMDS) + + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() + + async def async_media_next_track(self) -> None: + """Send next track command.""" + + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command(self.api.media_next_track()) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + + self.raise_for_scope(Scope.VEHICLE_CMDS) + await handle_vehicle_command(self.api.media_prev_track()) + + +class TeslemetryPollingMediaEntity(TeslemetryVehicleEntity, TeslemetryMediaEntity): + """Polling vehicle media player class.""" def __init__( self, data: TeslemetryVehicleData, - scoped: bool, + scopes: list[Scope], ) -> None: """Initialize the media player entity.""" super().__init__(data, "media") - self.scoped = scoped - if not scoped: + + self._attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + ) + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: self._attr_supported_features = MediaPlayerEntityFeature(0) def _async_update_attrs(self) -> None: """Update entity attributes.""" - self._volume_max = ( - self.get("vehicle_state_media_info_audio_volume_max") or VOLUME_MAX - ) - self._attr_state = STATES.get( - self.get("vehicle_state_media_info_media_playback_status") or "Off", - ) - self._attr_volume_step = ( - 1.0 - / self._volume_max - / ( - self.get("vehicle_state_media_info_audio_volume_increment") - or VOLUME_STEP - ) - ) + state = self.get("vehicle_state_media_info_media_playback_status") + self._attr_state = STATES.get(state) if state else None + self._attr_volume_level = ( + self.get("vehicle_state_media_info_audio_volume") or 0 + ) / VOLUME_FACTOR - if volume := self.get("vehicle_state_media_info_audio_volume"): - self._attr_volume_level = volume / self._volume_max - else: - self._attr_volume_level = None + duration = self.get("vehicle_state_media_info_now_playing_duration") + self._attr_media_duration = duration / 1000 if duration is not None else None - if duration := self.get("vehicle_state_media_info_now_playing_duration"): - self._attr_media_duration = duration / 1000 - else: - self._attr_media_duration = None - - if duration and ( - position := self.get("vehicle_state_media_info_now_playing_elapsed") - ): - self._attr_media_position = position / 1000 - else: - self._attr_media_position = None + # Return media position only when a media duration is > 0. + elapsed = self.get("vehicle_state_media_info_now_playing_elapsed") + self._attr_media_position = ( + elapsed / 1000 if duration and elapsed is not None else None + ) self._attr_media_title = self.get("vehicle_state_media_info_now_playing_title") self._attr_media_artist = self.get( @@ -113,42 +158,151 @@ class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity): ) self._attr_source = self.get("vehicle_state_media_info_now_playing_source") - async def async_set_volume_level(self, volume: float) -> None: - """Set volume level, range 0..1.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command( - self.api.adjust_volume(int(volume * self._volume_max)) + +class TeslemetryStreamingMediaEntity( + TeslemetryVehicleStreamEntity, TeslemetryMediaEntity, RestoreEntity +): + """Streaming vehicle media player class.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the media player entity.""" + super().__init__(data, "media") + + self._attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET ) - self._attr_volume_level = volume + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = MediaPlayerEntityFeature(0) + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + try: + self._attr_state = MediaPlayerState(state.state) + except ValueError: + self._attr_state = None + self._attr_volume_level = state.attributes.get("volume_level") + self._attr_media_title = state.attributes.get("media_title") + self._attr_media_artist = state.attributes.get("media_artist") + self._attr_media_album_name = state.attributes.get("media_album_name") + self._attr_media_playlist = state.attributes.get("media_playlist") + self._attr_media_duration = state.attributes.get("media_duration") + self._attr_media_position = state.attributes.get("media_position") + self._attr_source = state.attributes.get("source") + + self.async_write_ha_state() + + self.async_on_remove( + self.vehicle.stream_vehicle.listen_CenterDisplay( + self._async_handle_center_display + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaPlaybackStatus( + self._async_handle_media_playback_status + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaPlaybackSource( + self._async_handle_media_playback_source + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaAudioVolume( + self._async_handle_media_audio_volume + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingDuration( + self._async_handle_media_now_playing_duration + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingElapsed( + self._async_handle_media_now_playing_elapsed + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingArtist( + self._async_handle_media_now_playing_artist + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingAlbum( + self._async_handle_media_now_playing_album + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingTitle( + self._async_handle_media_now_playing_title + ) + ) + self.async_on_remove( + self.vehicle.stream_vehicle.listen_MediaNowPlayingStation( + self._async_handle_media_now_playing_station + ) + ) + + def _async_handle_center_display(self, value: str | None) -> None: + """Update entity attributes.""" + if value is not None: + self._attr_state = DISPLAY_STATES.get(value) + self.async_write_ha_state() + + def _async_handle_media_playback_status(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_state = MediaPlayerState.OFF if value is None else STATES.get(value) self.async_write_ha_state() - async def async_media_play(self) -> None: - """Send play command.""" - if self.state != MediaPlayerState.PLAYING: - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_toggle_playback()) - self._attr_state = MediaPlayerState.PLAYING - self.async_write_ha_state() + def _async_handle_media_playback_source(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_source = value + self.async_write_ha_state() - async def async_media_pause(self) -> None: - """Send pause command.""" - if self.state == MediaPlayerState.PLAYING: - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_toggle_playback()) - self._attr_state = MediaPlayerState.PAUSED - self.async_write_ha_state() + def _async_handle_media_audio_volume(self, value: float | None) -> None: + """Update entity attributes.""" + self._attr_volume_level = None if value is None else value / VOLUME_FACTOR + self.async_write_ha_state() - async def async_media_next_track(self) -> None: - """Send next track command.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_next_track()) + def _async_handle_media_now_playing_duration(self, value: int | None) -> None: + """Update entity attributes.""" + self._attr_media_duration = None if value is None else int(value / 1000) + self.async_write_ha_state() - async def async_media_previous_track(self) -> None: - """Send previous track command.""" - self.raise_for_scope(Scope.VEHICLE_CMDS) - await self.wake_up_if_asleep() - await handle_vehicle_command(self.api.media_prev_track()) + def _async_handle_media_now_playing_elapsed(self, value: int | None) -> None: + """Update entity attributes.""" + self._attr_media_position = None if value is None else int(value / 1000) + self.async_write_ha_state() + + def _async_handle_media_now_playing_artist(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_artist = value # Check if this is album artist or not + self.async_write_ha_state() + + def _async_handle_media_now_playing_album(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_album_name = value + self.async_write_ha_state() + + def _async_handle_media_now_playing_title(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_title = value + self.async_write_ha_state() + + def _async_handle_media_now_playing_station(self, value: str | None) -> None: + """Update entity attributes.""" + self._attr_media_channel = ( + value # could also be _attr_media_playlist when Spotify + ) + self.async_write_ha_state() diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 40d55dab71f..31915630951 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -18,7 +18,6 @@ VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) -METADATA = load_json_object_fixture("metadata.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} @@ -52,7 +51,7 @@ METADATA = { "proxy": False, "access": True, "polling": True, - "firmware": "2024.44.25", + "firmware": "2026.0.0", } }, } diff --git a/tests/components/teslemetry/fixtures/metadata.json b/tests/components/teslemetry/fixtures/metadata.json deleted file mode 100644 index 60282afc934..00000000000 --- a/tests/components/teslemetry/fixtures/metadata.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "uid": "abc-123", - "region": "NA", - "scopes": [ - "openid", - "offline_access", - "user_data", - "vehicle_device_data", - "vehicle_cmds", - "vehicle_charging_cmds", - "energy_device_data", - "energy_cmds" - ], - "vehicles": { - "LRW3F7EK4NC700000": { - "access": true, - "polling": true, - "proxy": true, - "firmware": "2024.44.25" - } - } -} diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 0cd238c4e52..051c7199d00 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -192,7 +192,7 @@ "api_version": 71, "autopark_state_v2": "unavailable", "calendar_supported": true, - "car_version": "2024.44.25 06f534d46010", + "car_version": "2026.0.0 06f534d46010", "center_display_state": 0, "dashcam_clip_save_available": true, "dashcam_state": "Recording", diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 6a6e9826dc2..84c50c3ebe9 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -1371,6 +1371,147 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor[binary_sensor.test_located_at_favorite-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.test_located_at_favorite', + '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': 'Located at favorite', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'located_at_favorite', + 'unique_id': 'LRW3F7EK4NC700000-located_at_favorite', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_favorite-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at favorite', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_favorite', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_located_at_home', + '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': 'Located at home', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'located_at_home', + 'unique_id': 'LRW3F7EK4NC700000-located_at_home', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at home', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_work-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.test_located_at_work', + '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': 'Located at work', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'located_at_work', + 'unique_id': 'LRW3F7EK4NC700000-located_at_work', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_located_at_work-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at work', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_work', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor[binary_sensor.test_offroad_lightbar-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2801,6 +2942,45 @@ 'state': 'unknown', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.test_located_at_favorite-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at favorite', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_favorite', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_located_at_home-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at home', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_binary_sensor_refresh[binary_sensor.test_located_at_work-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Located at work', + }), + 'context': , + 'entity_id': 'binary_sensor.test_located_at_work', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 56a8f759a21..a39e8a0ff74 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -375,7 +375,7 @@ 'vehicle_state_api_version': 71, 'vehicle_state_autopark_state_v2': 'unavailable', 'vehicle_state_calendar_supported': True, - 'vehicle_state_car_version': '2024.44.25 06f534d46010', + 'vehicle_state_car_version': '2026.0.0 06f534d46010', 'vehicle_state_center_display_state': 0, 'vehicle_state_dashcam_clip_save_available': True, 'vehicle_state_dashcam_state': 'Recording', diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr index 663e91a502c..7f721b95289 100644 --- a/tests/components/teslemetry/snapshots/test_media_player.ambr +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -47,7 +47,7 @@ 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'source': 'Audible', 'supported_features': , - 'volume_level': 0.16129355359011466, + 'volume_level': 0.16129354838709678, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -64,10 +64,12 @@ 'friendly_name': 'Test Media player', 'media_album_name': '', 'media_artist': '', + 'media_duration': 0.0, 'media_playlist': '', 'media_title': '', 'source': 'Spotify', 'supported_features': , + 'volume_level': 0.0, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -125,7 +127,43 @@ 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'source': 'Audible', 'supported_features': , - 'volume_level': 0.16129355359011466, + 'volume_level': 0.16129354838709678, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_update_streaming[off] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update_streaming[on] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist', + 'media_duration': 60, + 'media_position': 5, + 'source': 'Spotify', + 'supported_features': , + 'volume_level': 0.1935483870967742, }), 'context': , 'entity_id': 'media_player.test_media_player', diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr index fcd6f421993..391d81c086e 100644 --- a/tests/components/teslemetry/snapshots/test_update.ambr +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -41,7 +41,7 @@ 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', 'friendly_name': 'Test Update', 'in_progress': False, - 'installed_version': '2024.44.25', + 'installed_version': '2026.0.0', 'latest_version': '2024.12.0.0', 'release_summary': None, 'release_url': None, diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index ae462bfd026..de990dbe7bc 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -2,7 +2,9 @@ from unittest.mock import AsyncMock, patch +import pytest from syrupy.assertion import SnapshotAssertion +from teslemetry_stream import Signal from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_LEVEL, @@ -18,7 +20,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import assert_entities, assert_entities_alt, setup_platform +from . import assert_entities, assert_entities_alt, reload_platform, setup_platform from .const import COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT @@ -26,6 +28,7 @@ async def test_media_player( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct.""" @@ -38,6 +41,7 @@ async def test_media_player_alt( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct.""" @@ -51,6 +55,7 @@ async def test_media_player_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct without required scope.""" @@ -62,6 +67,7 @@ async def test_media_player_noscope( async def test_media_player_services( hass: HomeAssistant, snapshot: SnapshotAssertion, + mock_legacy: AsyncMock, ) -> None: """Tests that the media player services work.""" @@ -137,3 +143,62 @@ async def test_media_player_services( ) state = hass.states.get(entity_id) call.assert_called_once() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_streaming( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vehicle_data: AsyncMock, + mock_add_listener: AsyncMock, +) -> None: + """Tests that the media player entities with streaming are correct.""" + + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + + # Stream update + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CENTER_DISPLAY: "Off", + Signal.MEDIA_PLAYBACK_STATUS: None, + Signal.MEDIA_PLAYBACK_SOURCE: None, + Signal.MEDIA_AUDIO_VOLUME: None, + Signal.MEDIA_NOW_PLAYING_DURATION: None, + Signal.MEDIA_NOW_PLAYING_ELAPSED: None, + Signal.MEDIA_NOW_PLAYING_ARTIST: None, + Signal.MEDIA_NOW_PLAYING_ALBUM: None, + }, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_media_player") + assert state == snapshot(name="off") + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": { + Signal.CENTER_DISPLAY: "Driving", + Signal.MEDIA_PLAYBACK_STATUS: "Playing", + Signal.MEDIA_PLAYBACK_SOURCE: "Spotify", + Signal.MEDIA_AUDIO_VOLUME: 2, + Signal.MEDIA_NOW_PLAYING_DURATION: 60000, + Signal.MEDIA_NOW_PLAYING_ELAPSED: 5000, + Signal.MEDIA_NOW_PLAYING_ARTIST: "Test Artist", + Signal.MEDIA_NOW_PLAYING_ALBUM: "Test Album", + }, + "createdAt": "2024-10-04T10:55:17.000Z", + } + ) + await hass.async_block_till_done() + state = hass.states.get("media_player.test_media_player") + assert state == snapshot(name="on") + + await reload_platform(hass, entry, [Platform.MEDIA_PLAYER]) + + # Ensure the restored state is the same as the previous state + state = hass.states.get("media_player.test_media_player") + assert state == snapshot(name="on") From 532c860bf02c98c6374ed59636c8e708f52759a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 14 Mar 2025 16:04:11 +0100 Subject: [PATCH 1666/1941] Bump ruff to 0.11.0 (#140598) --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1af73b2b5e0..42e05a869c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.10.0 + rev: v0.11.0 hooks: - id: ruff args: diff --git a/pyproject.toml b/pyproject.toml index bcc657528a3..a9548844e62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -700,7 +700,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.10.0" +required-version = ">=0.11.0" [tool.ruff.lint] select = [ diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index a6ce0d38cb1..ff86915bbf3 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.10.0 +ruff==0.11.0 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index a9201bff6ce..758a4355176 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.10.0 \ + stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.11.0 \ 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" From e740e341c8514bf1f1469f6c536076807dc47483 Mon Sep 17 00:00:00 2001 From: Hessel Date: Fri, 14 Mar 2025 16:13:07 +0100 Subject: [PATCH 1667/1941] Change max ICP value to fixed value for Wallbox Integration (#140592) change max ICP value to fixed value Co-authored-by: Hessel van Es --- homeassistant/components/wallbox/number.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 462266636d7..a5880f6e0f7 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -71,9 +71,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_ICP_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_ICP_CURRENT_KEY, translation_key="maximum_icp_current", - max_value_fn=lambda coordinator: cast( - float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] - ), + max_value_fn=lambda _: 255, min_value_fn=lambda _: 6, set_value_fn=lambda coordinator: coordinator.async_set_icp_current, native_step=1, From 324f208d68f3d577efa9261edb47def1f6817d41 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 14 Mar 2025 16:22:23 +0100 Subject: [PATCH 1668/1941] Add lawn mower support to Google Assistant (#140530) * Add lawn mower support to google assistant * Update snapshots * Sort alphabetically * Refactor service call * Refactor service call * Feedback --- .../components/google_assistant/const.py | 4 + .../components/google_assistant/trait.py | 118 +++++++++++------- .../snapshots/test_diagnostics.ambr | 1 + .../components/google_assistant/test_trait.py | 60 +++++++++ 4 files changed, 141 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 8132ecaae2c..71738c9d13e 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -14,6 +14,7 @@ from homeassistant.components import ( input_boolean, input_button, input_select, + lawn_mower, light, lock, media_player, @@ -58,6 +59,7 @@ DEFAULT_EXPOSED_DOMAINS = [ "humidifier", "input_boolean", "input_select", + "lawn_mower", "light", "lock", "media_player", @@ -88,6 +90,7 @@ TYPE_GATE = f"{PREFIX_TYPES}GATE" TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER" TYPE_LIGHT = f"{PREFIX_TYPES}LIGHT" TYPE_LOCK = f"{PREFIX_TYPES}LOCK" +TYPE_MOWER = f"{PREFIX_TYPES}MOWER" TYPE_OUTLET = f"{PREFIX_TYPES}OUTLET" TYPE_RECEIVER = f"{PREFIX_TYPES}AUDIO_VIDEO_RECEIVER" TYPE_SCENE = f"{PREFIX_TYPES}SCENE" @@ -149,6 +152,7 @@ DOMAIN_TO_GOOGLE_TYPES = { input_boolean.DOMAIN: TYPE_SWITCH, input_button.DOMAIN: TYPE_SCENE, input_select.DOMAIN: TYPE_SENSOR, + lawn_mower.DOMAIN: TYPE_MOWER, light.DOMAIN: TYPE_LIGHT, lock.DOMAIN: TYPE_LOCK, media_player.DOMAIN: TYPE_SETTOP, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 44251a3be04..9edd340d7d9 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -21,6 +21,7 @@ from homeassistant.components import ( input_boolean, input_button, input_select, + lawn_mower, light, lock, media_player, @@ -42,6 +43,7 @@ from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.fan import FanEntityFeature from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import LockState from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType @@ -714,7 +716,7 @@ class DockTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - return domain == vacuum.DOMAIN + return domain in (vacuum.DOMAIN, lawn_mower.DOMAIN) def sync_attributes(self) -> dict[str, Any]: """Return dock attributes for a sync request.""" @@ -722,17 +724,32 @@ class DockTrait(_Trait): def query_attributes(self) -> dict[str, Any]: """Return dock query attributes.""" - return {"isDocked": self.state.state == vacuum.VacuumActivity.DOCKED} + domain = self.state.domain + state = self.state.state + if domain == vacuum.DOMAIN: + return {"isDocked": state == vacuum.VacuumActivity.DOCKED} + if domain == lawn_mower.DOMAIN: + return {"isDocked": state == lawn_mower.LawnMowerActivity.DOCKED} + raise NotImplementedError(f"Unsupported domain {domain}") async def execute(self, command, data, params, challenge): """Execute a dock command.""" - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_RETURN_TO_BASE, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) + domain = self.state.domain + service: str | None = None + + if domain == vacuum.DOMAIN: + service = vacuum.SERVICE_RETURN_TO_BASE + elif domain == lawn_mower.DOMAIN: + service = lawn_mower.SERVICE_DOCK + + if service: + await self.hass.services.async_call( + self.state.domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) @register_trait @@ -843,7 +860,7 @@ class StartStopTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - if domain == vacuum.DOMAIN: + if domain in (vacuum.DOMAIN, lawn_mower.DOMAIN): return True if ( @@ -863,6 +880,12 @@ class StartStopTrait(_Trait): & VacuumEntityFeature.PAUSE != 0 } + if domain == lawn_mower.DOMAIN: + return { + "pausable": self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & LawnMowerEntityFeature.PAUSE + != 0 + } if domain in COVER_VALVE_DOMAINS: return {} @@ -878,6 +901,11 @@ class StartStopTrait(_Trait): "isRunning": state == vacuum.VacuumActivity.CLEANING, "isPaused": state == vacuum.VacuumActivity.PAUSED, } + if domain == lawn_mower.DOMAIN: + return { + "isRunning": state == lawn_mower.LawnMowerActivity.MOWING, + "isPaused": state == lawn_mower.LawnMowerActivity.PAUSED, + } if domain in COVER_VALVE_DOMAINS: return { @@ -896,46 +924,52 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: await self._execute_vacuum(command, data, params, challenge) return + if domain == lawn_mower.DOMAIN: + await self._execute_lawn_mower(command, data, params, challenge) + return if domain in COVER_VALVE_DOMAINS: await self._execute_cover_or_valve(command, data, params, challenge) return async def _execute_vacuum(self, command, data, params, challenge): """Execute a StartStop command.""" + service: str | None = None if command == COMMAND_START_STOP: - if params["start"]: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_START, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) - else: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_STOP, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) + service = vacuum.SERVICE_START if params["start"] else vacuum.SERVICE_STOP elif command == COMMAND_PAUSE_UNPAUSE: - if params["pause"]: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_PAUSE, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) - else: - await self.hass.services.async_call( - self.state.domain, - vacuum.SERVICE_START, - {ATTR_ENTITY_ID: self.state.entity_id}, - blocking=not self.config.should_report_state, - context=data.context, - ) + service = vacuum.SERVICE_PAUSE if params["pause"] else vacuum.SERVICE_START + if service: + await self.hass.services.async_call( + self.state.domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) + + async def _execute_lawn_mower(self, command, data, params, challenge): + """Execute a StartStop command.""" + service: str | None = None + if command == COMMAND_START_STOP: + service = ( + lawn_mower.SERVICE_START_MOWING + if params["start"] + else lawn_mower.SERVICE_DOCK + ) + elif command == COMMAND_PAUSE_UNPAUSE: + service = ( + lawn_mower.SERVICE_PAUSE + if params["pause"] + else lawn_mower.SERVICE_START_MOWING + ) + if service: + await self.hass.services.async_call( + self.state.domain, + service, + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, + ) async def _execute_cover_or_valve(self, command, data, params, challenge): """Execute a StartStop command.""" diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 1ecedbd1173..cc5ccbb1de1 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -98,6 +98,7 @@ 'humidifier', 'input_boolean', 'input_select', + 'lawn_mower', 'light', 'lock', 'media_player', diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 1fc4a0e3a0c..cf9c8047049 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -21,6 +21,7 @@ from homeassistant.components import ( input_boolean, input_button, input_select, + lawn_mower, light, lock, media_player, @@ -44,6 +45,7 @@ from homeassistant.components.fan import FanEntityFeature from homeassistant.components.google_assistant import const, error, helpers, trait from homeassistant.components.google_assistant.error import SmartHomeError from homeassistant.components.humidifier import HumidifierEntityFeature +from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import LockEntityFeature from homeassistant.components.media_player import ( @@ -589,6 +591,64 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: assert unpause_calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} +async def test_dock_lawn_mower(hass: HomeAssistant) -> None: + """Test dock trait support for lawn mower domain.""" + assert helpers.get_google_type(lawn_mower.DOMAIN, None) is not None + assert trait.DockTrait.supported(lawn_mower.DOMAIN, 0, None, None) + + trt = trait.DockTrait( + hass, State("lawn_mower.bla", lawn_mower.LawnMowerActivity.MOWING), BASIC_CONFIG + ) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == {"isDocked": False} + + calls = async_mock_service(hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_DOCK) + await trt.execute(trait.COMMAND_DOCK, BASIC_DATA, {}, {}) + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + +async def test_startstop_lawn_mower(hass: HomeAssistant) -> None: + """Test startStop trait support for lawn mower domain.""" + assert helpers.get_google_type(lawn_mower.DOMAIN, None) is not None + assert trait.StartStopTrait.supported(lawn_mower.DOMAIN, 0, None, None) + + trt = trait.StartStopTrait( + hass, + State( + "lawn_mower.bla", + lawn_mower.LawnMowerActivity.PAUSED, + {ATTR_SUPPORTED_FEATURES: LawnMowerEntityFeature.PAUSE}, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == {"pausable": True} + + assert trt.query_attributes() == {"isRunning": False, "isPaused": True} + + start_calls = async_mock_service( + hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_START_MOWING + ) + await trt.execute(trait.COMMAND_START_STOP, BASIC_DATA, {"start": True}, {}) + assert len(start_calls) == 1 + assert start_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + pause_calls = async_mock_service(hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_PAUSE) + await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"pause": True}, {}) + assert len(pause_calls) == 1 + assert pause_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + unpause_calls = async_mock_service( + hass, lawn_mower.DOMAIN, lawn_mower.SERVICE_START_MOWING + ) + await trt.execute(trait.COMMAND_PAUSE_UNPAUSE, BASIC_DATA, {"pause": False}, {}) + assert len(unpause_calls) == 1 + assert unpause_calls[0].data == {ATTR_ENTITY_ID: "lawn_mower.bla"} + + @pytest.mark.parametrize( ( "domain", From 78a04776e4b6acacb1eb077d686dce0bd8a4e178 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Mar 2025 16:49:56 +0100 Subject: [PATCH 1669/1941] Add update_daily action to Habitica integration (#140328) * add update_daily action * day strings --- homeassistant/components/habitica/const.py | 8 + homeassistant/components/habitica/icons.json | 11 + homeassistant/components/habitica/services.py | 138 +++++++-- .../components/habitica/services.yaml | 94 +++++- .../components/habitica/strings.json | 136 ++++++++- tests/components/habitica/fixtures/tasks.json | 13 +- .../habitica/snapshots/test_services.ambr | 40 +++ tests/components/habitica/test_services.py | 275 +++++++++++++++++- 8 files changed, 691 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index cf9d08c160c..8b745ff2b99 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -52,6 +52,11 @@ ATTR_REMINDER = "reminder" ATTR_REMOVE_REMINDER = "remove_reminder" ATTR_CLEAR_REMINDER = "clear_reminder" ATTR_CLEAR_DATE = "clear_date" +ATTR_REPEAT = "repeat" +ATTR_INTERVAL = "every_x" +ATTR_START_DATE = "start_date" +ATTR_REPEAT_MONTHLY = "repeat_monthly" +ATTR_STREAK = "streak" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" @@ -73,6 +78,7 @@ SERVICE_UPDATE_HABIT = "update_habit" SERVICE_CREATE_HABIT = "create_habit" SERVICE_UPDATE_TODO = "update_todo" SERVICE_CREATE_TODO = "create_todo" +SERVICE_UPDATE_DAILY = "update_daily" DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf" X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" @@ -80,3 +86,5 @@ X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}" SECTION_REAUTH_LOGIN = "reauth_login" SECTION_REAUTH_API_KEY = "reauth_api_key" SECTION_DANGER_ZONE = "danger_zone" + +WEEK_DAYS = ["m", "t", "w", "th", "f", "s", "su"] diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 85adfa09304..fcb9ec56fa7 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -259,6 +259,17 @@ "sections": { "developer_options": "mdi:test-tube" } + }, + "update_daily": { + "service": "mdi:calendar-month", + "sections": { + "checklist_options": "mdi:format-list-checks", + "tag_options": "mdi:tag", + "developer_options": "mdi:test-tube", + "reminder_options": "mdi:reminder", + "repeat_weekly_options": "mdi:calendar-refresh", + "repeat_monthly_options": "mdi:calendar-refresh" + } } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index bb8f69a8d11..9fb0b0b7537 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import asdict -from datetime import datetime, time +from datetime import UTC, date, datetime, time import logging from typing import TYPE_CHECKING, Any, cast from uuid import UUID, uuid4 @@ -17,6 +17,7 @@ from habiticalib import ( NotAuthorizedError, NotFoundError, Reminders, + Repeat, Skill, Task, TaskData, @@ -39,6 +40,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.util import dt as dt_util from .const import ( ATTR_ADD_CHECKLIST_ITEM, @@ -53,6 +55,7 @@ from .const import ( ATTR_DATA, ATTR_DIRECTION, ATTR_FREQUENCY, + ATTR_INTERVAL, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -62,8 +65,12 @@ from .const import ( ATTR_REMOVE_CHECKLIST_ITEM, ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_REPEAT, + ATTR_REPEAT_MONTHLY, ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, + ATTR_START_DATE, + ATTR_STREAK, ATTR_TAG, ATTR_TARGET, ATTR_TASK, @@ -87,9 +94,11 @@ from .const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_DAILY, SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_TODO, + WEEK_DAYS, ) from .coordinator import HabiticaConfigEntry @@ -152,13 +161,24 @@ BASE_TASK_SCHEMA = vol.Schema( vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency), vol.Optional(ATTR_DATE): cv.date, vol.Optional(ATTR_CLEAR_DATE): cv.boolean, - vol.Optional(ATTR_REMINDER): vol.All(cv.ensure_list, [cv.datetime]), - vol.Optional(ATTR_REMOVE_REMINDER): vol.All(cv.ensure_list, [cv.datetime]), + vol.Optional(ATTR_REMINDER): vol.All( + cv.ensure_list, [vol.Any(cv.datetime, cv.time)] + ), + vol.Optional(ATTR_REMOVE_REMINDER): vol.All( + cv.ensure_list, [vol.Any(cv.datetime, cv.time)] + ), vol.Optional(ATTR_CLEAR_REMINDER): cv.boolean, vol.Optional(ATTR_ADD_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]), + vol.Optional(ATTR_START_DATE): cv.date, + vol.Optional(ATTR_INTERVAL): vol.All(int, vol.Range(0)), + vol.Optional(ATTR_REPEAT): vol.All(cv.ensure_list, [vol.In(WEEK_DAYS)]), + vol.Optional(ATTR_REPEAT_MONTHLY): vol.All( + cv.string, vol.In({"day_of_month", "day_of_week"}) + ), + vol.Optional(ATTR_STREAK): vol.All(int, vol.Range(0)), } ) @@ -175,6 +195,12 @@ SERVICE_CREATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend( } ) +SERVICE_DAILY_SCHEMA = { + vol.Optional(ATTR_REMINDER): vol.All(cv.ensure_list, [cv.time]), + vol.Optional(ATTR_REMOVE_REMINDER): vol.All(cv.ensure_list, [cv.time]), +} + + SERVICE_GET_TASKS_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -216,6 +242,7 @@ SERVICE_TASK_TYPE_MAP = { SERVICE_CREATE_HABIT: TaskType.HABIT, SERVICE_UPDATE_TODO: TaskType.TODO, SERVICE_CREATE_TODO: TaskType.TODO, + SERVICE_UPDATE_DAILY: TaskType.DAILY, } @@ -605,7 +632,9 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_TODO, + SERVICE_UPDATE_DAILY, ) + task_type = SERVICE_TASK_TYPE_MAP[call.service] current_task = None if is_update: @@ -614,7 +643,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 task for task in coordinator.data.tasks if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is SERVICE_TASK_TYPE_MAP[call.service] + and task.Type is task_type ) except StopIteration as e: raise ServiceValidationError( @@ -626,7 +655,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 data = Task() if not is_update: - data["type"] = SERVICE_TASK_TYPE_MAP[call.service] + data["type"] = task_type if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): data["text"] = text @@ -702,6 +731,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if frequency := call.data.get(ATTR_FREQUENCY): data["frequency"] = frequency + else: + frequency = current_task.frequency if current_task else Frequency.WEEKLY if up_down := call.data.get(ATTR_UP_DOWN): data["up"] = "up" in up_down @@ -752,23 +783,46 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 reminders = current_task.reminders if current_task else [] if add_reminders := call.data.get(ATTR_REMINDER): - existing_reminder_datetimes = { - r.time.replace(tzinfo=None) for r in reminders - } + if task_type is TaskType.TODO: + existing_reminder_datetimes = { + r.time.replace(tzinfo=None) for r in reminders + } - reminders.extend( - Reminders(id=uuid4(), time=r) - for r in add_reminders - if r not in existing_reminder_datetimes - ) + reminders.extend( + Reminders(id=uuid4(), time=r) + for r in add_reminders + if r not in existing_reminder_datetimes + ) + if task_type is TaskType.DAILY: + existing_reminder_times = { + r.time.time().replace(microsecond=0, second=0) for r in reminders + } + + reminders.extend( + Reminders( + id=uuid4(), + time=datetime.combine(date.today(), r, tzinfo=UTC), + ) + for r in add_reminders + if r not in existing_reminder_times + ) if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): - reminders = list( - filter( - lambda r: r.time.replace(tzinfo=None) not in remove_reminder, - reminders, + if task_type is TaskType.TODO: + reminders = list( + filter( + lambda r: r.time.replace(tzinfo=None) not in remove_reminder, + reminders, + ) + ) + if task_type is TaskType.DAILY: + reminders = list( + filter( + lambda r: r.time.time().replace(second=0, microsecond=0) + not in remove_reminder, + reminders, + ) ) - ) if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): reminders = [] @@ -776,6 +830,47 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 if add_reminders or remove_reminder or clear_reminders: data["reminders"] = reminders + if start_date := call.data.get(ATTR_START_DATE): + data["startDate"] = datetime.combine(start_date, time()) + else: + start_date = ( + current_task.startDate + if current_task and current_task.startDate + else dt_util.start_of_local_day() + ) + if repeat := call.data.get(ATTR_REPEAT): + if frequency is Frequency.WEEKLY: + data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS}) + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="frequency_not_weekly", + ) + if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY): + if frequency is not Frequency.MONTHLY: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="frequency_not_monthly", + ) + + if repeat_monthly == "day_of_week": + weekday = start_date.weekday() + data["weeksOfMonth"] = [(start_date.day - 1) // 7] + data["repeat"] = Repeat( + **{day: i == weekday for i, day in enumerate(WEEK_DAYS)} + ) + data["daysOfMonth"] = [] + + else: + data["daysOfMonth"] = [start_date.day] + data["weeksOfMonth"] = [] + + if interval := call.data.get(ATTR_INTERVAL): + data["everyX"] = interval + + if streak := call.data.get(ATTR_STREAK): + data["streak"] = streak + try: if is_update: if TYPE_CHECKING: @@ -805,7 +900,12 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 else: return response.data.to_dict(omit_none=True) - for service in (SERVICE_UPDATE_TODO, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_HABIT): + for service in ( + SERVICE_UPDATE_DAILY, + SERVICE_UPDATE_HABIT, + SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, + ): hass.services.async_register( DOMAIN, service, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index acbe4e62824..46b3211790e 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -268,7 +268,7 @@ update_todo: task: *task rename: *rename notes: *notes - checklist_options: + checklist_options: &checklist_options collapsed: true fields: add_checklist_item: &add_checklist_item @@ -320,7 +320,7 @@ update_todo: text: type: datetime-local multiple: true - clear_reminder: + clear_reminder: &clear_reminder required: false selector: constant: @@ -339,3 +339,93 @@ create_todo: reminder: *reminder tag: *tag developer_options: *developer_options +update_daily: + fields: + config_entry: *config_entry + task: *task + rename: *rename + notes: *notes + checklist_options: *checklist_options + priority: *priority + start_date: + required: false + selector: + date: + frequency: + required: false + selector: + select: + options: + - "daily" + - "weekly" + - "monthly" + - "yearly" + translation_key: "frequency" + mode: dropdown + every_x: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "🔃" + mode: box + repeat_weekly_options: + collapsed: true + fields: + repeat: + required: false + selector: + select: + options: + - "m" + - "t" + - "w" + - "th" + - "f" + - "s" + - "su" + mode: list + translation_key: repeat + multiple: true + repeat_monthly_options: + collapsed: true + fields: + repeat_monthly: + required: false + selector: + select: + options: + - "day_of_month" + - "day_of_week" + translation_key: repeat_monthly + mode: list + reminder_options: + collapsed: true + fields: + reminder: + required: false + selector: + text: + type: time + multiple: true + remove_reminder: + required: false + selector: + text: + type: time + multiple: true + clear_reminder: *clear_reminder + tag_options: *tag_options + developer_options: + collapsed: true + fields: + streak: + required: false + selector: + number: + min: 0 + step: 1 + unit_of_measurement: "▶▶" + mode: box + alias: *alias diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 513c0b36b27..cc67b767519 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -51,7 +51,8 @@ "reminder_options_name": "Reminders", "reminder_options_description": "Add, remove or clear reminders of a Habitica task.", "date_name": "Due date", - "date_description": "The to-do's due date." + "date_description": "The to-do's due date.", + "repeat_name": "Repeat on" }, "config": { "abort": { @@ -1037,6 +1038,122 @@ "description": "[%key:component::habitica::common::developer_options_description%]" } } + }, + "update_daily": { + "name": "Update a daily", + "description": "Updates a specific daily for a selected Habitica character", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::common::config_entry_description%]" + }, + "task": { + "name": "[%key:component::habitica::common::task_name%]", + "description": "The name (or task ID) of the daily you want to update." + }, + "rename": { + "name": "[%key:component::habitica::common::rename_name%]", + "description": "[%key:component::habitica::common::rename_description%]" + }, + "notes": { + "name": "[%key:component::habitica::common::notes_name%]", + "description": "[%key:component::habitica::common::notes_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%]" + }, + "priority": { + "name": "[%key:component::habitica::common::priority_name%]", + "description": "[%key:component::habitica::common::priority_description%]" + }, + "start_date": { + "name": "Start date", + "description": "Defines when the daily task becomes active and specifies the exact weekday or day of the month it repeats on." + }, + "frequency": { + "name": "Repeat interval", + "description": "The repetition interval of a daily." + }, + "every_x": { + "name": "Repeat every X", + "description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily')." + }, + "repeat": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "The days of the week the daily repeats." + }, + "repeat_monthly": { + "name": "[%key:component::habitica::common::repeat_name%]", + "description": "Whether a monthly recurring task repeats on the same calendar day each month or on the same weekday and week of the month, based on the start date." + }, + "add_checklist_item": { + "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "description": "[%key:component::habitica::common::add_checklist_item_description%]" + }, + "remove_checklist_item": { + "name": "[%key:component::habitica::common::remove_checklist_item_name%]", + "description": "[%key:component::habitica::common::remove_checklist_item_description%]" + }, + "score_checklist_item": { + "name": "[%key:component::habitica::common::score_checklist_item_name%]", + "description": "[%key:component::habitica::common::score_checklist_item_description%]" + }, + "unscore_checklist_item": { + "name": "[%key:component::habitica::common::unscore_checklist_item_name%]", + "description": "[%key:component::habitica::common::unscore_checklist_item_description%]" + }, + "streak": { + "name": "Adjust streak", + "description": "Adjust or reset the streak counter of the daily." + }, + "reminder": { + "name": "[%key:component::habitica::common::reminder_name%]", + "description": "[%key:component::habitica::common::reminder_description%]" + }, + "remove_reminder": { + "name": "[%key:component::habitica::common::remove_reminder_name%]", + "description": "[%key:component::habitica::common::remove_reminder_description%]" + }, + "clear_reminder": { + "name": "[%key:component::habitica::common::clear_reminder_name%]", + "description": "[%key:component::habitica::common::clear_reminder_description%]" + } + }, + "sections": { + "checklist_options": { + "name": "[%key:component::habitica::common::checklist_options_name%]", + "description": "[%key:component::habitica::common::checklist_options_description%]" + }, + "repeat_weekly_options": { + "name": "Weekly repeat days", + "description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly." + }, + "repeat_monthly_options": { + "name": "Monthly repeat day", + "description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly." + }, + "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%]" + }, + "reminder_options": { + "name": "[%key:component::habitica::common::reminder_options_name%]", + "description": "[%key:component::habitica::common::reminder_options_description%]" + } + } } }, "selector": { @@ -1079,6 +1196,23 @@ "monthly": "Monthly", "yearly": "Yearly" } + }, + "repeat": { + "options": { + "m": "[%key:common::time::monday%]", + "t": "[%key:common::time::tuesday%]", + "w": "[%key:common::time::wednesday%]", + "th": "[%key:common::time::thursday%]", + "f": "[%key:common::time::friday%]", + "s": "[%key:common::time::saturday%]", + "su": "[%key:common::time::sunday%]" + } + }, + "repeat_monthly": { + "options": { + "day_of_month": "Day of the month", + "day_of_week": "Day of the week" + } } } } diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 3dff57bdd51..085508b4432 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -605,7 +605,18 @@ "startDate": "2024-09-20T23:00:00.000Z", "daysOfMonth": [], "weeksOfMonth": [3], - "checklist": [], + "checklist": [ + { + "completed": false, + "id": "a2a6702d-58e1-46c2-a3ce-422d525cc0b6", + "text": "Checklist-item1" + }, + { + "completed": true, + "id": "9f64e1cd-b0ab-4577-8344-c7a5e1827997", + "text": "Checklist-item2" + } + ], "reminders": [], "createdAt": "2024-10-10T15:57:14.304Z", "updatedAt": "2024-11-27T23:47:29.986Z", diff --git a/tests/components/habitica/snapshots/test_services.ambr b/tests/components/habitica/snapshots/test_services.ambr index af0ec76f3a4..430cd379c0d 100644 --- a/tests/components/habitica/snapshots/test_services.ambr +++ b/tests/components/habitica/snapshots/test_services.ambr @@ -1116,6 +1116,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -3378,6 +3388,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -4511,6 +4531,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, @@ -5092,6 +5122,16 @@ 'winner': None, }), 'checklist': list([ + dict({ + 'completed': False, + 'id': 'a2a6702d-58e1-46c2-a3ce-422d525cc0b6', + 'text': 'Checklist-item1', + }), + dict({ + 'completed': True, + 'id': '9f64e1cd-b0ab-4577-8344-c7a5e1827997', + 'text': 'Checklist-item2', + }), ]), 'collapseChecklist': False, 'completed': False, diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 238cb8412ba..258346b9ca7 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -1,18 +1,20 @@ """Test Habitica actions.""" from collections.abc import Generator -from datetime import datetime +from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, patch from uuid import UUID from aiohttp import ClientError +from freezegun.api import freeze_time from habiticalib import ( Checklist, Direction, Frequency, HabiticaTaskResponse, Reminders, + Repeat, Skill, Task, TaskPriority, @@ -32,6 +34,7 @@ from homeassistant.components.habitica.const import ( ATTR_COUNTER_UP, ATTR_DIRECTION, ATTR_FREQUENCY, + ATTR_INTERVAL, ATTR_ITEM, ATTR_KEYWORD, ATTR_NOTES, @@ -40,8 +43,12 @@ from homeassistant.components.habitica.const import ( ATTR_REMOVE_CHECKLIST_ITEM, ATTR_REMOVE_REMINDER, ATTR_REMOVE_TAG, + ATTR_REPEAT, + ATTR_REPEAT_MONTHLY, ATTR_SCORE_CHECKLIST_ITEM, ATTR_SKILL, + ATTR_START_DATE, + ATTR_STREAK, ATTR_TAG, ATTR_TARGET, ATTR_TASK, @@ -63,6 +70,7 @@ from homeassistant.components.habitica.const import ( SERVICE_SCORE_REWARD, SERVICE_START_QUEST, SERVICE_TRANSFORMATION, + SERVICE_UPDATE_DAILY, SERVICE_UPDATE_HABIT, SERVICE_UPDATE_REWARD, SERVICE_UPDATE_TODO, @@ -952,6 +960,7 @@ async def test_get_tasks( (SERVICE_UPDATE_REWARD, "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"), (SERVICE_UPDATE_HABIT, "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a"), (SERVICE_UPDATE_TODO, "88de7cd9-af2b-49ce-9afd-bf941d87336b"), + (SERVICE_UPDATE_DAILY, "6e53f1f5-a315-4edd-984d-8d762e4a08ef"), ], ) @pytest.mark.usefixtures("habitica") @@ -1606,6 +1615,270 @@ async def test_create_todo( habitica.create_task.assert_awaited_with(call_args) +@pytest.mark.parametrize( + ("service_data", "call_args"), + [ + ( + { + ATTR_RENAME: "RENAME", + }, + Task(text="RENAME"), + ), + ( + { + ATTR_NOTES: "NOTES", + }, + Task(notes="NOTES"), + ), + ( + { + ATTR_ADD_CHECKLIST_ITEM: "Checklist-item", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("a2a6702d-58e1-46c2-a3ce-422d525cc0b6"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=True, + ), + Checklist( + id=UUID("12345678-1234-5678-1234-567812345678"), + text="Checklist-item", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_REMOVE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_SCORE_CHECKLIST_ITEM: "Checklist-item1", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("a2a6702d-58e1-46c2-a3ce-422d525cc0b6"), + text="Checklist-item1", + completed=True, + ), + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=True, + ), + ] + } + ), + ), + ( + { + ATTR_UNSCORE_CHECKLIST_ITEM: "Checklist-item2", + }, + Task( + { + "checklist": [ + Checklist( + id=UUID("a2a6702d-58e1-46c2-a3ce-422d525cc0b6"), + text="Checklist-item1", + completed=False, + ), + Checklist( + id=UUID("9f64e1cd-b0ab-4577-8344-c7a5e1827997"), + text="Checklist-item2", + completed=False, + ), + ] + } + ), + ), + ( + { + ATTR_PRIORITY: "trivial", + }, + Task(priority=TaskPriority.TRIVIAL), + ), + ( + { + ATTR_START_DATE: "2025-03-05", + }, + Task(startDate=datetime(2025, 3, 5)), + ), + ( + { + ATTR_FREQUENCY: "weekly", + }, + Task(frequency=Frequency.WEEKLY), + ), + ( + { + ATTR_INTERVAL: 5, + }, + Task(everyX=5), + ), + ( + { + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT: ["m", "t", "w", "th"], + }, + Task( + frequency=Frequency.WEEKLY, + repeat=Repeat(m=True, t=True, w=True, th=True), + ), + ), + ( + { + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_month", + }, + Task(frequency=Frequency.MONTHLY, daysOfMonth=[20], weeksOfMonth=[]), + ), + ( + { + ATTR_FREQUENCY: "monthly", + ATTR_REPEAT_MONTHLY: "day_of_week", + }, + Task( + frequency=Frequency.MONTHLY, + daysOfMonth=[], + weeksOfMonth=[2], + repeat=Repeat( + m=False, t=False, w=False, th=False, f=True, s=False, su=False + ), + ), + ), + ( + { + ATTR_REMINDER: ["10:00"], + }, + Task( + { + "reminders": [ + Reminders( + id=UUID("12345678-1234-5678-1234-567812345678"), + time=datetime(2025, 2, 25, 10, 0, tzinfo=UTC), + startDate=None, + ) + ] + } + ), + ), + ( + { + ATTR_REMOVE_REMINDER: ["10:00"], + }, + Task({"reminders": []}), + ), + ( + { + ATTR_CLEAR_REMINDER: True, + }, + Task({"reminders": []}), + ), + ( + { + ATTR_STREAK: 10, + }, + Task(streak=10), + ), + ( + { + ATTR_ALIAS: "ALIAS", + }, + Task(alias="ALIAS"), + ), + ], +) +@pytest.mark.usefixtures("mock_uuid4") +@freeze_time("2025-02-25T22:00:00.000Z") +async def test_update_daily( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], + call_args: Task, +) -> None: + """Test Habitica update daily action.""" + task_id = "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_DAILY, + 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) + + +@pytest.mark.parametrize( + "service_data", + [ + { + ATTR_FREQUENCY: "daily", + ATTR_REPEAT: ["m", "t", "w", "th"], + }, + { + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT_MONTHLY: "day_of_month", + }, + { + ATTR_FREQUENCY: "weekly", + ATTR_REPEAT_MONTHLY: "day_of_week", + }, + ], +) +@pytest.mark.usefixtures("mock_uuid4") +@freeze_time("2025-02-25T22:00:00.000Z") +async def test_update_daily_service_validation_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + service_data: dict[str, Any], +) -> None: + """Test Habitica update daily action.""" + task_id = "6e53f1f5-a315-4edd-984d-8d762e4a08ef" + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_DAILY, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: task_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + async def test_tags( hass: HomeAssistant, config_entry: MockConfigEntry, From 588366a514f3017cc0e17517cd133c9a55142803 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Mar 2025 16:55:42 +0100 Subject: [PATCH 1670/1941] Add setup function to improv_ble (#140594) --- homeassistant/components/improv_ble/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/improv_ble/__init__.py b/homeassistant/components/improv_ble/__init__.py index 985684cb5b8..ff40b65a8d0 100644 --- a/homeassistant/components/improv_ble/__init__.py +++ b/homeassistant/components/improv_ble/__init__.py @@ -1 +1,11 @@ """The Improv BLE integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up improv_ble from a config entry.""" + raise NotImplementedError From 2951eb5cc8e6c9be9ba16e2a9c5263ba9d57747d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:03:42 +0100 Subject: [PATCH 1671/1941] Fix len-test (PLC1802) (#140600) --- homeassistant/components/thethingsnetwork/sensor.py | 2 +- pyproject.toml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index ba512d07f18..5aa851d99ae 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -40,7 +40,7 @@ async def async_setup_entry( if (device_id, field_id) not in sensors and isinstance(ttn_value, TTNSensorValue) } - if len(new_sensors): + if new_sensors: async_add_entities(new_sensors.values()) sensors.update(new_sensors.keys()) diff --git a/pyproject.toml b/pyproject.toml index a9548844e62..6003b3d1de3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -856,8 +856,6 @@ ignore = [ # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605", - - "PLC1802", # disabled temporarily on ruff 0.10.0 update ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] From 160b98bd2858aa3f98efd4382be9249f58cc9458 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Fri, 14 Mar 2025 17:24:39 +0100 Subject: [PATCH 1672/1941] Fix media_player Toggle when in idle (#78192) * Remove idle as off state * Fix merge mistake * Fix merge mistake --------- Co-authored-by: Erik Montnemery --- homeassistant/components/media_player/__init__.py | 1 - tests/components/media_player/test_async_helpers.py | 10 +++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a30b01694fa..45d08bea7ce 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1031,7 +1031,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self.state in { MediaPlayerState.OFF, - MediaPlayerState.IDLE, MediaPlayerState.STANDBY, }: await self.async_turn_on() diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 680603c097d..3ab79db73e1 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -69,6 +69,10 @@ class SimpleMediaPlayer(mp.MediaPlayerEntity): """Put device in standby.""" self._state = STATE_STANDBY + def idle(self): + """Put device in idle.""" + self._state = STATE_IDLE + class ExtendedMediaPlayer(SimpleMediaPlayer): """Media player test class.""" @@ -92,7 +96,7 @@ class ExtendedMediaPlayer(SimpleMediaPlayer): def toggle(self): """Toggle the power on the media player.""" - if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: + if self._state in [STATE_OFF, STATE_STANDBY]: self._state = STATE_ON else: self._state = STATE_OFF @@ -187,3 +191,7 @@ async def test_toggle(player) -> None: assert player.state == STATE_STANDBY await player.async_toggle() assert player.state == STATE_ON + player.idle() + assert player.state == STATE_IDLE + await player.async_toggle() + assert player.state == STATE_OFF From 8964af428ae71d8e792d01ee42cdb38dff422b6b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 14 Mar 2025 19:20:18 +0100 Subject: [PATCH 1673/1941] Add missing translations for `options` attribute in AccuWeather integration (#140610) Add missing translations for options attribute --- homeassistant/components/accuweather/strings.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index d9777352b93..92428a9d599 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -106,6 +106,15 @@ "steady": "Steady", "rising": "Rising", "falling": "Falling" + }, + "state_attributes": { + "options": { + "state": { + "falling": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::falling%]", + "rising": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::rising%]", + "steady": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::steady%]" + } + } } }, "ragweed_pollen": { From 59cab7cd588d677f01d32738e4d9074020fab9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 14 Mar 2025 19:35:13 +0100 Subject: [PATCH 1674/1941] Add 700 RPM option to washer spin speed options at Home Connect (#140607) Add 700 RPM option to washer spin speed options --- homeassistant/components/home_connect/const.py | 1 + homeassistant/components/home_connect/services.yaml | 2 ++ homeassistant/components/home_connect/strings.json | 2 ++ 3 files changed, 5 insertions(+) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 999bb5da13d..1c607ccec28 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -284,6 +284,7 @@ SPIN_SPEED_OPTIONS = { "LaundryCare.Washer.EnumType.SpinSpeed.Off", "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM700", "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", "LaundryCare.Washer.EnumType.SpinSpeed.RPM900", "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 91b0089d653..613b3f5af3a 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -559,7 +559,9 @@ set_program_and_options: - laundry_care_washer_enum_type_spin_speed_off - laundry_care_washer_enum_type_spin_speed_r_p_m400 - laundry_care_washer_enum_type_spin_speed_r_p_m600 + - laundry_care_washer_enum_type_spin_speed_r_p_m700 - laundry_care_washer_enum_type_spin_speed_r_p_m800 + - laundry_care_washer_enum_type_spin_speed_r_p_m900 - laundry_care_washer_enum_type_spin_speed_r_p_m1000 - laundry_care_washer_enum_type_spin_speed_r_p_m1200 - laundry_care_washer_enum_type_spin_speed_r_p_m1400 diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index ec95f5fdb92..8d377ac9e04 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -460,6 +460,7 @@ "laundry_care_washer_enum_type_spin_speed_off": "Off", "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m700": "700 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm", "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", @@ -1430,6 +1431,7 @@ "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_m700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m700%]", "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_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]", "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%]", From b07c28126a687e02b828fd65bb34c2c949516cd6 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 14 Mar 2025 23:42:10 +0100 Subject: [PATCH 1675/1941] Bump pyOverkiz to 1.16.3 (#140621) Bump Overkiz to 1.16.3 --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 07ec02d76a6..70857f0ba11 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.2"], + "requirements": ["pyoverkiz==1.16.3"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index cadf1be7645..916bdcee3c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.2 +pyoverkiz==1.16.3 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0637d2a737a..929437bb7bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.2 +pyoverkiz==1.16.3 # homeassistant.components.onewire pyownet==0.10.0.post1 From 537302ce56e0b27429bf69e29d85e2855e23e271 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 14 Mar 2025 19:28:02 -0400 Subject: [PATCH 1676/1941] ZBT-1 and Yellow firmware update entities for Zigbee/Thread (#138505) * Initial implementation of hardware update model * Fixes * WIP: change the `homeassistant_sky_connect` integration type * More fixes * WIP * Display firmware info in the device page * Make progress more responsive * WIP: Yellow * Abstract the bootloader reset type * Clean up comments * Make the Yellow integration non-hardware * Use the correct radio device for Yellow * Avoid hardcoding strings * Use `FIRMWARE_VERSION` within config flows * Fix up unit tests * Revert integration type changes * Rewrite hardware ownership context manager name, for clarity * Move manifest parsing logic into a new package Pass the correct type to the firmware API library * Create and delete entities instead of mutating the entity description * Move entity replacement into a `async_setup_entry` callback * Change update entity category from "diagnostic" to "config" * Have the client library handle firmware fetching * Switch from dispatcher to `async_on_state_change` * Remove unnecessary type annotation on base update entity * Simplify state recomputation * Remove device registry code, since the devices will not be visible * Further simplify state computation * Give the device-less update entity a more descriptive name * Limit state changes to integer increments when sending firmware update progress * Re-raise `HomeAssistantError` if there is a problem during flashing * Remove unnecessary state write during entity creation * Rename `_maybe_recompute_state` to `_update_attributes` * Bump the flasher to 0.0.30 * Add some tests * Ensure the update entity has a sensible name * Initial ZBT-1 unit tests * Replace `_update_config_entry_after_install` with a more explicit `_firmware_info_callback` override * Write the firmware version to the config entry as well * Test the hardware update platform independently * Add unit tests to the Yellow and ZBT-1 integrations * Load firmware info from the config entry when creating the update entity * Test entity state restoration * Test the reloading of integrations marked as "owning" * Test installation failure cases * Test firmware type change callback failure case * Address review comments --- .../homeassistant_hardware/coordinator.py | 47 ++ .../homeassistant_hardware/manifest.json | 5 +- .../homeassistant_hardware/update.py | 331 +++++++++ .../components/homeassistant_hardware/util.py | 42 +- .../homeassistant_sky_connect/__init__.py | 25 +- .../homeassistant_sky_connect/config_flow.py | 53 +- .../homeassistant_sky_connect/const.py | 14 + .../homeassistant_sky_connect/update.py | 169 +++++ .../homeassistant_yellow/__init__.py | 16 +- .../homeassistant_yellow/config_flow.py | 12 +- .../components/homeassistant_yellow/const.py | 8 + .../components/homeassistant_yellow/update.py | 172 +++++ requirements_all.txt | 5 +- .../test_coordinator.py | 55 ++ .../homeassistant_hardware/test_update.py | 637 ++++++++++++++++++ .../homeassistant_hardware/test_util.py | 148 ++++ .../homeassistant_sky_connect/common.py | 21 + .../test_config_flow.py | 26 +- .../homeassistant_sky_connect/test_init.py | 3 +- .../homeassistant_sky_connect/test_update.py | 86 +++ .../homeassistant_yellow/test_config_flow.py | 3 +- .../homeassistant_yellow/test_update.py | 89 +++ 22 files changed, 1916 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/homeassistant_hardware/coordinator.py create mode 100644 homeassistant/components/homeassistant_hardware/update.py create mode 100644 homeassistant/components/homeassistant_sky_connect/update.py create mode 100644 homeassistant/components/homeassistant_yellow/update.py create mode 100644 tests/components/homeassistant_hardware/test_coordinator.py create mode 100644 tests/components/homeassistant_hardware/test_update.py create mode 100644 tests/components/homeassistant_sky_connect/common.py create mode 100644 tests/components/homeassistant_sky_connect/test_update.py create mode 100644 tests/components/homeassistant_yellow/test_update.py diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py new file mode 100644 index 00000000000..9eb900b13fd --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/coordinator.py @@ -0,0 +1,47 @@ +"""Home Assistant hardware firmware update coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiohttp import ClientSession +from ha_silabs_firmware_client import ( + FirmwareManifest, + FirmwareUpdateClient, + ManifestMissing, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_REFRESH_INTERVAL = timedelta(hours=8) + + +class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]): + """Coordinator to manage firmware updates.""" + + def __init__(self, hass: HomeAssistant, session: ClientSession, url: str) -> None: + """Initialize the firmware update coordinator.""" + super().__init__( + hass, + _LOGGER, + name="firmware update coordinator", + update_interval=FIRMWARE_REFRESH_INTERVAL, + always_update=False, + ) + self.hass = hass + self.session = session + + self.client = FirmwareUpdateClient(url, session) + + async def _async_update_data(self) -> FirmwareManifest: + try: + return await self.client.async_update_data() + except ManifestMissing as err: + raise UpdateFailed( + "GitHub release assets haven't been uploaded yet" + ) from err diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index 8f59ab61600..f3a02185b83 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -5,5 +5,8 @@ "codeowners": ["@home-assistant/core"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", - "requirements": ["universal-silabs-flasher==0.0.29"] + "requirements": [ + "universal-silabs-flasher==0.0.30", + "ha-silabs-firmware-client==0.2.0" + ] } diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py new file mode 100644 index 00000000000..e835286238f --- /dev/null +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -0,0 +1,331 @@ +"""Home Assistant Hardware base firmware update entity.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +import logging +from typing import Any, cast + +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +from universal_silabs_flasher.firmware import parse_firmware_image +from universal_silabs_flasher.flasher import Flasher +from yarl import URL + +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.restore_state import ExtraStoredData +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import FirmwareUpdateCoordinator +from .helpers import async_register_firmware_info_callback +from .util import ( + ApplicationType, + FirmwareInfo, + guess_firmware_info, + probe_silabs_firmware_info, +) + +_LOGGER = logging.getLogger(__name__) + +type FirmwareChangeCallbackType = Callable[ + [ApplicationType | None, ApplicationType | None], None +] + + +@dataclass(kw_only=True, frozen=True) +class FirmwareUpdateEntityDescription(UpdateEntityDescription): + """Describes Home Assistant Hardware firmware update entity.""" + + version_parser: Callable[[str], str] + fw_type: str | None + version_key: str | None + expected_firmware_type: ApplicationType | None + firmware_name: str | None + + +@dataclass +class FirmwareUpdateExtraStoredData(ExtraStoredData): + """Extra stored data for Home Assistant Hardware firmware update entity.""" + + firmware_manifest: FirmwareManifest | None = None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "firmware_manifest": ( + self.firmware_manifest.as_dict() + if self.firmware_manifest is not None + else None + ) + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> FirmwareUpdateExtraStoredData: + """Initialize the extra data from a dict.""" + if data["firmware_manifest"] is None: + return cls(firmware_manifest=None) + + return cls( + FirmwareManifest.from_json( + data["firmware_manifest"], + # This data is not technically part of the manifest and is loaded externally + url=URL(data["firmware_manifest"]["url"]), + html_url=URL(data["firmware_manifest"]["html_url"]), + ) + ) + + +class BaseFirmwareUpdateEntity( + CoordinatorEntity[FirmwareUpdateCoordinator], UpdateEntity +): + """Base Home Assistant Hardware firmware update entity.""" + + # Subclasses provide the mapping between firmware types and entity descriptions + entity_description: FirmwareUpdateEntityDescription + bootloader_reset_type: str | None = None + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + # Until this entity can be associated with a device, we must manually name it + _attr_has_entity_name = False + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the Hardware firmware update entity.""" + super().__init__(update_coordinator) + + self.entity_description = entity_description + self._current_device = device + self._config_entry = config_entry + self._current_firmware_info: FirmwareInfo | None = None + self._firmware_type_change_callbacks: set[FirmwareChangeCallbackType] = set() + + self._latest_manifest: FirmwareManifest | None = None + self._latest_firmware: FirmwareMetadata | None = None + + def add_firmware_type_changed_callback( + self, + change_callback: FirmwareChangeCallbackType, + ) -> CALLBACK_TYPE: + """Add a callback for when the firmware type changes.""" + self._firmware_type_change_callbacks.add(change_callback) + + @callback + def remove_callback() -> None: + self._firmware_type_change_callbacks.discard(change_callback) + + return remove_callback + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + self.async_on_remove( + async_register_firmware_info_callback( + self.hass, + self._current_device, + self._firmware_info_callback, + ) + ) + + self.async_on_remove( + self._config_entry.async_on_state_change(self._on_config_entry_change) + ) + + if (extra_data := await self.async_get_last_extra_data()) and ( + hardware_extra_data := FirmwareUpdateExtraStoredData.from_dict( + extra_data.as_dict() + ) + ): + self._latest_manifest = hardware_extra_data.firmware_manifest + + self._update_attributes() + + @property + def extra_restore_state_data(self) -> FirmwareUpdateExtraStoredData: + """Return state data to be restored.""" + return FirmwareUpdateExtraStoredData(firmware_manifest=self._latest_manifest) + + @callback + def _on_config_entry_change(self) -> None: + """Handle config entry changes.""" + self._update_attributes() + self.async_write_ha_state() + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self._current_firmware_info = firmware_info + + # If the firmware type does not change, we can just update the attributes + if ( + self._current_firmware_info.firmware_type + == self.entity_description.expected_firmware_type + ): + self._update_attributes() + self.async_write_ha_state() + return + + # Otherwise, fire the firmware type change callbacks. They are expected to + # replace the entity so there is no purpose in firing other callbacks. + for change_callback in self._firmware_type_change_callbacks.copy(): + try: + change_callback( + self.entity_description.expected_firmware_type, + self._current_firmware_info.firmware_type, + ) + except Exception: # noqa: BLE001 + _LOGGER.warning( + "Failed to call firmware type changed callback", exc_info=True + ) + + def _update_attributes(self) -> None: + """Recompute the attributes of the entity.""" + + # This entity is not currently associated with a device so we must manually + # give it a name + self._attr_name = f"{self._config_entry.title} Update" + self._attr_title = self.entity_description.firmware_name or "unknown" + + if ( + self._current_firmware_info is None + or self._current_firmware_info.firmware_version is None + ): + self._attr_installed_version = None + else: + self._attr_installed_version = self.entity_description.version_parser( + self._current_firmware_info.firmware_version + ) + + self._latest_firmware = None + self._attr_latest_version = None + self._attr_release_summary = None + self._attr_release_url = None + + if ( + self._latest_manifest is None + or self.entity_description.fw_type is None + or self.entity_description.version_key is None + ): + return + + try: + self._latest_firmware = next( + f + for f in self._latest_manifest.firmwares + if f.filename.startswith(self.entity_description.fw_type) + ) + except StopIteration: + pass + else: + version = cast( + str, self._latest_firmware.metadata[self.entity_description.version_key] + ) + self._attr_latest_version = self.entity_description.version_parser(version) + self._attr_release_summary = self._latest_firmware.release_notes + self._attr_release_url = str(self._latest_manifest.html_url) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._latest_manifest = self.coordinator.data + self._update_attributes() + self.async_write_ha_state() + + def _update_progress(self, offset: int, total_size: int) -> None: + """Handle update progress.""" + + # Firmware updates in ~30s so we still get responsive update progress even + # without decimal places + self._attr_update_percentage = round((offset * 100) / total_size) + self.async_write_ha_state() + + @asynccontextmanager + async def _temporarily_stop_hardware_owners( + self, device: str + ) -> AsyncIterator[None]: + """Temporarily stop addons and integrations communicating with the device.""" + firmware_info = await guess_firmware_info(self.hass, device) + _LOGGER.debug("Identified firmware info: %s", firmware_info) + + async with AsyncExitStack() as stack: + for owner in firmware_info.owners: + await stack.enter_async_context(owner.temporarily_stop(self.hass)) + + yield + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + assert self._latest_firmware is not None + assert self.entity_description.expected_firmware_type is not None + + # Start off by setting the progress bar to an indeterminate state + self._attr_in_progress = True + self._attr_update_percentage = None + self.async_write_ha_state() + + fw_data = await self.coordinator.client.async_fetch_firmware( + self._latest_firmware + ) + fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data) + + device = self._current_device + + flasher = Flasher( + device=device, + probe_methods=( + ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), + ApplicationType.EZSP.as_flasher_application_type(), + ApplicationType.SPINEL.as_flasher_application_type(), + ApplicationType.CPC.as_flasher_application_type(), + ), + bootloader_reset=self.bootloader_reset_type, + ) + + async with self._temporarily_stop_hardware_owners(device): + try: + try: + # Enter the bootloader with indeterminate progress + await flasher.enter_bootloader() + + # Flash the firmware, with progress + await flasher.flash_firmware( + fw_image, progress_callback=self._update_progress + ) + except Exception as err: + raise HomeAssistantError("Failed to flash firmware") from err + + # Probe the running application type with indeterminate progress + self._attr_update_percentage = None + self.async_write_ha_state() + + firmware_info = await probe_silabs_firmware_info( + device, + probe_methods=(self.entity_description.expected_firmware_type,), + ) + + if firmware_info is None: + raise HomeAssistantError( + "Failed to probe the firmware after flashing" + ) + + self._firmware_info_callback(firmware_info) + finally: + self._attr_in_progress = False + self.async_write_ha_state() diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 1afb786369e..64f363e4f23 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -4,7 +4,8 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable +from collections.abc import AsyncIterator, Iterable +from contextlib import asynccontextmanager from dataclasses import dataclass from enum import StrEnum import logging @@ -105,6 +106,28 @@ class OwningAddon: else: return addon_info.state == AddonState.RUNNING + @asynccontextmanager + async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]: + """Temporarily stop the add-on, restarting it after completion.""" + addon_manager = self._get_addon_manager(hass) + + try: + addon_info = await addon_manager.async_get_addon_info() + except AddonError: + yield + return + + if addon_info.state != AddonState.RUNNING: + yield + return + + try: + await addon_manager.async_stop_addon() + await addon_manager.async_wait_until_addon_state(AddonState.NOT_RUNNING) + yield + finally: + await addon_manager.async_start_addon_waiting() + @dataclass(kw_only=True) class OwningIntegration: @@ -123,6 +146,23 @@ class OwningIntegration: ConfigEntryState.SETUP_IN_PROGRESS, ) + @asynccontextmanager + async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]: + """Temporarily stop the integration, restarting it after completion.""" + if (entry := hass.config_entries.async_get_entry(self.config_entry_id)) is None: + yield + return + + if entry.state != ConfigEntryState.LOADED: + yield + return + + try: + await hass.config_entries.async_unload(entry.entry_id) + yield + finally: + await hass.config_entries.async_setup(entry.entry_id) + @dataclass(kw_only=True) class FirmwareInfo: diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 758f0c1e1ef..b3af47df61d 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -8,11 +8,16 @@ from homeassistant.components.homeassistant_hardware.util import guess_firmware_ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from .const import DESCRIPTION, DEVICE, FIRMWARE, FIRMWARE_VERSION, PRODUCT + _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + return True @@ -33,15 +38,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Add-on startup with type service get started before Core, always (e.g. the # Multi-Protocol add-on). Probing the firmware would interfere with the add-on, # so we can't safely probe here. Instead, we must make an educated guess! - firmware_guess = await guess_firmware_info( - hass, config_entry.data["device"] - ) + firmware_guess = await guess_firmware_info(hass, config_entry.data[DEVICE]) new_data = {**config_entry.data} - new_data["firmware"] = firmware_guess.firmware_type.value + new_data[FIRMWARE] = firmware_guess.firmware_type.value # Copy `description` to `product` - new_data["product"] = new_data["description"] + new_data[PRODUCT] = new_data[DESCRIPTION] hass.config_entries.async_update_entry( config_entry, @@ -50,6 +53,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version=2, ) + if config_entry.minor_version == 2: + # Add a `firmware_version` key + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + FIRMWARE_VERSION: None, + }, + version=1, + minor_version=3, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index d8446c2d3f9..d28d74a681c 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -24,7 +24,20 @@ from homeassistant.config_entries import ( from homeassistant.core import callback from homeassistant.helpers.service_info.usb import UsbServiceInfo -from .const import DOCS_WEB_FLASHER_URL, DOMAIN, HardwareVariant +from .const import ( + DESCRIPTION, + DEVICE, + DOCS_WEB_FLASHER_URL, + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + MANUFACTURER, + PID, + PRODUCT, + SERIAL_NUMBER, + VID, + HardwareVariant, +) from .util import get_hardware_variant, get_usb_service_info _LOGGER = logging.getLogger(__name__) @@ -37,6 +50,7 @@ if TYPE_CHECKING: def _get_translation_placeholders(self) -> dict[str, str]: return {} + else: # Multiple inheritance with `Protocol` seems to break TranslationPlaceholderProtocol = object @@ -67,7 +81,7 @@ class HomeAssistantSkyConnectConfigFlow( """Handle a config flow for Home Assistant SkyConnect.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize the config flow.""" @@ -82,7 +96,7 @@ class HomeAssistantSkyConnectConfigFlow( config_entry: ConfigEntry, ) -> OptionsFlow: """Return the options flow.""" - firmware_type = ApplicationType(config_entry.data["firmware"]) + firmware_type = ApplicationType(config_entry.data[FIRMWARE]) if firmware_type is ApplicationType.CPC: return HomeAssistantSkyConnectMultiPanOptionsFlowHandler(config_entry) @@ -100,7 +114,7 @@ class HomeAssistantSkyConnectConfigFlow( unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}" if await self.async_set_unique_id(unique_id): - self._abort_if_unique_id_configured(updates={"device": device}) + self._abort_if_unique_id_configured(updates={DEVICE: device}) discovery_info.device = await self.hass.async_add_executor_job( usb.get_serial_by_id, discovery_info.device @@ -126,14 +140,15 @@ class HomeAssistantSkyConnectConfigFlow( return self.async_create_entry( title=self._hw_variant.full_name, data={ - "vid": self._usb_info.vid, - "pid": self._usb_info.pid, - "serial_number": self._usb_info.serial_number, - "manufacturer": self._usb_info.manufacturer, - "description": self._usb_info.description, # For backwards compatibility - "product": self._usb_info.description, - "device": self._usb_info.device, - "firmware": self._probed_firmware_info.firmware_type.value, + VID: self._usb_info.vid, + PID: self._usb_info.pid, + SERIAL_NUMBER: self._usb_info.serial_number, + MANUFACTURER: self._usb_info.manufacturer, + DESCRIPTION: self._usb_info.description, # For backwards compatibility + PRODUCT: self._usb_info.description, + DEVICE: self._usb_info.device, + FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, }, ) @@ -148,7 +163,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( ) -> silabs_multiprotocol_addon.SerialPortSettings: """Return the radio serial port settings.""" return silabs_multiprotocol_addon.SerialPortSettings( - device=self.config_entry.data["device"], + device=self.config_entry.data[DEVICE], baudrate="115200", flow_control=True, ) @@ -182,7 +197,8 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( entry=self.config_entry, data={ **self.config_entry.data, - "firmware": ApplicationType.EZSP.value, + FIRMWARE: ApplicationType.EZSP.value, + FIRMWARE_VERSION: None, }, options=self.config_entry.options, ) @@ -201,15 +217,15 @@ class HomeAssistantSkyConnectOptionsFlowHandler( self._usb_info = get_usb_service_info(self.config_entry) self._hw_variant = HardwareVariant.from_usb_product_name( - self.config_entry.data["product"] + self.config_entry.data[PRODUCT] ) self._hardware_name = self._hw_variant.full_name self._device = self._usb_info.device self._probed_firmware_info = FirmwareInfo( device=self._device, - firmware_type=ApplicationType(self.config_entry.data["firmware"]), - firmware_version=None, + firmware_type=ApplicationType(self.config_entry.data[FIRMWARE]), + firmware_version=self.config_entry.data[FIRMWARE_VERSION], source="guess", owners=[], ) @@ -225,7 +241,8 @@ class HomeAssistantSkyConnectOptionsFlowHandler( entry=self.config_entry, data={ **self.config_entry.data, - "firmware": self._probed_firmware_info.firmware_type.value, + FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, }, options=self.config_entry.options, ) diff --git a/homeassistant/components/homeassistant_sky_connect/const.py b/homeassistant/components/homeassistant_sky_connect/const.py index cae0b98a25b..70ff047366d 100644 --- a/homeassistant/components/homeassistant_sky_connect/const.py +++ b/homeassistant/components/homeassistant_sky_connect/const.py @@ -7,6 +7,20 @@ from typing import Self DOMAIN = "homeassistant_sky_connect" DOCS_WEB_FLASHER_URL = "https://skyconnect.home-assistant.io/firmware-update/" +NABU_CASA_FIRMWARE_RELEASES_URL = ( + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" +) + +FIRMWARE = "firmware" +FIRMWARE_VERSION = "firmware_version" +SERIAL_NUMBER = "serial_number" +MANUFACTURER = "manufacturer" +PRODUCT = "product" +DESCRIPTION = "description" +PID = "pid" +VID = "vid" +DEVICE = "device" + @dataclasses.dataclass(frozen=True) class VariantInfo: diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py new file mode 100644 index 00000000000..43e3f1ca255 --- /dev/null +++ b/homeassistant/components/homeassistant_sky_connect/update.py @@ -0,0 +1,169 @@ +"""Home Assistant SkyConnect firmware update entity.""" + +from __future__ import annotations + +import logging + +import aiohttp + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import FIRMWARE, FIRMWARE_VERSION, NABU_CASA_FIRMWARE_RELEASES_URL + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="skyconnect_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="skyconnect_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + None: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> FirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data[FIRMWARE] + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) if firmware_type is not None else None + ] + + entity = FirmwareUpdateEntity( + device=config_entry.data["device"], + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """SkyConnect firmware update entity.""" + + bootloader_reset_type = None + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the SkyConnect firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + + self._attr_unique_id = ( + f"{self._config_entry.data['serial_number']}_{self.entity_description.key}" + ) + + # Use the cached firmware info if it exists + if self._config_entry.data[FIRMWARE] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]), + firmware_version=self._config_entry.data[FIRMWARE_VERSION], + owners=[], + source="homeassistant_sky_connect", + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + FIRMWARE: firmware_info.firmware_type, + FIRMWARE_VERSION: firmware_info.firmware_version, + }, + ) + super()._firmware_info_callback(firmware_info) diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index b0837eeedbe..06f908ab61e 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import discovery_flow from homeassistant.helpers.hassio import is_hassio -from .const import FIRMWARE, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA +from .const import FIRMWARE, FIRMWARE_VERSION, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data=ZHA_HW_DISCOVERY_DATA, ) + await hass.config_entries.async_forward_entry_setups(entry, ["update"]) + return True @@ -87,6 +89,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> minor_version=2, ) + if config_entry.minor_version == 2: + # Add a `firmware_version` key + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + FIRMWARE_VERSION: None, + }, + version=1, + minor_version=3, + ) + _LOGGER.debug( "Migration to version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index b916c6e46ca..5472c346e94 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -37,7 +37,14 @@ from homeassistant.config_entries import ( from homeassistant.core import HomeAssistant, async_get_hass, callback from homeassistant.helpers import discovery_flow, selector -from .const import DOMAIN, FIRMWARE, RADIO_DEVICE, ZHA_DOMAIN, ZHA_HW_DISCOVERY_DATA +from .const import ( + DOMAIN, + FIRMWARE, + FIRMWARE_VERSION, + RADIO_DEVICE, + ZHA_DOMAIN, + ZHA_HW_DISCOVERY_DATA, +) from .hardware import BOARD_NAME _LOGGER = logging.getLogger(__name__) @@ -55,7 +62,7 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Yellow.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self, *args: Any, **kwargs: Any) -> None: """Instantiate config flow.""" @@ -310,6 +317,7 @@ class HomeAssistantYellowOptionsFlowHandler( data={ **self.config_entry.data, FIRMWARE: self._probed_firmware_info.firmware_type.value, + FIRMWARE_VERSION: self._probed_firmware_info.firmware_version, }, ) diff --git a/homeassistant/components/homeassistant_yellow/const.py b/homeassistant/components/homeassistant_yellow/const.py index 79753ae9b9e..b98b1133d01 100644 --- a/homeassistant/components/homeassistant_yellow/const.py +++ b/homeassistant/components/homeassistant_yellow/const.py @@ -2,7 +2,10 @@ DOMAIN = "homeassistant_yellow" +RADIO_MODEL = "Home Assistant Yellow" +RADIO_MANUFACTURER = "Nabu Casa" RADIO_DEVICE = "/dev/ttyAMA1" + ZHA_HW_DISCOVERY_DATA = { "name": "Yellow", "port": { @@ -14,4 +17,9 @@ ZHA_HW_DISCOVERY_DATA = { } FIRMWARE = "firmware" +FIRMWARE_VERSION = "firmware_version" ZHA_DOMAIN = "zha" + +NABU_CASA_FIRMWARE_RELEASES_URL = ( + "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest" +) diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py new file mode 100644 index 00000000000..88d4f2912d3 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/update.py @@ -0,0 +1,172 @@ +"""Home Assistant Yellow firmware update entity.""" + +from __future__ import annotations + +import logging + +import aiohttp + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + FIRMWARE, + FIRMWARE_VERSION, + NABU_CASA_FIRMWARE_RELEASES_URL, + RADIO_DEVICE, +) + +_LOGGER = logging.getLogger(__name__) + + +FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="yellow_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="yellow_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + None: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> FirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data[FIRMWARE] + entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) if firmware_type is not None else None + ] + + entity = FirmwareUpdateEntity( + device=RADIO_DEVICE, + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + NABU_CASA_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """Yellow firmware update entity.""" + + bootloader_reset_type = "yellow" # Triggers a GPIO reset + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the Yellow firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + + self._attr_unique_id = self.entity_description.key + + # Use the cached firmware info if it exists + if self._config_entry.data[FIRMWARE] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]), + firmware_version=self._config_entry.data[FIRMWARE_VERSION], + owners=[], + source="homeassistant_yellow", + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + FIRMWARE: firmware_info.firmware_type, + FIRMWARE_VERSION: firmware_info.firmware_version, + }, + ) + super()._firmware_info_callback(firmware_info) diff --git a/requirements_all.txt b/requirements_all.txt index 916bdcee3c4..f02cdb56fc9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1105,6 +1105,9 @@ ha-iotawattpy==0.1.2 # homeassistant.components.philips_js ha-philipsjs==3.2.2 +# homeassistant.components.homeassistant_hardware +ha-silabs-firmware-client==0.2.0 + # homeassistant.components.habitica habiticalib==0.3.7 @@ -2974,7 +2977,7 @@ unifi_ap==0.0.2 unifiled==0.11 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.29 +universal-silabs-flasher==0.0.30 # homeassistant.components.upb upb-lib==0.6.1 diff --git a/tests/components/homeassistant_hardware/test_coordinator.py b/tests/components/homeassistant_hardware/test_coordinator.py new file mode 100644 index 00000000000..9c57aac6811 --- /dev/null +++ b/tests/components/homeassistant_hardware/test_coordinator.py @@ -0,0 +1,55 @@ +"""Test firmware update coordinator for Home Assistant Hardware.""" + +from unittest.mock import AsyncMock, Mock, call, patch + +from ha_silabs_firmware_client import FirmwareManifest, ManifestMissing +import pytest +from yarl import URL + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util + + +async def test_firmware_update_coordinator_fetching( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the firmware update coordinator loads manifests.""" + session = async_get_clientsession(hass) + + manifest = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=(), + ) + + mock_client = Mock() + mock_client.async_update_data = AsyncMock(side_effect=[ManifestMissing(), manifest]) + + with patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + return_value=mock_client, + ): + coordinator = FirmwareUpdateCoordinator( + hass, session, "https://example.org/firmware" + ) + + listener = Mock() + coordinator.async_add_listener(listener) + + # The first update will fail + await coordinator.async_refresh() + assert listener.mock_calls == [call()] + assert coordinator.data is None + assert "GitHub release assets haven't been uploaded yet" in caplog.text + + # The second will succeed + await coordinator.async_refresh() + assert listener.mock_calls == [call(), call()] + assert coordinator.data == manifest + + await coordinator.async_shutdown() diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py new file mode 100644 index 00000000000..0c351141e12 --- /dev/null +++ b/tests/components/homeassistant_hardware/test_update.py @@ -0,0 +1,637 @@ +"""Test Home Assistant Hardware firmware update entity.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator +import dataclasses +import logging +from unittest.mock import AsyncMock, Mock, patch + +import aiohttp +from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata +import pytest +from yarl import URL + +from homeassistant.components.homeassistant_hardware.coordinator import ( + FirmwareUpdateCoordinator, +) +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, + async_register_firmware_info_provider, +) +from homeassistant.components.homeassistant_hardware.update import ( + BaseFirmwareUpdateEntity, + FirmwareUpdateEntityDescription, + FirmwareUpdateExtraStoredData, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, + OwningIntegration, +) +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import EVENT_STATE_CHANGED, EntityCategory +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + HomeAssistantError, + State, + callback, +) +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + async_capture_events, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache_with_extra_data, +) + +TEST_DOMAIN = "test" +TEST_DEVICE = "/dev/serial/by-id/some-unique-serial-device-12345" +TEST_FIRMWARE_RELEASES_URL = "https://example.org/firmware" +TEST_UPDATE_ENTITY_ID = "update.test_firmware" +TEST_MANIFEST = FirmwareManifest( + url=URL("https://example.org/firmware"), + html_url=URL("https://example.org/release_notes"), + created_at=dt_util.utcnow(), + firmwares=( + FirmwareMetadata( + filename="skyconnect_zigbee_ncp_test.gbl", + checksum="aaa", + size=123, + release_notes="Some release notes go here", + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, + url=URL("https://example.org/firmwares/skyconnect_zigbee_ncp_test.gbl"), + ), + ), +) + + +TEST_FIRMWARE_ENTITY_DESCRIPTIONS: dict[ + ApplicationType | None, FirmwareUpdateEntityDescription +] = { + ApplicationType.EZSP: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split(" ", 1)[0], + fw_type="skyconnect_zigbee_ncp", + version_key="ezsp_version", + expected_firmware_type=ApplicationType.EZSP, + firmware_name="EmberZNet", + ), + ApplicationType.SPINEL: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0], + fw_type="skyconnect_openthread_rcp", + version_key="ot_rcp_version", + expected_firmware_type=ApplicationType.SPINEL, + firmware_name="OpenThread RCP", + ), + None: FirmwareUpdateEntityDescription( + key="firmware", + display_precision=0, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + version_parser=lambda fw: fw, + fw_type=None, + version_key=None, + expected_firmware_type=None, + firmware_name=None, + ), +} + + +def _mock_async_create_update_entity( + hass: HomeAssistant, + config_entry: ConfigEntry, + session: aiohttp.ClientSession, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> MockFirmwareUpdateEntity: + """Create an update entity that handles firmware type changes.""" + firmware_type = config_entry.data["firmware"] + entity_description = TEST_FIRMWARE_ENTITY_DESCRIPTIONS[ + ApplicationType(firmware_type) if firmware_type is not None else None + ] + + entity = MockFirmwareUpdateEntity( + device=config_entry.data["device"], + config_entry=config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + TEST_FIRMWARE_RELEASES_URL, + ), + entity_description=entity_description, + ) + + def firmware_type_changed( + old_type: ApplicationType | None, new_type: ApplicationType | None + ) -> None: + """Replace the current entity when the firmware type changes.""" + er.async_get(hass).async_remove(entity.entity_id) + async_add_entities( + [ + _mock_async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + ] + ) + + entity.async_on_remove( + entity.add_firmware_type_changed_callback(firmware_type_changed) + ) + + return entity + + +async def mock_async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, ["update"]) + return True + + +async def mock_async_setup_update_entities( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the firmware update config entry.""" + session = async_get_clientsession(hass) + entity = _mock_async_create_update_entity( + hass, config_entry, session, async_add_entities + ) + + async_add_entities([entity]) + + +class MockFirmwareUpdateEntity(BaseFirmwareUpdateEntity): + """Mock SkyConnect firmware update entity.""" + + bootloader_reset_type = None + + def __init__( + self, + device: str, + config_entry: ConfigEntry, + update_coordinator: FirmwareUpdateCoordinator, + entity_description: FirmwareUpdateEntityDescription, + ) -> None: + """Initialize the mock SkyConnect firmware update entity.""" + super().__init__(device, config_entry, update_coordinator, entity_description) + self._attr_unique_id = self.entity_description.key + + # Use the cached firmware info if it exists + if self._config_entry.data["firmware"] is not None: + self._current_firmware_info = FirmwareInfo( + device=device, + firmware_type=ApplicationType(self._config_entry.data["firmware"]), + firmware_version=self._config_entry.data["firmware_version"], + owners=[], + source=TEST_DOMAIN, + ) + + @callback + def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None: + """Handle updated firmware info being pushed by an integration.""" + super()._firmware_info_callback(firmware_info) + + self.hass.config_entries.async_update_entry( + self._config_entry, + data={ + **self._config_entry.data, + "firmware": firmware_info.firmware_type, + "firmware_version": firmware_info.firmware_version, + }, + ) + + +@pytest.fixture(name="update_config_entry") +async def mock_update_config_entry( + hass: HomeAssistant, +) -> AsyncGenerator[ConfigEntry]: + """Set up a mock Home Assistant Hardware firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, "homeassistant_hardware", {}) + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=mock_async_setup_entry, + ), + built_in=False, + ) + mock_platform(hass, "test.config_flow") + mock_platform( + hass, + "test.update", + MockPlatform(async_setup_entry=mock_async_setup_update_entities), + ) + + # Set up a mock integration using the hardware update entity + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "device": TEST_DEVICE, + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + }, + ) + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.coordinator.FirmwareUpdateClient", + autospec=True, + ) as mock_update_client, + mock_config_flow(TEST_DOMAIN, ConfigFlow), + ): + mock_update_client.return_value.async_update_data.return_value = TEST_MANIFEST + yield config_entry + + +async def test_update_entity_installation( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test the Hardware firmware update entity installation.""" + + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + # Set up another integration communicating with the device + owning_config_entry = MockConfigEntry( + domain="another_integration", + data={ + "device": { + "path": TEST_DEVICE, + "flow_control": "hardware", + "baudrate": 115200, + }, + "radio_type": "ezsp", + }, + version=4, + ) + owning_config_entry.add_to_hass(hass) + owning_config_entry.mock_state(hass, ConfigEntryState.LOADED) + + # The integration provides firmware info + mock_hw_module = Mock() + mock_hw_module.get_firmware_info = lambda hass, config_entry: FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[OwningIntegration(config_entry_id=config_entry.entry_id)], + source="another_integration", + ) + + async_register_firmware_info_provider(hass, "another_integration", mock_hw_module) + + # Pretend the other integration loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "another_integration", + mock_hw_module.get_firmware_info(hass, owning_config_entry), + ) + + state_before_update = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_before_update is not None + assert state_before_update.state == "unknown" + assert state_before_update.attributes["title"] == "EmberZNet" + assert state_before_update.attributes["installed_version"] == "7.3.1.0" + assert state_before_update.attributes["latest_version"] is None + + # When we check for an update, one will be shown + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + state_after_update = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_update is not None + assert state_after_update.state == "on" + assert state_after_update.attributes["title"] == "EmberZNet" + assert state_after_update.attributes["installed_version"] == "7.3.1.0" + assert state_after_update.attributes["latest_version"] == "7.4.4.0" + assert state_after_update.attributes["release_summary"] == ( + "Some release notes go here" + ) + assert state_after_update.attributes["release_url"] == ( + "https://example.org/release_notes" + ) + + mock_firmware = Mock() + mock_flasher = AsyncMock() + + async def mock_flash_firmware(fw_image, progress_callback): + await asyncio.sleep(0) + progress_callback(0, 100) + await asyncio.sleep(0) + progress_callback(50, 100) + await asyncio.sleep(0) + progress_callback(100, 100) + + mock_flasher.flash_firmware = mock_flash_firmware + + # When we install it, the other integration is reloaded + with ( + patch( + "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", + return_value=mock_firmware, + ), + patch( + "homeassistant.components.homeassistant_hardware.update.Flasher", + return_value=mock_flasher, + ), + patch( + "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", + return_value=FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ), + ), + patch.object( + owning_config_entry, "async_unload", wraps=owning_config_entry.async_unload + ) as owning_config_entry_unload, + ): + state_changes: list[Event[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) + await hass.services.async_call( + "update", + "install", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + # Progress events are emitted during the installation + assert len(state_changes) == 7 + + # Indeterminate progress first + assert state_changes[0].data["new_state"].attributes["in_progress"] is True + assert state_changes[0].data["new_state"].attributes["update_percentage"] is None + + # Then the update starts + assert state_changes[1].data["new_state"].attributes["update_percentage"] == 0 + assert state_changes[2].data["new_state"].attributes["update_percentage"] == 50 + assert state_changes[3].data["new_state"].attributes["update_percentage"] == 100 + + # Once it is done, we probe the firmware + assert state_changes[4].data["new_state"].attributes["in_progress"] is True + assert state_changes[4].data["new_state"].attributes["update_percentage"] is None + + # Finally, the update finishes + assert state_changes[5].data["new_state"].attributes["update_percentage"] is None + assert state_changes[6].data["new_state"].attributes["update_percentage"] is None + assert state_changes[6].data["new_state"].attributes["in_progress"] is False + + # The owning integration was unloaded and is again running + assert len(owning_config_entry_unload.mock_calls) == 1 + + # After the firmware update, the entity has the new version and the correct state + state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_install is not None + assert state_after_install.state == "off" + assert state_after_install.attributes["title"] == "EmberZNet" + assert state_after_install.attributes["installed_version"] == "7.4.4.0" + assert state_after_install.attributes["latest_version"] == "7.4.4.0" + + +async def test_update_entity_installation_failure( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test installation failing during flashing.""" + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + state_before_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_before_install is not None + assert state_before_install.state == "on" + assert state_before_install.attributes["title"] == "EmberZNet" + assert state_before_install.attributes["installed_version"] == "7.3.1.0" + assert state_before_install.attributes["latest_version"] == "7.4.4.0" + + mock_flasher = AsyncMock() + mock_flasher.flash_firmware.side_effect = RuntimeError( + "Something broke during flashing!" + ) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", + return_value=Mock(), + ), + patch( + "homeassistant.components.homeassistant_hardware.update.Flasher", + return_value=mock_flasher, + ), + pytest.raises(HomeAssistantError, match="Failed to flash firmware"), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + # After the firmware update fails, we can still try again + state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_install is not None + assert state_after_install.state == "on" + assert state_after_install.attributes["title"] == "EmberZNet" + assert state_after_install.attributes["installed_version"] == "7.3.1.0" + assert state_after_install.attributes["latest_version"] == "7.4.4.0" + + +async def test_update_entity_installation_probe_failure( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test installation failing during post-flashing probing.""" + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + state_before_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_before_install is not None + assert state_before_install.state == "on" + assert state_before_install.attributes["title"] == "EmberZNet" + assert state_before_install.attributes["installed_version"] == "7.3.1.0" + assert state_before_install.attributes["latest_version"] == "7.4.4.0" + + with ( + patch( + "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", + return_value=Mock(), + ), + patch( + "homeassistant.components.homeassistant_hardware.update.Flasher", + return_value=AsyncMock(), + ), + patch( + "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", + return_value=None, + ), + pytest.raises( + HomeAssistantError, match="Failed to probe the firmware after flashing" + ), + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": TEST_UPDATE_ENTITY_ID}, + blocking=True, + ) + + # After the firmware update fails, we can still try again + state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state_after_install is not None + assert state_after_install.state == "on" + assert state_after_install.attributes["title"] == "EmberZNet" + assert state_after_install.attributes["installed_version"] == "7.3.1.0" + assert state_after_install.attributes["latest_version"] == "7.4.4.0" + + +async def test_update_entity_state_restoration( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test the Hardware firmware update entity state restoration.""" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State(TEST_UPDATE_ENTITY_ID, "on"), + FirmwareUpdateExtraStoredData( + firmware_manifest=TEST_MANIFEST + ).as_dict(), + ) + ], + ) + + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + # The state is correctly restored + state = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state is not None + assert state.state == "on" + assert state.attributes["title"] == "EmberZNet" + assert state.attributes["installed_version"] == "7.3.1.0" + assert state.attributes["latest_version"] == "7.4.4.0" + assert state.attributes["release_summary"] == ("Some release notes go here") + assert state.attributes["release_url"] == ("https://example.org/release_notes") + + +async def test_update_entity_firmware_missing_from_manifest( + hass: HomeAssistant, update_config_entry: ConfigEntry +) -> None: + """Test the Hardware firmware update entity handles missing firmware.""" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State(TEST_UPDATE_ENTITY_ID, "on"), + # Ensure the manifest does not contain our expected firmware type + FirmwareUpdateExtraStoredData( + firmware_manifest=dataclasses.replace(TEST_MANIFEST, firmwares=()) + ).as_dict(), + ) + ], + ) + + assert await hass.config_entries.async_setup(update_config_entry.entry_id) + await hass.async_block_till_done() + + # The state is restored, accounting for the missing firmware + state = hass.states.get(TEST_UPDATE_ENTITY_ID) + assert state is not None + assert state.state == "unknown" + assert state.attributes["title"] == "EmberZNet" + assert state.attributes["installed_version"] == "7.3.1.0" + assert state.attributes["latest_version"] is None + assert state.attributes["release_summary"] is None + assert state.attributes["release_url"] is None + + +async def test_update_entity_graceful_firmware_type_callback_errors( + hass: HomeAssistant, + update_config_entry: ConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test firmware update entity handling of firmware type callback errors.""" + + session = async_get_clientsession(hass) + update_entity = MockFirmwareUpdateEntity( + device=TEST_DEVICE, + config_entry=update_config_entry, + update_coordinator=FirmwareUpdateCoordinator( + hass, + session, + TEST_FIRMWARE_RELEASES_URL, + ), + entity_description=TEST_FIRMWARE_ENTITY_DESCRIPTIONS[ApplicationType.EZSP], + ) + update_entity.hass = hass + await update_entity.async_added_to_hass() + + callback = Mock(side_effect=RuntimeError("Callback failed")) + unregister_callback = update_entity.add_firmware_type_changed_callback(callback) + + with caplog.at_level(logging.WARNING): + await async_notify_firmware_info( + hass, + "some_integration", + FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="probe", + ), + ) + + unregister_callback() + assert "Failed to call firmware type changed callback" in caplog.text diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index b467380c431..1b7bfe4a8ac 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -205,6 +205,93 @@ async def test_owning_addon(hass: HomeAssistant) -> None: assert (await owning_addon.is_running(hass)) is False +async def test_owning_addon_temporarily_stop_info_error(hass: HomeAssistant) -> None: + """Test `OwningAddon` temporarily stopping with an info error.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + mock_manager = AsyncMock() + mock_manager.async_get_addon_info.side_effect = AddonError() + + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", + return_value=mock_manager, + ): + async with owning_addon.temporarily_stop(hass): + pass + + # We never restart it + assert len(mock_manager.async_get_addon_info.mock_calls) == 1 + assert len(mock_manager.async_stop_addon.mock_calls) == 0 + assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 0 + assert len(mock_manager.async_start_addon_waiting.mock_calls) == 0 + + +async def test_owning_addon_temporarily_stop_not_running(hass: HomeAssistant) -> None: + """Test `OwningAddon` temporarily stopping when the addon is not running.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + + mock_manager = AsyncMock() + mock_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + + with patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", + return_value=mock_manager, + ): + async with owning_addon.temporarily_stop(hass): + pass + + # We never restart it + assert len(mock_manager.async_get_addon_info.mock_calls) == 1 + assert len(mock_manager.async_stop_addon.mock_calls) == 0 + assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 0 + assert len(mock_manager.async_start_addon_waiting.mock_calls) == 0 + + +async def test_owning_addon_temporarily_stop(hass: HomeAssistant) -> None: + """Test `OwningAddon` temporarily stopping when the addon is running.""" + + owning_addon = OwningAddon(slug="some-addon-slug") + + mock_manager = AsyncMock() + mock_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname="core_some_addon_slug", + options={}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ) + + mock_manager.async_stop_addon = AsyncMock() + mock_manager.async_wait_until_addon_state = AsyncMock() + mock_manager.async_start_addon_waiting = AsyncMock() + + # The error is propagated but it doesn't affect restarting the addon + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.WaitingAddonManager", + return_value=mock_manager, + ), + pytest.raises(RuntimeError), + ): + async with owning_addon.temporarily_stop(hass): + raise RuntimeError("Some error") + + # We restart it + assert len(mock_manager.async_get_addon_info.mock_calls) == 1 + assert len(mock_manager.async_stop_addon.mock_calls) == 1 + assert len(mock_manager.async_wait_until_addon_state.mock_calls) == 1 + assert len(mock_manager.async_start_addon_waiting.mock_calls) == 1 + + async def test_owning_integration(hass: HomeAssistant) -> None: """Test `OwningIntegration`.""" config_entry = MockConfigEntry(domain="mock_domain", unique_id="some_unique_id") @@ -225,6 +312,67 @@ async def test_owning_integration(hass: HomeAssistant) -> None: assert (await owning_integration2.is_running(hass)) is False +async def test_owning_integration_temporarily_stop_missing_entry( + hass: HomeAssistant, +) -> None: + """Test temporarily stopping the integration when the config entry doesn't exist.""" + missing_integration = OwningIntegration(config_entry_id="missing_entry_id") + + with ( + patch.object(hass.config_entries, "async_unload") as mock_unload, + patch.object(hass.config_entries, "async_setup") as mock_setup, + ): + async with missing_integration.temporarily_stop(hass): + pass + + # Because there's no matching entry, no unload or setup calls are made + assert len(mock_unload.mock_calls) == 0 + assert len(mock_setup.mock_calls) == 0 + + +async def test_owning_integration_temporarily_stop_not_loaded( + hass: HomeAssistant, +) -> None: + """Test temporarily stopping the integration when the config entry is not loaded.""" + entry = MockConfigEntry(domain="test_domain") + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.NOT_LOADED) + + integration = OwningIntegration(config_entry_id=entry.entry_id) + + with ( + patch.object(hass.config_entries, "async_unload") as mock_unload, + patch.object(hass.config_entries, "async_setup") as mock_setup, + ): + async with integration.temporarily_stop(hass): + pass + + # Since the entry was not loaded, we never unload or re-setup + assert len(mock_unload.mock_calls) == 0 + assert len(mock_setup.mock_calls) == 0 + + +async def test_owning_integration_temporarily_stop_loaded(hass: HomeAssistant) -> None: + """Test temporarily stopping the integration when the config entry is loaded.""" + entry = MockConfigEntry(domain="test_domain") + entry.add_to_hass(hass) + entry.mock_state(hass, ConfigEntryState.LOADED) + + integration = OwningIntegration(config_entry_id=entry.entry_id) + + with ( + patch.object(hass.config_entries, "async_unload") as mock_unload, + patch.object(hass.config_entries, "async_setup") as mock_setup, + pytest.raises(RuntimeError), + ): + async with integration.temporarily_stop(hass): + raise RuntimeError("Some error during the temporary stop") + + # We expect one unload followed by one setup call + mock_unload.assert_called_once_with(entry.entry_id) + mock_setup.assert_called_once_with(entry.entry_id) + + async def test_firmware_info(hass: HomeAssistant) -> None: """Test `FirmwareInfo`.""" diff --git a/tests/components/homeassistant_sky_connect/common.py b/tests/components/homeassistant_sky_connect/common.py new file mode 100644 index 00000000000..335fd6d2e12 --- /dev/null +++ b/tests/components/homeassistant_sky_connect/common.py @@ -0,0 +1,21 @@ +"""Common constants for the SkyConnect integration tests.""" + +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +USB_DATA_SKY = UsbServiceInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="9e2adbd75b8beb119fe564a0f320645d", + manufacturer="Nabu Casa", + description="SkyConnect v1.0", +) + +USB_DATA_ZBT1 = UsbServiceInfo( + device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", + vid="10C4", + pid="EA60", + serial_number="9e2adbd75b8beb119fe564a0f320645d", + manufacturer="Nabu Casa", + description="Home Assistant Connect ZBT-1", +) diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index d8542002ae8..44a5e0029c3 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -22,26 +22,10 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo +from .common import USB_DATA_SKY, USB_DATA_ZBT1 + from tests.common import MockConfigEntry -USB_DATA_SKY = UsbServiceInfo( - device="/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", - vid="10C4", - pid="EA60", - serial_number="9e2adbd75b8beb119fe564a0f320645d", - manufacturer="Nabu Casa", - description="SkyConnect v1.0", -) - -USB_DATA_ZBT1 = UsbServiceInfo( - device="/dev/serial/by-id/usb-Nabu_Casa_Home_Assistant_Connect_ZBT-1_9e2adbd75b8beb119fe564a0f320645d-if00-port0", - vid="10C4", - pid="EA60", - serial_number="9e2adbd75b8beb119fe564a0f320645d", - manufacturer="Nabu Casa", - description="Home Assistant Connect ZBT-1", -) - @pytest.mark.parametrize( ("usb_data", "model"), @@ -76,7 +60,7 @@ async def test_config_flow( return_value=FirmwareInfo( device=usb_data.device, firmware_type=ApplicationType.EZSP, - firmware_version=None, + firmware_version="7.4.4.0 build 0", owners=[], source="probe", ), @@ -92,6 +76,7 @@ async def test_config_flow( config_entry = result["result"] assert config_entry.data == { "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", "device": usb_data.device, "manufacturer": usb_data.manufacturer, "pid": usb_data.pid, @@ -161,7 +146,7 @@ async def test_options_flow( return_value=FirmwareInfo( device=usb_data.device, firmware_type=ApplicationType.EZSP, - firmware_version=None, + firmware_version="7.4.4.0 build 0", owners=[], source="probe", ), @@ -177,6 +162,7 @@ async def test_options_flow( assert config_entry.data == { "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", "device": usb_data.device, "manufacturer": usb_data.manufacturer, "pid": usb_data.pid, diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 8e90039a4fc..c467a9e0d60 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -44,7 +44,7 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(config_entry.entry_id) assert config_entry.version == 1 - assert config_entry.minor_version == 2 + assert config_entry.minor_version == 3 assert config_entry.data == { "description": "SkyConnect v1.0", "device": "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0", @@ -54,6 +54,7 @@ async def test_config_entry_migration_v2(hass: HomeAssistant) -> None: "manufacturer": "Nabu Casa", "product": "SkyConnect v1.0", # `description` has been copied to `product` "firmware": "spinel", # new key + "firmware_version": None, # new key } await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/homeassistant_sky_connect/test_update.py b/tests/components/homeassistant_sky_connect/test_update.py new file mode 100644 index 00000000000..9fb7528987e --- /dev/null +++ b/tests/components/homeassistant_sky_connect/test_update.py @@ -0,0 +1,86 @@ +"""Test SkyConnect firmware update entity.""" + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import USB_DATA_ZBT1 + +from tests.common import MockConfigEntry + +UPDATE_ENTITY_ID = ( + "update.homeassistant_sky_connect_9e2adbd75b8beb119fe564a0f320645d_firmware" +) + + +async def test_zbt1_update_entity(hass: HomeAssistant) -> None: + """Test the ZBT-1 firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the ZBT-1 integration + zbt1_config_entry = MockConfigEntry( + domain="homeassistant_sky_connect", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": USB_DATA_ZBT1.device, + "manufacturer": USB_DATA_ZBT1.manufacturer, + "pid": USB_DATA_ZBT1.pid, + "product": USB_DATA_ZBT1.description, + "serial_number": USB_DATA_ZBT1.serial_number, + "vid": USB_DATA_ZBT1.vid, + }, + version=1, + minor_version=3, + ) + zbt1_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(zbt1_config_entry.entry_id) + await hass.async_block_till_done() + + # Pretend ZHA loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=USB_DATA_ZBT1.device, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp.state == "unknown" + assert state_ezsp.attributes["title"] == "EmberZNet" + assert state_ezsp.attributes["installed_version"] == "7.3.1.0" + assert state_ezsp.attributes["latest_version"] is None + + # Now, have OTBR push some info + await async_notify_firmware_info( + hass, + "otbr", + FirmwareInfo( + device=USB_DATA_ZBT1.device, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="otbr", + ), + ) + await hass.async_block_till_done() + + # After the firmware update, the entity has the new version and the correct state + state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel.state == "unknown" + assert state_spinel.attributes["title"] == "OpenThread RCP" + assert state_spinel.attributes["installed_version"] == "2.4.4.0" + assert state_spinel.attributes["latest_version"] is None diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 78fd45c6b5b..46fec0a1f30 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -350,7 +350,7 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: return_value=FirmwareInfo( device=RADIO_DEVICE, firmware_type=ApplicationType.EZSP, - firmware_version=None, + firmware_version="7.4.4.0 build 0", owners=[], source="probe", ), @@ -366,6 +366,7 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: assert config_entry.data == { "firmware": "ezsp", + "firmware_version": "7.4.4.0 build 0", } diff --git a/tests/components/homeassistant_yellow/test_update.py b/tests/components/homeassistant_yellow/test_update.py new file mode 100644 index 00000000000..269ff2afc49 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_update.py @@ -0,0 +1,89 @@ +"""Test Yellow firmware update entity.""" + +from unittest.mock import patch + +from homeassistant.components.homeassistant_hardware.helpers import ( + async_notify_firmware_info, +) +from homeassistant.components.homeassistant_hardware.util import ( + ApplicationType, + FirmwareInfo, +) +from homeassistant.components.homeassistant_yellow.const import RADIO_DEVICE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +UPDATE_ENTITY_ID = "update.homeassistant_yellow_firmware" + + +async def test_yellow_update_entity(hass: HomeAssistant) -> None: + """Test the Yellow firmware update entity.""" + await async_setup_component(hass, "homeassistant", {}) + + # Set up the Yellow integration + yellow_config_entry = MockConfigEntry( + domain="homeassistant_yellow", + data={ + "firmware": "ezsp", + "firmware_version": "7.3.1.0 build 0", + "device": RADIO_DEVICE, + }, + version=1, + minor_version=3, + ) + yellow_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.homeassistant_yellow.is_hassio", return_value=True + ), + patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ), + ): + assert await hass.config_entries.async_setup(yellow_config_entry.entry_id) + await hass.async_block_till_done() + + # Pretend ZHA loaded and notified hardware of the running firmware + await async_notify_firmware_info( + hass, + "zha", + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.3.1.0 build 0", + owners=[], + source="zha", + ), + ) + await hass.async_block_till_done() + + state_ezsp = hass.states.get(UPDATE_ENTITY_ID) + assert state_ezsp.state == "unknown" + assert state_ezsp.attributes["title"] == "EmberZNet" + assert state_ezsp.attributes["installed_version"] == "7.3.1.0" + assert state_ezsp.attributes["latest_version"] is None + + # Now, have OTBR push some info + await async_notify_firmware_info( + hass, + "otbr", + FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=ApplicationType.SPINEL, + firmware_version="SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4; EFR32; Oct 21 2024 14:40:57", + owners=[], + source="otbr", + ), + ) + await hass.async_block_till_done() + + # After the firmware update, the entity has the new version and the correct state + state_spinel = hass.states.get(UPDATE_ENTITY_ID) + assert state_spinel.state == "unknown" + assert state_spinel.attributes["title"] == "OpenThread RCP" + assert state_spinel.attributes["installed_version"] == "2.4.4.0" + assert state_spinel.attributes["latest_version"] is None From 11e15b1405f651cd1f8f2293194e2be9be9bcebf Mon Sep 17 00:00:00 2001 From: Jeff Terrace Date: Fri, 14 Mar 2025 20:16:35 -0400 Subject: [PATCH 1677/1941] Move redundant attribute and key error handling to event parser caller (#140630) --- homeassistant/components/onvif/event.py | 13 +- homeassistant/components/onvif/parsers.py | 867 ++++++++++------------ tests/components/onvif/test_parsers.py | 38 +- 3 files changed, 420 insertions(+), 498 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index b7b34f7be9f..d1b93304ccc 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -174,11 +174,20 @@ class EventManager: UNHANDLED_TOPICS.add(topic) continue - event = await parser(unique_id, msg) + try: + event = await parser(unique_id, msg) + error = None + except (AttributeError, KeyError) as e: + event = None + error = e if not event: LOGGER.warning( - "%s: Unable to parse event from %s: %s", self.name, unique_id, msg + "%s: Unable to parse event from %s: %s: %s", + self.name, + unique_id, + error, + msg, ) return diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 6eb1d001796..7544f92292a 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -54,19 +54,16 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/MotionAlarm """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Motion Alarm", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Motion Alarm", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService") @@ -77,20 +74,17 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBlurry/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Blurry", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Image Too Blurry", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService") @@ -101,20 +95,17 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooDark/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Dark", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Image Too Dark", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService") @@ -125,20 +116,17 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBright/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Image Too Bright", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Image Too Bright", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService") @@ -149,19 +137,16 @@ async def async_parse_scene_change(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/GlobalSceneChange/* """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Global Scene Change", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Global Scene Change", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") @@ -170,29 +155,26 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: Topic: tns1:AudioAnalytics/Audio/DetectedSound """ - try: - audio_source = "" - audio_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "AudioSourceConfigurationToken": - audio_source = source.Value - if source.Name == "AudioAnalyticsConfigurationToken": - audio_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + audio_source = "" + audio_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "AudioSourceConfigurationToken": + audio_source = source.Value + if source.Name == "AudioAnalyticsConfigurationToken": + audio_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", - "Detected Sound", - "binary_sensor", - "sound", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", + "Detected Sound", + "binary_sensor", + "sound", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") @@ -201,30 +183,26 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/FieldDetector/ObjectsInside """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - evt = Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Field Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None - return evt + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Field Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") @@ -233,29 +211,26 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/CellMotionDetector/Motion """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Cell Motion Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Cell Motion Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion") @@ -264,29 +239,26 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MotionRegionDetector/Motion """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Motion Region Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value in ["1", "true"], - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Motion Region Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value in ["1", "true"], + ) @PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") @@ -295,30 +267,27 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/TamperDetector/Tamper """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Tamper Detection", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Tamper Detection", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect") @@ -327,23 +296,20 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/DogCatDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Pet Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Pet Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect") @@ -352,23 +318,20 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/VehicleDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Vehicle Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Vehicle Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) _TAPO_EVENT_TEMPLATES: dict[str, Event] = { @@ -420,32 +383,28 @@ async def async_parse_tplink_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/PeopleDetector/People Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - for item in payload.Data.SimpleItem: - event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None) - if event_template is None: - continue + for item in payload.Data.SimpleItem: + event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None) + if event_template is None: + continue - return dataclasses.replace( - event_template, - uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - value=item.Value == "true", - ) - - except (AttributeError, KeyError): - return None + return dataclasses.replace( + event_template, + uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + value=item.Value == "true", + ) return None @@ -456,23 +415,20 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/PeopleDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Person Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Person Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect") @@ -481,23 +437,20 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/FaceDetect """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Face Detection", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Face Detection", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor") @@ -506,23 +459,20 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/MyRuleDetector/Visitor """ - try: - video_source = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "Source": - video_source = _normalize_video_source(source.Value) + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) - return Event( - f"{uid}_{topic}_{video_source}", - "Visitor Detection", - "binary_sensor", - "occupancy", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Visitor Detection", + "binary_sensor", + "occupancy", + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:Device/Trigger/DigitalInput") @@ -531,19 +481,16 @@ async def async_parse_digital_input(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/DigitalInput """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Digital Input", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Digital Input", + "binary_sensor", + None, + None, + payload.Data.SimpleItem[0].Value == "true", + ) @PARSERS.register("tns1:Device/Trigger/Relay") @@ -552,19 +499,16 @@ async def async_parse_relay(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/Relay """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Relay Triggered", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "active", - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Relay Triggered", + "binary_sensor", + None, + None, + payload.Data.SimpleItem[0].Value == "active", + ) @PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") @@ -573,20 +517,17 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: Topic: tns1:Device/HardwareFailure/StorageFailure """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Storage Failure", - "binary_sensor", - "problem", - None, - payload.Data.SimpleItem[0].Value == "true", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Storage Failure", + "binary_sensor", + "problem", + None, + payload.Data.SimpleItem[0].Value == "true", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:Monitoring/ProcessorUsage") @@ -595,23 +536,20 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/ProcessorUsage """ - try: - topic, payload = extract_message(msg) - usage = float(payload.Data.SimpleItem[0].Value) - if usage <= 1: - usage *= 100 + topic, payload = extract_message(msg) + usage = float(payload.Data.SimpleItem[0].Value) + if usage <= 1: + usage *= 100 - return Event( - f"{uid}_{topic}", - "Processor Usage", - "sensor", - None, - "percent", - int(usage), - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}", + "Processor Usage", + "sensor", + None, + "percent", + int(usage), + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") @@ -620,20 +558,17 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReboot """ - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Reboot", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Reboot", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") @@ -642,21 +577,18 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReset """ - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Reset", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Reset", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + entity_enabled=False, + ) @PARSERS.register("tns1:Monitoring/Backup/Last") @@ -665,22 +597,18 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/Backup/Last """ - - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Backup", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Backup", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + entity_enabled=False, + ) @PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") @@ -689,21 +617,18 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ - try: - topic, payload = extract_message(msg) - date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) - return Event( - f"{uid}_{topic}", - "Last Clock Synchronization", - "sensor", - "timestamp", - None, - date_time, - EntityCategory.DIAGNOSTIC, - entity_enabled=False, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) + return Event( + f"{uid}_{topic}", + "Last Clock Synchronization", + "sensor", + "timestamp", + None, + date_time, + EntityCategory.DIAGNOSTIC, + entity_enabled=False, + ) @PARSERS.register("tns1:RecordingConfig/JobState") @@ -713,20 +638,17 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: Topic: tns1:RecordingConfig/JobState """ - try: - topic, payload = extract_message(msg) - source = payload.Source.SimpleItem[0].Value - return Event( - f"{uid}_{topic}_{source}", - "Recording Job State", - "binary_sensor", - None, - None, - payload.Data.SimpleItem[0].Value == "Active", - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value + return Event( + f"{uid}_{topic}_{source}", + "Recording Job State", + "binary_sensor", + None, + None, + payload.Data.SimpleItem[0].Value == "Active", + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:RuleEngine/LineDetector/Crossed") @@ -735,30 +657,27 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/LineDetector/Crossed """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = source.Value - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = source.Value + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Line Detector Crossed", - "sensor", - None, - None, - payload.Data.SimpleItem[0].Value, - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Line Detector Crossed", + "sensor", + None, + None, + payload.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:RuleEngine/CountAggregation/Counter") @@ -767,30 +686,27 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: Topic: tns1:RuleEngine/CountAggregation/Counter """ - try: - video_source = "" - video_analytics = "" - rule = "" - topic, payload = extract_message(msg) - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - if source.Name == "VideoAnalyticsConfigurationToken": - video_analytics = source.Value - if source.Name == "Rule": - rule = source.Value + video_source = "" + video_analytics = "" + rule = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + if source.Name == "VideoAnalyticsConfigurationToken": + video_analytics = source.Value + if source.Name == "Rule": + rule = source.Value - return Event( - f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", - "Count Aggregation Counter", - "sensor", - None, - None, - payload.Data.SimpleItem[0].Value, - EntityCategory.DIAGNOSTIC, - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", + "Count Aggregation Counter", + "sensor", + None, + None, + payload.Data.SimpleItem[0].Value, + EntityCategory.DIAGNOSTIC, + ) @PARSERS.register("tns1:UserAlarm/IVA/HumanShapeDetect") @@ -799,21 +715,18 @@ async def async_parse_human_shape_detect(uid: str, msg) -> Event | None: Topic: tns1:UserAlarm/IVA/HumanShapeDetect """ - try: - topic, payload = extract_message(msg) - video_source = "" - for source in payload.Source.SimpleItem: - if source.Name == "VideoSourceConfigurationToken": - video_source = _normalize_video_source(source.Value) - break + topic, payload = extract_message(msg) + video_source = "" + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + break - return Event( - f"{uid}_{topic}_{video_source}", - "Human Shape Detect", - "binary_sensor", - "motion", - None, - payload.Data.SimpleItem[0].Value == "true", - ) - except (AttributeError, KeyError): - return None + return Event( + f"{uid}_{topic}_{video_source}", + "Human Shape Detect", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 4f7e10abae6..70b78fea971 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -5,6 +5,7 @@ import os import onvif import onvif.settings +import pytest from zeep import Client from zeep.transports import Transport @@ -732,25 +733,24 @@ async def test_tapo_intrusion(hass: HomeAssistant) -> None: async def test_tapo_missing_attributes(hass: HomeAssistant) -> None: """Tests async_parse_tplink_detector with missing fields.""" - event = await get_event( - { - "Message": { - "_value_1": { - "Data": { - "ElementItem": [], - "Extension": None, - "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], - "_attr_1": None, - }, - } - }, - "Topic": { - "_value_1": "tns1:RuleEngine/PeopleDetector/People", - }, - } - ) - - assert event is None + with pytest.raises(AttributeError, match="SimpleItem"): + await get_event( + { + "Message": { + "_value_1": { + "Data": { + "ElementItem": [], + "Extension": None, + "SimpleItem": [{"Name": "IsPeople", "Value": "true"}], + "_attr_1": None, + }, + } + }, + "Topic": { + "_value_1": "tns1:RuleEngine/PeopleDetector/People", + }, + } + ) async def test_tapo_unknown_type(hass: HomeAssistant) -> None: From fa836118b2e59aa70a1078ffe2d44a447dee3f1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Mar 2025 15:24:55 -1000 Subject: [PATCH 1678/1941] Bump bluetooth-data-tools to 1.26.1 (#140635) changelog: https://github.com/Bluetooth-Devices/bluetooth-data-tools/compare/v1.26.0...v1.26.1 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 3430787958e..eed21dcc0c8 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", - "bluetooth-data-tools==1.26.0", + "bluetooth-data-tools==1.26.1", "dbus-fast==2.39.5", "habluetooth==3.27.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index f0d06a4e880..1896f2109a7 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.26.1", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 5e12c395c2c..270495c8770 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.26.0", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.26.1", "led-ble==1.1.6"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index d79b93388f5..810fce41e05 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.26.0"] + "requirements": ["bluetooth-data-tools==1.26.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8f9a9670fee..ef50d88c44a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.4.5 -bluetooth-data-tools==1.26.0 +bluetooth-data-tools==1.26.1 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index f02cdb56fc9..4ed89f94334 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -640,7 +640,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.0 +bluetooth-data-tools==1.26.1 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 929437bb7bf..3322b42a3b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -564,7 +564,7 @@ bluetooth-auto-recovery==1.4.5 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.26.0 +bluetooth-data-tools==1.26.1 # homeassistant.components.bond bond-async==0.2.1 From c54a2e733872104c6eeaf2948a14a8d26ef2cee8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Mar 2025 15:27:51 -1000 Subject: [PATCH 1679/1941] Bump nexia to 2.4.0 (#140634) changelog: https://github.com/bdraco/nexia/compare/2.2.2...2.4.0 --- 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 09b79d37c55..e7ab63d4712 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.2.2"] + "requirements": ["nexia==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4ed89f94334..68a07bc732c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1486,7 +1486,7 @@ nettigo-air-monitor==4.1.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.2.2 +nexia==2.4.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3322b42a3b1..e3ce76ad507 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1246,7 +1246,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.1.0 # homeassistant.components.nexia -nexia==2.2.2 +nexia==2.4.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 From ed2ef04b984aceec094f6eec26be5bf6263350e9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 14 Mar 2025 21:48:47 -0400 Subject: [PATCH 1680/1941] Bump Python-Snoo to 0.6.3 (#140628) Bump python-Snoo to 0.6.3 --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index c9306e58413..0de1e6cf760 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.1"] + "requirements": ["python-snoo==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68a07bc732c..250d6597718 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2470,7 +2470,7 @@ python-roborock==2.12.2 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.1 +python-snoo==0.6.3 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3ce76ad507..c4c6463d48a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2000,7 +2000,7 @@ python-roborock==2.12.2 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.1 +python-snoo==0.6.3 # homeassistant.components.songpal python-songpal==0.16.2 From baafcf48dcd9c1e5bd8ebc8a9f96e1e05c90eecc Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 14 Mar 2025 22:06:09 -0400 Subject: [PATCH 1681/1941] Separate Roborock entities to a new dock device (#140612) * Seperate entities to a new dock device * update entity names * Update homeassistant/components/roborock/coordinator.py --------- Co-authored-by: Paulus Schoutsen --- .../components/roborock/binary_sensor.py | 4 ++++ .../components/roborock/coordinator.py | 17 +++++++++++++++++ homeassistant/components/roborock/entity.py | 5 ++++- homeassistant/components/roborock/select.py | 4 ++++ homeassistant/components/roborock/sensor.py | 6 ++++++ homeassistant/components/roborock/switch.py | 12 +++++++++++- tests/components/roborock/test_sensor.py | 2 +- tests/components/roborock/test_switch.py | 8 ++++---- 8 files changed, 51 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index f2b1564c7b5..95640812b11 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -26,6 +26,8 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription): """A class that describes Roborock binary sensors.""" value_fn: Callable[[DeviceProp], bool | int | None] + # If it is a dock entity + is_dock_entity: bool = False BINARY_SENSOR_DESCRIPTIONS = [ @@ -35,6 +37,7 @@ BINARY_SENSOR_DESCRIPTIONS = [ device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.dry_status, + is_dock_entity=True, ), RoborockBinarySensorDescription( key="water_box_carriage_status", @@ -105,6 +108,7 @@ class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity super().__init__( f"{description.key}_{coordinator.duid_slug}", coordinator, + is_dock_entity=description.is_dock_entity, ) self.entity_description = description diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index c94fb785079..bf06387b377 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -128,6 +128,23 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self._api_client = api_client self._is_cloud_api = False + @cached_property + def dock_device_info(self) -> DeviceInfo: + """Gets the device info for the dock. + + This must happen after the coordinator does the first update. + Which will be the case when this is called. + """ + dock_type = self.roborock_device_info.props.status.dock_type + return DeviceInfo( + name=f"{self.roborock_device_info.device.name} Dock", + identifiers={(DOMAIN, f"{self.duid}_dock")}, + manufacturer="Roborock", + model=f"{self.roborock_device_info.product.model} Dock", + model_id=str(dock_type.value) if dock_type is not None else "Unknown", + sw_version=self.roborock_device_info.device.fv, + ) + async def _async_setup(self) -> None: """Set up the coordinator.""" # Verify we can communicate locally - if we can't, switch to cloud api diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py index d417ac17159..404f239c93a 100644 --- a/homeassistant/components/roborock/entity.py +++ b/homeassistant/components/roborock/entity.py @@ -121,12 +121,15 @@ class RoborockCoordinatedEntityV1( listener_request: list[RoborockDataProtocol] | RoborockDataProtocol | None = None, + is_dock_entity: bool = False, ) -> None: """Initialize the coordinated Roborock Device.""" RoborockEntityV1.__init__( self, unique_id=unique_id, - device_info=coordinator.device_info, + device_info=coordinator.device_info + if not is_dock_entity + else coordinator.dock_device_info, api=coordinator.api, ) CoordinatorEntity.__init__(self, coordinator=coordinator) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index c22a4deed3b..42245c458eb 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -32,6 +32,8 @@ class RoborockSelectDescription(SelectEntityDescription): parameter_lambda: Callable[[str, DeviceProp], list[int]] protocol_listener: RoborockDataProtocol | None = None + # If it is a dock entity + is_dock_entity: bool = False SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ @@ -70,6 +72,7 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ parameter_lambda=lambda key, _: [ RoborockDockDustCollectionModeCode.as_dict().get(key) ], + is_dock_entity=True, ), ] @@ -117,6 +120,7 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): f"{entity_description.key}_{coordinator.duid_slug}", coordinator, entity_description.protocol_listener, + is_dock_entity=entity_description.is_dock_entity, ) self._attr_options = options diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index f95dc5fa98f..7b019acb39b 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -47,6 +47,9 @@ class RoborockSensorDescription(SensorEntityDescription): protocol_listener: RoborockDataProtocol | None = None + # If it is a dock entity + is_dock_entity: bool = False + @dataclass(frozen=True, kw_only=True) class RoborockSensorDescriptionA01(SensorEntityDescription): @@ -197,6 +200,7 @@ SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=RoborockDockErrorCode.keys(), + is_dock_entity=True, ), RoborockSensorDescription( key="mop_clean_remaining", @@ -205,6 +209,7 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda data: data.status.rdt, translation_key="mop_drying_remaining_time", entity_category=EntityCategory.DIAGNOSTIC, + is_dock_entity=True, ), ] @@ -335,6 +340,7 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): f"{description.key}_{coordinator.duid_slug}", coordinator, description.protocol_listener, + is_dock_entity=description.is_dock_entity, ) @property diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 0171d59abfd..636066c1ed5 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -35,6 +35,8 @@ class RoborockSwitchDescription(SwitchEntityDescription): update_value: Callable[[AttributeCache, bool], Coroutine[Any, Any, None]] # Attribute from cache attribute: str + # If it is a dock entity + is_dock_entity: bool = False SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ @@ -47,6 +49,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ key="child_lock", translation_key="child_lock", entity_category=EntityCategory.CONFIG, + is_dock_entity=True, ), RoborockSwitchDescription( cache_key=CacheableAttribute.flow_led_status, @@ -57,6 +60,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ key="status_indicator", translation_key="status_indicator", entity_category=EntityCategory.CONFIG, + is_dock_entity=True, ), RoborockSwitchDescription( cache_key=CacheableAttribute.dnd_timer, @@ -147,7 +151,13 @@ class RoborockSwitch(RoborockEntityV1, SwitchEntity): ) -> None: """Initialize the entity.""" self.entity_description = entity_description - super().__init__(unique_id, coordinator.device_info, coordinator.api) + super().__init__( + unique_id, + coordinator.device_info + if not entity_description.is_dock_entity + else coordinator.dock_device_info, + coordinator.api, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index e33d3aa78d5..4925c5da219 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -53,7 +53,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_cleaning_area").state == "21.0" assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" - assert hass.states.get("sensor.roborock_s7_maxv_dock_error").state == "ok" + assert hass.states.get("sensor.roborock_s7_maxv_dock_dock_error").state == "ok" assert hass.states.get("sensor.roborock_s7_maxv_total_cleaning_count").state == "31" assert ( hass.states.get("sensor.roborock_s7_maxv_last_clean_begin").state diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index e2df9a3498f..120c4fc4860 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -22,8 +22,8 @@ def platforms() -> list[Platform]: @pytest.mark.parametrize( ("entity_id"), [ - ("switch.roborock_s7_maxv_child_lock"), - ("switch.roborock_s7_maxv_status_indicator_light"), + ("switch.roborock_s7_maxv_dock_child_lock"), + ("switch.roborock_s7_maxv_dock_status_indicator_light"), ("switch.roborock_s7_maxv_do_not_disturb"), ], ) @@ -59,8 +59,8 @@ async def test_update_success( @pytest.mark.parametrize( ("entity_id", "service"), [ - ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_ON), - ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_OFF), + ("switch.roborock_s7_maxv_dock_status_indicator_light", SERVICE_TURN_ON), + ("switch.roborock_s7_maxv_dock_status_indicator_light", SERVICE_TURN_OFF), ], ) @pytest.mark.parametrize( From 07e7672b78f3f399e4a23fb4087b60fa50388c44 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 15 Mar 2025 05:07:59 +0300 Subject: [PATCH 1682/1941] Anthropic conversation extended thinking support (#139662) * Anthropic conversation extended thinking support * update conversation snapshots * Add conversation test * Update openai_conversation snapshots * Removed metadata * Removed metadata * Removed thinking * cosmetic fix * combine user messages * Apply suggestions from code review * Add tests for chat_log messages conversion * s/THINKING_BUDGET_TOKENS/THINKING_BUDGET/ * Apply suggestions from code review * Update tests * Update homeassistant/components/anthropic/strings.json Co-authored-by: Paulus Schoutsen * apply suggestions from code review --------- Co-authored-by: Robert Resch Co-authored-by: Paulus Schoutsen --- .../components/anthropic/config_flow.py | 31 +- homeassistant/components/anthropic/const.py | 5 + .../components/anthropic/conversation.py | 296 ++++++++++----- .../components/anthropic/strings.json | 9 +- tests/components/anthropic/conftest.py | 16 + .../snapshots/test_conversation.ambr | 317 ++++++++++++++++ .../components/anthropic/test_config_flow.py | 26 ++ .../components/anthropic/test_conversation.py | 344 +++++++++++++++++- 8 files changed, 940 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 5f1f4fdeea7..e53a479d7d4 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -34,10 +34,12 @@ from .const import ( CONF_PROMPT, CONF_RECOMMENDED, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, ) _LOGGER = logging.getLogger(__name__) @@ -128,21 +130,29 @@ class AnthropicOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_LLM_HASS_API] == "none": user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + if user_input.get( + CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET + ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): + errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + if not errors: + return self.async_create_entry(title="", data=user_input) + else: + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + } suggested_values = options.copy() if not suggested_values.get(CONF_PROMPT): @@ -156,6 +166,7 @@ class AnthropicOptionsFlow(OptionsFlow): return self.async_show_form( step_id="init", data_schema=schema, + errors=errors or None, ) @@ -205,6 +216,10 @@ def anthropic_config_option_schema( CONF_TEMPERATURE, default=RECOMMENDED_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_THINKING_BUDGET, + default=RECOMMENDED_THINKING_BUDGET, + ): int, } ) return schema diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 0dbf9c51ac1..38e4270e6e1 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -13,3 +13,8 @@ CONF_MAX_TOKENS = "max_tokens" RECOMMENDED_MAX_TOKENS = 1024 CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 +CONF_THINKING_BUDGET = "thinking_budget" +RECOMMENDED_THINKING_BUDGET = 0 +MIN_THINKING_BUDGET = 1024 + +THINKING_MODELS = ["claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest"] diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index ff403e61a91..5e5ad464eaa 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -1,23 +1,32 @@ """Conversation support for Anthropic.""" -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, Callable, Iterable import json -from typing import Any, Literal +from typing import Any, Literal, cast import anthropic from anthropic import AsyncStream from anthropic._types import NOT_GIVEN from anthropic.types import ( InputJSONDelta, - Message, MessageParam, MessageStreamEvent, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawContentBlockStopEvent, + RawMessageStartEvent, + RawMessageStopEvent, + RedactedThinkingBlock, + RedactedThinkingBlockParam, + SignatureDelta, TextBlock, TextBlockParam, TextDelta, + ThinkingBlock, + ThinkingBlockParam, + ThinkingConfigDisabledParam, + ThinkingConfigEnabledParam, + ThinkingDelta, ToolParam, ToolResultBlockParam, ToolUseBlock, @@ -39,11 +48,15 @@ from .const import ( CONF_MAX_TOKENS, CONF_PROMPT, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, DOMAIN, LOGGER, + MIN_THINKING_BUDGET, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, + THINKING_MODELS, ) # Max number of back and forth with the LLM to generate a response @@ -71,73 +84,101 @@ def _format_tool( ) -def _message_convert( - message: Message, -) -> MessageParam: - """Convert from class to TypedDict.""" - param_content: list[TextBlockParam | ToolUseBlockParam] = [] +def _convert_content( + chat_content: Iterable[conversation.Content], +) -> list[MessageParam]: + """Transform HA chat_log content into Anthropic API format.""" + messages: list[MessageParam] = [] - for message_content in message.content: - if isinstance(message_content, TextBlock): - param_content.append(TextBlockParam(type="text", text=message_content.text)) - elif isinstance(message_content, ToolUseBlock): - param_content.append( - ToolUseBlockParam( - type="tool_use", - id=message_content.id, - name=message_content.name, - input=message_content.input, - ) + for content in chat_content: + if isinstance(content, conversation.ToolResultContent): + tool_result_block = ToolResultBlockParam( + type="tool_result", + tool_use_id=content.tool_call_id, + content=json.dumps(content.tool_result), ) - - return MessageParam(role=message.role, content=param_content) - - -def _convert_content(chat_content: conversation.Content) -> MessageParam: - """Create tool response content.""" - if isinstance(chat_content, conversation.ToolResultContent): - return MessageParam( - role="user", - content=[ - ToolResultBlockParam( - type="tool_result", - tool_use_id=chat_content.tool_call_id, - content=json.dumps(chat_content.tool_result), - ) - ], - ) - if isinstance(chat_content, conversation.AssistantContent): - return MessageParam( - role="assistant", - content=[ - TextBlockParam(type="text", text=chat_content.content or ""), - *[ - ToolUseBlockParam( - type="tool_use", - id=tool_call.id, - name=tool_call.tool_name, - input=tool_call.tool_args, + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=[tool_result_block], ) - for tool_call in chat_content.tool_calls or () - ], - ], - ) - if isinstance(chat_content, conversation.UserContent): - return MessageParam( - role="user", - content=chat_content.content, - ) - # Note: We don't pass SystemContent here as its passed to the API as the prompt - raise ValueError(f"Unexpected content type: {type(chat_content)}") + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + tool_result_block, + ] + else: + messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] + elif isinstance(content, conversation.UserContent): + # Combine consequent user messages + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=content.content, + ) + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + TextBlockParam(type="text", text=content.content), + ] + else: + messages[-1]["content"].append( # type: ignore[attr-defined] + TextBlockParam(type="text", text=content.content) + ) + elif isinstance(content, conversation.AssistantContent): + # Combine consequent assistant messages + if not messages or messages[-1]["role"] != "assistant": + messages.append( + MessageParam( + role="assistant", + content=[], + ) + ) + + if content.content: + messages[-1]["content"].append( # type: ignore[union-attr] + TextBlockParam(type="text", text=content.content) + ) + if content.tool_calls: + messages[-1]["content"].extend( # type: ignore[union-attr] + [ + ToolUseBlockParam( + type="tool_use", + id=tool_call.id, + name=tool_call.tool_name, + input=tool_call.tool_args, + ) + for tool_call in content.tool_calls + ] + ) + else: + # Note: We don't pass SystemContent here as its passed to the API as the prompt + raise TypeError(f"Unexpected content type: {type(content)}") + + return messages async def _transform_stream( result: AsyncStream[MessageStreamEvent], + messages: list[MessageParam], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform the response stream into HA format. A typical stream of responses might look something like the following: - RawMessageStartEvent with no content + - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled) + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - ... + - RawContentBlockDeltaEvent with a SignatureDelta + - RawContentBlockStopEvent + - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally) + - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta) - RawContentBlockStartEvent with an empty TextBlock - RawContentBlockDeltaEvent with a TextDelta - RawContentBlockDeltaEvent with a TextDelta @@ -151,44 +192,103 @@ async def _transform_stream( - RawContentBlockStopEvent - RawMessageDeltaEvent with a stop_reason='tool_use' - RawMessageStopEvent(type='message_stop') + + Each message could contain multiple blocks of the same type. """ if result is None: raise TypeError("Expected a stream of messages") - current_tool_call: dict | None = None + current_message: MessageParam | None = None + current_block: ( + TextBlockParam + | ToolUseBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam + | None + ) = None + current_tool_args: str async for response in result: LOGGER.debug("Received response: %s", response) - if isinstance(response, RawContentBlockStartEvent): + if isinstance(response, RawMessageStartEvent): + if response.message.role != "assistant": + raise ValueError("Unexpected message role") + current_message = MessageParam(role=response.message.role, content=[]) + elif isinstance(response, RawContentBlockStartEvent): if isinstance(response.content_block, ToolUseBlock): - current_tool_call = { - "id": response.content_block.id, - "name": response.content_block.name, - "input": "", - } + current_block = ToolUseBlockParam( + type="tool_use", + id=response.content_block.id, + name=response.content_block.name, + input="", + ) + current_tool_args = "" elif isinstance(response.content_block, TextBlock): + current_block = TextBlockParam( + type="text", text=response.content_block.text + ) yield {"role": "assistant"} + if response.content_block.text: + yield {"content": response.content_block.text} + elif isinstance(response.content_block, ThinkingBlock): + current_block = ThinkingBlockParam( + type="thinking", + thinking=response.content_block.thinking, + signature=response.content_block.signature, + ) + elif isinstance(response.content_block, RedactedThinkingBlock): + current_block = RedactedThinkingBlockParam( + type="redacted_thinking", data=response.content_block.data + ) + LOGGER.debug( + "Some of Claude’s internal reasoning has been automatically " + "encrypted for safety reasons. This doesn’t affect the quality of " + "responses" + ) elif isinstance(response, RawContentBlockDeltaEvent): + if current_block is None: + raise ValueError("Unexpected delta without a block") if isinstance(response.delta, InputJSONDelta): - if current_tool_call is None: - raise ValueError("Unexpected delta without a tool call") - current_tool_call["input"] += response.delta.partial_json + current_tool_args += response.delta.partial_json elif isinstance(response.delta, TextDelta): - LOGGER.debug("yielding delta: %s", response.delta.text) + text_block = cast(TextBlockParam, current_block) + text_block["text"] += response.delta.text yield {"content": response.delta.text} + elif isinstance(response.delta, ThinkingDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["thinking"] += response.delta.thinking + elif isinstance(response.delta, SignatureDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["signature"] += response.delta.signature elif isinstance(response, RawContentBlockStopEvent): - if current_tool_call: + if current_block is None: + raise ValueError("Unexpected stop event without a current block") + if current_block["type"] == "tool_use": + tool_block = cast(ToolUseBlockParam, current_block) + tool_args = json.loads(current_tool_args) + tool_block["input"] = tool_args yield { "tool_calls": [ llm.ToolInput( - id=current_tool_call["id"], - tool_name=current_tool_call["name"], - tool_args=json.loads(current_tool_call["input"]), + id=tool_block["id"], + tool_name=tool_block["name"], + tool_args=tool_args, ) ] } - current_tool_call = None + elif current_block["type"] == "thinking": + thinking_block = cast(ThinkingBlockParam, current_block) + LOGGER.debug("Thinking: %s", thinking_block["thinking"]) + + if current_message is None: + raise ValueError("Unexpected stop event without a current message") + current_message["content"].append(current_block) # type: ignore[union-attr] + current_block = None + elif isinstance(response, RawMessageStopEvent): + if current_message is not None: + messages.append(current_message) + current_message = None class AnthropicConversationEntity( @@ -254,34 +354,50 @@ class AnthropicConversationEntity( system = chat_log.content[0] if not isinstance(system, conversation.SystemContent): raise TypeError("First message must be a system message") - messages = [_convert_content(content) for content in chat_log.content[1:]] + messages = _convert_content(chat_log.content[1:]) client = self.entry.runtime_data + thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - try: - stream = await client.messages.create( - model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - messages=messages, - tools=tools or NOT_GIVEN, - max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - system=system.content, - temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - stream=True, + model_args = { + "model": model, + "messages": messages, + "tools": tools or NOT_GIVEN, + "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + "system": system.content, + "stream": True, + } + if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET: + model_args["thinking"] = ThinkingConfigEnabledParam( + type="enabled", budget_tokens=thinking_budget ) + else: + model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") + model_args["temperature"] = options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ) + + try: + stream = await client.messages.create(**model_args) except anthropic.AnthropicError as err: raise HomeAssistantError( f"Sorry, I had a problem talking to Anthropic: {err}" ) from err messages.extend( - [ - _convert_content(content) - async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(stream) - ) - ] + _convert_content( + [ + content + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(stream, messages) + ) + if not isinstance(content, conversation.AssistantContent) + ] + ) ) if not chat_log.unresponded_tool_results: diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 9550a1a6672..c2caf3a6666 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -23,12 +23,17 @@ "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings" + "recommended": "Recommended model settings", + "thinking_budget_tokens": "Thinking budget" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." } } + }, + "error": { + "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." } } } diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index f8ab098cc09..7419ea6c28f 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.anthropic import CONF_CHAT_MODEL from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -38,6 +39,21 @@ def mock_config_entry_with_assist( return mock_config_entry +@pytest.fixture +def mock_config_entry_with_extended_thinking( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with assist.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_CHAT_MODEL: "claude-3-7-sonnet-latest", + }, + ) + return mock_config_entry + + @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index de414019317..c0ed986f002 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -1,4 +1,321 @@ # serializer version: 1 +# name: test_extended_thinking_tool_call + list([ + dict({ + 'content': ''' + Current time is 16:00:00. Today's date is 2024-06-03. + You are a voice assistant for Home Assistant. + Answer questions about the world truthfully. + Answer in plain text. Keep it simple and to the point. + Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant. + ''', + 'role': 'system', + }), + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.claude', + 'content': 'Certainly, calling it now!', + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'toolu_0123456789AbCdEfGhIjKlM', + 'tool_args': dict({ + 'param1': 'test_value', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.claude', + 'role': 'tool_result', + 'tool_call_id': 'toolu_0123456789AbCdEfGhIjKlM', + 'tool_name': 'test_tool', + 'tool_result': 'Test response', + }), + dict({ + 'agent_id': 'conversation.claude', + 'content': 'I have successfully called the function', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- +# name: test_extended_thinking_tool_call.1 + list([ + dict({ + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': 'The user asked me to call a test function.Is it a test? What would the function do? Would it violate any privacy or security policies?', + 'type': 'thinking', + }), + dict({ + 'data': 'EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9KWPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeVsJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOKiKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny', + 'type': 'redacted_thinking', + }), + dict({ + 'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==', + 'thinking': "Okay, let's give it a shot. Will I pass the test?", + 'type': 'thinking', + }), + dict({ + 'text': 'Certainly, calling it now!', + 'type': 'text', + }), + dict({ + 'id': 'toolu_0123456789AbCdEfGhIjKlM', + 'input': dict({ + 'param1': 'test_value', + }), + 'name': 'test_tool', + 'type': 'tool_use', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': list([ + dict({ + 'content': '"Test response"', + 'tool_use_id': 'toolu_0123456789AbCdEfGhIjKlM', + 'type': 'tool_result', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'I have successfully called the function', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content0] + list([ + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content1] + list([ + dict({ + 'content': 'What shape is a donut?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'A donut is a torus.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content2] + list([ + dict({ + 'content': list([ + dict({ + 'text': 'What shape is a donut?', + 'type': 'text', + }), + dict({ + 'text': 'Can you tell me?', + 'type': 'text', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'A donut is a torus.', + 'type': 'text', + }), + dict({ + 'text': 'Hope this helps.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content3] + list([ + dict({ + 'content': list([ + dict({ + 'text': 'What shape is a donut?', + 'type': 'text', + }), + dict({ + 'text': 'Can you tell me?', + 'type': 'text', + }), + dict({ + 'text': 'Please?', + 'type': 'text', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'A donut is a torus.', + 'type': 'text', + }), + dict({ + 'text': 'Hope this helps.', + 'type': 'text', + }), + dict({ + 'text': 'You are welcome.', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- +# name: test_history_conversion[content4] + list([ + dict({ + 'content': 'Turn off the lights and make me coffee', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Sure.', + 'type': 'text', + }), + dict({ + 'id': 'mock-tool-call-id', + 'input': dict({ + 'domain': 'light', + }), + 'name': 'HassTurnOff', + 'type': 'tool_use', + }), + dict({ + 'id': 'mock-tool-call-id-2', + 'input': dict({ + }), + 'name': 'MakeCoffee', + 'type': 'tool_use', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Thank you', + 'type': 'text', + }), + dict({ + 'content': '{"success": true, "response": "Lights are off."}', + 'tool_use_id': 'mock-tool-call-id', + 'type': 'tool_result', + }), + dict({ + 'content': '{"success": false, "response": "Not enough milk."}', + 'tool_use_id': 'mock-tool-call-id-2', + 'type': 'tool_result', + }), + ]), + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Should I add milk to the shopping list?', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + dict({ + 'content': 'Are you sure?', + 'role': 'user', + }), + dict({ + 'content': list([ + dict({ + 'text': 'Yes, I am sure!', + 'type': 'text', + }), + ]), + 'role': 'assistant', + }), + ]) +# --- # name: test_unknown_hass_api dict({ 'continue_conversation': False, diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 5973d9a3ee8..30aba6e1b1f 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -21,9 +21,11 @@ from homeassistant.components.anthropic.const import ( CONF_PROMPT, CONF_RECOMMENDED, CONF_TEMPERATURE, + CONF_THINKING_BUDGET, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_THINKING_BUDGET, ) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -94,6 +96,28 @@ async def test_options( assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL +async def test_options_thinking_budget_more_than_max( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test error about thinking budget being more than max tokens.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + "prompt": "Speak like a pirate", + "max_tokens": 8192, + "chat_model": "claude-3-7-sonnet-latest", + "temperature": 1, + "thinking_budget": 16384, + }, + ) + await hass.async_block_till_done() + assert options["type"] is FlowResultType.FORM + assert options["errors"] == {"thinking_budget": "thinking_budget_too_large"} + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -186,6 +210,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, }, ), ( @@ -195,6 +220,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_THINKING_BUDGET: RECOMMENDED_THINKING_BUDGET, }, { CONF_RECOMMENDED: True, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 6c8244a59ba..67a4434a664 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -14,13 +14,18 @@ from anthropic.types import ( RawMessageStartEvent, RawMessageStopEvent, RawMessageStreamEvent, + RedactedThinkingBlock, + SignatureDelta, TextBlock, TextDelta, + ThinkingBlock, + ThinkingDelta, ToolUseBlock, Usage, ) from freezegun import freeze_time from httpx import URL, Request, Response +import pytest from syrupy.assertion import SnapshotAssertion import voluptuous as vol @@ -28,7 +33,7 @@ from homeassistant.components import conversation from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import chat_session, intent, llm from homeassistant.setup import async_setup_component from homeassistant.util import ulid as ulid_util @@ -86,6 +91,57 @@ def create_content_block( ] +def create_thinking_block( + index: int, thinking_parts: list[str] +) -> list[RawMessageStreamEvent]: + """Create a thinking block with the specified deltas.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=ThinkingBlock(signature="", thinking="", type="thinking"), + index=index, + ), + *[ + RawContentBlockDeltaEvent( + delta=ThinkingDelta(thinking=thinking_part, type="thinking_delta"), + index=index, + type="content_block_delta", + ) + for thinking_part in thinking_parts + ], + RawContentBlockDeltaEvent( + delta=SignatureDelta( + signature="ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/N" + "oB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ" + "4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo" + "21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==", + type="signature_delta", + ), + index=index, + type="content_block_delta", + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + +def create_redacted_thinking_block(index: int) -> list[RawMessageStreamEvent]: + """Create a redacted thinking block.""" + return [ + RawContentBlockStartEvent( + type="content_block_start", + content_block=RedactedThinkingBlock( + data="EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9K" + "WPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeV" + "sJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOK" + "iKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny", + type="redacted_thinking", + ), + index=index, + ), + RawContentBlockStopEvent(index=index, type="content_block_stop"), + ] + + def create_tool_use_block( index: int, tool_id: str, tool_name: str, json_parts: list[str] ) -> list[RawMessageStreamEvent]: @@ -381,7 +437,7 @@ async def test_function_exception( return stream_generator( create_messages( [ - *create_content_block(0, "Certainly, calling it now!"), + *create_content_block(0, ["Certainly, calling it now!"]), *create_tool_use_block( 1, "toolu_0123456789AbCdEfGhIjKlM", @@ -464,7 +520,7 @@ async def test_assist_api_tools_conversion( new_callable=AsyncMock, return_value=stream_generator( create_messages( - create_content_block(0, "Hello, how can I help you?"), + create_content_block(0, ["Hello, how can I help you?"]), ), ), ) as mock_create: @@ -509,7 +565,7 @@ async def test_conversation_id( def create_stream_generator(*args, **kwargs) -> Any: return stream_generator( create_messages( - create_content_block(0, "Hello, how can I help you?"), + create_content_block(0, ["Hello, how can I help you?"]), ), ) @@ -547,3 +603,283 @@ async def test_conversation_id( ) assert result.conversation_id == "koala" + + +async def test_extended_thinking( + hass: HomeAssistant, + mock_config_entry_with_extended_thinking: MockConfigEntry, + mock_init_component, +) -> None: + """Test extended thinking support.""" + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_thinking_block( + 0, + [ + "The user has just", + ' greeted me with "Hi".', + " This is a simple greeting an", + "d doesn't require any Home Assistant function", + " calls. I should respond with", + " a friendly greeting and let them know I'm available", + " to help with their smart home.", + ], + ), + *create_content_block(1, ["Hello, how can I help you today?"]), + ] + ), + ), + ): + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id="conversation.claude" + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + assert len(chat_log.content) == 3 + assert chat_log.content[1].content == "hello" + assert chat_log.content[2].content == "Hello, how can I help you today?" + + +async def test_redacted_thinking( + hass: HomeAssistant, + mock_config_entry_with_extended_thinking: MockConfigEntry, + mock_init_component, +) -> None: + """Test extended thinking with redacted thinking blocks.""" + with patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_redacted_thinking_block(0), + *create_redacted_thinking_block(1), + *create_redacted_thinking_block(2), + *create_content_block(3, ["How can I help you today?"]), + ] + ), + ), + ): + result = await conversation.async_converse( + hass, + "ANTHROPIC_MAGIC_STRING_TRIGGER_REDACTED_THINKING_46C9A13E193C177646C7398A9" + "8432ECCCE4C1253D5E2D82641AC0E52CC2876CB", + None, + Context(), + agent_id="conversation.claude", + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + assert len(chat_log.content) == 3 + assert chat_log.content[2].content == "How can I help you today?" + + +@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +async def test_extended_thinking_tool_call( + mock_get_tools, + hass: HomeAssistant, + mock_config_entry_with_extended_thinking: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, +) -> None: + """Test that thinking blocks and their order are preserved in with tool calls.""" + agent_id = "conversation.claude" + context = Context() + + mock_tool = AsyncMock() + mock_tool.name = "test_tool" + mock_tool.description = "Test function" + mock_tool.parameters = vol.Schema( + {vol.Optional("param1", description="Test parameters"): str} + ) + mock_tool.async_call.return_value = "Test response" + + mock_get_tools.return_value = [mock_tool] + + def completion_result(*args, messages, **kwargs): + for message in messages: + for content in message["content"]: + if not isinstance(content, str) and content["type"] == "tool_use": + return stream_generator( + create_messages( + create_content_block( + 0, ["I have ", "successfully called ", "the function"] + ), + ) + ) + + return stream_generator( + create_messages( + [ + *create_thinking_block( + 0, + [ + "The user asked me to", + " call a test function.", + "Is it a test? What", + " would the function", + " do? Would it violate", + " any privacy or security", + " policies?", + ], + ), + *create_redacted_thinking_block(1), + *create_thinking_block( + 2, ["Okay, let's give it a shot.", " Will I pass the test?"] + ), + *create_content_block(3, ["Certainly, calling it now!"]), + *create_tool_use_block( + 1, + "toolu_0123456789AbCdEfGhIjKlM", + "test_tool", + ['{"para', 'm1": "test_valu', 'e"}'], + ), + ] + ) + ) + + with ( + patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + side_effect=completion_result, + ) as mock_create, + freeze_time("2024-06-03 23:00:00"), + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + ) + + chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( + result.conversation_id + ) + + assert chat_log.content == snapshot + assert mock_create.mock_calls[1][2]["messages"] == snapshot + + +@pytest.mark.parametrize( + "content", + [ + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="A donut is a torus." + ), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.UserContent("Can you tell me?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="A donut is a torus." + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="Hope this helps." + ), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("What shape is a donut?"), + conversation.chat_log.UserContent("Can you tell me?"), + conversation.chat_log.UserContent("Please?"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="A donut is a torus." + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="Hope this helps." + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", content="You are welcome." + ), + ], + [ + conversation.chat_log.SystemContent("You are a helpful assistant."), + conversation.chat_log.UserContent("Turn off the lights and make me coffee"), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", + content="Sure.", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="HassTurnOff", + tool_args={"domain": "light"}, + ), + llm.ToolInput( + id="mock-tool-call-id-2", + tool_name="MakeCoffee", + tool_args={}, + ), + ], + ), + conversation.chat_log.UserContent("Thank you"), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude", + tool_call_id="mock-tool-call-id", + tool_name="HassTurnOff", + tool_result={"success": True, "response": "Lights are off."}, + ), + conversation.chat_log.ToolResultContent( + agent_id="conversation.claude", + tool_call_id="mock-tool-call-id-2", + tool_name="MakeCoffee", + tool_result={"success": False, "response": "Not enough milk."}, + ), + conversation.chat_log.AssistantContent( + agent_id="conversation.claude", + content="Should I add milk to the shopping list?", + ), + ], + ], +) +async def test_history_conversion( + hass: HomeAssistant, + mock_config_entry_with_assist: MockConfigEntry, + mock_init_component, + snapshot: SnapshotAssertion, + content: list[conversation.chat_log.Content], +) -> None: + """Test conversion of chat_log entries into API parameters.""" + conversation_id = "conversation_id" + with ( + chat_session.async_get_chat_session(hass, conversation_id) as session, + conversation.async_get_chat_log(hass, session) as chat_log, + patch( + "anthropic.resources.messages.AsyncMessages.create", + new_callable=AsyncMock, + return_value=stream_generator( + create_messages( + [ + *create_content_block(0, ["Yes, I am sure!"]), + ] + ), + ), + ) as mock_create, + ): + chat_log.content = content + + await conversation.async_converse( + hass, + "Are you sure?", + conversation_id, + Context(), + agent_id="conversation.claude", + ) + + assert mock_create.mock_calls[0][2]["messages"] == snapshot From 5dc1a321dd8623efc681ff9781a6c93e54c76276 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Mar 2025 16:14:09 -1000 Subject: [PATCH 1683/1941] Rework cover reproduce_state to consider supported features (#140558) * Handle open/closed state in reproduce_state for tilt only covers fixes #137144 * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * rework * rework * rework * rework * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * back compat * back compat * back compat * cleanups * cleanups * cleanups * cleanups * comments * comments --- .../components/cover/reproduce_state.py | 256 ++++++++--- .../components/cover/test_reproduce_state.py | 407 ++++++++++++++++-- 2 files changed, 570 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index 307fe5f11bd..de3e0cebfb7 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -3,12 +3,14 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Coroutine, Iterable +from functools import partial import logging -from typing import Any +from typing import Any, Final from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -16,7 +18,8 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, ) -from homeassistant.core import Context, HomeAssistant, State +from homeassistant.core import Context, HomeAssistant, ServiceResponse, State +from homeassistant.util.enum import try_parse_enum from . import ( ATTR_CURRENT_POSITION, @@ -24,17 +27,140 @@ from . import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, + CoverEntityFeature, CoverState, ) _LOGGER = logging.getLogger(__name__) -VALID_STATES = { - CoverState.CLOSED, - CoverState.CLOSING, - CoverState.OPEN, - CoverState.OPENING, -} + +OPENING_STATES = {CoverState.OPENING, CoverState.OPEN} +CLOSING_STATES = {CoverState.CLOSING, CoverState.CLOSED} +VALID_STATES: set[CoverState] = OPENING_STATES | CLOSING_STATES + +FULL_OPEN: Final = 100 +FULL_CLOSE: Final = 0 + + +def _determine_features(current_attrs: dict[str, Any]) -> CoverEntityFeature: + """Determine supported features based on current attributes.""" + features = CoverEntityFeature(0) + if ATTR_CURRENT_POSITION in current_attrs: + features |= ( + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + ) + if ATTR_CURRENT_TILT_POSITION in current_attrs: + features |= ( + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + ) + if features == CoverEntityFeature(0): + features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + return features + + +async def _async_set_position( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + target_position: int, +) -> bool: + """Set the position of the cover. + + Returns True if the position was set, False if there is no + supported method for setting the position. + """ + if target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: target_position} + ) + else: + # Requested a position but the cover doesn't support it + return False + return True + + +async def _async_set_tilt_position( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + target_tilt_position: int, +) -> bool: + """Set the tilt position of the cover. + + Returns True if the tilt position was set, False if there is no + supported method for setting the tilt position. + """ + if target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features: + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: target_tilt_position}, + ) + else: + # Requested a tilt position but the cover doesn't support it + return False + return True + + +async def _async_close_cover( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + set_position: bool, + set_tilt: bool, +) -> None: + """Close the cover if it was not closed by setting the position.""" + if not set_position: + if CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_CLOSE} + ) + if not set_tilt: + if CoverEntityFeature.CLOSE_TILT in features: + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: FULL_CLOSE}, + ) + + +async def _async_open_cover( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + set_position: bool, + set_tilt: bool, +) -> None: + """Open the cover if it was not opened by setting the position.""" + if not set_position: + if CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_OPEN} + ) + if not set_tilt: + if CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: FULL_OPEN}, + ) async def _async_reproduce_state( @@ -45,74 +171,72 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - if (cur_state := hass.states.get(state.entity_id)) is None: - _LOGGER.warning("Unable to find entity %s", state.entity_id) + entity_id = state.entity_id + if (cur_state := hass.states.get(entity_id)) is None: + _LOGGER.warning("Unable to find entity %s", entity_id) return - if state.state not in VALID_STATES: - _LOGGER.warning( - "Invalid state specified for %s: %s", state.entity_id, state.state - ) + if (target_state := state.state) not in VALID_STATES: + _LOGGER.warning("Invalid state specified for %s: %s", entity_id, target_state) return + current_attrs = cur_state.attributes + target_attrs = state.attributes + + current_position = current_attrs.get(ATTR_CURRENT_POSITION) + target_position = target_attrs.get(ATTR_CURRENT_POSITION) + position_matches = current_position == target_position + + current_tilt_position = current_attrs.get(ATTR_CURRENT_TILT_POSITION) + target_tilt_position = target_attrs.get(ATTR_CURRENT_TILT_POSITION) + tilt_position_matches = current_tilt_position == target_tilt_position + + state_matches = cur_state.state == target_state # Return if we are already at the right state. - if ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_CURRENT_POSITION) - == state.attributes.get(ATTR_CURRENT_POSITION) - and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) - == state.attributes.get(ATTR_CURRENT_TILT_POSITION) - ): + if state_matches and position_matches and tilt_position_matches: return - service_data = {ATTR_ENTITY_ID: state.entity_id} - service_data_tilting = {ATTR_ENTITY_ID: state.entity_id} + features = try_parse_enum( + CoverEntityFeature, current_attrs.get(ATTR_SUPPORTED_FEATURES) + ) + if features is None: + # Backwards compatibility for integrations that + # don't set supported features since it previously + # worked without it. + _LOGGER.warning("Supported features is not set for %s", entity_id) + features = _determine_features(current_attrs) - if not ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_CURRENT_POSITION) - == state.attributes.get(ATTR_CURRENT_POSITION) - ): - # Open/Close - if state.state in [CoverState.CLOSED, CoverState.CLOSING]: - service = SERVICE_CLOSE_COVER - elif state.state in [CoverState.OPEN, CoverState.OPENING]: - if ( - ATTR_CURRENT_POSITION in cur_state.attributes - and ATTR_CURRENT_POSITION in state.attributes - ): - service = SERVICE_SET_COVER_POSITION - service_data[ATTR_POSITION] = state.attributes[ATTR_CURRENT_POSITION] - else: - service = SERVICE_OPEN_COVER + service_call = partial( + hass.services.async_call, + DOMAIN, + context=context, + blocking=True, + ) + service_data = {ATTR_ENTITY_ID: entity_id} - await hass.services.async_call( - DOMAIN, service, service_data, context=context, blocking=True + set_position = ( + not position_matches + and target_position is not None + and await _async_set_position( + service_call, service_data, features, target_position + ) + ) + set_tilt = ( + not tilt_position_matches + and target_tilt_position is not None + and await _async_set_tilt_position( + service_call, service_data, features, target_tilt_position + ) + ) + + if target_state in CLOSING_STATES: + await _async_close_cover( + service_call, service_data, features, set_position, set_tilt ) - if ( - ATTR_CURRENT_TILT_POSITION in state.attributes - and ATTR_CURRENT_TILT_POSITION in cur_state.attributes - and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) - != state.attributes.get(ATTR_CURRENT_TILT_POSITION) - ): - # Tilt position - if state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100: - service_tilting = SERVICE_OPEN_COVER_TILT - elif state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0: - service_tilting = SERVICE_CLOSE_COVER_TILT - else: - service_tilting = SERVICE_SET_COVER_TILT_POSITION - service_data_tilting[ATTR_TILT_POSITION] = state.attributes[ - ATTR_CURRENT_TILT_POSITION - ] - - await hass.services.async_call( - DOMAIN, - service_tilting, - service_data_tilting, - context=context, - blocking=True, + elif target_state in OPENING_STATES: + await _async_open_cover( + service_call, service_data, features, set_position, set_tilt ) diff --git a/tests/components/cover/test_reproduce_state.py b/tests/components/cover/test_reproduce_state.py index 4aad27011fa..57fc5aed5e9 100644 --- a/tests/components/cover/test_reproduce_state.py +++ b/tests/components/cover/test_reproduce_state.py @@ -7,9 +7,11 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, + CoverEntityFeature, CoverState, ) from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -27,35 +29,213 @@ async def test_reproducing_states( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test reproducing Cover states.""" - hass.states.async_set("cover.entity_close", CoverState.CLOSED, {}) + hass.states.async_set( + "cover.entity_close", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_close_open", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.open_only_supports_close_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.open_missing_all_features", + CoverState.OPEN, + ) + hass.states.async_set( + "cover.closed_missing_all_features_has_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + }, + ) + hass.states.async_set( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 50, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_tilt_close_open", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.open_only_supports_tilt_close_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + }, + ) + hass.states.async_set( + "cover.open_only_supports_position", + CoverState.OPEN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION}, + ) hass.states.async_set( "cover.entity_close_attr", CoverState.CLOSED, - {ATTR_CURRENT_POSITION: 0, ATTR_CURRENT_TILT_POSITION: 0}, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( - "cover.entity_close_tilt", CoverState.CLOSED, {ATTR_CURRENT_TILT_POSITION: 50} + "cover.entity_close_tilt", + CoverState.CLOSED, + { + ATTR_CURRENT_TILT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, ) - hass.states.async_set("cover.entity_open", CoverState.OPEN, {}) hass.states.async_set( - "cover.entity_slightly_open", CoverState.OPEN, {ATTR_CURRENT_POSITION: 50} + "cover.entity_open", + CoverState.OPEN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN}, + ) + hass.states.async_set( + "cover.entity_slightly_open", + CoverState.OPEN, + { + ATTR_CURRENT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_open_attr", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 0}, + { + ATTR_CURRENT_POSITION: 100, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_open_tilt", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 50, ATTR_CURRENT_TILT_POSITION: 50}, + { + ATTR_CURRENT_POSITION: 50, + ATTR_CURRENT_TILT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_entirely_open", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 100}, + { + ATTR_CURRENT_POSITION: 100, + ATTR_CURRENT_TILT_POSITION: 100, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.tilt_only_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.tilt_only_closed", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.tilt_only_tilt_position_100", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 100, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_only_tilt_position_0", + CoverState.CLOSED, + { + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_open_only_supports_tilt_position", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + ATTR_CURRENT_TILT_POSITION: 50, + }, + ) + hass.states.async_set( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + }, ) - close_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER) open_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) close_tilt_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER_TILT) @@ -70,6 +250,31 @@ async def test_reproducing_states( hass, [ State("cover.entity_close", CoverState.CLOSED), + State("cover.closed_only_supports_close_open", CoverState.CLOSED), + State("cover.closed_only_supports_tilt_close_open", CoverState.CLOSED), + State("cover.open_only_supports_close_open", CoverState.OPEN), + State("cover.open_only_supports_tilt_close_open", CoverState.OPEN), + State("cover.open_missing_all_features", CoverState.OPEN), + State( + "cover.closed_missing_all_features_has_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + }, + ), + State( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 50, + }, + ), + State( + "cover.closed_only_supports_position", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 0}, + ), + State("cover.open_only_supports_position", CoverState.OPEN), State( "cover.entity_close_attr", CoverState.CLOSED, @@ -101,6 +306,39 @@ async def test_reproducing_states( CoverState.OPEN, {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 100}, ), + State( + "cover.tilt_only_open", + CoverState.OPEN, + {}, + ), + State( + "cover.tilt_only_tilt_position_100", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 100}, + ), + State( + "cover.tilt_only_closed", + CoverState.CLOSED, + {}, + ), + State( + "cover.tilt_only_tilt_position_0", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 0}, + ), + State( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 50}, + ), + State( + "cover.tilt_open_only_supports_tilt_position", + CoverState.OPEN, + ), + State( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.CLOSED, + ), ], ) @@ -127,6 +365,35 @@ async def test_reproducing_states( hass, [ State("cover.entity_close", CoverState.OPEN), + State( + "cover.closed_only_supports_close_open", + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 100}, + ), + State( + "cover.open_only_supports_close_open", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 50}, + ), + State( + "cover.open_only_supports_tilt_close_open", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 50}, + ), + State("cover.closed_only_supports_tilt_close_open", CoverState.OPEN), + State("cover.open_missing_all_features", CoverState.CLOSED), + State( + "cover.closed_missing_all_features_has_position", + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 70}, + ), + State( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 20}, + ), + State("cover.closed_only_supports_position", CoverState.OPEN), + State("cover.open_only_supports_position", CoverState.CLOSED), State( "cover.entity_close_attr", CoverState.OPEN, @@ -152,6 +419,39 @@ async def test_reproducing_states( ), # Should not raise State("cover.non_existing", "on"), + State( + "cover.tilt_only_open", + CoverState.CLOSED, + {}, + ), + State( + "cover.tilt_only_tilt_position_100", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 0}, + ), + State( + "cover.tilt_only_closed", + CoverState.OPEN, + {}, + ), + State( + "cover.tilt_only_tilt_position_0", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 100}, + ), + State( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 70}, + ), + State( + "cover.tilt_open_only_supports_tilt_position", + CoverState.CLOSED, + ), + State( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.OPEN, + ), ], ) @@ -159,8 +459,10 @@ async def test_reproducing_states( {"entity_id": "cover.entity_open"}, {"entity_id": "cover.entity_open_attr"}, {"entity_id": "cover.entity_entirely_open"}, + {"entity_id": "cover.open_only_supports_close_open"}, + {"entity_id": "cover.open_missing_all_features"}, ] - assert len(close_calls) == 3 + assert len(close_calls) == len(valid_close_calls) for call in close_calls: assert call.domain == "cover" assert call.data in valid_close_calls @@ -170,8 +472,9 @@ async def test_reproducing_states( {"entity_id": "cover.entity_close"}, {"entity_id": "cover.entity_slightly_open"}, {"entity_id": "cover.entity_open_tilt"}, + {"entity_id": "cover.closed_only_supports_close_open"}, ] - assert len(open_calls) == 3 + assert len(open_calls) == len(valid_open_calls) for call in open_calls: assert call.domain == "cover" assert call.data in valid_open_calls @@ -180,27 +483,77 @@ async def test_reproducing_states( valid_close_tilt_calls = [ {"entity_id": "cover.entity_open_tilt"}, {"entity_id": "cover.entity_entirely_open"}, + {"entity_id": "cover.tilt_only_open"}, + {"entity_id": "cover.entity_open_attr"}, + {"entity_id": "cover.tilt_only_tilt_position_100"}, + {"entity_id": "cover.open_only_supports_tilt_close_open"}, ] - assert len(close_tilt_calls) == 2 + assert len(close_tilt_calls) == len(valid_close_tilt_calls) for call in close_tilt_calls: assert call.domain == "cover" assert call.data in valid_close_tilt_calls valid_close_tilt_calls.remove(call.data) - assert len(open_tilt_calls) == 1 - assert open_tilt_calls[0].domain == "cover" - assert open_tilt_calls[0].data == {"entity_id": "cover.entity_close_tilt"} + valid_open_tilt_calls = [ + {"entity_id": "cover.entity_close_tilt"}, + {"entity_id": "cover.tilt_only_closed"}, + {"entity_id": "cover.tilt_only_tilt_position_0"}, + {"entity_id": "cover.closed_only_supports_tilt_close_open"}, + ] + assert len(open_tilt_calls) == len(valid_open_tilt_calls) + for call in open_tilt_calls: + assert call.domain == "cover" + assert call.data in valid_open_tilt_calls + valid_open_tilt_calls.remove(call.data) - assert len(position_calls) == 1 - assert position_calls[0].domain == "cover" - assert position_calls[0].data == { - "entity_id": "cover.entity_close_attr", - ATTR_POSITION: 50, - } + valid_position_calls = [ + { + "entity_id": "cover.entity_close_attr", + ATTR_POSITION: 50, + }, + { + "entity_id": "cover.closed_missing_all_features_has_position", + ATTR_POSITION: 70, + }, + { + "entity_id": "cover.closed_only_supports_position", + ATTR_POSITION: 100, + }, + { + "entity_id": "cover.open_only_supports_position", + ATTR_POSITION: 0, + }, + ] + assert len(position_calls) == len(valid_position_calls) + for call in position_calls: + assert call.domain == "cover" + assert call.data in valid_position_calls + valid_position_calls.remove(call.data) - assert len(position_tilt_calls) == 1 - assert position_tilt_calls[0].domain == "cover" - assert position_tilt_calls[0].data == { - "entity_id": "cover.entity_close_attr", - ATTR_TILT_POSITION: 50, - } + valid_position_tilt_calls = [ + { + "entity_id": "cover.entity_close_attr", + ATTR_TILT_POSITION: 50, + }, + { + "entity_id": "cover.open_missing_all_features_has_tilt_position", + ATTR_TILT_POSITION: 20, + }, + { + "entity_id": "cover.tilt_open_only_supports_tilt_position", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_closed_only_supports_tilt_position", + ATTR_TILT_POSITION: 100, + }, + { + "entity_id": "cover.tilt_partial_open_only_supports_tilt_position", + ATTR_TILT_POSITION: 70, + }, + ] + assert len(position_tilt_calls) == len(valid_position_tilt_calls) + for call in position_tilt_calls: + assert call.domain == "cover" + assert call.data in valid_position_tilt_calls + valid_position_tilt_calls.remove(call.data) From 13b6cfa438441f258e6de1934e045f2611a9b220 Mon Sep 17 00:00:00 2001 From: Tim Laing <11019084+timlaing@users.noreply.github.com> Date: Sat, 15 Mar 2025 02:54:49 +0000 Subject: [PATCH 1684/1941] Add generate content service for OpenAI to match Google AI (#122818) * Aded Generate Content Service for OpenAI to match Google AI * Fixed code for commit checks * Addressed code review comments * Address review comments * Addressed @balloob review comments. * Address futher review comments from @balloob --- .../openai_conversation/__init__.py | 145 +++++++- .../components/openai_conversation/const.py | 22 +- .../components/openai_conversation/icons.json | 3 + .../openai_conversation/manifest.json | 2 +- .../openai_conversation/services.yaml | 20 ++ .../openai_conversation/strings.json | 18 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../openai_conversation/test_init.py | 314 +++++++++++++++++- 9 files changed, 500 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 0fbda9b7f4a..d7fc5205f17 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -2,7 +2,26 @@ from __future__ import annotations +import base64 +from mimetypes import guess_file_type +from pathlib import Path + import openai +from openai.types.chat.chat_completion import ChatCompletion +from openai.types.chat.chat_completion_content_part_image_param import ( + ChatCompletionContentPartImageParam, + ImageURL, +) +from openai.types.chat.chat_completion_content_part_param import ( + ChatCompletionContentPartParam, +) +from openai.types.chat.chat_completion_content_part_text_param import ( + ChatCompletionContentPartTextParam, +) +from openai.types.chat.chat_completion_user_message_param import ( + ChatCompletionUserMessageParam, +) +from openai.types.images_response import ImagesResponse import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -22,15 +41,33 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER +from .const import ( + CONF_CHAT_MODEL, + CONF_FILENAMES, + CONF_PROMPT, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, +) SERVICE_GENERATE_IMAGE = "generate_image" +SERVICE_GENERATE_CONTENT = "generate_content" + PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] +def encode_file(file_path: str) -> tuple[str, str]: + """Return base64 version of file contents.""" + mime_type, _ = guess_file_type(file_path) + if mime_type is None: + mime_type = "application/octet-stream" + with open(file_path, "rb") as image_file: + return (mime_type, base64.b64encode(image_file.read()).decode("utf-8")) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up OpenAI Conversation.""" @@ -49,9 +86,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client: openai.AsyncClient = entry.runtime_data try: - response = await client.images.generate( + response: ImagesResponse = await client.images.generate( model="dall-e-3", - prompt=call.data["prompt"], + prompt=call.data[CONF_PROMPT], size=call.data["size"], quality=call.data["quality"], style=call.data["style"], @@ -63,6 +100,105 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return response.data[0].model_dump(exclude={"b64_json"}) + async def send_prompt(call: ServiceCall) -> ServiceResponse: + """Send a prompt to ChatGPT and return the response.""" + entry_id = call.data["config_entry"] + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is None or entry.domain != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={"config_entry": entry_id}, + ) + + model: str = entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + client: openai.AsyncClient = entry.runtime_data + + prompt_parts: list[ChatCompletionContentPartParam] = [ + ChatCompletionContentPartTextParam( + type="text", + text=call.data[CONF_PROMPT], + ) + ] + + def append_files_to_prompt() -> None: + for filename in call.data[CONF_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") + mime_type, base64_file = encode_file(filename) + if "image/" not in mime_type: + raise HomeAssistantError( + "Only images are supported by the OpenAI API," + f"`{filename}` is not an image file" + ) + prompt_parts.append( + ChatCompletionContentPartImageParam( + type="image_url", + image_url=ImageURL( + url=f"data:{mime_type};base64,{base64_file}" + ), + ) + ) + + if CONF_FILENAMES in call.data: + await hass.async_add_executor_job(append_files_to_prompt) + + messages: list[ChatCompletionUserMessageParam] = [ + ChatCompletionUserMessageParam( + role="user", + content=prompt_parts, + ) + ] + + try: + response: ChatCompletion = await client.chat.completions.create( + model=model, + messages=messages, + n=1, + response_format={ + "type": "json_object", + }, + ) + + except openai.OpenAIError as err: + raise HomeAssistantError(f"Error generating content: {err}") from err + except FileNotFoundError as err: + raise HomeAssistantError(f"Error generating content: {err}") from err + + response_text: str = "" + for response_choice in response.choices: + if response_choice.message.content is not None: + response_text += response_choice.message.content.strip() + + return {"text": response_text} + + hass.services.async_register( + DOMAIN, + SERVICE_GENERATE_CONTENT, + send_prompt, + schema=vol.Schema( + { + vol.Required("config_entry"): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_PROMPT): cv.string, + vol.Optional(CONF_FILENAMES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_GENERATE_IMAGE, @@ -74,7 +210,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "integration": DOMAIN, } ), - vol.Required("prompt"): cv.string, + vol.Required(CONF_PROMPT): cv.string, vol.Optional("size", default="1024x1024"): vol.In( ("1024x1024", "1024x1792", "1792x1024") ), @@ -84,6 +220,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ), supports_response=SupportsResponse.ONLY, ) + return True diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 793e021e332..c9987cb81b9 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -3,22 +3,24 @@ import logging DOMAIN = "openai_conversation" -LOGGER = logging.getLogger(__package__) +LOGGER: logging.Logger = logging.getLogger(__package__) -CONF_RECOMMENDED = "recommended" -CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" +CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 150 -CONF_TOP_P = "top_p" -RECOMMENDED_TOP_P = 1.0 -CONF_TEMPERATURE = "temperature" -RECOMMENDED_TEMPERATURE = 1.0 +CONF_PROMPT = "prompt" +CONF_PROMPT = "prompt" CONF_REASONING_EFFORT = "reasoning_effort" +CONF_RECOMMENDED = "recommended" +CONF_TEMPERATURE = "temperature" +CONF_TOP_P = "top_p" +RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" +RECOMMENDED_MAX_TOKENS = 150 RECOMMENDED_REASONING_EFFORT = "low" +RECOMMENDED_TEMPERATURE = 1.0 +RECOMMENDED_TOP_P = 1.0 -UNSUPPORTED_MODELS = [ +UNSUPPORTED_MODELS: list[str] = [ "o1-mini", "o1-mini-2024-09-12", "o1-preview", diff --git a/homeassistant/components/openai_conversation/icons.json b/homeassistant/components/openai_conversation/icons.json index 3abecd640d1..f0ece31c304 100644 --- a/homeassistant/components/openai_conversation/icons.json +++ b/homeassistant/components/openai_conversation/icons.json @@ -2,6 +2,9 @@ "services": { "generate_image": { "service": "mdi:image-sync" + }, + "generate_content": { + "service": "mdi:receipt-text" } } } diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index a7aa7884dc4..cc1c56b0927 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.61.0"] + "requirements": ["openai==1.65.2"] } diff --git a/homeassistant/components/openai_conversation/services.yaml b/homeassistant/components/openai_conversation/services.yaml index 3db71cae383..75fa097f25d 100644 --- a/homeassistant/components/openai_conversation/services.yaml +++ b/homeassistant/components/openai_conversation/services.yaml @@ -38,3 +38,23 @@ generate_image: options: - "vivid" - "natural" +generate_content: + fields: + config_entry: + required: true + selector: + config_entry: + integration: openai_conversation + prompt: + required: true + selector: + text: + multiline: true + example: "Hello, how can I help you?" + filenames: + selector: + text: + multiline: true + example: | + - /path/to/file1.txt + - /path/to/file2.txt diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index aba4fdc3d40..c9d7ee112bd 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -72,6 +72,24 @@ "description": "The style of the generated image" } } + }, + "generate_content": { + "name": "Generate content", + "description": "Sends a conversational query to ChatGPT including any attached image files", + "fields": { + "config_entry": { + "name": "Config entry", + "description": "The config entry to use for this action" + }, + "prompt": { + "name": "Prompt", + "description": "The prompt to send" + }, + "filenames": { + "name": "Files", + "description": "List of files to upload" + } + } } }, "exceptions": { diff --git a/requirements_all.txt b/requirements_all.txt index 250d6597718..5947a0c5ad9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1580,7 +1580,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.61.0 +openai==1.65.2 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4c6463d48a..97af399a260 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1325,7 +1325,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.61.0 +openai==1.65.2 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index d78ce398c92..05a92d0b98e 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,18 +1,21 @@ """Tests for the OpenAI integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, mock_open, patch -from httpx import Response +from httpx import Request, Response from openai import ( APIConnectionError, AuthenticationError, BadRequestError, RateLimitError, ) +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.image import Image from openai.types.images_response import ImagesResponse import pytest +from homeassistant.components.openai_conversation import CONF_FILENAMES from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component @@ -114,7 +117,9 @@ async def test_generate_image_service_error( patch( "openai.resources.images.AsyncImages.generate", side_effect=RateLimitError( - response=Response(status_code=None, request=""), + response=Response( + status_code=500, request=Request(method="GET", url="") + ), body=None, message="Reason", ), @@ -133,22 +138,60 @@ async def test_generate_image_service_error( ) +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_service_with_image_not_allowed_path( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate content service with an image in a not allowed path.""" + with ( + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=False), + pytest.raises( + HomeAssistantError, + match=( + "Cannot read `doorbell_snapshot.jpg`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" + ), + ), + ): + await hass.services.async_call( + "openai_conversation", + "generate_content", + { + "config_entry": mock_config_entry.entry_id, + "prompt": "Describe this image from my doorbell camera", + "filenames": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.parametrize( + ("service_name", "error"), + [ + ("generate_image", "Invalid config entry provided. Got invalid_entry"), + ("generate_content", "Invalid config entry provided. Got invalid_entry"), + ], +) async def test_invalid_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, + service_name: str, + error: str, ) -> None: """Assert exception when invalid config entry is provided.""" service_data = { "prompt": "Picture of a dog", "config_entry": "invalid_entry", } - with pytest.raises( - ServiceValidationError, match="Invalid config entry provided. Got invalid_entry" - ): + with pytest.raises(ServiceValidationError, match=error): await hass.services.async_call( "openai_conversation", - "generate_image", + service_name, service_data, blocking=True, return_response=True, @@ -158,18 +201,29 @@ async def test_invalid_config_entry( @pytest.mark.parametrize( ("side_effect", "error"), [ - (APIConnectionError(request=None), "Connection error"), + ( + APIConnectionError(request=Request(method="GET", url="test")), + "Connection error", + ), ( AuthenticationError( - response=Response(status_code=None, request=""), body=None, message=None + response=Response( + status_code=500, request=Request(method="GET", url="test") + ), + body=None, + message="", ), "Invalid API key", ), ( BadRequestError( - response=Response(status_code=None, request=""), body=None, message=None + response=Response( + status_code=500, request=Request(method="GET", url="test") + ), + body=None, + message="", ), - "openai_conversation integration not ready yet: None", + "openai_conversation integration not ready yet", ), ], ) @@ -188,3 +242,241 @@ async def test_init_error( assert await async_setup_component(hass, "openai_conversation", {}) await hass.async_block_till_done() assert error in caplog.text + + +@pytest.mark.parametrize( + ("service_data", "expected_args", "number_of_files"), + [ + ( + {"prompt": "Picture of a dog", "filenames": []}, + { + "messages": [ + { + "content": [ + { + "type": "text", + "text": "Picture of a dog", + }, + ], + }, + ], + }, + 0, + ), + ( + {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, + { + "messages": [ + { + "content": [ + { + "type": "text", + "text": "Picture of a dog", + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64,BASE64IMAGE1", + }, + }, + ], + }, + ], + }, + 1, + ), + ( + { + "prompt": "Picture of a dog", + "filenames": ["/a/b/c.jpg", "d/e/f.jpg"], + }, + { + "messages": [ + { + "content": [ + { + "type": "text", + "text": "Picture of a dog", + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64,BASE64IMAGE1", + }, + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/jpeg;base64,BASE64IMAGE2", + }, + }, + ], + }, + ], + }, + 2, + ), + ], +) +async def test_generate_content_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + service_data, + expected_args, + number_of_files, +) -> None: + """Test generate content service.""" + service_data["config_entry"] = mock_config_entry.entry_id + expected_args["model"] = "gpt-4o-mini" + expected_args["n"] = 1 + expected_args["response_format"] = {"type": "json_object"} + expected_args["messages"][0]["role"] = "user" + + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ) as mock_create, + patch( + "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] + ) as mock_b64encode, + patch("builtins.open", mock_open(read_data="ABC")) as mock_file, + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + ): + mock_create.return_value = ChatCompletion( + id="", + model="", + created=1700000000, + object="chat.completion", + choices=[ + Choice( + index=0, + finish_reason="stop", + message=ChatCompletionMessage( + role="assistant", + content="This is the response", + ), + ) + ], + ) + + response = await hass.services.async_call( + "openai_conversation", + "generate_content", + service_data, + blocking=True, + return_response=True, + ) + assert response == {"text": "This is the response"} + assert len(mock_create.mock_calls) == 1 + assert mock_create.mock_calls[0][2] == expected_args + assert mock_b64encode.call_count == number_of_files + for idx, file in enumerate(service_data[CONF_FILENAMES]): + assert mock_file.call_args_list[idx][0][0] == file + + +@pytest.mark.parametrize( + ( + "service_data", + "error", + "number_of_files", + "exists_side_effect", + "is_allowed_side_effect", + ), + [ + ( + {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, + "`/a/b/c.jpg` does not exist", + 0, + [False], + [True], + ), + ( + { + "prompt": "Picture of a dog", + "filenames": ["/a/b/c.jpg", "d/e/f.png"], + }, + "Cannot read `d/e/f.png`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`", + 1, + [True, True], + [True, False], + ), + ( + {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.pdf"]}, + "Only images are supported by the OpenAI API,`/a/b/c.pdf` is not an image file", + 1, + [True], + [True], + ), + ], +) +async def test_generate_content_service_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + service_data, + error, + number_of_files, + exists_side_effect, + is_allowed_side_effect, +) -> None: + """Test generate content service.""" + service_data["config_entry"] = mock_config_entry.entry_id + + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ) as mock_create, + patch( + "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] + ) as mock_b64encode, + patch("builtins.open", mock_open(read_data="ABC")), + patch("pathlib.Path.exists", side_effect=exists_side_effect), + patch.object( + hass.config, "is_allowed_path", side_effect=is_allowed_side_effect + ), + ): + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + "openai_conversation", + "generate_content", + service_data, + blocking=True, + return_response=True, + ) + assert len(mock_create.mock_calls) == 0 + assert mock_b64encode.call_count == number_of_files + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_content_service_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate content service handles errors.""" + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + side_effect=RateLimitError( + response=Response( + status_code=417, request=Request(method="GET", url="") + ), + body=None, + message="Reason", + ), + ), + pytest.raises(HomeAssistantError, match="Error generating content: Reason"), + ): + await hass.services.async_call( + "openai_conversation", + "generate_content", + { + "config_entry": mock_config_entry.entry_id, + "prompt": "Image of an epic fail", + }, + blocking=True, + return_response=True, + ) From 99f661538d073b33f577a3e4868d6ea176e4a5de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 00:27:06 -1000 Subject: [PATCH 1685/1941] Bump aioesphomeapi to 29.7.0 (#140641) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.6.0...v29.7.0 --- 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 8d1cafee926..075185dffbb 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.6.0", + "aioesphomeapi==29.7.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.12.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5947a0c5ad9..4b2e0e0053a 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.6.0 +aioesphomeapi==29.7.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97af399a260..e9f7d5bee74 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.6.0 +aioesphomeapi==29.7.0 # homeassistant.components.flo aioflo==2021.11.0 From f801cfee7e59d917cbb73fc30af05695b249502e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 00:27:21 -1000 Subject: [PATCH 1686/1941] Bump habluetooth to 3.32.0 (#140640) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.27.0...v3.32.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index eed21dcc0c8..ff8de8509a3 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", "dbus-fast==2.39.5", - "habluetooth==3.27.0" + "habluetooth==3.32.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ef50d88c44a..59a56c8ea15 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ dbus-fast==2.39.5 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.27.0 +habluetooth==3.32.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 4b2e0e0053a..8e52f822de3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.27.0 +habluetooth==3.32.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9f7d5bee74..2960379bda0 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.27.0 +habluetooth==3.32.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 From 940625505f56f0adef0b0978927d5072ab139250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 15 Mar 2025 14:17:16 +0100 Subject: [PATCH 1687/1941] Handle non documented options at Home Connect select entities (#140608) * Allow non documented options at select entities * Don't allow undocumented options --- .../components/home_connect/select.py | 12 ++++++---- tests/components/home_connect/test_select.py | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index ef3e2ccbf82..527fd827399 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -413,6 +413,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): """Select setting class for Home Connect.""" entity_description: HomeConnectSelectEntityDescription + _original_option_keys: set[str | None] def __init__( self, @@ -421,6 +422,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" + self._original_option_keys = set(desc.values_translation_key) super().__init__( coordinator, appliance, @@ -471,10 +473,12 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): ) if setting and setting.constraints and setting.constraints.allowed_values: + self._original_option_keys = set(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 + for option in self._original_option_keys + if option is not None + and option in self.entity_description.values_translation_key ] @@ -491,7 +495,7 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): desc: HomeConnectSelectEntityDescription, ) -> None: """Initialize the entity.""" - self._original_option_keys = set(desc.values_translation_key.keys()) + self._original_option_keys = set(desc.values_translation_key) super().__init__( coordinator, appliance, @@ -524,5 +528,5 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity): self.entity_description.values_translation_key[option] for option in self._original_option_keys if option is not None + and option in self.entity_description.values_translation_key ] - self.__dict__.pop("options", None) diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 22ece365e6b..8ce91ed681c 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -521,9 +521,18 @@ async def test_select_functionality( ( "select.hood_ambient_light_color", SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, - [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(50)], + [f"BSH.Common.EnumType.AmbientLightColor.Color{i}" for i in range(1, 50)], {str(i) for i in range(1, 50)}, ), + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + [ + "A.Non.Documented.Option", + "BSH.Common.EnumType.AmbientLightColor.Color42", + ], + {"42"}, + ), ], ) async def test_fetch_allowed_values( @@ -679,6 +688,17 @@ async def test_select_entity_error( "laundry_care_washer_enum_type_temperature_ul_extra_hot", }, ), + ( + "select.washer_temperature", + OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, + [ + "A.Non.Documented.Option", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + ], + { + "laundry_care_washer_enum_type_temperature_ul_warm", + }, + ), ], ) async def test_options_functionality( From b7e2e041bcb253de998973f9ff3f0047420fcb7b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 15:08:21 +0100 Subject: [PATCH 1688/1941] Make Oven setpoint follow temperature UoM in SmartThings (#140666) --- .../components/smartthings/sensor.py | 15 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ks_oven_01061.json | 566 ++++++++++++++++++ .../fixtures/devices/da_ks_oven_01061.json | 153 +++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 398 ++++++++++++ 6 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ec4d9ee6207..fd447da427e 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -132,6 +132,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): capability_ignore_list: list[set[Capability]] | None = None options_attribute: Attribute | None = None exists_fn: Callable[[Status], bool] | None = None + use_temperature_unit: bool = False CAPABILITY_TO_SENSORS: dict[ @@ -573,7 +574,7 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.OVEN_SETPOINT, translation_key="oven_setpoint", device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + use_temperature_unit=True, value_fn=lambda value: value if value != 0 else None, ) ] @@ -1026,7 +1027,10 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, rooms, {capability}) + capabilities_to_subscribe = {capability} + if entity_description.use_temperature_unit: + capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) + super().__init__(client, device, rooms, capabilities_to_subscribe) self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}" self._attribute = attribute self.capability = capability @@ -1041,7 +1045,12 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" - unit = self._internal_state[self.capability][self._attribute].unit + if self.entity_description.use_temperature_unit: + unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit + else: + unit = self._internal_state[self.capability][self._attribute].unit return ( UNITS.get(unit, unit) if unit diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index c10668210e0..8e2956440cb 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -114,6 +114,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_rvc_normal_000001", "da_ks_microwave_0101x", "da_ks_range_0101x", + "da_ks_oven_01061", "hue_color_temperature_bulb", "hue_rgbw_color_bulb", "c2c_shade", diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json b/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json new file mode 100644 index 00000000000..b8b403ba908 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ks_oven_01061.json @@ -0,0 +1,566 @@ +{ + "components": { + "main": { + "ovenSetpoint": { + "ovenSetpointRange": { + "value": null + }, + "ovenSetpoint": { + "value": 220, + "timestamp": "2025-03-15T12:06:07.818Z" + } + }, + "refresh": {}, + "samsungce.doorState": { + "doorState": { + "value": "closed", + "timestamp": "2025-03-15T09:25:35.157Z" + } + }, + "samsungce.microwavePower": { + "supportedPowerLevels": { + "value": null + }, + "powerLevel": { + "value": "0W", + "timestamp": "2025-03-15T12:06:07.803Z" + } + }, + "samsungce.waterReservoir": { + "slotState": { + "value": null + } + }, + "samsungce.kitchenDeviceDefaults": { + "defaultOperationTime": { + "value": null + }, + "defaultOvenMode": { + "value": "Convection", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "defaultOvenSetpoint": { + "value": null + } + }, + "execute": { + "data": { + "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": "TP1X_DA-KS-OVEN-01061", + "timestamp": "2025-03-13T20:35:02.073Z" + } + }, + "samsungce.ovenDrainageRequirement": { + "drainageRequirement": { + "value": null + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "AKS-WW-TP1X-21-OVEN_40211229", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "di": { + "value": "9447959a-0dfa-6b27-d40d-650da525c53f", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "n": { + "value": "[oven] Samsung", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnmo": { + "value": "TP1X_DA-KS-OVEN-01061|40457041|50030018001611000A00000000000000", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "vid": { + "value": "DA-KS-OVEN-01061", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnpv": { + "value": "DAWIT 3.0", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "mnos": { + "value": "TizenRT 3.1", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "pi": { + "value": "9447959a-0dfa-6b27-d40d-650da525c53f", + "timestamp": "2025-01-08T17:29:14.260Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-08T17:29:14.260Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-15T09:47:55.406Z" + } + }, + "samsungce.kitchenDeviceIdentification": { + "regionCode": { + "value": "EU", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "modelCode": { + "value": "NQ7000B-/EU7", + "timestamp": "2025-03-15T12:06:07.758Z" + }, + "fuel": { + "value": null + }, + "type": { + "value": "oven", + "timestamp": "2025-01-08T17:29:12.924Z" + }, + "representativeComponent": { + "value": null + } + }, + "samsungce.kitchenModeSpecification": { + "specification": { + "value": { + "single": [ + { + "mode": "NoOperation", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "Autocook", + "supportedOperations": [], + "supportedOptions": {} + }, + { + "mode": "Convection", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 160, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "FanConventional", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "LargeGrill", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 150, + "max": 230, + "default": 220, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "FanGrill", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 230, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "MicroWaveGrill", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 200, + "default": 200, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "300W", + "supportedValues": ["100W", "180W", "300W", "450W", "600W"] + } + } + }, + { + "mode": "MicroWaveConvection", + "supportedOperations": ["set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 40, + "max": 200, + "default": 180, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "300W", + "supportedValues": ["100W", "180W", "300W", "450W", "600W"] + } + } + }, + { + "mode": "AirFryer", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 150, + "max": 230, + "default": 220, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "MicroWave", + "supportedOperations": ["set"], + "supportedOptions": { + "operationTime": { + "min": "00:00:10", + "max": "01:30:00", + "default": "00:00:30", + "resolution": "00:00:10" + }, + "powerLevel": { + "default": "800W", + "supportedValues": [ + "100W", + "180W", + "300W", + "450W", + "600W", + "700W", + "800W" + ] + } + } + }, + { + "mode": "Deodorization", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "operationTime": { + "min": "00:00:10", + "max": "00:15:00", + "default": "00:05:00", + "resolution": "00:00:10" + } + } + }, + { + "mode": "KeepWarm", + "supportedOperations": ["start", "set"], + "supportedOptions": { + "temperature": { + "C": { + "min": 60, + "max": 100, + "default": 60, + "resolution": 5 + } + }, + "operationTime": { + "min": "00:01:00", + "max": "10:00:00", + "default": "01:00:00", + "resolution": "00:01:00" + } + } + }, + { + "mode": "SteamClean", + "supportedOperations": ["set"], + "supportedOptions": {} + } + ] + }, + "timestamp": "2025-01-08T17:29:14.757Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.waterReservoir", + "samsungce.ovenDrainageRequirement" + ], + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.definedRecipe": { + "definedRecipe": { + "value": { + "cavityId": "0", + "recipeType": "0", + "categoryId": 0, + "itemId": 0, + "servingSize": 0, + "browingLevel": 0, + "option": 0 + }, + "timestamp": "2025-03-15T12:06:07.803Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2025-01-08T17:29:12.924Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "43CB2ZD4VUEGW", + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-13T20:35:02.073Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 30, + "unit": "C", + "timestamp": "2025-03-15T12:06:32.918Z" + } + }, + "samsungce.ovenOperatingState": { + "completionTime": { + "value": "2025-03-15T12:06:09.550Z", + "timestamp": "2025-03-15T12:06:09.554Z" + }, + "operatingState": { + "value": "running", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "progress": { + "value": 0, + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "ovenJobState": { + "value": "preheat", + "timestamp": "2025-03-15T12:06:07.803Z" + }, + "operationTime": { + "value": "00:00:00", + "timestamp": "2025-03-15T12:06:07.866Z" + } + }, + "ovenMode": { + "supportedOvenModes": { + "value": ["Others", "Bake", "Broil", "ConvectionBroil", "warming"], + "timestamp": "2025-01-08T17:29:14.757Z" + }, + "ovenMode": { + "value": "Bake", + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "ovenOperatingState": { + "completionTime": { + "value": "2025-03-15T12:06:09.550Z", + "timestamp": "2025-03-15T12:06:09.554Z" + }, + "machineState": { + "value": "running", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-03-15T12:06:07.866Z" + }, + "supportedMachineStates": { + "value": null + }, + "ovenJobState": { + "value": "preheat", + "timestamp": "2025-03-15T12:06:07.803Z" + }, + "operationTime": { + "value": 0, + "timestamp": "2025-03-15T12:06:07.866Z" + } + }, + "samsungce.ovenMode": { + "supportedOvenModes": { + "value": [ + "NoOperation", + "Autocook", + "Convection", + "FanConventional", + "LargeGrill", + "FanGrill", + "MicroWaveGrill", + "MicroWaveConvection", + "AirFryer", + "MicroWave", + "Deodorization", + "KeepWarm", + "SteamClean" + ], + "timestamp": "2025-01-08T17:29:14.757Z" + }, + "ovenMode": { + "value": "Convection", + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "high", + "timestamp": "2025-03-15T12:06:07.956Z" + }, + "supportedBrightnessLevel": { + "value": ["off", "high"], + "timestamp": "2025-03-15T12:06:07.758Z" + } + }, + "samsungce.kidsLock": { + "lockState": { + "value": "unlocked", + "timestamp": "2025-03-13T20:35:02.170Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json b/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json new file mode 100644 index 00000000000..e82e28d2275 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ks_oven_01061.json @@ -0,0 +1,153 @@ +{ + "items": [ + { + "deviceId": "9447959a-0dfa-6b27-d40d-650da525c53f", + "name": "[oven] Samsung", + "label": "Oven", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-KS-OVEN-01061", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "a81dc8da-5a3f-43b6-8c8a-1309f37eeeb9", + "ownerId": "97ee2149-9de0-3287-8245-24d6fd1609aa", + "roomId": "eb2167dd-8b8d-4131-b59e-5dd391b2e151", + "deviceTypeName": "Samsung OCF Oven", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "ovenSetpoint", + "version": 1 + }, + { + "id": "ovenMode", + "version": 1 + }, + { + "id": "ovenOperatingState", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.doorState", + "version": 1 + }, + { + "id": "samsungce.definedRecipe", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceIdentification", + "version": 1 + }, + { + "id": "samsungce.kitchenDeviceDefaults", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.ovenMode", + "version": 1 + }, + { + "id": "samsungce.ovenOperatingState", + "version": 1 + }, + { + "id": "samsungce.microwavePower", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.kitchenModeSpecification", + "version": 1 + }, + { + "id": "samsungce.kidsLock", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.waterReservoir", + "version": 1 + }, + { + "id": "samsungce.ovenDrainageRequirement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Oven", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2025-01-08T17:29:12.549Z", + "profile": { + "id": "eb34598f-f96a-3420-a90a-71693052eaa3" + }, + "ocf": { + "ocfDeviceType": "oic.d.oven", + "name": "[oven] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "TP1X_DA-KS-OVEN-01061|40457041|50030018001611000A00000000000000", + "platformVersion": "DAWIT 3.0", + "platformOS": "TizenRT 3.1", + "hwVersion": "Realtek", + "firmwareVersion": "AKS-WW-TP1X-21-OVEN_40211229", + "vendorId": "DA-KS-OVEN-01061", + "vendorResourceClientServerVersion": "Realtek Release 3.1.211122", + "lastSignupTime": "2025-01-08T17:29:08.536664213Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 825ab49e814..e4db4742a3b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -398,6 +398,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ks_oven_01061] + 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', + '9447959a-0dfa-6b27-d40d-650da525c53f', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'TP1X_DA-KS-OVEN-01061', + 'model_id': None, + 'name': 'Oven', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'AKS-WW-TP1X-21-OVEN_40211229', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ks_range_0101x] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 98e619596fd..b6d7bd80333 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2085,6 +2085,404 @@ 'state': '-17', }) # --- +# name: test_all_entities[da_ks_oven_01061][sensor.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.oven_completion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Completion time', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'completion_time', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.completionTime', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_completion_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Oven Completion time', + }), + 'context': , + 'entity_id': 'sensor.oven_completion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-03-15T12:06:09+00:00', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_job_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_job_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Job state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_job_state', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenJobState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_job_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven 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.oven_job_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'preheat', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_machine_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_machine_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Machine state', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_machine_state', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.machineState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_machine_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Machine state', + 'options': list([ + 'ready', + 'running', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_machine_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'running', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_oven_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': 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, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_oven_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Oven mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_mode', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_oven_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven 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.oven_oven_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'bake', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.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.oven_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': 'Set point', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'oven_setpoint', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.ovenSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_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.oven_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': '9447959a-0dfa-6b27-d40d-650da525c53f.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ks_oven_01061][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- # name: test_all_entities[da_ks_range_0101x][sensor.vulcan_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 89e75367311adc7066388e26d5416b4b653e6d1c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 15 Mar 2025 15:38:45 +0100 Subject: [PATCH 1689/1941] Add missing translations for `options` attribute in Nettigo Air Monitor integration (#140662) Add missing translations for options attribute --- homeassistant/components/nam/strings.json | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index 22fb1dc30d2..be9fb1fbb07 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -101,6 +101,17 @@ "medium": "Medium", "high": "High", "very_high": "Very high" + }, + "state_attributes": { + "options": { + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + } } }, "pmsx003_pm1": { @@ -123,6 +134,17 @@ "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + }, + "state_attributes": { + "options": { + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + } } }, "sds011_pm10": { @@ -148,6 +170,17 @@ "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + }, + "state_attributes": { + "options": { + "state": { + "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]", + "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]", + "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]", + "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]", + "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]" + } + } } }, "sps30_pm1": { From 58ff593f96f8f751207728da269712285f847523 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 15 Mar 2025 17:11:04 +0100 Subject: [PATCH 1690/1941] Bump `aioshelly` to version 13.4.0 (#140671) Bump aioshelly to version 13.4.0 --- 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 c9cbd778e95..e863720e476 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.3.0"], + "requirements": ["aioshelly==13.4.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 8e52f822de3..67a7a1e8c1f 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.3.0 +aioshelly==13.4.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2960379bda0..b80ad271ffa 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.3.0 +aioshelly==13.4.0 # homeassistant.components.skybell aioskybell==22.7.0 From 2fd91e7f9c940923f6332d9db432a11ff06add26 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 15 Mar 2025 18:10:35 +0100 Subject: [PATCH 1691/1941] Remove unknown from Shelly sensor state (#140597) --- homeassistant/components/shelly/sensor.py | 6 +++--- homeassistant/components/shelly/strings.json | 5 +---- tests/components/shelly/conftest.py | 9 +++++++- tests/components/shelly/test_sensor.py | 22 +++++++++++++++++--- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 183a1aa06a1..0020c6e0614 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -374,9 +374,9 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { key="sensor|sensorOp", name="Operation", device_class=SensorDeviceClass.ENUM, - options=["unknown", "warmup", "normal", "fault"], + options=["warmup", "normal", "fault"], translation_key="operation", - value=lambda value: value, + value=lambda value: None if value == "unknown" else value, extra_state_attributes=lambda block: {"self_test": block.selfTest}, ), ("valve", "valve"): BlockSensorDescription( @@ -391,8 +391,8 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { "failure", "opened", "opening", - "unknown", ], + value=lambda value: None if value == "unknown" else value, entity_category=EntityCategory.DIAGNOSTIC, removal_condition=lambda _, block: block.valve == "not_connected", ), diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index eb869b54e4c..cc511c93afe 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -106,7 +106,6 @@ "state_attributes": { "detected": { "state": { - "unknown": "Unknown", "none": "None", "mild": "Mild", "heavy": "Heavy", @@ -141,7 +140,6 @@ "sensor": { "operation": { "state": { - "unknown": "Unknown", "warmup": "Warm-up", "normal": "Normal", "fault": "Fault" @@ -164,8 +162,7 @@ "closing": "Closing", "failure": "Failure", "opened": "Opened", - "opening": "Opening", - "unknown": "[%key:component::shelly::entity::sensor::operation::state::unknown%]" + "opening": "Opening" } } } diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8ea04ea3bfb..5c0f912b72d 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -134,11 +134,18 @@ MOCK_BLOCKS = [ set_state=AsyncMock(side_effect=mock_light_set_state), ), Mock( - sensor_ids={"motion": 0, "temp": 22.1, "gas": "mild", "motionActive": 1}, + sensor_ids={ + "motion": 0, + "temp": 22.1, + "gas": "mild", + "motionActive": 1, + "sensorOp": "normal", + }, channel="0", motion=0, temp=22.1, gas="mild", + sensorOp="normal", targetTemp=4, description="sensor_0", type="sensor", diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index d0fec65c7de..d37a146e314 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -345,14 +345,30 @@ async def test_block_sensor_without_value( assert hass.states.get(entity_id) is None +@pytest.mark.parametrize( + ("entity", "initial_state", "block_id", "attribute", "value"), + [ + ("test_name_battery", "98", DEVICE_BLOCK_ID, "battery", None), + ("test_name_operation", "normal", SENSOR_BLOCK_ID, "sensorOp", "unknown"), + ], +) async def test_block_sensor_unknown_value( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity: str, + initial_state: str, + block_id: int, + attribute: str, + value: str | None, ) -> None: """Test block sensor unknown value.""" - entity_id = f"{SENSOR_DOMAIN}.test_name_battery" + entity_id = f"{SENSOR_DOMAIN}.{entity}" await init_integration(hass, 1) - monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "battery", None) + assert hass.states.get(entity_id).state == initial_state + + monkeypatch.setattr(mock_block_device.blocks[block_id], attribute, value) mock_block_device.mock_update() assert hass.states.get(entity_id).state == STATE_UNKNOWN From c1c8deed0ccfc8dd33e44f7efc332b26f739ccea Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+mtielen@users.noreply.github.com> Date: Sat, 15 Mar 2025 19:56:45 +0100 Subject: [PATCH 1692/1941] Fix sensor values for Power and Energy for Wolf Heatpumps (#139007) * Add sensor values for Power and Energy * test * test * Sensor test * Fix test * fix test * Fixing test coverage * refactored * WolfllinkSensorEntityDescriptions and updated tests * fix test * Add name_fn and test_sensor adoptions * fix test coverage * Revert "fix test coverage" This reverts commit 2405751f5a9d0d5be67b78b39a510240a794a7e5. * resolve requested changes and fix test * Fix Snapshot * clean up * Fixed unknown state in snapshot test --- homeassistant/components/wolflink/sensor.py | 178 ++++--- tests/components/wolflink/__init__.py | 13 + tests/components/wolflink/conftest.py | 109 +++++ .../wolflink/snapshots/test_sensor.ambr | 445 ++++++++++++++++++ tests/components/wolflink/test_sensor.py | 45 ++ 5 files changed, 721 insertions(+), 69 deletions(-) create mode 100644 tests/components/wolflink/conftest.py create mode 100644 tests/components/wolflink/snapshots/test_sensor.ambr create mode 100644 tests/components/wolflink/test_sensor.py diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index cf6d712dd0d..0f58817a38d 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -2,19 +2,35 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from wolf_comm.models import ( + EnergyParameter, HoursParameter, ListItemParameter, Parameter, PercentageParameter, + PowerParameter, Pressure, SimpleParameter, Temperature, ) -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPressure, UnitOfTemperature, UnitOfTime +from homeassistant.const import ( + PERCENTAGE, + UnitOfEnergy, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,31 +39,88 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES +def get_listitem_resolve_state(wolf_object, state): + """Resolve list item state.""" + resolved_state = [item for item in wolf_object.items if item.value == int(state)] + if resolved_state: + resolved_name = resolved_state[0].name + state = STATES.get(resolved_name, resolved_name) + return state + + +@dataclass(kw_only=True, frozen=True) +class WolflinkSensorEntityDescription(SensorEntityDescription): + """Describes Wolflink sensor entity.""" + + value_fn: Callable[[Parameter, str], str | None] = lambda param, value: value + supported_fn: Callable[[Parameter], bool] + + +SENSOR_DESCRIPTIONS = [ + WolflinkSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + supported_fn=lambda param: isinstance(param, Temperature), + ), + WolflinkSensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.BAR, + supported_fn=lambda param: isinstance(param, Pressure), + ), + WolflinkSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + supported_fn=lambda param: isinstance(param, EnergyParameter), + ), + WolflinkSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + supported_fn=lambda param: isinstance(param, PowerParameter), + ), + WolflinkSensorEntityDescription( + key="percentage", + native_unit_of_measurement=PERCENTAGE, + supported_fn=lambda param: isinstance(param, PercentageParameter), + ), + WolflinkSensorEntityDescription( + key="list_item", + translation_key="state", + supported_fn=lambda param: isinstance(param, ListItemParameter), + value_fn=get_listitem_resolve_state, + ), + WolflinkSensorEntityDescription( + key="hours", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.HOURS, + supported_fn=lambda param: isinstance(param, HoursParameter), + ), + WolflinkSensorEntityDescription( + key="default", + supported_fn=lambda param: isinstance(param, SimpleParameter), + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all entries for Wolf Platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] parameters = hass.data[DOMAIN][config_entry.entry_id][PARAMETERS] device_id = hass.data[DOMAIN][config_entry.entry_id][DEVICE_ID] - entities: list[WolfLinkSensor] = [] - for parameter in parameters: - if isinstance(parameter, Temperature): - entities.append(WolfLinkTemperature(coordinator, parameter, device_id)) - if isinstance(parameter, Pressure): - entities.append(WolfLinkPressure(coordinator, parameter, device_id)) - if isinstance(parameter, PercentageParameter): - entities.append(WolfLinkPercentage(coordinator, parameter, device_id)) - if isinstance(parameter, ListItemParameter): - entities.append(WolfLinkState(coordinator, parameter, device_id)) - if isinstance(parameter, HoursParameter): - entities.append(WolfLinkHours(coordinator, parameter, device_id)) - if isinstance(parameter, SimpleParameter): - entities.append(WolfLinkSensor(coordinator, parameter, device_id)) + entities: list[WolfLinkSensor] = [ + WolfLinkSensor(coordinator, parameter, device_id, description) + for parameter in parameters + for description in SENSOR_DESCRIPTIONS + if description.supported_fn(parameter) + ] async_add_entities(entities, True) @@ -55,9 +128,18 @@ async def async_setup_entry( class WolfLinkSensor(CoordinatorEntity, SensorEntity): """Base class for all Wolf entities.""" - def __init__(self, coordinator, wolf_object: Parameter, device_id) -> None: + entity_description: WolflinkSensorEntityDescription + + def __init__( + self, + coordinator, + wolf_object: Parameter, + device_id: str, + description: WolflinkSensorEntityDescription, + ) -> None: """Initialize.""" super().__init__(coordinator) + self.entity_description = description self.wolf_object = wolf_object self._attr_name = wolf_object.name self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" @@ -69,68 +151,26 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): ) @property - def native_value(self): + def native_value(self) -> str | None: """Return the state. Wolf Client is returning only changed values so we need to store old value here.""" if self.wolf_object.parameter_id in self.coordinator.data: new_state = self.coordinator.data[self.wolf_object.parameter_id] self.wolf_object.value_id = new_state[0] self._state = new_state[1] + if ( + isinstance(self.wolf_object, ListItemParameter) + and self._state is not None + ): + self._state = self.entity_description.value_fn( + self.wolf_object, self._state + ) return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" return { "parameter_id": self.wolf_object.parameter_id, "value_id": self.wolf_object.value_id, "parent": self.wolf_object.parent, } - - -class WolfLinkHours(WolfLinkSensor): - """Class for hour based entities.""" - - _attr_icon = "mdi:clock" - _attr_native_unit_of_measurement = UnitOfTime.HOURS - - -class WolfLinkTemperature(WolfLinkSensor): - """Class for temperature based entities.""" - - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - - -class WolfLinkPressure(WolfLinkSensor): - """Class for pressure based entities.""" - - _attr_device_class = SensorDeviceClass.PRESSURE - _attr_native_unit_of_measurement = UnitOfPressure.BAR - - -class WolfLinkPercentage(WolfLinkSensor): - """Class for percentage based entities.""" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self.wolf_object.unit - - -class WolfLinkState(WolfLinkSensor): - """Class for entities which has defined list of state.""" - - _attr_translation_key = "state" - - @property - def native_value(self): - """Return the state converting with supported values.""" - state = super().native_value - if state is not None: - resolved_state = [ - item for item in self.wolf_object.items if item.value == int(state) - ] - if resolved_state: - resolved_name = resolved_state[0].name - return STATES.get(resolved_name, resolved_name) - return state diff --git a/tests/components/wolflink/__init__.py b/tests/components/wolflink/__init__.py index dea7c5195ad..11c82ad9f61 100644 --- a/tests/components/wolflink/__init__.py +++ b/tests/components/wolflink/__init__.py @@ -1 +1,14 @@ """Tests for the Wolf SmartSet Service integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the wolflink 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/wolflink/conftest.py b/tests/components/wolflink/conftest.py new file mode 100644 index 00000000000..9c69c0d69bb --- /dev/null +++ b/tests/components/wolflink/conftest.py @@ -0,0 +1,109 @@ +"""Fixtures for Wolflink integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest +from wolf_comm import ( + EnergyParameter, + HoursParameter, + ListItem, + ListItemParameter, + PercentageParameter, + PowerParameter, + Pressure, + SimpleParameter, + Temperature, + Value, +) + +from homeassistant.components.wolflink.const import ( + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Wolf SmartSet", + domain=DOMAIN, + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + DEVICE_NAME: "test-device", + DEVICE_GATEWAY: "5678", + DEVICE_ID: "1234", + }, + unique_id="1234", + version=1, + minor_version=2, + ) + + +@pytest.fixture +def mock_wolflink() -> Generator[MagicMock]: + """Return a mocked wolflink client.""" + with ( + patch( + "homeassistant.components.wolflink.WolfClient", autospec=True + ) as wolflink_mock, + patch( + "homeassistant.components.wolflink.config_flow.WolfClient", + new=wolflink_mock, + ), + ): + wolflink = wolflink_mock.return_value + + wolflink.fetch_parameters.return_value = [ + EnergyParameter(6002800000, "Energy Parameter", "Heating", 6005200000), + ListItemParameter( + 8002800000, + "List Item Parameter", + "Heating", + [ListItem("0", "Aus"), ListItem("1", "Ein")], + 8005200000, + ), + PowerParameter(5002800000, "Power Parameter", "Heating", 5005200000), + Pressure(4002800000, "Pressure Parameter", "Heating", 4005200000), + Temperature(3002800000, "Temperature Parameter", "Solar", 3005200000), + PercentageParameter( + 2002800000, "Percentage Parameter", "Solar", 2005200000 + ), + HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000), + SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000), + ] + + wolflink.fetch_value.return_value = [ + Value(6002800000, "183", 1), + Value(8002800000, "1", 1), + Value(5002800000, "50", 1), + Value(4002800000, "3", 1), + Value(3002800000, "65", 1), + Value(2002800000, "20", 1), + Value(7002800000, "10", 1), + Value(1002800000, "12", 1), + ] + + yield wolflink + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wolflink: MagicMock +) -> MockConfigEntry: + """Set up the Wolflink integration for testing.""" + await setup_integration(hass, mock_config_entry) + + return mock_config_entry diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6fdccfb303c --- /dev/null +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -0,0 +1,445 @@ +# serializer version: 1 +# name: test_device_entry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://www.wolf-smartset.com/', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wolflink', + '1234', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WOLF GmbH', + 'model': None, + 'model_id': None, + 'name': 'Wolf SmartSet', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.energy_parameter-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.energy_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:6005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Parameter', + 'parameter_id': 6005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 6002800000, + }), + 'context': , + 'entity_id': 'sensor.energy_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '183', + }) +# --- +# name: test_sensors[sensor.hours_parameter-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.hours_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:clock', + 'original_name': 'Hours Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:7005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.hours_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Hours Parameter', + 'icon': 'mdi:clock', + 'parameter_id': 7005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 7002800000, + }), + 'context': , + 'entity_id': 'sensor.hours_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensors[sensor.list_item_parameter-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.list_item_parameter', + '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': 'List Item Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': '1234:8005200000', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.list_item_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'List Item Parameter', + 'parameter_id': 8005200000, + 'parent': 'Heating', + 'value_id': 8002800000, + }), + 'context': , + 'entity_id': 'sensor.list_item_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ein', + }) +# --- +# name: test_sensors[sensor.percentage_parameter-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.percentage_parameter', + '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': 'Percentage Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:2005200000', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.percentage_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Percentage Parameter', + 'parameter_id': 2005200000, + 'parent': 'Solar', + 'unit_of_measurement': '%', + 'value_id': 2002800000, + }), + 'context': , + 'entity_id': 'sensor.percentage_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_sensors[sensor.power_parameter-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.power_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:5005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.power_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Power Parameter', + 'parameter_id': 5005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 5002800000, + }), + 'context': , + 'entity_id': 'sensor.power_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensors[sensor.pressure_parameter-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.pressure_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:4005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.pressure_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Pressure Parameter', + 'parameter_id': 4005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 4002800000, + }), + 'context': , + 'entity_id': 'sensor.pressure_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensors[sensor.simple_parameter-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.simple_parameter', + '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': 'Simple Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:1005200000', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.simple_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Simple Parameter', + 'parameter_id': 1005200000, + 'parent': 'DHW', + 'value_id': 1002800000, + }), + 'context': , + 'entity_id': 'sensor.simple_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensors[sensor.temperature_parameter-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.temperature_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:3005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.temperature_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Temperature Parameter', + 'parameter_id': 3005200000, + 'parent': 'Solar', + 'unit_of_measurement': , + 'value_id': 3002800000, + }), + 'context': , + 'entity_id': 'sensor.temperature_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- diff --git a/tests/components/wolflink/test_sensor.py b/tests/components/wolflink/test_sensor.py new file mode 100644 index 00000000000..8fc78f707d5 --- /dev/null +++ b/tests/components/wolflink/test_sensor.py @@ -0,0 +1,45 @@ +"""Test the Wolf SmartSet Service Sensor platform.""" + +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, patch, snapshot_platform + + +async def test_device_entry( + hass: HomeAssistant, + mock_wolflink: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device entry creation.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device({(mock_config_entry.domain, "1234")}) + assert device == snapshot + + +async def test_sensors( + hass: HomeAssistant, + mock_wolflink: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test wolflink sensors.""" + + with patch("homeassistant.components.wolflink.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 02a75edf1da04795c835f153a1b5e0d4a3e9944b Mon Sep 17 00:00:00 2001 From: Jeff Terrace Date: Sat, 15 Mar 2025 15:03:40 -0400 Subject: [PATCH 1693/1941] Add onvif parser support for reolink package and hikvision alarm (#140669) --- homeassistant/components/onvif/parsers.py | 23 ++++++ tests/components/onvif/test_parsers.py | 90 +++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 7544f92292a..e5a731c73f6 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -49,6 +49,7 @@ def local_datetime_or_none(value: str) -> datetime.datetime | None: @PARSERS.register("tns1:VideoSource/MotionAlarm") +@PARSERS.register("tns1:Device/Trigger/tnshik:AlarmIn") async def async_parse_motion_alarm(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -475,6 +476,28 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: ) +@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Package") +async def async_parse_package_detector(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:RuleEngine/MyRuleDetector/Package + """ + video_source = "" + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: + if source.Name == "Source": + video_source = _normalize_video_source(source.Value) + + return Event( + f"{uid}_{topic}_{video_source}", + "Package Detection", + "binary_sensor", + "occupancy", + None, + payload.Data.SimpleItem[0].Value == "true", + ) + + @PARSERS.register("tns1:Device/Trigger/DigitalInput") async def async_parse_digital_input(uid: str, msg) -> Event | None: """Handle parsing event message. diff --git a/tests/components/onvif/test_parsers.py b/tests/components/onvif/test_parsers.py index 70b78fea971..8448a6e8195 100644 --- a/tests/components/onvif/test_parsers.py +++ b/tests/components/onvif/test_parsers.py @@ -789,3 +789,93 @@ async def test_tapo_unknown_type(hass: HomeAssistant) -> None: ) assert event is None + + +async def test_reolink_package(hass: HomeAssistant) -> None: + """Tests reolink package event.""" + event = await get_event( + { + "SubscriptionReference": None, + "Topic": { + "_value_1": "tns1:RuleEngine/MyRuleDetector/Package", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": None, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [{"Name": "Source", "Value": "000"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "State", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 3, 12, 9, 54, 27, tzinfo=datetime.UTC + ), + "PropertyOperation": "Initialized", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Package Detection" + assert event.platform == "binary_sensor" + assert event.device_class == "occupancy" + assert event.value + assert event.uid == (f"{TEST_UID}_tns1:RuleEngine/MyRuleDetector/Package_000") + + +async def test_hikvision_alarm(hass: HomeAssistant) -> None: + """Tests hikvision camera alarm event.""" + event = await get_event( + { + "SubscriptionReference": None, + "Topic": { + "_value_1": "tns1:Device/Trigger/tnshik:AlarmIn", + "Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet", + "_attr_1": {}, + }, + "ProducerReference": None, + "Message": { + "_value_1": { + "Source": { + "SimpleItem": [{"Name": "AlarmInToken", "Value": "AlarmIn_1"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Key": None, + "Data": { + "SimpleItem": [{"Name": "State", "Value": "true"}], + "ElementItem": [], + "Extension": None, + "_attr_1": None, + }, + "Extension": None, + "UtcTime": datetime.datetime( + 2025, 3, 13, 22, 57, 26, tzinfo=datetime.UTC + ), + "PropertyOperation": "Initialized", + "_attr_1": {}, + } + }, + } + ) + + assert event is not None + assert event.name == "Motion Alarm" + assert event.platform == "binary_sensor" + assert event.device_class == "motion" + assert event.value + assert event.uid == (f"{TEST_UID}_tns1:Device/Trigger/tnshik:AlarmIn_AlarmIn_1") From bff73ee5f8f92a886f79c20fd213074541f2f1e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 20:28:04 +0100 Subject: [PATCH 1694/1941] Add EHS test fixture to SmartThings (#140199) --- tests/components/smartthings/conftest.py | 1 + .../device_status/da_sac_ehs_000001_sub.json | 680 ++++++++++++++++++ .../devices/da_sac_ehs_000001_sub.json | 202 ++++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_sensor.ambr | 378 ++++++++++ .../smartthings/snapshots/test_switch.ambr | 47 ++ 6 files changed, 1341 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json create mode 100644 tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 8e2956440cb..3e0047e255a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -106,6 +106,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_ref_normal_000001", "vd_network_audio_002s", "iphone", + "da_sac_ehs_000001_sub", "da_wm_dw_000001", "da_wm_wd_000001", "da_wm_wd_000001_1", diff --git a/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json new file mode 100644 index 00000000000..e27c6c3de21 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_sac_ehs_000001_sub.json @@ -0,0 +1,680 @@ +{ + "components": { + "main": { + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + }, + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 8193810.0, + "deltaEnergy": 0, + "power": 2.539, + "powerEnergy": 0.009404173966911105, + "persistedEnergy": 8193810.0, + "energySaved": 0, + "start": "2025-03-09T11:14:44Z", + "end": "2025-03-09T11:14:57Z" + }, + "timestamp": "2025-03-09T11:14:57.338Z" + } + }, + "samsungce.ehsCycleData": { + "outdoor": { + "value": [ + { + "timestamp": "2025-03-09T02:00:29Z", + "data": "0038003870FF3C3B46020218019A00050000" + }, + { + "timestamp": "2025-03-09T02:05:29Z", + "data": "0034003471FF3C3C46020218019A00050000" + }, + { + "timestamp": "2025-03-09T02:10:29Z", + "data": "002D002D71FF3D3D460201C9019A00050000" + } + ], + "unit": "C", + "timestamp": "2025-03-09T11:11:30.786Z" + }, + "indoor": { + "value": [ + { + "timestamp": "2025-03-09T02:00:29Z", + "data": "5F055C050505002564000000000000000001FFFF00079440" + }, + { + "timestamp": "2025-03-09T02:05:29Z", + "data": "60055E050505002563000000000000000001FFFF00079445" + }, + { + "timestamp": "2025-03-09T02:10:29Z", + "data": "61055F050505002560000000000000000001FFFF0007944B" + } + ], + "unit": "C", + "timestamp": "2025-03-09T11:11:30.786Z" + } + }, + "custom.outingMode": { + "outingMode": { + "value": "off", + "timestamp": "2025-03-09T08:00:05.571Z" + } + }, + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-03-09T08:00:05.562Z" + } + }, + "refresh": {}, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 40, + "unit": "C", + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "maximumSetpoint": { + "value": 55, + "unit": "C", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["eco", "std", "force"], + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "airConditionerMode": { + "value": "std", + "timestamp": "2025-03-09T08:00:05.562Z" + } + }, + "samsungce.ehsFsvSettings": { + "fsvSettings": { + "value": [ + { + "id": "1031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 37, + "maxValue": 70, + "value": 70, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 15, + "maxValue": 37, + "value": 25, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1051", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 50, + "maxValue": 70, + "value": 55, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "1052", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 30, + "maxValue": 40, + "value": 40, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2011", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -20, + "maxValue": 5, + "value": -3, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 10, + "maxValue": 20, + "value": 15, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2021", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 50, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2022", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 32, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2031", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 50, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2032", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 17, + "maxValue": 70, + "value": 38, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "2091", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2092", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 4, + "value": 0, + "isValid": true + }, + { + "id": "2093", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 1, + "maxValue": 4, + "value": 4, + "isValid": true + }, + { + "id": "3011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 2, + "isValid": true + }, + { + "id": "3071", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4011", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + }, + { + "id": "4012", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": -15, + "maxValue": 20, + "value": 0, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4021", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 2, + "value": 0, + "isValid": true + }, + { + "id": "4042", + "inUse": true, + "resolution": 1, + "type": "temperature", + "minValue": 5, + "maxValue": 15, + "value": 10, + "isValid": true, + "temperatureUnit": "C" + }, + { + "id": "4061", + "inUse": true, + "resolution": 1, + "type": "etc", + "minValue": 0, + "maxValue": 1, + "value": 0, + "isValid": true + } + ], + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.information"], + "if": ["oic.if.a"], + "x.com.samsung.da.modelNum": "SAC_EHS_MONO|220614|61007400001600000400000000000000", + "x.com.samsung.da.description": "EHS", + "x.com.samsung.da.serialNum": "", + "x.com.samsung.da.versionId": "Samsung Electronics", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.number": "DB91-02102A 2023-01-11", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02100A 2020-07-10", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version" + }, + { + "x.com.samsung.da.number": "DB91-02103B 2022-06-14", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "" + }, + { + "x.com.samsung.da.number": "DB91-02450A 2022-07-06", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "EHS MONO LOWTEMP" + } + ] + } + }, + "data": { + "href": "/information/vs/0" + }, + "timestamp": "2023-08-02T14:32:28.435Z" + } + }, + "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": "SAC_EHS_MONO", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-03-09T08:00:05.514Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T11:00:27.522Z" + } + }, + "ocf": { + "st": { + "value": "2025-03-06T08:37:35Z", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnfv": { + "value": "20240611.1", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "di": { + "value": "1f98ebd0-ac48-d802-7f62-000001200100", + "timestamp": "2025-03-09T08:18:05.955Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-03-09T08:18:05.955Z" + }, + "n": { + "value": "Eco Heating System", + "timestamp": "2025-03-09T08:18:05.955Z" + }, + "mnmo": { + "value": "SAC_EHS_MONO|220614|61007400001600000400000000000000", + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "vid": { + "value": "DA-SAC-EHS-000001-SUB", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnpv": { + "value": "4.0", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "pi": { + "value": "1f98ebd0-ac48-d802-7f62-000001200100", + "timestamp": "2025-03-09T08:18:05.953Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-03-09T08:18:05.955Z" + } + }, + "remoteControlStatus": { + "remoteControlEnabled": { + "value": "true", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2023-08-02T14:36:25.480Z" + }, + "drMaxDuration": { + "value": null + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": null + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T11:00:22.880Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["remoteControlStatus", "demandResponseLoadControl"], + "timestamp": "2025-03-09T08:31:30.641Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 23070101, + "timestamp": "2023-08-02T14:32:26.195Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": null + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 54.3, + "unit": "C", + "timestamp": "2025-03-09T10:43:24.134Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2024-11-08T01:41:37.280Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-03-08T12:06:55.069Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2024-11-08T01:41:37.280Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-03-09T07:15:48.438Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 48, + "unit": "C", + "timestamp": "2025-03-09T10:58:50.857Z" + } + } + }, + "INDOOR": { + "samsungce.ehsThermostat": { + "connectionState": { + "value": "disconnected", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "samsungce.toggleSwitch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T11:14:44.775Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 39.2, + "unit": "C", + "timestamp": "2025-03-09T11:15:49.852Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-03-09T07:06:20.699Z" + }, + "maximumSetpoint": { + "value": 65, + "unit": "C", + "timestamp": "2025-03-09T07:06:20.699Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": ["auto", "cool", "heat"], + "timestamp": "2025-03-09T08:18:06.394Z" + }, + "airConditionerMode": { + "value": "heat", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "samsungce.ehsTemperatureReference": { + "temperatureReference": { + "value": "water", + "timestamp": "2025-03-09T07:06:20.699Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 25, + "unit": "C", + "timestamp": "2025-03-09T11:14:44.734Z" + } + }, + "samsungce.sacDisplayCondition": { + "switch": { + "value": "enabled", + "timestamp": "2025-03-09T08:18:06.394Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-03-09T11:14:57.238Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json new file mode 100644 index 00000000000..dffe57b3280 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_sac_ehs_000001_sub.json @@ -0,0 +1,202 @@ +{ + "items": [ + { + "deviceId": "1f98ebd0-ac48-d802-7f62-000001200100", + "name": "Eco Heating System", + "label": "Eco Heating System", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-SAC-EHS-000001-SUB", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "d22d6401-6070-4928-8e7b-b724e2dbf425", + "ownerId": "35445a41-3ae2-4bc0-6f51-31705de6b96f", + "roomId": "169ef666-a51d-4d74-9b45-e660ecd4a8d7", + "deviceTypeName": "Samsung OCF Air Conditioner", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "remoteControlStatus", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.outingMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.ehsFsvSettings", + "version": 1 + }, + { + "id": "samsungce.ehsCycleData", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "INDOOR", + "label": "INDOOR", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.ehsTemperatureReference", + "version": 1 + }, + { + "id": "samsungce.sacDisplayCondition", + "version": 1 + }, + { + "id": "samsungce.ehsThermostat", + "version": 1 + }, + { + "id": "samsungce.toggleSwitch", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-08-02T14:32:26.006Z", + "parentDeviceId": "1f98ebd0-ac48-d802-7f62-12592d8286b7", + "profile": { + "id": "54b9789f-2c8c-310d-9e14-9a84903c792b" + }, + "ocf": { + "ocfDeviceType": "oic.d.airconditioner", + "name": "Eco Heating System", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "SAC_EHS_MONO|220614|61007400001600000400000000000000", + "platformVersion": "4.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20240611.1", + "vendorId": "DA-SAC-EHS-000001-SUB", + "vendorResourceClientServerVersion": "3.2.20", + "lastSignupTime": "2023-08-02T14:32:25.282882Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index e4db4742a3b..5a3ba833cf5 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -530,6 +530,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_sac_ehs_000001_sub] + 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', + '1f98ebd0-ac48-d802-7f62-000001200100', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'SAC_EHS_MONO', + 'model_id': None, + 'name': 'Eco Heating System', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20240611.1', + 'via_device_id': None, + }) +# --- # name: test_devices[da_wm_dw_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index b6d7bd80333..d5ee2ffad22 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -3406,6 +3406,384 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_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.eco_heating_system_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': '1f98ebd0-ac48-d802-7f62-000001200100.coolingSetpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_cooling_set_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Eco Heating System Cooling set point', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_cooling_set_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_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.eco_heating_system_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8193.81', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_difference-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.eco_heating_system_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_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.eco_heating_system_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + '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': '1f98ebd0-ac48-d802-7f62-000001200100.energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_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.eco_heating_system_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Eco Heating System Power', + 'power_consumption_end': '2025-03-09T11:14:57Z', + 'power_consumption_start': '2025-03-09T11:14:44Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.539', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power_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.eco_heating_system_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100.powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eco Heating System Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.4041739669111e-06', + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_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.eco_heating_system_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': '1f98ebd0-ac48-d802-7f62-000001200100.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][sensor.eco_heating_system_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Eco Heating System Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eco_heating_system_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54.3', + }) +# --- # name: test_all_entities[da_wm_dw_000001][sensor.dishwasher_completion_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index f1b5ce8412e..08db5ffc244 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -140,6 +140,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_sac_ehs_000001_sub][switch.eco_heating_system-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.eco_heating_system', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1f98ebd0-ac48-d802-7f62-000001200100', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_sac_ehs_000001_sub][switch.eco_heating_system-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Eco Heating System', + }), + 'context': , + 'entity_id': 'switch.eco_heating_system', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][switch.dishwasher-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 43898d7845760eea9fe77cd0e9c1c2a6ac1ee190 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 20:28:51 +0100 Subject: [PATCH 1695/1941] Add valve platform to SmartThings (#140195) * 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 valve * 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 * Finalize * Fix * Fix --- .../components/smartthings/__init__.py | 1 + homeassistant/components/smartthings/valve.py | 73 ++++++++++++++++ .../smartthings/snapshots/test_valve.ambr | 50 +++++++++++ tests/components/smartthings/test_valve.py | 87 +++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 homeassistant/components/smartthings/valve.py create mode 100644 tests/components/smartthings/snapshots/test_valve.ambr create mode 100644 tests/components/smartthings/test_valve.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index f95719a8d02..538a4a16171 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -79,6 +79,7 @@ PLATFORMS = [ Platform.SCENE, Platform.SENSOR, Platform.SWITCH, + Platform.VALVE, ] diff --git a/homeassistant/components/smartthings/valve.py b/homeassistant/components/smartthings/valve.py new file mode 100644 index 00000000000..a38eb9e65c4 --- /dev/null +++ b/homeassistant/components/smartthings/valve.py @@ -0,0 +1,73 @@ +"""Support for valves through the SmartThings cloud API.""" + +from __future__ import annotations + +from pysmartthings import Attribute, Capability, Category, Command, SmartThings + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +DEVICE_CLASS_MAP: dict[Category | str, ValveDeviceClass] = { + Category.WATER_VALVE: ValveDeviceClass.WATER, + Category.GAS_VALVE: ValveDeviceClass.GAS, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add valves for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsValve(entry_data.client, entry_data.rooms, device) + for device in entry_data.devices.values() + if Capability.VALVE in device.status[MAIN] + ) + + +class SmartThingsValve(SmartThingsEntity, ValveEntity): + """Define a SmartThings valve.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_reports_position = False + _attr_name = None + + def __init__( + self, client: SmartThings, rooms: dict[str, str], device: FullDevice + ) -> None: + """Init the class.""" + super().__init__(client, device, rooms, {Capability.VALVE}) + self._attr_device_class = DEVICE_CLASS_MAP.get( + device.device.components[0].user_category + or device.device.components[0].manufacturer_category + ) + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.execute_device_command( + Capability.VALVE, + Command.OPEN, + ) + + async def async_close_valve(self) -> None: + """Close the valve.""" + await self.execute_device_command( + Capability.VALVE, + Command.CLOSE, + ) + + @property + def is_closed(self) -> bool: + """Return if the valve is closed.""" + return self.get_attribute_value(Capability.VALVE, Attribute.VALVE) == "closed" diff --git a/tests/components/smartthings/snapshots/test_valve.ambr b/tests/components/smartthings/snapshots/test_valve.ambr new file mode 100644 index 00000000000..bdb61187e3a --- /dev/null +++ b/tests/components/smartthings/snapshots/test_valve.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_all_entities[virtual_valve][valve.volvo-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.volvo', + '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': '612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[virtual_valve][valve.volvo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'volvo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.volvo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/smartthings/test_valve.py b/tests/components/smartthings/test_valve.py new file mode 100644 index 00000000000..f0ba34c8264 --- /dev/null +++ b/tests/components/smartthings/test_valve.py @@ -0,0 +1,87 @@ +"""Test for the SmartThings valve platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.VALVE) + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_OPEN_VALVE, Command.OPEN), + (SERVICE_CLOSE_VALVE, Command.CLOSE), + ], +) +async def test_valve_open_close( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test valve open and close command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VALVE_DOMAIN, + action, + {ATTR_ENTITY_ID: "valve.volvo"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", Capability.VALVE, command, MAIN + ) + + +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +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("valve.volvo").state == ValveState.CLOSED + + await trigger_update( + hass, + devices, + "612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3", + Capability.VALVE, + Attribute.VALVE, + "open", + ) + + assert hass.states.get("valve.volvo").state == ValveState.OPEN From 16556fa2a9ec089acf472394febb48aef35bee62 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 10:06:00 -1000 Subject: [PATCH 1696/1941] Bump PySwitchBot to 0.57.1 (#140681) changelog: https://github.com/sblibs/pySwitchbot/compare/0.56.1...0.57.1 fixes #140405 --- 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 567a33a8f43..85d5bcf6436 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.1"] + "requirements": ["PySwitchbot==0.57.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67a7a1e8c1f..dea5efd2c33 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.1 +PySwitchbot==0.57.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b80ad271ffa..90e81a93c7c 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.1 +PySwitchbot==0.57.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From ed0b1f58dc4bafb0472b8e2c046ae8c9a9f1fb82 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 15 Mar 2025 21:30:19 +0100 Subject: [PATCH 1697/1941] Bump aioautomower to 2025.3.1 (#140682) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/husqvarna_automower/fixtures/mower.json | 4 +++- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 2 ++ 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 0eabf5ec0d6..45d4df95a04 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2025.1.1"] + "requirements": ["aioautomower==2025.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index dea5efd2c33..bf7db107b74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.1.1 +aioautomower==2025.3.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90e81a93c7c..9714d3003f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.1.1 +aioautomower==2025.3.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 8ab2f96e42f..ee368bf6546 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -176,13 +176,15 @@ ], "statistics": { "cuttingBladeUsageTime": 123, + "downTime": 123, "numberOfChargingCycles": 1380, "numberOfCollisions": 11396, "totalChargingTime": 4334400, "totalCuttingTime": 4194000, "totalDriveDistance": 1780272, "totalRunningTime": 4564800, - "totalSearchingTime": 370800 + "totalSearchingTime": 370800, + "upTime": 456 }, "stayOutZones": { "dirty": false, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 2dab82451a6..9d5004c8f6d 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -95,6 +95,7 @@ }), 'statistics': dict({ 'cutting_blade_usage_time': 123, + 'downtime': 123, 'number_of_charging_cycles': 1380, 'number_of_collisions': 11396, 'total_charging_time': 4334400, @@ -102,6 +103,7 @@ 'total_drive_distance': 1780272, 'total_running_time': 4564800, 'total_searching_time': 370800, + 'uptime': 456, }), 'stay_out_zones': dict({ 'dirty': False, From 76244e0d6b488396e4dd496e1be6c48adb8545e9 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sat, 15 Mar 2025 17:07:45 -0400 Subject: [PATCH 1698/1941] Fix Elk-M1 missing TLS 1.2 check (#140672) * Fix for missing TLS 1.2 check * Fix error message. * combine startswith --------- Co-authored-by: J. Nick Koston --- homeassistant/components/elkm1/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 5286b7ad66f..4bf51b99de1 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -101,9 +101,11 @@ def hostname_from_url(url: str) -> str: def _host_validator(config: dict[str, str]) -> dict[str, str]: """Validate that a host is properly configured.""" - if config[CONF_HOST].startswith("elks://"): + if config[CONF_HOST].startswith(("elks://", "elksv1_2://")): if CONF_USERNAME not in config or CONF_PASSWORD not in config: - raise vol.Invalid("Specify username and password for elks://") + raise vol.Invalid( + "Specify username and password for elks:// or elksv1_2://" + ) elif not config[CONF_HOST].startswith("elk://") and not config[ CONF_HOST ].startswith("serial://"): From d69bcc02b0800b3794e34c04f3301371a1be3615 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 15 Mar 2025 12:00:23 -1000 Subject: [PATCH 1699/1941] Pass scanner mode to shelly Bluetooth scanner (#140689) habluetooth will eventually be able to make better decisions on how to route data based on the scanning mode. --- .../components/shelly/bluetooth/__init__.py | 18 ++++++++++++++++-- tests/components/shelly/test_diagnostics.py | 10 ++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index cad1b9f044d..2b772bd1b78 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -7,7 +7,10 @@ from typing import TYPE_CHECKING from aioshelly.ble import async_start_scanner, create_scanner from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION -from homeassistant.components.bluetooth import async_register_scanner +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + async_register_scanner, +) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from ..const import BLEScannerMode @@ -15,6 +18,11 @@ from ..const import BLEScannerMode if TYPE_CHECKING: from ..coordinator import ShellyRpcCoordinator +BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE = { + BLEScannerMode.PASSIVE: BluetoothScanningMode.PASSIVE, + BLEScannerMode.ACTIVE: BluetoothScanningMode.ACTIVE, +} + async def async_connect_scanner( hass: HomeAssistant, @@ -25,7 +33,13 @@ async def async_connect_scanner( """Connect scanner.""" device = coordinator.device entry = coordinator.config_entry - scanner = create_scanner(coordinator.bluetooth_source, entry.title) + bluetooth_scanning_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode] + scanner = create_scanner( + coordinator.bluetooth_source, + entry.title, + requested_mode=bluetooth_scanning_mode, + current_mode=bluetooth_scanning_mode, + ) unload_callbacks = [ async_register_scanner( hass, diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index d89f21f5992..84ebd50c425 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -109,8 +109,14 @@ async def test_rpc_config_entry_diagnostics( "bluetooth": { "scanner": { "connectable": False, - "current_mode": None, - "requested_mode": None, + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, "discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY}, "discovered_devices_and_advertisement_data": [ { From 675b6842902ca8a4689e842b3ed9c6ff01142e21 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 15 Mar 2025 23:09:55 +0100 Subject: [PATCH 1700/1941] Check Celsius in SmartThings oven setpoint (#140687) --- homeassistant/components/smartthings/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index fd447da427e..1437cbe6000 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -575,7 +575,8 @@ CAPABILITY_TO_SENSORS: dict[ translation_key="oven_setpoint", device_class=SensorDeviceClass.TEMPERATURE, use_temperature_unit=True, - value_fn=lambda value: value if value != 0 else None, + # Set the value to None if it is 0 F (-17 C) + value_fn=lambda value: None if value in {0, -17} else value, ) ] }, From 91e0f1cb466a01acd42705e2435a8a728961f0a8 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 15 Mar 2025 18:40:02 -0400 Subject: [PATCH 1701/1941] Add voip_utils to voip loggers (#140695) * Add voip_utils to voip loggers * Sort --- homeassistant/components/voip/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 1e4c249c720..dfd397fde14 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -6,6 +6,7 @@ "dependencies": ["assist_pipeline", "assist_satellite", "intent", "network"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", + "loggers": ["voip_utils"], "quality_scale": "internal", "requirements": ["voip-utils==0.3.1"] } From 4050c216ed213bfe09d76d45381f55446ee9a005 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 16 Mar 2025 02:57:45 +0100 Subject: [PATCH 1702/1941] Add Remote calendar integration (#138862) * Add remote_calendar with storage * Use coordinator and remove storage * cleanup * cleanup * remove init from config_flow * add some tests * some fixes * test-before-setup * fix error handling * remove unneeded code * fix updates * load calendar in the event loop * allow redirects * test_update_failed * tests * address review * use error from local_calendar * adress more comments * remove unique_id * add unique entity_id * add excemption * abort_entries_match * unique_id * add , * cleanup * deduplicate call * don't raise for status end de-nest * multiline * test * tests * use raise_for_status again * use respx * just use config_entry argument that already is defined * Also assert on the config entry result title and data * improve config_flow * update quality scale * address review --------- Co-authored-by: Allen Porter --- CODEOWNERS | 2 + .../components/remote_calendar/__init__.py | 33 ++ .../components/remote_calendar/calendar.py | 92 ++++ .../components/remote_calendar/config_flow.py | 70 ++++ .../components/remote_calendar/const.py | 4 + .../components/remote_calendar/coordinator.py | 67 +++ .../components/remote_calendar/manifest.json | 12 + .../remote_calendar/quality_scale.yaml | 100 +++++ .../components/remote_calendar/strings.json | 33 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + script/hassfest/translations.py | 1 + tests/components/remote_calendar/__init__.py | 11 + tests/components/remote_calendar/conftest.py | 89 ++++ .../remote_calendar/test_calendar.py | 394 ++++++++++++++++++ .../remote_calendar/test_config_flow.py | 276 ++++++++++++ tests/components/remote_calendar/test_init.py | 73 ++++ 19 files changed, 1266 insertions(+) create mode 100644 homeassistant/components/remote_calendar/__init__.py create mode 100644 homeassistant/components/remote_calendar/calendar.py create mode 100644 homeassistant/components/remote_calendar/config_flow.py create mode 100644 homeassistant/components/remote_calendar/const.py create mode 100644 homeassistant/components/remote_calendar/coordinator.py create mode 100644 homeassistant/components/remote_calendar/manifest.json create mode 100644 homeassistant/components/remote_calendar/quality_scale.yaml create mode 100644 homeassistant/components/remote_calendar/strings.json create mode 100644 tests/components/remote_calendar/__init__.py create mode 100644 tests/components/remote_calendar/conftest.py create mode 100644 tests/components/remote_calendar/test_calendar.py create mode 100644 tests/components/remote_calendar/test_config_flow.py create mode 100644 tests/components/remote_calendar/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 4e8f78ca873..cfc37f6f908 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1252,6 +1252,8 @@ build.json @home-assistant/supervisor /tests/components/refoss/ @ashionky /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core +/homeassistant/components/remote_calendar/ @Thomas55555 +/tests/components/remote_calendar/ @Thomas55555 /homeassistant/components/renault/ @epenet /tests/components/renault/ @epenet /homeassistant/components/renson/ @jimmyd-be diff --git a/homeassistant/components/remote_calendar/__init__.py b/homeassistant/components/remote_calendar/__init__.py new file mode 100644 index 00000000000..910eeae8268 --- /dev/null +++ b/homeassistant/components/remote_calendar/__init__.py @@ -0,0 +1,33 @@ +"""The Remote Calendar integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RemoteCalendarConfigEntry, RemoteCalendarDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: RemoteCalendarConfigEntry +) -> bool: + """Set up Remote Calendar from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + coordinator = RemoteCalendarDataUpdateCoordinator(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: RemoteCalendarConfigEntry +) -> bool: + """Handle unload of an entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py new file mode 100644 index 00000000000..bd83a5f18cc --- /dev/null +++ b/homeassistant/components/remote_calendar/calendar.py @@ -0,0 +1,92 @@ +"""Calendar platform for a Remote Calendar.""" + +from datetime import datetime +import logging + +from ical.event import Event + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from . import RemoteCalendarConfigEntry +from .const import CONF_CALENDAR_NAME +from .coordinator import RemoteCalendarDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RemoteCalendarConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the remote calendar platform.""" + coordinator = entry.runtime_data + entity = RemoteCalendarEntity(coordinator, entry) + async_add_entities([entity]) + + +class RemoteCalendarEntity( + CoordinatorEntity[RemoteCalendarDataUpdateCoordinator], CalendarEntity +): + """A calendar entity backed by a remote iCalendar url.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: RemoteCalendarDataUpdateCoordinator, + entry: RemoteCalendarConfigEntry, + ) -> None: + """Initialize RemoteCalendarEntity.""" + super().__init__(coordinator) + self._attr_name = entry.data[CONF_CALENDAR_NAME] + self._attr_unique_id = entry.entry_id + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + now = dt_util.now() + events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + events = self.coordinator.data.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, + ) + return [_get_calendar_event(event) for event in events] + + +def _get_calendar_event(event: Event) -> CalendarEvent: + """Return a CalendarEvent from an API event.""" + + return CalendarEvent( + summary=event.summary, + start=( + dt_util.as_local(event.start) + if isinstance(event.start, datetime) + else event.start + ), + end=( + dt_util.as_local(event.end) + if isinstance(event.end, datetime) + else event.end + ), + description=event.description, + uid=event.uid, + rrule=event.rrule.as_rrule_str() if event.rrule else None, + recurrence_id=event.recurrence_id, + location=event.location, + ) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py new file mode 100644 index 00000000000..03d0e7ea96a --- /dev/null +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for Remote Calendar integration.""" + +import logging +from typing import Any + +from httpx import HTTPError, InvalidURL +from ical.calendar_stream import IcsCalendarStream +from ical.exceptions import CalendarParseError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_URL +from homeassistant.helpers.httpx_client import get_async_client + +from .const import CONF_CALENDAR_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CALENDAR_NAME): str, + vol.Required(CONF_URL): str, + } +) + + +class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Remote Calendar.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + errors: dict = {} + _LOGGER.debug("User input: %s", user_input) + self._async_abort_entries_match( + {CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]} + ) + self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) + client = get_async_client(self.hass) + try: + res = await client.get(user_input[CONF_URL], follow_redirects=True) + res.raise_for_status() + except (HTTPError, InvalidURL) as err: + errors["base"] = "cannot_connect" + _LOGGER.debug("An error occurred: %s", err) + else: + try: + await self.hass.async_add_executor_job( + IcsCalendarStream.calendar_from_ics, res.text + ) + except CalendarParseError as err: + errors["base"] = "invalid_ics_file" + _LOGGER.debug("Invalid .ics file: %s", err) + else: + return self.async_create_entry( + title=user_input[CONF_CALENDAR_NAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/remote_calendar/const.py b/homeassistant/components/remote_calendar/const.py new file mode 100644 index 00000000000..060d7633111 --- /dev/null +++ b/homeassistant/components/remote_calendar/const.py @@ -0,0 +1,4 @@ +"""Constants for the Remote Calendar integration.""" + +DOMAIN = "remote_calendar" +CONF_CALENDAR_NAME = "calendar_name" diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py new file mode 100644 index 00000000000..7ee95695e61 --- /dev/null +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -0,0 +1,67 @@ +"""Data UpdateCoordinator for the Remote Calendar integration.""" + +from datetime import timedelta +import logging + +from httpx import HTTPError, InvalidURL +from ical.calendar import Calendar +from ical.calendar_stream import IcsCalendarStream +from ical.exceptions import CalendarParseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(days=1) + + +class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): + """Class to manage fetching calendar data.""" + + config_entry: RemoteCalendarConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: RemoteCalendarConfigEntry, + ) -> None: + """Initialize data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + always_update=True, + ) + self._etag = None + self._client = get_async_client(hass) + self._url = config_entry.data[CONF_URL] + + async def _async_update_data(self) -> Calendar: + """Update data from the url.""" + try: + res = await self._client.get(self._url, follow_redirects=True) + res.raise_for_status() + except (HTTPError, InvalidURL) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unable_to_fetch", + translation_placeholders={"err": str(err)}, + ) from err + try: + return await self.hass.async_add_executor_job( + IcsCalendarStream.calendar_from_ics, res.text + ) + except CalendarParseError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="unable_to_parse", + translation_placeholders={"err": str(err)}, + ) from err diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json new file mode 100644 index 00000000000..260f465f993 --- /dev/null +++ b/homeassistant/components/remote_calendar/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "remote_calendar", + "name": "Remote Calendar", + "codeowners": ["@Thomas55555"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/remote_calendar", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["ical"], + "quality_scale": "silver", + "requirements": ["ical==8.3.0"] +} diff --git a/homeassistant/components/remote_calendar/quality_scale.yaml b/homeassistant/components/remote_calendar/quality_scale.yaml new file mode 100644 index 00000000000..3693d75f2cf --- /dev/null +++ b/homeassistant/components/remote_calendar/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + config-flow: done + test-before-configure: done + unique-config-entry: + status: exempt + comment: | + No unique identifier. + config-flow-test-coverage: done + runtime-data: done + test-before-setup: done + appropriate-polling: done + entity-unique-id: done + has-entity-name: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + dependency-transparency: done + action-setup: + status: exempt + comment: | + There are no actions. + common-modules: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-actions: + status: exempt + comment: No actions available. + brands: done + # Silver + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: | + There are no actions. + reauthentication-flow: + status: exempt + comment: | + There is no authentication required. + parallel-updates: done + test-coverage: done + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: no configuration options + + # Gold + devices: + status: exempt + comment: No devices. One URL is always assigned to one calendar. + diagnostics: + status: todo + comment: Diagnostics not implemented, yet. + discovery-update-info: + status: todo + comment: No discovery protocol available. + discovery: + status: exempt + comment: No discovery protocol available. + 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: + status: exempt + comment: No devices. One URL is always assigned to one calendar. + entity-category: done + entity-device-class: + status: exempt + comment: No devices classes for calendars. + entity-disabled-by-default: + status: exempt + comment: Only one entity per entry. + entity-translations: + status: exempt + comment: Entity name is defined by the user, so no translation possible. + exception-translations: done + icon-translations: + status: exempt + comment: Only the default icon is used. + reconfiguration-flow: + status: exempt + comment: no configuration possible + repair-issues: todo + stale-devices: + status: exempt + comment: No devices. One URL is always assigned to one calendar. + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json new file mode 100644 index 00000000000..c833676a410 --- /dev/null +++ b/homeassistant/components/remote_calendar/strings.json @@ -0,0 +1,33 @@ +{ + "title": "Remote Calendar", + "config": { + "step": { + "user": { + "description": "Please choose a name for the calendar to be imported", + "data": { + "calendar_name": "Calendar Name", + "url": "Calendar URL" + }, + "data_description": { + "calendar_name": "The name of the calendar shown in th UI.", + "url": "The URL of the remote calendar." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_ics_file": "[%key:component::local_calendar::config::error::invalid_ics_file%]" + } + }, + "exceptions": { + "unable_to_fetch": { + "message": "Unable to fetch calendar data: {err}" + }, + "unable_to_parse": { + "message": "Unable to parse calendar data: {err}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8284f77ef94..a9c4a6b0a93 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -513,6 +513,7 @@ FLOWS = { "rdw", "recollect_waste", "refoss", + "remote_calendar", "renault", "renson", "reolink", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b916526aaf3..55fcb08ba92 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5265,6 +5265,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "remote_calendar": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "renault": { "name": "Renault", "integration_type": "hub", @@ -7690,6 +7695,7 @@ "plant", "proximity", "random", + "remote_calendar", "rpi_power", "schedule", "season", diff --git a/requirements_all.txt b/requirements_all.txt index bf7db107b74..98ce16a4560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1193,6 +1193,7 @@ ibmiotf==0.3.4 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo +# homeassistant.components.remote_calendar ical==8.3.0 # homeassistant.components.caldav diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9714d3003f6..f6880d377be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1010,6 +1010,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.google # homeassistant.components.local_calendar # homeassistant.components.local_todo +# homeassistant.components.remote_calendar ical==8.3.0 # homeassistant.components.caldav diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index c257f185f51..8e59bd8582e 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -40,6 +40,7 @@ ALLOW_NAME_TRANSLATION = { "local_ip", "local_todo", "nmap_tracker", + "remote_calendar", "rpi_power", "swiss_public_transport", "waze_travel_time", diff --git a/tests/components/remote_calendar/__init__.py b/tests/components/remote_calendar/__init__.py new file mode 100644 index 00000000000..2ffb157f072 --- /dev/null +++ b/tests/components/remote_calendar/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the Remote Calendar integration.""" + +from homeassistant.core import HomeAssistant + +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) diff --git a/tests/components/remote_calendar/conftest.py b/tests/components/remote_calendar/conftest.py new file mode 100644 index 00000000000..bf5184bbf54 --- /dev/null +++ b/tests/components/remote_calendar/conftest.py @@ -0,0 +1,89 @@ +"""Fixtures for Remote Calendar.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +import textwrap +from typing import Any +import urllib + +import pytest + +from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +CALENDAR_NAME = "Home Assistant Events" +TEST_ENTITY = "calendar.home_assistant_events" +CALENDER_URL = "https://some.calendar.com/calendar.ics" +FRIENDLY_NAME = "Home Assistant Events" + + +@pytest.fixture(name="time_zone") +def mock_time_zone() -> str: + """Fixture for time zone to use in tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + return "America/Regina" + + +@pytest.fixture(autouse=True) +async def set_time_zone(hass: HomeAssistant, time_zone: str): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + await hass.config.async_set_time_zone(time_zone) + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for mock configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_CALENDAR_NAME: CALENDAR_NAME, CONF_URL: CALENDER_URL} + ) + + +type GetEventsFn = Callable[[str, str], Awaitable[list[dict[str, Any]]]] + + +@pytest.fixture(name="get_events") +def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + return await response.json() + + return _fetch + + +def event_fields(data: dict[str, str]) -> dict[str, str]: + """Filter event API response to minimum fields.""" + return { + k: data[k] + for k in ("summary", "start", "end", "recurrence_id", "location") + if data.get(k) + } + + +@pytest.fixture(name="ics_content") +def mock_ics_content(request: pytest.FixtureRequest) -> str: + """Fixture to allow tests to set initial ics content for the calendar store.""" + default_content = textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + """ + ) + return request.param if hasattr(request, "param") else default_content diff --git a/tests/components/remote_calendar/test_calendar.py b/tests/components/remote_calendar/test_calendar.py new file mode 100644 index 00000000000..6ae817321c3 --- /dev/null +++ b/tests/components/remote_calendar/test_calendar.py @@ -0,0 +1,394 @@ +"""Tests for calendar platform of Remote Calendar.""" + +from datetime import datetime +import textwrap + +from httpx import Response +import pytest +import respx + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import ( + CALENDER_URL, + FRIENDLY_NAME, + TEST_ENTITY, + GetEventsFn, + event_fields, +) + +from tests.common import MockConfigEntry + + +@respx.mock +async def test_empty_calendar( + hass: HomeAssistant, + config_entry: MockConfigEntry, + get_events: GetEventsFn, +) -> None: + """Test querying the API and fetching events.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//hacksw/handcal//NONSGML v1.0//EN + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00") + assert len(events) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == FRIENDLY_NAME + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": FRIENDLY_NAME, + } + + +@pytest.mark.parametrize( + "ics_content", + [ + textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART;TZID=Europe/Berlin:19970714T190000 + DTEND;TZID=Europe/Berlin:19970715T060000 + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART;TZID=America/Regina:19970714T110000 + DTEND;TZID=America/Regina:19970714T220000 + END:VEVENT + END:VCALENDAR + """ + ), + textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Bastille Day Party + DTSTART;TZID=America/Los_Angeles:19970714T100000 + DTEND;TZID=America/Los_Angeles:19970714T210000 + END:VEVENT + END:VCALENDAR + """ + ), + ], +) +@respx.mock +async def test_api_date_time_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, + ics_content: str, +) -> None: + """Test an event with a start/end date time.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + } + ] + + # Query events in UTC + + # Time range before event + events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T16:00:00Z") + assert len(events) == 0 + # Time range after event + events = await get_events("1997-07-15T05:00:00Z", "1997-07-15T06:00:00Z") + assert len(events) == 0 + + # Overlap with event start + events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T18:00:00Z") + assert len(events) == 1 + # Overlap with event end + events = await get_events("1997-07-15T03:00:00Z", "1997-07-15T06:00:00Z") + assert len(events) == 1 + + # Query events overlapping with start and end but in another timezone + events = await get_events("1997-07-12T23:00:00-01:00", "1997-07-14T17:00:00-01:00") + assert len(events) == 1 + events = await get_events("1997-07-15T02:00:00-01:00", "1997-07-15T05:00:00-01:00") + assert len(events) == 1 + + +@respx.mock +async def test_api_date_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a start/end date all day event.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + DTSTART:20070628 + DTEND:20070709 + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + events = await get_events("2007-06-20T00:00:00", "2007-07-20T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Festival International de Jazz de Montreal", + "start": {"date": "2007-06-28"}, + "end": {"date": "2007-07-09"}, + } + ] + + # Time range before event (timezone is -6) + events = await get_events("2007-06-26T00:00:00Z", "2007-06-28T01:00:00Z") + assert len(events) == 0 + # Time range after event + events = await get_events("2007-07-10T00:00:00Z", "2007-07-11T00:00:00Z") + assert len(events) == 0 + + # Overlap with event start (timezone is -6) + events = await get_events("2007-06-26T00:00:00Z", "2007-06-28T08:00:00Z") + assert len(events) == 1 + # Overlap with event end + events = await get_events("2007-07-09T00:00:00Z", "2007-07-11T00:00:00Z") + assert len(events) == 1 + + +@pytest.mark.freeze_time(datetime(2007, 6, 28, 12)) +@respx.mock +async def test_active_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a start/end date time.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + LOCATION:Montreal + DTSTART:20070628 + DTEND:20070709 + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == FRIENDLY_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": FRIENDLY_NAME, + "message": "Festival International de Jazz de Montreal", + "all_day": True, + "description": "", + "location": "Montreal", + "start_time": "2007-06-28 00:00:00", + "end_time": "2007-07-09 00:00:00", + } + + +@pytest.mark.freeze_time(datetime(2007, 6, 27, 12)) +@respx.mock +async def test_upcoming_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a start/end date time.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + SUMMARY:Festival International de Jazz de Montreal + LOCATION:Montreal + DTSTART:20070628 + DTEND:20070709 + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == FRIENDLY_NAME + assert state.state == STATE_OFF + assert dict(state.attributes) == { + "friendly_name": FRIENDLY_NAME, + "message": "Festival International de Jazz de Montreal", + "all_day": True, + "description": "", + "location": "Montreal", + "start_time": "2007-06-28 00:00:00", + "end_time": "2007-07-09 00:00:00", + } + + +@respx.mock +async def test_recurring_event( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test an event with a recurrence rule.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTSTART:20220829T090000 + DTEND:20220829T100000 + SUMMARY:Monday meeting + RRULE:FREQ=WEEKLY;BYDAY=MO + END:VEVENT + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + + events = await get_events("2022-08-20T00:00:00", "2022-09-20T00:00:00") + assert list(map(event_fields, events)) == [ + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-08-29T09:00:00-06:00"}, + "end": {"dateTime": "2022-08-29T10:00:00-06:00"}, + "recurrence_id": "20220829T090000", + }, + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-09-05T09:00:00-06:00"}, + "end": {"dateTime": "2022-09-05T10:00:00-06:00"}, + "recurrence_id": "20220905T090000", + }, + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-09-12T09:00:00-06:00"}, + "end": {"dateTime": "2022-09-12T10:00:00-06:00"}, + "recurrence_id": "20220912T090000", + }, + { + "summary": "Monday meeting", + "start": {"dateTime": "2022-09-19T09:00:00-06:00"}, + "end": {"dateTime": "2022-09-19T10:00:00-06:00"}, + "recurrence_id": "20220919T090000", + }, + ] + + +@respx.mock +@pytest.mark.parametrize( + ("time_zone", "event_order"), + [ + ("America/Los_Angeles", ["One", "Two", "All Day Event"]), + ("America/Regina", ["One", "Two", "All Day Event"]), + ("UTC", ["One", "All Day Event", "Two"]), + ("Asia/Tokyo", ["All Day Event", "One", "Two"]), + ], +) +async def test_all_day_iter_order( + get_events: GetEventsFn, + hass: HomeAssistant, + config_entry: MockConfigEntry, + event_order: list[str], +) -> None: + """Test the sort order of an all day events depending on the time zone.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=textwrap.dedent( + """\ + BEGIN:VCALENDAR + + BEGIN:VEVENT + DTSTART:20221008 + DTEND:20221009 + SUMMARY:All Day Event + END:VEVENT + + BEGIN:VEVENT + DTSTART:20221007T230000Z + DTEND:20221008T233000Z + SUMMARY:One + END:VEVENT + + BEGIN:VEVENT + DTSTART:20221008T010000Z + DTEND:20221008T020000Z + SUMMARY:Two + END:VEVENT + + END:VCALENDAR + """ + ), + ) + ) + await setup_integration(hass, config_entry) + + events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z") + assert [event["summary"] for event in events] == event_order diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py new file mode 100644 index 00000000000..626bc2c6e03 --- /dev/null +++ b/tests/components/remote_calendar/test_config_flow.py @@ -0,0 +1,276 @@ +"""Test the Remote Calendar config flow.""" + +from httpx import ConnectError, Response, UnsupportedProtocol +import pytest +import respx + +from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration +from .conftest import CALENDAR_NAME, CALENDER_URL + +from tests.common import MockConfigEntry + + +@respx.mock +async def test_form_import_ics(hass: HomeAssistant, ics_content: str) -> None: + """Test we get the import form.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == CALENDAR_NAME + assert result2["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@pytest.mark.parametrize( + ("side_effect"), + [ + ConnectError("Connection failed"), + UnsupportedProtocol("Unsupported protocol"), + ], +) +@respx.mock +async def test_form_inavild_url( + hass: HomeAssistant, + side_effect: Exception, + ics_content: str, +) -> None: + """Test we get the import form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + respx.get("invalid-url.com").mock(side_effect=side_effect) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "invalid-url.com", + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@pytest.mark.parametrize( + ("url", "log_message"), + [ + ( + "unsupported://protocol.com", # Test for httpx.UnsupportedProtocol + "Request URL has an unsupported protocol 'unsupported://'", + ), + ( + "invalid-url", # Test for httpx.ProtocolError + "Request URL is missing an 'http://' or 'https://' protocol", + ), + ( + "https://example.com:abc/", # Test for httpx.InvalidURL + "Invalid port: 'abc'", + ), + ], +) +async def test_unsupported_inputs( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, url: str, log_message: str +) -> None: + """Test that an unsupported inputs results in a form error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: url, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert log_message in caplog.text + ## It's not possible to test a successful config flow because, we need to mock httpx.get here + ## and then the exception isn't raised anymore. + + +@respx.mock +async def test_form_http_status_error(hass: HomeAssistant, ics_content: str) -> None: + """Test we http status.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=403, + ) + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +@respx.mock +async def test_no_valid_calendar(hass: HomeAssistant, ics_content: str) -> None: + """Test invalid ics content.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text="blabla", + ) + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_ics_file"} + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == CALENDAR_NAME + assert result3["data"] == { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: CALENDER_URL, + } + + +async def test_duplicate_name( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test two calendars cannot be added with the same name.""" + + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: CALENDAR_NAME, + CONF_URL: "http://other-calendar.com", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_duplicate_url( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test two calendars cannot be added with the same url.""" + + await setup_integration(hass, config_entry) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CALENDAR_NAME: "new name", + CONF_URL: CALENDER_URL, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/remote_calendar/test_init.py b/tests/components/remote_calendar/test_init.py new file mode 100644 index 00000000000..08f5c8b45c0 --- /dev/null +++ b/tests/components/remote_calendar/test_init.py @@ -0,0 +1,73 @@ +"""Tests for init platform of Remote Calendar.""" + +from httpx import ConnectError, Response, UnsupportedProtocol +import pytest +import respx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import CALENDER_URL, TEST_ENTITY + +from tests.common import MockConfigEntry + + +@respx.mock +async def test_load_unload( + hass: HomeAssistant, config_entry: MockConfigEntry, ics_content: str +) -> None: + """Test loading and unloading a config entry.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == STATE_OFF + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@respx.mock +async def test_raise_for_status( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test update failed using respx to simulate HTTP exceptions.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=403, + ) + ) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + "side_effect", + [ + ConnectError("Connection failed"), + UnsupportedProtocol("Unsupported protocol"), + ValueError("Invalid response"), + ], +) +@respx.mock +async def test_update_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test update failed using respx to simulate different exceptions.""" + respx.get(CALENDER_URL).mock(side_effect=side_effect) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY From 3a6ddcf4285df8ed1aefaf330db24a7b97e368c3 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 16 Mar 2025 05:24:27 +0300 Subject: [PATCH 1703/1941] Bump openai to 1.66.3 (#140690) --- homeassistant/components/openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index cc1c56b0927..a4e46f6457b 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.65.2"] + "requirements": ["openai==1.66.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 98ce16a4560..0b8d1da4499 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1581,7 +1581,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.65.2 +openai==1.66.3 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6880d377be..99cdb5004a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1326,7 +1326,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.65.2 +openai==1.66.3 # homeassistant.components.openerz openerz-api==0.3.0 From 7b9ea63f171f3c7fb9f186a38833e5ea383497d4 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 16 Mar 2025 03:26:18 +0100 Subject: [PATCH 1704/1941] Split out yaml loading into own package (#140683) * Split out yaml loading into library * Code review * Code review * Fix check config script --- homeassistant/helpers/check_config.py | 2 +- homeassistant/package_constraints.txt | 1 + homeassistant/scripts/check_config.py | 8 +- homeassistant/util/yaml/__init__.py | 16 +- homeassistant/util/yaml/const.py | 3 - homeassistant/util/yaml/dumper.py | 95 +---- homeassistant/util/yaml/input.py | 51 +-- homeassistant/util/yaml/loader.py | 501 +++----------------------- homeassistant/util/yaml/objects.py | 50 +-- pyproject.toml | 1 + requirements.txt | 1 + tests/common.py | 2 +- tests/helpers/test_service.py | 4 +- tests/snapshots/test_config.ambr | 10 +- tests/util/yaml/test_init.py | 4 +- 15 files changed, 71 insertions(+), 678 deletions(-) delete mode 100644 homeassistant/util/yaml/const.py diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 0841585e1a1..836536da9ee 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -8,6 +8,7 @@ import os from pathlib import Path from typing import NamedTuple, Self +from annotatedyaml import loader as yaml_loader import voluptuous as vol from homeassistant import loader @@ -29,7 +30,6 @@ from homeassistant.requirements import ( async_clear_install_history, async_get_integration_with_requirements, ) -from homeassistant.util.yaml import loader as yaml_loader from . import config_validation as cv from .typing import ConfigType diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 59a56c8ea15..3a13b59eced 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,6 +10,7 @@ aiohttp==3.11.13 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 +annotatedyaml==0.1.1 astral==2.2 async-interrupt==1.2.2 async-upnp-client==0.43.0 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index a24568e9a6f..ca3df5080b5 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -12,6 +12,9 @@ import os from typing import Any from unittest.mock import patch +from annotatedyaml import loader as yaml_loader +from annotatedyaml.loader import Secrets + from homeassistant import core, loader from homeassistant.config import get_default_config_dir from homeassistant.config_entries import ConfigEntries @@ -23,7 +26,6 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.check_config import async_check_ha_config_file -from homeassistant.util.yaml import Secrets, loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs @@ -31,9 +33,9 @@ REQUIREMENTS = ("colorlog==6.8.2",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { - "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), + "load": ("annotatedyaml.loader.load_yaml", yaml_loader.load_yaml), "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict), - "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml), + "secrets": ("annotatedyaml.loader.secret_yaml", yaml_loader.secret_yaml), } PATCHES: dict[str, Any] = {} diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index 3b1f5c4cc0a..a3c0ab3d083 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -1,17 +1,11 @@ """YAML utility functions.""" -from .const import SECRET_YAML +from annotatedyaml import SECRET_YAML, YamlTypeError +from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute +from annotatedyaml.objects import Input + from .dumper import dump, save_yaml -from .input import UndefinedSubstitution, extract_inputs, substitute -from .loader import ( - Secrets, - YamlTypeError, - load_yaml, - load_yaml_dict, - parse_yaml, - secret_yaml, -) -from .objects import Input +from .loader import Secrets, load_yaml, load_yaml_dict, parse_yaml, secret_yaml __all__ = [ "SECRET_YAML", diff --git a/homeassistant/util/yaml/const.py b/homeassistant/util/yaml/const.py deleted file mode 100644 index 811c7d149f7..00000000000 --- a/homeassistant/util/yaml/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants.""" - -SECRET_YAML = "secrets.yaml" diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index 61772b6989d..059be2c1c5b 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -1,96 +1,5 @@ """Custom dumper and representers.""" -from collections import OrderedDict -from typing import Any +from annotatedyaml.dumper import add_representer, dump, represent_odict, save_yaml -import yaml - -from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass - -# mypy: allow-untyped-calls, no-warn-return-any - - -try: - from yaml import CSafeDumper as FastestAvailableSafeDumper -except ImportError: - from yaml import ( # type: ignore[assignment] - SafeDumper as FastestAvailableSafeDumper, - ) - - -def dump(_dict: dict | list) -> str: - """Dump YAML to a string and remove null.""" - return yaml.dump( - _dict, - default_flow_style=False, - allow_unicode=True, - sort_keys=False, - Dumper=FastestAvailableSafeDumper, - ).replace(": null\n", ":\n") - - -def save_yaml(path: str, data: dict) -> None: - """Save YAML to a file.""" - # Dump before writing to not truncate the file if dumping fails - str_data = dump(data) - with open(path, "w", encoding="utf-8") as outfile: - outfile.write(str_data) - - -# From: https://gist.github.com/miracle2k/3184458 -def represent_odict( # type: ignore[no-untyped-def] - dumper, tag, mapping, flow_style=None -) -> yaml.MappingNode: - """Like BaseRepresenter.represent_mapping but does not issue the sort().""" - value: list = [] - node = yaml.MappingNode(tag, value, flow_style=flow_style) - if dumper.alias_key is not None: - dumper.represented_objects[dumper.alias_key] = node - best_style = True - if hasattr(mapping, "items"): - mapping = mapping.items() - for item_key, item_value in mapping: - node_key = dumper.represent_data(item_key) - node_value = dumper.represent_data(item_value) - if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style): - best_style = False - if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style): - best_style = False - value.append((node_key, node_value)) - if flow_style is None: - if dumper.default_flow_style is not None: - node.flow_style = dumper.default_flow_style - else: - node.flow_style = best_style - return node - - -def add_representer(klass: Any, representer: Any) -> None: - """Add to representer to the dumper.""" - FastestAvailableSafeDumper.add_representer(klass, representer) - - -add_representer( - OrderedDict, - lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), -) - -add_representer( - NodeDictClass, - lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), -) - -add_representer( - NodeListClass, - lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), -) - -add_representer( - NodeStrClass, - lambda dumper, value: dumper.represent_scalar("tag:yaml.org,2002:str", str(value)), -) - -add_representer( - Input, - lambda dumper, value: dumper.represent_scalar("!input", value.name), -) +__all__ = ["add_representer", "dump", "represent_odict", "save_yaml"] diff --git a/homeassistant/util/yaml/input.py b/homeassistant/util/yaml/input.py index ff9b37f18f1..5dad8a63ae5 100644 --- a/homeassistant/util/yaml/input.py +++ b/homeassistant/util/yaml/input.py @@ -2,55 +2,8 @@ from __future__ import annotations -from typing import Any +from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute from .objects import Input - -class UndefinedSubstitution(Exception): - """Error raised when we find a substitution that is not defined.""" - - def __init__(self, input_name: str) -> None: - """Initialize the undefined substitution exception.""" - super().__init__(f"No substitution found for input {input_name}") - self.input = input - - -def extract_inputs(obj: Any) -> set[str]: - """Extract input from a structure.""" - found: set[str] = set() - _extract_inputs(obj, found) - return found - - -def _extract_inputs(obj: Any, found: set[str]) -> None: - """Extract input from a structure.""" - if isinstance(obj, Input): - found.add(obj.name) - return - - if isinstance(obj, list): - for val in obj: - _extract_inputs(val, found) - return - - if isinstance(obj, dict): - for val in obj.values(): - _extract_inputs(val, found) - return - - -def substitute(obj: Any, substitutions: dict[str, Any]) -> Any: - """Substitute values.""" - if isinstance(obj, Input): - if obj.name not in substitutions: - raise UndefinedSubstitution(obj.name) - return substitutions[obj.name] - - if isinstance(obj, list): - return [substitute(val, substitutions) for val in obj] - - if isinstance(obj, dict): - return {key: substitute(val, substitutions) for key, val in obj.items()} - - return obj +__all__ = ["Input", "UndefinedSubstitution", "extract_inputs", "substitute"] diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 3911d62040b..1f8338a1ff7 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -2,157 +2,37 @@ from __future__ import annotations -from collections.abc import Callable, Iterator -import fnmatch -from io import StringIO, TextIOWrapper -import logging +from io import StringIO import os -from pathlib import Path -from typing import Any, TextIO, overload +from typing import TextIO +from annotatedyaml import YAMLException, YamlTypeError +from annotatedyaml.loader import ( + HAS_C_LOADER, + JSON_TYPE, + LoaderType, + Secrets, + add_constructor, + load_yaml as load_annotated_yaml, + load_yaml_dict as load_annotated_yaml_dict, + parse_yaml as parse_annotated_yaml, + secret_yaml as annotated_secret_yaml, +) import yaml -try: - from yaml import CSafeLoader as FastestAvailableSafeLoader - - HAS_C_LOADER = True -except ImportError: - HAS_C_LOADER = False - from yaml import ( # type: ignore[assignment] - SafeLoader as FastestAvailableSafeLoader, - ) - -from propcache.api import cached_property - from homeassistant.exceptions import HomeAssistantError -from .const import SECRET_YAML -from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass - -# mypy: allow-untyped-calls, no-warn-return-any - -JSON_TYPE = list | dict | str - -_LOGGER = logging.getLogger(__name__) - - -class YamlTypeError(HomeAssistantError): - """Raised by load_yaml_dict if top level data is not a dict.""" - - -class Secrets: - """Store secrets while loading YAML.""" - - def __init__(self, config_dir: Path) -> None: - """Initialize secrets.""" - self.config_dir = config_dir - self._cache: dict[Path, dict[str, str]] = {} - - def get(self, requester_path: str, secret: str) -> str: - """Return the value of a secret.""" - current_path = Path(requester_path) - - secret_dir = current_path - while True: - secret_dir = secret_dir.parent - - try: - secret_dir.relative_to(self.config_dir) - except ValueError: - # We went above the config dir - break - - secrets = self._load_secret_yaml(secret_dir) - - if secret in secrets: - _LOGGER.debug( - "Secret %s retrieved from secrets.yaml in folder %s", - secret, - secret_dir, - ) - return secrets[secret] - - raise HomeAssistantError(f"Secret {secret} not defined") - - def _load_secret_yaml(self, secret_dir: Path) -> dict[str, str]: - """Load the secrets yaml from path.""" - if (secret_path := secret_dir / SECRET_YAML) in self._cache: - return self._cache[secret_path] - - _LOGGER.debug("Loading %s", secret_path) - try: - secrets = load_yaml(str(secret_path)) - - if not isinstance(secrets, dict): - raise HomeAssistantError("Secrets is not a dictionary") - - if "logger" in secrets: - logger = str(secrets["logger"]).lower() - if logger == "debug": - _LOGGER.setLevel(logging.DEBUG) - else: - _LOGGER.error( - ( - "Error in secrets.yaml: 'logger: debug' expected, but" - " 'logger: %s' found" - ), - logger, - ) - del secrets["logger"] - except FileNotFoundError: - secrets = {} - - self._cache[secret_path] = secrets - - return secrets - - -class _LoaderMixin: - """Mixin class with extensions for YAML loader.""" - - name: str - stream: Any - - @cached_property - def get_name(self) -> str: - """Get the name of the loader.""" - return self.name - - @cached_property - def get_stream_name(self) -> str: - """Get the name of the stream.""" - return getattr(self.stream, "name", "") - - -class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): - """The fastest available safe loader, either C or Python.""" - - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - self.stream = stream - - # Set name in same way as the Python loader does in yaml.reader.__init__ - if isinstance(stream, str): - self.name = "" - elif isinstance(stream, bytes): - self.name = "" - else: - self.name = getattr(stream, "name", "") - - super().__init__(stream) - self.secrets = secrets - - -class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): - """Python safe loader.""" - - def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: - """Initialize a safe line loader.""" - super().__init__(stream) - self.secrets = secrets - - -type LoaderType = FastSafeLoader | PythonSafeLoader +__all__ = [ + "HAS_C_LOADER", + "JSON_TYPE", + "Secrets", + "YamlTypeError", + "add_constructor", + "load_yaml", + "load_yaml_dict", + "parse_yaml", + "secret_yaml", +] def load_yaml( @@ -164,15 +44,9 @@ def load_yaml( except for FileNotFoundError which will be re-raised. """ try: - with open(fname, encoding="utf-8") as conf_file: - return parse_yaml(conf_file, secrets) - except UnicodeDecodeError as exc: - _LOGGER.error("Unable to read file %s: %s", fname, exc) - raise HomeAssistantError(exc) from exc - except FileNotFoundError: - raise - except OSError as exc: - raise HomeAssistantError(exc) from exc + return load_annotated_yaml(fname, secrets) + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc def load_yaml_dict( @@ -183,320 +57,27 @@ def load_yaml_dict( Raise if the top level is not a dict. Return an empty dict if the file is empty. """ - loaded_yaml = load_yaml(fname, secrets) - if loaded_yaml is None: - loaded_yaml = {} - if not isinstance(loaded_yaml, dict): - raise YamlTypeError(f"YAML file {fname} does not contain a dict") - return loaded_yaml + try: + return load_annotated_yaml_dict(fname, secrets) + except YamlTypeError: + raise + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc def parse_yaml( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: """Parse YAML with the fastest available loader.""" - if not HAS_C_LOADER: - return _parse_yaml_python(content, secrets) try: - return _parse_yaml(FastSafeLoader, content, secrets) - except yaml.YAMLError: - # Loading failed, so we now load with the Python loader which has more - # readable exceptions - if isinstance(content, (StringIO, TextIO, TextIOWrapper)): - # Rewind the stream so we can try again - content.seek(0, 0) - return _parse_yaml_python(content, secrets) - - -def _parse_yaml_python( - content: str | TextIO | StringIO, secrets: Secrets | None = None -) -> JSON_TYPE: - """Parse YAML with the python loader (this is very slow).""" - try: - return _parse_yaml(PythonSafeLoader, content, secrets) - except yaml.YAMLError as exc: - _LOGGER.error(str(exc)) - raise HomeAssistantError(exc) from exc - - -def _parse_yaml( - loader: type[FastSafeLoader | PythonSafeLoader], - content: str | TextIO, - secrets: Secrets | None = None, -) -> JSON_TYPE: - """Load a YAML file.""" - return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] - - -@overload -def _add_reference( - obj: list | NodeListClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeListClass: ... - - -@overload -def _add_reference( - obj: str | NodeStrClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeStrClass: ... - - -@overload -def _add_reference( - obj: dict | NodeDictClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeDictClass: ... - - -def _add_reference( - obj: dict | list | str | NodeDictClass | NodeListClass | NodeStrClass, - loader: LoaderType, - node: yaml.nodes.Node, -) -> NodeDictClass | NodeListClass | NodeStrClass: - """Add file reference information to an object.""" - if isinstance(obj, list): - obj = NodeListClass(obj) - elif isinstance(obj, str): - obj = NodeStrClass(obj) - elif isinstance(obj, dict): - obj = NodeDictClass(obj) - return _add_reference_to_node_class(obj, loader, node) - - -@overload -def _add_reference_to_node_class( - obj: NodeListClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeListClass: ... - - -@overload -def _add_reference_to_node_class( - obj: NodeStrClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeStrClass: ... - - -@overload -def _add_reference_to_node_class( - obj: NodeDictClass, loader: LoaderType, node: yaml.nodes.Node -) -> NodeDictClass: ... - - -def _add_reference_to_node_class( - obj: NodeDictClass | NodeListClass | NodeStrClass, - loader: LoaderType, - node: yaml.nodes.Node, -) -> NodeDictClass | NodeListClass | NodeStrClass: - """Add file reference information to a node class object.""" - try: # suppress is much slower - obj.__config_file__ = loader.get_name - obj.__line__ = node.start_mark.line + 1 - except AttributeError: - pass - return obj - - -def _raise_if_no_value[NodeT: yaml.nodes.Node, _R]( - func: Callable[[LoaderType, NodeT], _R], -) -> Callable[[LoaderType, NodeT], _R]: - def wrapper(loader: LoaderType, node: NodeT) -> _R: - if not node.value: - raise HomeAssistantError( - f"{node.start_mark}: {node.tag} needs an argument." - ) - return func(loader, node) - - return wrapper - - -@_raise_if_no_value -def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: - """Load another YAML file and embed it using the !include tag. - - Example: - device_tracker: !include device_tracker.yaml - - """ - fname = os.path.join(os.path.dirname(loader.get_name), node.value) - try: - loaded_yaml = load_yaml(fname, loader.secrets) - if loaded_yaml is None: - loaded_yaml = NodeDictClass() - return _add_reference(loaded_yaml, loader, node) - except FileNotFoundError as exc: - raise HomeAssistantError( - f"{node.start_mark}: Unable to read file {fname}" - ) from exc - - -def _is_file_valid(name: str) -> bool: - """Decide if a file is valid.""" - return not name.startswith(".") - - -def _find_files(directory: str, pattern: str) -> Iterator[str]: - """Recursively load files in a directory.""" - for root, dirs, files in os.walk(directory, topdown=True): - dirs[:] = [d for d in dirs if _is_file_valid(d)] - for basename in sorted(files): - if _is_file_valid(basename) and fnmatch.fnmatch(basename, pattern): - filename = os.path.join(root, basename) - yield filename - - -@_raise_if_no_value -def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDictClass: - """Load multiple files from directory as a dictionary.""" - mapping = NodeDictClass() - loc = os.path.join(os.path.dirname(loader.get_name), node.value) - for fname in _find_files(loc, "*.yaml"): - filename = os.path.splitext(os.path.basename(fname))[0] - if os.path.basename(fname) == SECRET_YAML: - continue - loaded_yaml = load_yaml(fname, loader.secrets) - if loaded_yaml is None: - # Special case, an empty file included by !include_dir_named is treated - # as an empty dictionary - loaded_yaml = NodeDictClass() - mapping[filename] = loaded_yaml - return _add_reference_to_node_class(mapping, loader, node) - - -@_raise_if_no_value -def _include_dir_merge_named_yaml( - loader: LoaderType, node: yaml.nodes.Node -) -> NodeDictClass: - """Load multiple files from directory as a merged dictionary.""" - mapping = NodeDictClass() - loc = os.path.join(os.path.dirname(loader.get_name), node.value) - for fname in _find_files(loc, "*.yaml"): - if os.path.basename(fname) == SECRET_YAML: - continue - loaded_yaml = load_yaml(fname, loader.secrets) - if isinstance(loaded_yaml, dict): - mapping.update(loaded_yaml) - return _add_reference_to_node_class(mapping, loader, node) - - -@_raise_if_no_value -def _include_dir_list_yaml( - loader: LoaderType, node: yaml.nodes.Node -) -> list[JSON_TYPE]: - """Load multiple files from directory as a list.""" - loc = os.path.join(os.path.dirname(loader.get_name), node.value) - return [ - loaded_yaml - for f in _find_files(loc, "*.yaml") - if os.path.basename(f) != SECRET_YAML - and (loaded_yaml := load_yaml(f, loader.secrets)) is not None - ] - - -@_raise_if_no_value -def _include_dir_merge_list_yaml( - loader: LoaderType, node: yaml.nodes.Node -) -> JSON_TYPE: - """Load multiple files from directory as a merged list.""" - loc: str = os.path.join(os.path.dirname(loader.get_name), node.value) - merged_list: list[JSON_TYPE] = [] - for fname in _find_files(loc, "*.yaml"): - if os.path.basename(fname) == SECRET_YAML: - continue - loaded_yaml = load_yaml(fname, loader.secrets) - if isinstance(loaded_yaml, list): - merged_list.extend(loaded_yaml) - return _add_reference(merged_list, loader, node) - - -def _handle_mapping_tag( - loader: LoaderType, node: yaml.nodes.MappingNode -) -> NodeDictClass: - """Load YAML mappings into an ordered dictionary to preserve key order.""" - loader.flatten_mapping(node) - nodes = loader.construct_pairs(node) - - seen: dict = {} - for (key, _), (child_node, _) in zip(nodes, node.value, strict=False): - line = child_node.start_mark.line - - try: - hash(key) - except TypeError as exc: - fname = loader.get_stream_name - raise yaml.MarkedYAMLError( - context=f'invalid key: "{key}"', - context_mark=yaml.Mark( - fname, - 0, - line, - -1, - None, - None, # type: ignore[arg-type] - ), - ) from exc - - if key in seen: - fname = loader.get_stream_name - _LOGGER.warning( - 'YAML file %s contains duplicate key "%s". Check lines %d and %d', - fname, - key, - seen[key], - line, - ) - seen[key] = line - - return _add_reference_to_node_class(NodeDictClass(nodes), loader, node) - - -def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: - """Add line number and file name to Load YAML sequence.""" - (obj,) = loader.construct_yaml_seq(node) - return _add_reference(obj, loader, node) - - -def _handle_scalar_tag( - loader: LoaderType, node: yaml.nodes.ScalarNode -) -> str | int | float | None: - """Add line number and file name to Load YAML sequence.""" - obj = node.value - if not isinstance(obj, str): - return obj - return _add_reference_to_node_class(NodeStrClass(obj), loader, node) - - -def _env_var_yaml(loader: LoaderType, node: yaml.nodes.Node) -> str: - """Load environment variables and embed it into the configuration YAML.""" - args = node.value.split() - - # Check for a default value - if len(args) > 1: - return os.getenv(args[0], " ".join(args[1:])) - if args[0] in os.environ: - return os.environ[args[0]] - _LOGGER.error("Environment variable %s not defined", node.value) - raise HomeAssistantError(node.value) + return parse_annotated_yaml(content, secrets) + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Load secrets and embed it into the configuration YAML.""" - if loader.secrets is None: - raise HomeAssistantError("Secrets not supported in this YAML file") - - return loader.secrets.get(loader.get_name, node.value) - - -def add_constructor(tag: Any, constructor: Any) -> None: - """Add to constructor to all loaders.""" - for yaml_loader in (FastSafeLoader, PythonSafeLoader): - yaml_loader.add_constructor(tag, constructor) - - -add_constructor("!include", _include_yaml) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _handle_mapping_tag) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG, _handle_scalar_tag) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq) -add_constructor("!env_var", _env_var_yaml) -add_constructor("!secret", secret_yaml) -add_constructor("!include_dir_list", _include_dir_list_yaml) -add_constructor("!include_dir_merge_list", _include_dir_merge_list_yaml) -add_constructor("!include_dir_named", _include_dir_named_yaml) -add_constructor("!include_dir_merge_named", _include_dir_merge_named_yaml) -add_constructor("!input", Input.from_node) + try: + return annotated_secret_yaml(loader, node) + except YAMLException as exc: + raise HomeAssistantError(str(exc)) from exc diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index 7e4019331c6..4b21e8118b3 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,52 +2,6 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any +from annotatedyaml.objects import Input, NodeDictClass, NodeListClass, NodeStrClass -import voluptuous as vol -from voluptuous.schema_builder import _compile_scalar -import yaml - - -class NodeListClass(list): - """Wrapper class to be able to add attributes on a list.""" - - __slots__ = ("__config_file__", "__line__") - - __config_file__: str - __line__: int | str - - -class NodeStrClass(str): - """Wrapper class to be able to add attributes on a string.""" - - __slots__ = ("__config_file__", "__line__") - - __config_file__: str - __line__: int | str - - def __voluptuous_compile__(self, schema: vol.Schema) -> Any: - """Needed because vol.Schema.compile does not handle str subclasses.""" - return _compile_scalar(self) # type: ignore[no-untyped-call] - - -class NodeDictClass(dict): - """Wrapper class to be able to add attributes on a dict.""" - - __slots__ = ("__config_file__", "__line__") - - __config_file__: str - __line__: int | str - - -@dataclass(slots=True, frozen=True) -class Input: - """Input that should be substituted.""" - - name: str - - @classmethod - def from_node(cls, loader: yaml.Loader, node: yaml.nodes.Node) -> Input: - """Create a new placeholder from a node.""" - return cls(node.value) +__all__ = ["Input", "NodeDictClass", "NodeListClass", "NodeStrClass"] diff --git a/pyproject.toml b/pyproject.toml index 6003b3d1de3..a2f1e9360f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", + "annotatedyaml==0.1.1", "astral==2.2", "async-interrupt==1.2.2", "attrs==25.1.0", diff --git a/requirements.txt b/requirements.txt index 13c58f6cd71..1397b6bec06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 +annotatedyaml==0.1.1 astral==2.2 async-interrupt==1.2.2 attrs==25.1.0 diff --git a/tests/common.py b/tests/common.py index df674d1824c..f426d2aebd2 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,6 +29,7 @@ from typing import Any, Literal, NoReturn from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 +from annotatedyaml import load_yaml_dict, loader as yaml_loader import pytest from syrupy import SnapshotAssertion import voluptuous as vol @@ -109,7 +110,6 @@ from homeassistant.util.json import ( ) from homeassistant.util.signal_type import SignalType from homeassistant.util.unit_system import METRIC_SYSTEM -from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader from .testing_config.custom_components.test_constant_deprecation import ( import_deprecated_constant, diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 142f7a23f81..70ab20e87fa 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -961,7 +961,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: side_effect=service._load_services_files, ) as proxy_load_services_files, patch( - "homeassistant.util.yaml.loader.load_yaml", + "annotatedyaml.loader.load_yaml", side_effect=load_yaml, ) as mock_load_yaml, ): @@ -1033,7 +1033,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: side_effect=service._load_services_files, ) as proxy_load_services_files, patch( - "homeassistant.util.yaml.loader.load_yaml", + "annotatedyaml.loader.load_yaml", side_effect=load_yaml, ) as mock_load_yaml, ): diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 6fcbce7d8d6..7531bf5a663 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -434,7 +434,7 @@ # name: test_yaml_error[basic] ''' mapping values are not allowed here - in "configuration.yaml", line 4, column 14 + in "/fixtures/core/config/yaml_errors/basic/configuration.yaml", line 4, column 14 ''' # --- # name: test_yaml_error[basic].1 @@ -448,7 +448,7 @@ # name: test_yaml_error[basic_include] ''' mapping values are not allowed here - in "integrations/iot_domain.yaml", line 3, column 12 + in "/fixtures/core/config/yaml_errors/basic_include/integrations/iot_domain.yaml", line 3, column 12 ''' # --- # name: test_yaml_error[basic_include].1 @@ -462,7 +462,7 @@ # name: test_yaml_error[include_dir_list] ''' mapping values are not allowed here - in "iot_domain/iot_domain_1.yaml", line 3, column 10 + in "/fixtures/core/config/yaml_errors/include_dir_list/iot_domain/iot_domain_1.yaml", line 3, column 10 ''' # --- # name: test_yaml_error[include_dir_list].1 @@ -476,7 +476,7 @@ # name: test_yaml_error[include_dir_merge_list] ''' mapping values are not allowed here - in "iot_domain/iot_domain_1.yaml", line 3, column 12 + in "/fixtures/core/config/yaml_errors/include_dir_merge_list/iot_domain/iot_domain_1.yaml", line 3, column 12 ''' # --- # name: test_yaml_error[include_dir_merge_list].1 @@ -490,7 +490,7 @@ # name: test_yaml_error[packages_include_dir_named] ''' mapping values are not allowed here - in "integrations/adr_0007_1.yaml", line 4, column 9 + in "/fixtures/core/config/yaml_errors/packages_include_dir_named/integrations/adr_0007_1.yaml", line 4, column 9 ''' # --- # name: test_yaml_error[packages_include_dir_named].1 diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 0346e21044f..dacbd2c1247 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -374,7 +374,7 @@ def test_include_dir_merge_named_recursive(mock_walk: Mock) -> None: } -@patch("homeassistant.util.yaml.loader.open", create=True) +@patch("annotatedyaml.loader.open", create=True) @pytest.mark.usefixtures("try_both_loaders") def test_load_yaml_encoding_error(mock_open: Mock) -> None: """Test raising a UnicodeDecodeError.""" @@ -598,7 +598,7 @@ def test_load_yaml_wrap_oserror( ) -> None: """Test load_yaml wraps OSError in HomeAssistantError.""" with ( - patch("homeassistant.util.yaml.loader.open", side_effect=open_exception), + patch("annotatedyaml.loader.open", side_effect=open_exception), pytest.raises(load_yaml_exception), ): yaml_loader.load_yaml("bla") From 6b6470f3456929b06ca7ebbed329bcdde1e0036d Mon Sep 17 00:00:00 2001 From: Serge Wagener <5746932+Foxi352@users.noreply.github.com> Date: Sun, 16 Mar 2025 08:29:44 +0100 Subject: [PATCH 1705/1941] Update knx-frontend and increase BinarySensor reset_after limit (#140196) Bumped to newest knx-frontend version and adapt knx ui schema --- homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/storage/entity_store_schema.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 98e3a6a5242..bde6dfa226f 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "requirements": [ "xknx==3.6.0", "xknxproject==3.8.2", - "knx-frontend==2025.1.30.194235" + "knx-frontend==2025.3.8.214559" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index d99ffa86f52..cde18a181ec 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -114,7 +114,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema( ), vol.Optional(CONF_RESET_AFTER): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, max=10, step=0.1, unit_of_measurement="s" + min=0, max=600, step=0.1, unit_of_measurement="s" ) ), }, diff --git a/requirements_all.txt b/requirements_all.txt index 0b8d1da4499..758456c5e9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1285,7 +1285,7 @@ kiwiki-client==0.1.1 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.30.194235 +knx-frontend==2025.3.8.214559 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 99cdb5004a0..562ccd14163 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1084,7 +1084,7 @@ kegtron-ble==0.4.0 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2025.1.30.194235 +knx-frontend==2025.3.8.214559 # homeassistant.components.konnected konnected==1.2.0 From 5f8564bfc5572ae15b346d8fcbe4e0fb1fe698c8 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 16 Mar 2025 05:11:08 -0400 Subject: [PATCH 1706/1941] Fix audiobooks always start from beginning on Sonos (#140663) * play audible favorite * play audible favorite * simplify tests --- .../components/sonos/media_player.py | 19 ++++++++++++++----- tests/components/sonos/test_media_player.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 0c66484202f..a774de0ae5b 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -462,11 +462,20 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Play a favorite.""" uri = favorite.reference.get_uri() soco = self.coordinator.soco - if soco.music_source_from_uri(uri) in [ - MUSIC_SRC_RADIO, - MUSIC_SRC_LINE_IN, - ]: - soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT) + if ( + soco.music_source_from_uri(uri) + in [ + MUSIC_SRC_RADIO, + MUSIC_SRC_LINE_IN, + ] + or favorite.reference.item_class == "object.item.audioItem.audioBook" + ): + soco.play_uri( + uri, + title=favorite.title, + meta=favorite.resource_meta_data, + timeout=LONG_SERVICE_TIMEOUT, + ) else: soco.clear_queue() soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index cec40c997a7..78d88a1ea98 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -692,6 +692,7 @@ async def test_select_source_line_in_tv( "play_uri": 1, "play_uri_uri": "x-sonosapi-radio:ST%3aetc", "play_uri_title": "James Taylor Radio", + "play_uri_meta": 'James Taylor Radioobject.item.audioItem.audioBroadcast.#stationSA_RINCON60423_X_#Svc60423-99999999-Token', }, ), ( @@ -700,6 +701,16 @@ async def test_select_source_line_in_tv( "play_uri": 1, "play_uri_uri": "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc", "play_uri_title": "66 - Watercolors", + "play_uri_meta": '66 - Watercolorsobject.item.audioItem.audioBroadcastSA_RINCON9479_X_#Svc9479-99999999-Token', + }, + ), + ( + "American Tall Tales", + { + "play_uri": 1, + "play_uri_uri": "x-rincon-cpcontainer:101340c8reftitle%C9F27_com?sid=239&flags=16584&sn=5", + "play_uri_title": "American Tall Tales", + "play_uri_meta": 'American Tall Talesobject.item.audioItem.audioBookSA_RINCON61191_X_#Svc6-0-Token', }, ), ], @@ -726,6 +737,7 @@ async def test_select_source_play_uri( soco_mock.play_uri.assert_called_with( result.get("play_uri_uri"), title=result.get("play_uri_title"), + meta=result.get("play_uri_meta"), timeout=LONG_SERVICE_TIMEOUT, ) From 011a07615574a871d418b28b0211d25bedac9796 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 16 Mar 2025 19:16:21 +1000 Subject: [PATCH 1707/1941] Fix auto seat heater in Teslemetry (#140703) Fix auto seat heater --- homeassistant/components/teslemetry/switch.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index 4098a050fd9..516a6f9852f 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope, Seat +from tesla_fleet_api.const import Scope from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.switch import ( @@ -62,21 +62,15 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = ( TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_left", streaming_listener=lambda x, y: x.listen_AutoSeatClimateLeft(y), - on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), - off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_LEFT, False - ), + on_func=lambda api: api.remote_auto_seat_climate_request(1, True), + off_func=lambda api: api.remote_auto_seat_climate_request(1, False), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( key="climate_state_auto_seat_climate_right", streaming_listener=lambda x, y: x.listen_AutoSeatClimateRight(y), - on_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, True - ), - off_func=lambda api: api.remote_auto_seat_climate_request( - Seat.FRONT_RIGHT, False - ), + on_func=lambda api: api.remote_auto_seat_climate_request(2, True), + off_func=lambda api: api.remote_auto_seat_climate_request(2, False), scopes=[Scope.VEHICLE_CMDS], ), TeslemetrySwitchEntityDescription( From 4e0985e1a73fbffa3c1fb6fcbaa804692453b445 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 09:00:43 -0400 Subject: [PATCH 1708/1941] Add Select entity to Snoo (#140638) --- homeassistant/components/snoo/__init__.py | 2 +- homeassistant/components/snoo/select.py | 78 ++++++++++++++++++++++ homeassistant/components/snoo/strings.json | 18 +++++ tests/components/snoo/test_select.py | 75 +++++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/snoo/select.py create mode 100644 tests/components/snoo/test_select.py diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index aaf0c828830..ca561a52a3f 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -17,7 +17,7 @@ from .coordinator import SnooConfigEntry, SnooCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: diff --git a/homeassistant/components/snoo/select.py b/homeassistant/components/snoo/select.py new file mode 100644 index 00000000000..44624ed1a2d --- /dev/null +++ b/homeassistant/components/snoo/select.py @@ -0,0 +1,78 @@ +"""Support for Snoo Select.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData, SnooDevice, SnooLevels +from python_snoo.exceptions import SnooCommandException +from python_snoo.snoo import Snoo + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSelectEntityDescription(SelectEntityDescription): + """Describes a Snoo Select.""" + + value_fn: Callable[[SnooData], str] + set_value_fn: Callable[[Snoo, SnooDevice, str], Awaitable[None]] + + +SELECT_DESCRIPTIONS: list[SnooSelectEntityDescription] = [ + SnooSelectEntityDescription( + key="intensity", + translation_key="intensity", + value_fn=lambda data: data.state_machine.level.name, + set_value_fn=lambda snoo_api, device, state: snoo_api.set_level( + device, SnooLevels[state] + ), + options=[level.name for level in SnooLevels], + ), +] + + +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( + SnooSelect(coordinator, description) + for coordinator in coordinators.values() + for description in SELECT_DESCRIPTIONS + ) + + +class SnooSelect(SnooDescriptionEntity, SelectEntity): + """A sensor using Snoo coordinator.""" + + entity_description: SnooSelectEntityDescription + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.snoo, self.device, option + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="select_failed", + translation_placeholders={"name": str(self.name), "option": option}, + ) from err diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index 567fa30fca7..47e59603a14 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -21,6 +21,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "select_failed": { + "message": "Error while updating {name} to {option}" + } + }, "entity": { "sensor": { "state": { @@ -39,6 +44,19 @@ "time_left": { "name": "Time left" } + }, + "select": { + "intensity": { + "name": "Intensity", + "state": { + "baseline": "[%key:component::snoo::entity::sensor::state::state::baseline%]", + "level1": "[%key:component::snoo::entity::sensor::state::state::level1%]", + "level2": "[%key:component::snoo::entity::sensor::state::state::level2%]", + "level3": "[%key:component::snoo::entity::sensor::state::state::level3%]", + "level4": "[%key:component::snoo::entity::sensor::state::state::level4%]", + "stop": "[%key:component::snoo::entity::sensor::state::state::stop%]" + } + } } } } diff --git a/tests/components/snoo/test_select.py b/tests/components/snoo/test_select.py new file mode 100644 index 00000000000..e00721b2ab8 --- /dev/null +++ b/tests/components/snoo/test_select.py @@ -0,0 +1,75 @@ +"""Test Snoo Selects.""" + +import copy +from unittest.mock import AsyncMock + +import pytest +from python_snoo.containers import SnooDevice, SnooLevels, SnooStates + +from homeassistant.components.select import SERVICE_SELECT_OPTION +from homeassistant.components.snoo.select import SnooCommandException +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_select(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test select and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("select")) == 1 + assert hass.states.get("select.test_snoo_intensity").state == STATE_UNAVAILABLE + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("select")) == 1 + assert hass.states.get("select.test_snoo_intensity").state == "stop" + + +async def test_update_success(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test changing values for select entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("select.test_snoo_intensity").state == "stop" + + async def update_level(device: SnooDevice, level: SnooStates, _hold: bool = False): + new_data = copy.deepcopy(MOCK_SNOO_DATA) + new_data.state_machine.level = SnooLevels(level.value) + find_update_callback(bypass_api, device.serialNumber)(new_data) + + bypass_api.set_level.side_effect = update_level + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "level1"}, + blocking=True, + target={"entity_id": "select.test_snoo_intensity"}, + ) + + assert bypass_api.set_level.assert_called_once + assert hass.states.get("select.test_snoo_intensity").state == "level1" + + +async def test_update_failed(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test failing to change values for select entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("select.test_snoo_intensity").state == "stop" + + bypass_api.set_level.side_effect = SnooCommandException + with pytest.raises( + HomeAssistantError, match="Error while updating Intensity to level1" + ): + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + service_data={"option": "level1"}, + blocking=True, + target={"entity_id": "select.test_snoo_intensity"}, + ) + + assert bypass_api.set_level.assert_called_once + assert hass.states.get("select.test_snoo_intensity").state == "stop" From d365092bcc2591ce4f0e253ae78ecccc976eece7 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Sun, 16 Mar 2025 13:05:08 +0000 Subject: [PATCH 1709/1941] Add price cap support to Ohme (#140537) * Add price cap support * Change service input to box mode * Add icon for set_price_cap service * Improve test coverage * Change ohme service description wording --- homeassistant/components/ohme/icons.json | 6 ++ homeassistant/components/ohme/services.py | 36 +++++++++- homeassistant/components/ohme/services.yaml | 13 ++++ homeassistant/components/ohme/strings.json | 17 +++++ homeassistant/components/ohme/switch.py | 70 +++++++++++++++---- tests/components/ohme/conftest.py | 2 + .../ohme/snapshots/test_switch.ambr | 47 +++++++++++++ tests/components/ohme/test_services.py | 27 ++++++- tests/components/ohme/test_switch.py | 48 ++++++++++++- 9 files changed, 246 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json index 0e4d58a5294..8613f2542c4 100644 --- a/homeassistant/components/ohme/icons.json +++ b/homeassistant/components/ohme/icons.json @@ -54,6 +54,9 @@ "state": { "off": "mdi:sleep-off" } + }, + "price_cap": { + "default": "mdi:car-speed-limiter" } }, "time": { @@ -65,6 +68,9 @@ "services": { "list_charge_slots": { "service": "mdi:clock-start" + }, + "set_price_cap": { + "service": "mdi:car-speed-limiter" } } } diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py index 7d06b909d88..be044f01740 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -17,9 +17,11 @@ from homeassistant.helpers import selector from .const import DOMAIN -SERVICE_LIST_CHARGE_SLOTS = "list_charge_slots" ATTR_CONFIG_ENTRY: Final = "config_entry" -SERVICE_SCHEMA: Final = vol.Schema( +ATTR_PRICE_CAP: Final = "price_cap" + +SERVICE_LIST_CHARGE_SLOTS = "list_charge_slots" +SERVICE_LIST_CHARGE_SLOTS_SCHEMA: Final = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( { @@ -29,6 +31,18 @@ SERVICE_SCHEMA: Final = vol.Schema( } ) +SERVICE_SET_PRICE_CAP = "set_price_cap" +SERVICE_SET_PRICE_CAP_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(ATTR_PRICE_CAP): vol.Coerce(float), + } +) + def __get_client(call: ServiceCall) -> OhmeApiClient: """Get the client from the config entry.""" @@ -66,10 +80,26 @@ def async_setup_services(hass: HomeAssistant) -> None: return {"slots": client.slots} + async def set_price_cap( + service_call: ServiceCall, + ) -> None: + """List of charge slots.""" + client = __get_client(service_call) + price_cap = service_call.data[ATTR_PRICE_CAP] + await client.async_change_price_cap(cap=price_cap) + hass.services.async_register( DOMAIN, SERVICE_LIST_CHARGE_SLOTS, list_charge_slots, - schema=SERVICE_SCHEMA, + schema=SERVICE_LIST_CHARGE_SLOTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_PRICE_CAP, + set_price_cap, + schema=SERVICE_SET_PRICE_CAP_SCHEMA, + supports_response=SupportsResponse.NONE, + ) diff --git a/homeassistant/components/ohme/services.yaml b/homeassistant/components/ohme/services.yaml index c5c8ee18138..a45bc131511 100644 --- a/homeassistant/components/ohme/services.yaml +++ b/homeassistant/components/ohme/services.yaml @@ -5,3 +5,16 @@ list_charge_slots: selector: config_entry: integration: ohme +set_price_cap: + fields: + config_entry: + required: true + selector: + config_entry: + integration: ohme + price_cap: + required: true + selector: + number: + min: 0 + mode: box diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 187e825c159..1da17183bb2 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -42,6 +42,20 @@ "description": "The Ohme config entry for which to return charge slots." } } + }, + "set_price_cap": { + "name": "Set price cap", + "description": "Prevents charging when the electricity price exceeds a defined threshold.", + "fields": { + "config_entry": { + "name": "Ohme account", + "description": "The Ohme config entry for which to return charge slots." + }, + "price_cap": { + "name": "Price cap", + "description": "Threshold in 1/100ths of your local currency." + } + } } }, "entity": { @@ -102,6 +116,9 @@ }, "sleep_when_inactive": { "name": "Sleep when inactive" + }, + "price_cap": { + "name": "Price cap" } }, "time": { diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py index c4465ec7e97..47e3bf8a99d 100644 --- a/homeassistant/components/ohme/switch.py +++ b/homeassistant/components/ohme/switch.py @@ -1,9 +1,10 @@ """Platform for switch.""" +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from ohme import ApiException +from ohme import ApiException, OhmeApiClient from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -19,28 +20,37 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): - """Class describing Ohme switch entities.""" +class OhmeConfigSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): + """Class describing Ohme configuration switch entities.""" configuration_key: str -SWITCH_DEVICE_INFO = [ - OhmeSwitchDescription( +@dataclass(frozen=True, kw_only=True) +class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription): + """Class describing basic Ohme switch entities.""" + + is_on_fn: Callable[[OhmeApiClient], bool] + off_fn: Callable[[OhmeApiClient], Awaitable] + on_fn: Callable[[OhmeApiClient], Awaitable] + + +SWITCH_CONFIG = [ + OhmeConfigSwitchDescription( key="lock_buttons", translation_key="lock_buttons", entity_category=EntityCategory.CONFIG, is_supported_fn=lambda client: client.is_capable("buttonsLockable"), configuration_key="buttonsLocked", ), - OhmeSwitchDescription( + OhmeConfigSwitchDescription( key="require_approval", translation_key="require_approval", entity_category=EntityCategory.CONFIG, is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"), configuration_key="pluginsRequireApproval", ), - OhmeSwitchDescription( + OhmeConfigSwitchDescription( key="sleep_when_inactive", translation_key="sleep_when_inactive", entity_category=EntityCategory.CONFIG, @@ -49,6 +59,17 @@ SWITCH_DEVICE_INFO = [ ), ] +SWITCH_DESCRIPTION = [ + OhmeSwitchDescription( + key="price_cap", + translation_key="price_cap", + is_supported_fn=lambda client: client.cap_available, + is_on_fn=lambda client: client.cap_enabled, + on_fn=lambda client: client.async_change_price_cap(True), + off_fn=lambda client: client.async_change_price_cap(False), + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -56,15 +77,17 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switches.""" - coordinators = config_entry.runtime_data - coordinator_map = [ - (SWITCH_DEVICE_INFO, coordinators.device_info_coordinator), - ] + coordinator = config_entry.runtime_data.device_info_coordinator + + async_add_entities( + OhmeConfigSwitch(coordinator, description) + for description in SWITCH_CONFIG + if description.is_supported_fn(coordinator.client) + ) async_add_entities( OhmeSwitch(coordinator, description) - for entities, coordinator in coordinator_map - for description in entities + for description in SWITCH_DESCRIPTION if description.is_supported_fn(coordinator.client) ) @@ -74,6 +97,27 @@ class OhmeSwitch(OhmeEntity, SwitchEntity): entity_description: OhmeSwitchDescription + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.entity_description.is_on_fn(self.coordinator.client) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.entity_description.off_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.entity_description.on_fn(self.coordinator.client) + await self.coordinator.async_request_refresh() + + +class OhmeConfigSwitch(OhmeEntity, SwitchEntity): + """Configuration switch for Ohme.""" + + entity_description: OhmeConfigSwitchDescription + @property def is_on(self) -> bool: """Return the entity value to represent the entity state.""" diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index d05e34d1ed2..e8a7d27b2c3 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -60,6 +60,8 @@ def mock_client(): client.preconditioning = 15 client.serial = "chargerid" client.ct_connected = True + client.cap_available = True + client.cap_enabled = True client.energy = 1000 client.device_info = { "name": "Ohme Home Pro", diff --git a/tests/components/ohme/snapshots/test_switch.ambr b/tests/components/ohme/snapshots/test_switch.ambr index 49bf5d5709a..4790d96c551 100644 --- a/tests/components/ohme/snapshots/test_switch.ambr +++ b/tests/components/ohme/snapshots/test_switch.ambr @@ -46,6 +46,53 @@ 'state': 'on', }) # --- +# name: test_switches[switch.ohme_home_pro_price_cap-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.ohme_home_pro_price_cap', + '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': 'Price cap', + 'platform': 'ohme', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'price_cap', + 'unique_id': 'chargerid_price_cap', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.ohme_home_pro_price_cap-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ohme Home Pro Price cap', + }), + 'context': , + 'entity_id': 'switch.ohme_home_pro_price_cap', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switches[switch.ohme_home_pro_require_approval-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ohme/test_services.py b/tests/components/ohme/test_services.py index 76c7ce94b57..2513635c1c2 100644 --- a/tests/components/ohme/test_services.py +++ b/tests/components/ohme/test_services.py @@ -1,6 +1,6 @@ """Tests for services.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from syrupy.assertion import SnapshotAssertion @@ -8,6 +8,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.ohme.const import DOMAIN from homeassistant.components.ohme.services import ( ATTR_CONFIG_ENTRY, + ATTR_PRICE_CAP, SERVICE_LIST_CHARGE_SLOTS, ) from homeassistant.core import HomeAssistant @@ -47,6 +48,30 @@ async def test_list_charge_slots( ) +async def test_set_price_cap( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test set price cap service.""" + + await setup_integration(hass, mock_config_entry) + mock_client.async_change_price_cap = AsyncMock() + + await hass.services.async_call( + DOMAIN, + "set_price_cap", + { + ATTR_CONFIG_ENTRY: mock_config_entry.entry_id, + ATTR_PRICE_CAP: 10.0, + }, + blocking=True, + ) + + mock_client.async_change_price_cap.assert_called_once_with(cap=10.0) + + async def test_list_charge_slots_exception( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/ohme/test_switch.py b/tests/components/ohme/test_switch.py index b16b70d67f8..8d82a5a3ea4 100644 --- a/tests/components/ohme/test_switch.py +++ b/tests/components/ohme/test_switch.py @@ -1,6 +1,6 @@ """Tests for switches.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from syrupy import SnapshotAssertion @@ -32,7 +32,49 @@ async def test_switches( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_switch_on( +async def test_cap_switch_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the switch turn_on action.""" + await setup_integration(hass, mock_config_entry) + mock_client.async_change_price_cap = AsyncMock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.ohme_home_pro_price_cap", + }, + blocking=True, + ) + + mock_client.async_change_price_cap.assert_called_once_with(True) + + +async def test_cap_switch_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test the switch turn_off action.""" + await setup_integration(hass, mock_config_entry) + mock_client.async_change_price_cap = AsyncMock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "switch.ohme_home_pro_price_cap", + }, + blocking=True, + ) + + mock_client.async_change_price_cap.assert_called_once_with(False) + + +async def test_config_switch_on( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock, @@ -52,7 +94,7 @@ async def test_switch_on( assert len(mock_client.async_set_configuration_value.mock_calls) == 1 -async def test_switch_off( +async def test_config_switch_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock, From d560083e150ccff48e27882bbb47180b6d146be8 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 16 Mar 2025 09:09:21 -0400 Subject: [PATCH 1710/1941] Album art not available for Sonos media library favorites (#140557) * get album art uri for favorites * add tests * update typing * update typing * update typing * simplify --- homeassistant/components/sonos/favorites.py | 2 +- .../components/sonos/media_browser.py | 16 ++++++++++-- .../sonos/fixtures/sonos_favorites.json | 1 + .../sonos/snapshots/test_media_browser.ambr | 25 +++++++++++++++++++ tests/components/sonos/test_media_browser.py | 4 +++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 5050555a7cb..333c4809e62 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -105,7 +105,7 @@ class SonosFavorites(SonosHouseholdCoordinator): @soco_error() def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: """Update cache of known favorites and return if cache has changed.""" - new_favorites = soco.music_library.get_sonos_favorites() + new_favorites = soco.music_library.get_sonos_favorites(full_album_art_uri=True) # Polled update_id values do not match event_id values # Each speaker can return a different polled update_id diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 995d6cea08c..16b425dae50 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -165,6 +165,8 @@ async def async_browse_media( favorites_folder_payload, speaker.favorites, media_content_id, + media, + get_browse_image_url, ) payload = { @@ -443,7 +445,10 @@ def favorites_payload(favorites: SonosFavorites) -> BrowseMedia: def favorites_folder_payload( - favorites: SonosFavorites, media_content_id: str + favorites: SonosFavorites, + media_content_id: str, + media: SonosMedia, + get_browse_image_url: GetBrowseImageUrlType, ) -> BrowseMedia: """Create response payload to describe all items of a type of favorite. @@ -463,7 +468,14 @@ def favorites_folder_payload( media_content_type="favorite_item_id", can_play=True, can_expand=False, - thumbnail=getattr(favorite, "album_art_uri", None), + thumbnail=get_thumbnail_url_full( + media=media, + is_internal=True, + media_content_type="favorite_item_id", + media_content_id=favorite.item_id, + get_browse_image_url=get_browse_image_url, + item=favorite, + ), ) ) diff --git a/tests/components/sonos/fixtures/sonos_favorites.json b/tests/components/sonos/fixtures/sonos_favorites.json index d5463c3d02b..40213ea8715 100644 --- a/tests/components/sonos/fixtures/sonos_favorites.json +++ b/tests/components/sonos/fixtures/sonos_favorites.json @@ -27,6 +27,7 @@ "title": "1984", "parent_id": "FV:2", "item_id": "FV:2/8", + "album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.2%2fmusic%2fiTunes%2520Music%2fAerosmith%2f1984&v=742", "resource_meta_data": "1984object.container.album.musicAlbumRINCON_AssociatedZPUDN", "resources": [ { diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index 9f6560c0f75..24f08eaf95b 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -44,6 +44,31 @@ 'title': 'Favorites', }) # --- +# name: test_browse_media_favorites[object.container.album.musicAlbum-favorites_folder] + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'album', + 'media_content_id': 'FV:2/8', + 'media_content_type': 'favorite_item_id', + 'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.2/music/iTunes%20Music/Aerosmith/1984&v=742', + 'title': '1984', + }), + ]), + 'children_media_class': 'album', + 'media_class': 'directory', + 'media_content_id': '', + 'media_content_type': 'favorites', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Albums', + }) +# --- # name: test_browse_media_favorites[object.item.audioItem.audioBook-favorites_folder] dict({ 'can_expand': True, diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index 323140e285d..ce6e103be58 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -190,6 +190,10 @@ async def test_browse_media_library_albums( "object.item.audioItem.audioBook", "favorites_folder", ), + ( + "object.container.album.musicAlbum", + "favorites_folder", + ), ], ) async def test_browse_media_favorites( From 4ca31da0a504a8b9824397988de43bab8278a124 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 16 Mar 2025 14:51:36 +0100 Subject: [PATCH 1711/1941] Bump annotatedyaml to 0.2.0 (#140715) --- 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 3a13b59eced..af437c4b079 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ aiohttp==3.11.13 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.1.1 +annotatedyaml==0.2.0 astral==2.2 async-interrupt==1.2.2 async-upnp-client==0.43.0 diff --git a/pyproject.toml b/pyproject.toml index a2f1e9360f3..31d0ce4e42d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", - "annotatedyaml==0.1.1", + "annotatedyaml==0.2.0", "astral==2.2", "async-interrupt==1.2.2", "attrs==25.1.0", diff --git a/requirements.txt b/requirements.txt index 1397b6bec06..22ffcfb54e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.1.1 +annotatedyaml==0.2.0 astral==2.2 async-interrupt==1.2.2 attrs==25.1.0 From 012b4645f314aea2d867128be602fc169c8d168f Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 16 Mar 2025 14:51:53 +0100 Subject: [PATCH 1712/1941] Don't reload onedrive on options flow (#140712) --- homeassistant/components/onedrive/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 17dead653f0..f5d841683d5 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -106,11 +106,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: - await hass.config_entries.async_reload(entry.entry_id) - - 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() From 056616f9c51fb707733e59b2d779d269d460df85 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 16 Mar 2025 17:59:25 +0300 Subject: [PATCH 1713/1941] Stronger type annotations for conversation content (#140725) stronger type annotations for conversation content --- .../components/conversation/chat_log.py | 15 +++++++-------- .../conversation.py | 16 +++------------- .../openai_conversation/conversation.py | 6 +++--- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 355f423dbb6..2de785dae7d 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -51,8 +51,7 @@ def async_get_chat_log( ) 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] + or content.content != user_input.text ): chat_log.async_add_user_content(UserContent(content=user_input.text)) @@ -128,7 +127,7 @@ class ConverseError(HomeAssistantError): class SystemContent: """Base class for chat messages.""" - role: str = field(init=False, default="system") + role: Literal["system"] = field(init=False, default="system") content: str @@ -136,7 +135,7 @@ class SystemContent: class UserContent: """Assistant content.""" - role: str = field(init=False, default="user") + role: Literal["user"] = field(init=False, default="user") content: str @@ -144,7 +143,7 @@ class UserContent: class AssistantContent: """Assistant content.""" - role: str = field(init=False, default="assistant") + role: Literal["assistant"] = field(init=False, default="assistant") agent_id: str content: str | None = None tool_calls: list[llm.ToolInput] | None = None @@ -154,7 +153,7 @@ class AssistantContent: class ToolResultContent: """Tool result content.""" - role: str = field(init=False, default="tool_result") + role: Literal["tool_result"] = field(init=False, default="tool_result") agent_id: str tool_call_id: str tool_name: str @@ -193,8 +192,8 @@ class ChatLog: return ( last_msg.role == "assistant" - and last_msg.content is not None # type: ignore[union-attr] - and last_msg.content.strip().endswith( # type: ignore[union-attr] + and last_msg.content is not None + and last_msg.content.strip().endswith( ( "?", ";", # Greek question mark diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 93546431391..4648f1afb4c 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -188,7 +188,7 @@ def _convert_content( | conversation.SystemContent, ) -> Content: """Convert HA content to Google content.""" - if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] + if content.role != "assistant" or not content.tool_calls: role = "model" if content.role == "assistant" else content.role return Content( role=role, @@ -321,24 +321,14 @@ class GoogleGenerativeAIConversationEntity( for chat_content in chat_log.content[1:-1]: if chat_content.role == "tool_result": - # mypy doesn't like picking a type based on checking shared property 'role' - tool_results.append(cast(conversation.ToolResultContent, chat_content)) + tool_results.append(chat_content) continue if tool_results: messages.append(_create_google_tool_response_content(tool_results)) tool_results.clear() - messages.append( - _convert_content( - cast( - conversation.UserContent - | conversation.SystemContent - | conversation.AssistantContent, - chat_content, - ) - ) - ) + messages.append(_convert_content(chat_content)) if tool_results: messages.append(_create_google_tool_response_content(tool_results)) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index e42319f8e96..d910cf54471 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -82,13 +82,13 @@ def _convert_content_to_param( tool_call_id=content.tool_call_id, content=json.dumps(content.tool_result), ) - if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] - role = content.role + if content.role != "assistant" or not content.tool_calls: + role: Literal["system", "user", "assistant", "developer"] = content.role if role == "system": role = "developer" return cast( ChatCompletionMessageParam, - {"role": content.role, "content": content.content}, # type: ignore[union-attr] + {"role": content.role, "content": content.content}, ) # Handle the Assistant content including tool calls. From 214d14b06b7dfef7ffdf5099b5705d39ae764353 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 11:57:21 -0400 Subject: [PATCH 1714/1941] Add binary sensor to Snoo (#140729) * Add binary sensor * Update homeassistant/components/snoo/binary_sensor.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/snoo/__init__.py | 2 +- .../components/snoo/binary_sensor.py | 70 +++++++++++++++++++ homeassistant/components/snoo/strings.json | 9 +++ tests/components/snoo/test_binary_sensor.py | 30 ++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/snoo/binary_sensor.py create mode 100644 tests/components/snoo/test_binary_sensor.py diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index ca561a52a3f..23b5d5201db 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -17,7 +17,7 @@ from .coordinator import SnooConfigEntry, SnooCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: diff --git a/homeassistant/components/snoo/binary_sensor.py b/homeassistant/components/snoo/binary_sensor.py new file mode 100644 index 00000000000..3c91db5b86d --- /dev/null +++ b/homeassistant/components/snoo/binary_sensor.py @@ -0,0 +1,70 @@ +"""Support for Snoo Binary Sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from python_snoo.containers import SnooData + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, + EntityCategory, +) +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 SnooBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Snoo Binary Sensor.""" + + value_fn: Callable[[SnooData], bool] + + +BINARY_SENSOR_DESCRIPTIONS: list[SnooBinarySensorEntityDescription] = [ + SnooBinarySensorEntityDescription( + key="left_clip", + translation_key="left_clip", + value_fn=lambda data: data.left_safety_clip, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SnooBinarySensorEntityDescription( + key="right_clip", + translation_key="right_clip", + value_fn=lambda data: data.left_safety_clip, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + 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( + SnooBinarySensor(coordinator, description) + for coordinator in coordinators.values() + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class SnooBinarySensor(SnooDescriptionEntity, BinarySensorEntity): + """A Binary sensor using Snoo coordinator.""" + + entity_description: SnooBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index 47e59603a14..8211480f771 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -27,6 +27,15 @@ } }, "entity": { + "binary_sensor": { + "left_clip": { + "name": "Left safety clip" + }, + "right_clip": { + "name": "Right safety clip" + } + }, + "sensor": { "state": { "name": "State", diff --git a/tests/components/snoo/test_binary_sensor.py b/tests/components/snoo/test_binary_sensor.py new file mode 100644 index 00000000000..77b2e36c1fe --- /dev/null +++ b/tests/components/snoo/test_binary_sensor.py @@ -0,0 +1,30 @@ +"""Test Snoo Binary Sensors.""" + +from unittest.mock import AsyncMock + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_binary_sensors(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test binary sensors and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("binary_sensor")) == 2 + assert ( + hass.states.get("binary_sensor.test_snoo_left_safety_clip").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("binary_sensor.test_snoo_right_safety_clip").state + == STATE_UNAVAILABLE + ) + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("binary_sensor")) == 2 + assert hass.states.get("binary_sensor.test_snoo_left_safety_clip").state == STATE_ON + assert ( + hass.states.get("binary_sensor.test_snoo_right_safety_clip").state == STATE_ON + ) From bb7b5b9ccb7fe3a9d97048e7ff25418562f998c4 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 16 Mar 2025 20:18:18 +0300 Subject: [PATCH 1715/1941] OpenAI Responses API (#140713) --- .../openai_conversation/__init__.py | 99 ++-- .../openai_conversation/conversation.py | 200 +++---- .../openai_conversation/test_conversation.py | 488 ++++++++++-------- .../openai_conversation/test_init.py | 109 ++-- 4 files changed, 463 insertions(+), 433 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index d7fc5205f17..fcf6ab298dc 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -7,21 +7,15 @@ from mimetypes import guess_file_type from pathlib import Path import openai -from openai.types.chat.chat_completion import ChatCompletion -from openai.types.chat.chat_completion_content_part_image_param import ( - ChatCompletionContentPartImageParam, - ImageURL, -) -from openai.types.chat.chat_completion_content_part_param import ( - ChatCompletionContentPartParam, -) -from openai.types.chat.chat_completion_content_part_text_param import ( - ChatCompletionContentPartTextParam, -) -from openai.types.chat.chat_completion_user_message_param import ( - ChatCompletionUserMessageParam, -) from openai.types.images_response import ImagesResponse +from openai.types.responses import ( + EasyInputMessageParam, + Response, + ResponseInputImageParam, + ResponseInputMessageContentListParam, + ResponseInputParam, + ResponseInputTextParam, +) import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -44,10 +38,18 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CHAT_MODEL, CONF_FILENAMES, + CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, + CONF_TEMPERATURE, + CONF_TOP_P, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, ) SERVICE_GENERATE_IMAGE = "generate_image" @@ -112,17 +114,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: translation_placeholders={"config_entry": entry_id}, ) - model: str = entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model: str = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) client: openai.AsyncClient = entry.runtime_data - prompt_parts: list[ChatCompletionContentPartParam] = [ - ChatCompletionContentPartTextParam( - type="text", - text=call.data[CONF_PROMPT], - ) + content: ResponseInputMessageContentListParam = [ + ResponseInputTextParam(type="input_text", text=call.data[CONF_PROMPT]) ] - def append_files_to_prompt() -> None: + def append_files_to_content() -> None: for filename in call.data[CONF_FILENAMES]: if not hass.config.is_allowed_path(filename): raise HomeAssistantError( @@ -138,46 +137,52 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "Only images are supported by the OpenAI API," f"`{filename}` is not an image file" ) - prompt_parts.append( - ChatCompletionContentPartImageParam( - type="image_url", - image_url=ImageURL( - url=f"data:{mime_type};base64,{base64_file}" - ), + content.append( + ResponseInputImageParam( + type="input_image", + file_id=filename, + image_url=f"data:{mime_type};base64,{base64_file}", + detail="auto", ) ) if CONF_FILENAMES in call.data: - await hass.async_add_executor_job(append_files_to_prompt) + await hass.async_add_executor_job(append_files_to_content) - messages: list[ChatCompletionUserMessageParam] = [ - ChatCompletionUserMessageParam( - role="user", - content=prompt_parts, - ) + messages: ResponseInputParam = [ + EasyInputMessageParam(type="message", role="user", content=content) ] try: - response: ChatCompletion = await client.chat.completions.create( - model=model, - messages=messages, - n=1, - response_format={ - "type": "json_object", - }, - ) + model_args = { + "model": model, + "input": messages, + "max_output_tokens": entry.options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": entry.options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + "user": call.context.user_id, + "store": False, + } + + if model.startswith("o"): + model_args["reasoning"] = { + "effort": entry.options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } + + response: Response = await client.responses.create(**model_args) except openai.OpenAIError as err: raise HomeAssistantError(f"Error generating content: {err}") from err except FileNotFoundError as err: raise HomeAssistantError(f"Error generating content: {err}") from err - response_text: str = "" - for response_choice in response.choices: - if response_choice.message.content is not None: - response_text += response_choice.message.content.strip() - - return {"text": response_text} + return {"text": response.output_text} hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index d910cf54471..7a8830ffd95 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -2,21 +2,25 @@ from collections.abc import AsyncGenerator, Callable import json -from typing import Any, Literal, cast +from typing import Any, Literal import openai from openai._streaming import AsyncStream -from openai._types import NOT_GIVEN -from openai.types.chat import ( - ChatCompletionAssistantMessageParam, - ChatCompletionChunk, - ChatCompletionMessageParam, - ChatCompletionMessageToolCallParam, - ChatCompletionToolMessageParam, - ChatCompletionToolParam, +from openai.types.responses import ( + EasyInputMessageParam, + FunctionToolParam, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionToolCallParam, + ResponseInputParam, + ResponseOutputItemAddedEvent, + ResponseOutputMessage, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ToolParam, ) -from openai.types.chat.chat_completion_message_tool_call_param import Function -from openai.types.shared_params import FunctionDefinition +from openai.types.responses.response_input_param import FunctionCallOutput from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation @@ -60,123 +64,81 @@ async def async_setup_entry( def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> ChatCompletionToolParam: +) -> FunctionToolParam: """Format tool specification.""" - tool_spec = FunctionDefinition( + return FunctionToolParam( + type="function", name=tool.name, parameters=convert(tool.parameters, custom_serializer=custom_serializer), + description=tool.description, + strict=False, ) - if tool.description: - tool_spec["description"] = tool.description - return ChatCompletionToolParam(type="function", function=tool_spec) def _convert_content_to_param( content: conversation.Content, -) -> ChatCompletionMessageParam: +) -> ResponseInputParam: """Convert any native chat message for this agent to the native format.""" - if content.role == "tool_result": - assert type(content) is conversation.ToolResultContent - return ChatCompletionToolMessageParam( - role="tool", - tool_call_id=content.tool_call_id, - content=json.dumps(content.tool_result), - ) - if content.role != "assistant" or not content.tool_calls: - role: Literal["system", "user", "assistant", "developer"] = content.role + messages: ResponseInputParam = [] + if isinstance(content, conversation.ToolResultContent): + return [ + FunctionCallOutput( + type="function_call_output", + call_id=content.tool_call_id, + output=json.dumps(content.tool_result), + ) + ] + + if content.content: + role: Literal["user", "assistant", "system", "developer"] = content.role if role == "system": role = "developer" - return cast( - ChatCompletionMessageParam, - {"role": content.role, "content": content.content}, + messages.append( + EasyInputMessageParam(type="message", role=role, content=content.content) ) - # Handle the Assistant content including tool calls. - assert type(content) is conversation.AssistantContent - return ChatCompletionAssistantMessageParam( - role="assistant", - content=content.content, - tool_calls=[ - ChatCompletionMessageToolCallParam( - id=tool_call.id, - function=Function( - arguments=json.dumps(tool_call.tool_args), - name=tool_call.tool_name, - ), - type="function", + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + messages.extend( + # https://github.com/openai/openai-python/issues/2205 + ResponseFunctionToolCallParam( # type: ignore[typeddict-item] + type="function_call", + name=tool_call.tool_name, + arguments=json.dumps(tool_call.tool_args), + call_id=tool_call.id, ) for tool_call in content.tool_calls - ], - ) + ) + return messages async def _transform_stream( - result: AsyncStream[ChatCompletionChunk], + result: AsyncStream[ResponseStreamEvent], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform an OpenAI delta stream into HA format.""" - current_tool_call: dict | None = None + async for event in result: + LOGGER.debug("Received event: %s", event) - async for chunk in result: - LOGGER.debug("Received chunk: %s", chunk) - choice = chunk.choices[0] - - if choice.finish_reason: - if current_tool_call: - yield { - "tool_calls": [ - llm.ToolInput( - id=current_tool_call["id"], - tool_name=current_tool_call["tool_name"], - tool_args=json.loads(current_tool_call["tool_args"]), - ) - ] - } - - break - - delta = chunk.choices[0].delta - - # We can yield delta messages not continuing or starting tool calls - if current_tool_call is None and not delta.tool_calls: - yield { # type: ignore[misc] - key: value - for key in ("role", "content") - if (value := getattr(delta, key)) is not None - } - continue - - # When doing tool calls, we should always have a tool call - # object or we have gotten stopped above with a finish_reason set. - if ( - not delta.tool_calls - or not (delta_tool_call := delta.tool_calls[0]) - or not delta_tool_call.function - ): - raise ValueError("Expected delta with tool call") - - if current_tool_call and delta_tool_call.index == current_tool_call["index"]: - current_tool_call["tool_args"] += delta_tool_call.function.arguments or "" - continue - - # We got tool call with new index, so we need to yield the previous - if current_tool_call: + if isinstance(event, ResponseOutputItemAddedEvent): + if isinstance(event.item, ResponseOutputMessage): + yield {"role": event.item.role} + elif isinstance(event.item, ResponseFunctionToolCall): + current_tool_call = event.item + elif isinstance(event, ResponseTextDeltaEvent): + yield {"content": event.delta} + elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): + current_tool_call.arguments += event.delta + elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): + current_tool_call.status = "completed" yield { "tool_calls": [ llm.ToolInput( - id=current_tool_call["id"], - tool_name=current_tool_call["tool_name"], - tool_args=json.loads(current_tool_call["tool_args"]), + id=current_tool_call.call_id, + tool_name=current_tool_call.name, + tool_args=json.loads(current_tool_call.arguments), ) ] } - current_tool_call = { - "index": delta_tool_call.index, - "id": delta_tool_call.id, - "tool_name": delta_tool_call.function.name, - "tool_args": delta_tool_call.function.arguments or "", - } - class OpenAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent @@ -241,7 +203,7 @@ class OpenAIConversationEntity( except conversation.ConverseError as err: return err.as_conversation_result() - tools: list[ChatCompletionToolParam] | None = None + tools: list[ToolParam] | None = None if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) @@ -249,7 +211,11 @@ class OpenAIConversationEntity( ] model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - messages = [_convert_content_to_param(content) for content in chat_log.content] + messages = [ + m + for content in chat_log.content + for m in _convert_content_to_param(content) + ] client = self.entry.runtime_data @@ -257,24 +223,28 @@ class OpenAIConversationEntity( for _iteration in range(MAX_TOOL_ITERATIONS): model_args = { "model": model, - "messages": messages, - "tools": tools or NOT_GIVEN, - "max_completion_tokens": options.get( + "input": messages, + "max_output_tokens": options.get( CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS ), "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), "user": chat_log.conversation_id, + "store": False, "stream": True, } + if tools: + model_args["tools"] = tools if model.startswith("o"): - model_args["reasoning_effort"] = options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) + model_args["reasoning"] = { + "effort": options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } try: - result = await client.chat.completions.create(**model_args) + result = await client.responses.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 @@ -282,14 +252,10 @@ class OpenAIConversationEntity( LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err - messages.extend( - [ - _convert_content_to_param(content) - async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(result) - ) - ] - ) + async for content in chat_log.async_add_delta_content_stream( + user_input.agent_id, _transform_stream(result) + ): + messages.extend(_convert_content_to_param(content)) if not chat_log.unresponded_tool_results: break diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 238fd5f2d7b..bfcacefb044 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -3,14 +3,28 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from httpx import Response +import httpx from openai import AuthenticationError, RateLimitError -from openai.types.chat.chat_completion_chunk import ( - ChatCompletionChunk, - Choice, - ChoiceDelta, - ChoiceDeltaToolCall, - ChoiceDeltaToolCallFunction, +from openai.types import ResponseFormatText +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseCreatedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseInProgressEvent, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningItem, + ResponseStreamEvent, + ResponseTextConfig, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, ) import pytest from syrupy.assertion import SnapshotAssertion @@ -28,40 +42,65 @@ from tests.components.conversation import ( mock_chat_log, # noqa: F401 ) -ASSIST_RESPONSE_FINISH = ( - # Assistant message - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], - ), - # Finish stream - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[Choice(index=0, finish_reason="stop", delta=ChoiceDelta())], - ), -) - @pytest.fixture def mock_create_stream() -> Generator[AsyncMock]: """Mock stream response.""" - async def mock_generator(stream): - for value in stream: + async def mock_generator(events, **kwargs): + response = Response( + id="resp_A", + created_at=1700000000, + error=None, + incomplete_details=None, + instructions=kwargs.get("instructions"), + metadata=kwargs.get("metadata", {}), + model=kwargs.get("model", "gpt-4o-mini"), + object="response", + output=[], + parallel_tool_calls=kwargs.get("parallel_tool_calls", True), + temperature=kwargs.get("temperature", 1.0), + tool_choice=kwargs.get("tool_choice", "auto"), + tools=kwargs.get("tools"), + top_p=kwargs.get("top_p", 1.0), + max_output_tokens=kwargs.get("max_output_tokens", 100000), + previous_response_id=kwargs.get("previous_response_id"), + reasoning=kwargs.get("reasoning"), + status="in_progress", + text=kwargs.get( + "text", ResponseTextConfig(format=ResponseFormatText(type="text")) + ), + truncation=kwargs.get("truncation", "disabled"), + usage=None, + user=kwargs.get("user"), + store=kwargs.get("store", True), + ) + yield ResponseCreatedEvent( + response=response, + type="response.created", + ) + yield ResponseInProgressEvent( + response=response, + type="response.in_progress", + ) + + for value in events: + if isinstance(value, ResponseOutputItemDoneEvent): + response.output.append(value.item) yield value + response.status = "completed" + yield ResponseCompletedEvent( + response=response, + type="response.completed", + ) + with patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", AsyncMock(), ) as mock_create: mock_create.side_effect = lambda **kwargs: mock_generator( - mock_create.return_value.pop(0) + mock_create.return_value.pop(0), **kwargs ) yield mock_create @@ -99,13 +138,17 @@ async def test_entity( [ ( RateLimitError( - response=Response(status_code=429, request=""), body=None, message=None + response=httpx.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 + response=httpx.Response(status_code=401, request=""), + body=None, + message=None, ), "Error talking to OpenAI", ), @@ -120,7 +163,7 @@ async def test_error_handling( ) -> None: """Test that we handle errors when calling completion API.""" with patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock, side_effect=exception, ): @@ -144,6 +187,165 @@ async def test_conversation_agent( assert agent.supported_languages == "*" +def create_message_item( + id: str, text: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(text, str): + text = [text] + + content = ResponseOutputText(annotations=[], text="", type="output_text") + events = [ + ResponseOutputItemAddedEvent( + item=ResponseOutputMessage( + id=id, + content=[], + type="message", + role="assistant", + status="in_progress", + ), + output_index=output_index, + type="response.output_item.added", + ), + ResponseContentPartAddedEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + type="response.content_part.added", + ), + ] + + content.text = "".join(text) + events.extend( + ResponseTextDeltaEvent( + content_index=0, + delta=delta, + item_id=id, + output_index=output_index, + type="response.output_text.delta", + ) + for delta in text + ) + + events.extend( + [ + ResponseTextDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + text="".join(text), + type="response.output_text.done", + ), + ResponseContentPartDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + type="response.content_part.done", + ), + ResponseOutputItemDoneEvent( + item=ResponseOutputMessage( + id=id, + content=[content], + role="assistant", + status="completed", + type="message", + ), + output_index=output_index, + type="response.output_item.done", + ), + ] + ) + + return events + + +def create_function_tool_call_item( + id: str, arguments: str | list[str], call_id: str, name: str, output_index: int +) -> list[ResponseStreamEvent]: + """Create a function tool call item.""" + if isinstance(arguments, str): + arguments = [arguments] + + events = [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="", + call_id=call_id, + name=name, + type="function_call", + status="in_progress", + ), + output_index=output_index, + type="response.output_item.added", + ) + ] + + events.extend( + ResponseFunctionCallArgumentsDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + type="response.function_call_arguments.delta", + ) + for delta in arguments + ) + + events.append( + ResponseFunctionCallArgumentsDoneEvent( + arguments="".join(arguments), + item_id=id, + output_index=output_index, + type="response.function_call_arguments.done", + ) + ) + + events.append( + ResponseOutputItemDoneEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="".join(arguments), + call_id=call_id, + name=name, + type="function_call", + status="completed", + ), + output_index=output_index, + type="response.output_item.done", + ) + ) + + return events + + +def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a reasoning item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + ), + output_index=output_index, + type="response.output_item.added", + ), + ResponseOutputItemDoneEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + ), + output_index=output_index, + type="response.output_item.done", + ), + ] + + async def test_function_call( hass: HomeAssistant, mock_config_entry_with_assist: MockConfigEntry, @@ -156,111 +358,27 @@ async def test_function_call( mock_create_stream.return_value = [ # Initial conversation ( + # Wait for the model to think + *create_reasoning_item(id="rs_A", output_index=0), # First tool call - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_1", - index=0, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments=None, - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name=None, - arguments='{"para', - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name=None, - arguments='m1":"call1"}', - ), - ) - ] - ), - ) - ], + *create_function_tool_call_item( + id="fc_1", + arguments=['{"para', 'm1":"call1"}'], + call_id="call_call_1", + name="test_tool", + output_index=1, ), # Second tool call - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_2", - index=1, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments='{"param1":"call2"}', - ), - ) - ] - ), - ) - ], - ), - # Finish stream - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice(index=0, finish_reason="tool_calls", delta=ChoiceDelta()) - ], + *create_function_tool_call_item( + id="fc_2", + arguments='{"param1":"call2"}', + call_id="call_call_2", + name="test_tool", + output_index=2, ), ), # Response after tool responses - ASSIST_RESPONSE_FINISH, + create_message_item(id="msg_A", text="Cool", output_index=0), ] mock_chat_log.mock_tool_results( { @@ -288,99 +406,27 @@ async def test_function_call( ( "Test function call started with missing arguments", ( - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_1", - index=0, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments=None, - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[Choice(index=0, delta=ChoiceDelta(content="Cool"))], + *create_function_tool_call_item( + id="fc_1", + arguments=[], + call_id="call_call_1", + name="test_tool", + output_index=0, ), + *create_message_item(id="msg_A", text="Cool", output_index=1), ), ), ( "Test invalid JSON", ( - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - id="call_call_1", - index=0, - function=ChoiceDeltaToolCallFunction( - name="test_tool", - arguments=None, - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-A", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta( - tool_calls=[ - ChoiceDeltaToolCall( - index=0, - function=ChoiceDeltaToolCallFunction( - name=None, - arguments='{"para', - ), - ) - ] - ), - ) - ], - ), - ChatCompletionChunk( - id="chatcmpl-B", - created=1700000000, - model="gpt-4-1106-preview", - object="chat.completion.chunk", - choices=[ - Choice( - index=0, - delta=ChoiceDelta(content="Cool"), - finish_reason="tool_calls", - ) - ], + *create_function_tool_call_item( + id="fc_1", + arguments=['{"para'], + call_id="call_call_1", + name="test_tool", + output_index=0, ), + *create_message_item(id="msg_A", text="Cool", output_index=1), ), ), ], @@ -392,7 +438,7 @@ async def test_function_call_invalid( mock_create_stream: AsyncMock, mock_chat_log: MockChatLog, # noqa: F811 description: str, - messages: tuple[ChatCompletionChunk], + messages: tuple[ResponseStreamEvent], ) -> None: """Test function call containing invalid data.""" mock_create_stream.return_value = [messages] @@ -432,7 +478,9 @@ async def test_assist_api_tools_conversion( hass.states.async_set(f"{component}.test", "on") async_expose_entity(hass, "conversation", f"{component}.test", True) - mock_create_stream.return_value = [ASSIST_RESPONSE_FINISH] + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Cool", output_index=0) + ] await conversation.async_converse( hass, "hello", None, Context(), agent_id="conversation.openai" diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 05a92d0b98e..5aef68841ee 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -2,17 +2,16 @@ from unittest.mock import AsyncMock, mock_open, patch -from httpx import Request, Response +import httpx from openai import ( APIConnectionError, AuthenticationError, BadRequestError, RateLimitError, ) -from openai.types.chat.chat_completion import ChatCompletion, Choice -from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.image import Image from openai.types.images_response import ImagesResponse +from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest from homeassistant.components.openai_conversation import CONF_FILENAMES @@ -117,8 +116,8 @@ async def test_generate_image_service_error( patch( "openai.resources.images.AsyncImages.generate", side_effect=RateLimitError( - response=Response( - status_code=500, request=Request(method="GET", url="") + response=httpx.Response( + status_code=500, request=httpx.Request(method="GET", url="") ), body=None, message="Reason", @@ -202,13 +201,13 @@ async def test_invalid_config_entry( ("side_effect", "error"), [ ( - APIConnectionError(request=Request(method="GET", url="test")), + APIConnectionError(request=httpx.Request(method="GET", url="test")), "Connection error", ), ( AuthenticationError( - response=Response( - status_code=500, request=Request(method="GET", url="test") + response=httpx.Response( + status_code=500, request=httpx.Request(method="GET", url="test") ), body=None, message="", @@ -217,8 +216,8 @@ async def test_invalid_config_entry( ), ( BadRequestError( - response=Response( - status_code=500, request=Request(method="GET", url="test") + response=httpx.Response( + status_code=500, request=httpx.Request(method="GET", url="test") ), body=None, message="", @@ -250,11 +249,11 @@ async def test_init_error( ( {"prompt": "Picture of a dog", "filenames": []}, { - "messages": [ + "input": [ { "content": [ { - "type": "text", + "type": "input_text", "text": "Picture of a dog", }, ], @@ -266,18 +265,18 @@ async def test_init_error( ( {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, { - "messages": [ + "input": [ { "content": [ { - "type": "text", + "type": "input_text", "text": "Picture of a dog", }, { - "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,BASE64IMAGE1", - }, + "type": "input_image", + "image_url": "data:image/jpeg;base64,BASE64IMAGE1", + "detail": "auto", + "file_id": "/a/b/c.jpg", }, ], }, @@ -291,24 +290,24 @@ async def test_init_error( "filenames": ["/a/b/c.jpg", "d/e/f.jpg"], }, { - "messages": [ + "input": [ { "content": [ { - "type": "text", + "type": "input_text", "text": "Picture of a dog", }, { - "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,BASE64IMAGE1", - }, + "type": "input_image", + "image_url": "data:image/jpeg;base64,BASE64IMAGE1", + "detail": "auto", + "file_id": "/a/b/c.jpg", }, { - "type": "image_url", - "image_url": { - "url": "data:image/jpeg;base64,BASE64IMAGE2", - }, + "type": "input_image", + "image_url": "data:image/jpeg;base64,BASE64IMAGE2", + "detail": "auto", + "file_id": "d/e/f.jpg", }, ], }, @@ -329,13 +328,17 @@ async def test_generate_content_service( """Test generate content service.""" service_data["config_entry"] = mock_config_entry.entry_id expected_args["model"] = "gpt-4o-mini" - expected_args["n"] = 1 - expected_args["response_format"] = {"type": "json_object"} - expected_args["messages"][0]["role"] = "user" + expected_args["max_output_tokens"] = 150 + expected_args["top_p"] = 1.0 + expected_args["temperature"] = 1.0 + expected_args["user"] = None + expected_args["store"] = False + expected_args["input"][0]["type"] = "message" + expected_args["input"][0]["role"] = "user" with ( patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock, ) as mock_create, patch( @@ -345,19 +348,27 @@ async def test_generate_content_service( patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), ): - mock_create.return_value = ChatCompletion( - id="", - model="", - created=1700000000, - object="chat.completion", - choices=[ - Choice( - index=0, - finish_reason="stop", - message=ChatCompletionMessage( - role="assistant", - content="This is the response", - ), + mock_create.return_value = Response( + object="response", + id="resp_A", + created_at=1700000000, + model="gpt-4o-mini", + parallel_tool_calls=True, + tool_choice="auto", + tools=[], + output=[ + ResponseOutputMessage( + type="message", + id="msg_A", + content=[ + ResponseOutputText( + type="output_text", + text="This is the response", + annotations=[], + ) + ], + role="assistant", + status="completed", ) ], ) @@ -427,7 +438,7 @@ async def test_generate_content_service_invalid( with ( patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock, ) as mock_create, patch( @@ -459,10 +470,10 @@ async def test_generate_content_service_error( """Test generate content service handles errors.""" with ( patch( - "openai.resources.chat.completions.AsyncCompletions.create", + "openai.resources.responses.AsyncResponses.create", side_effect=RateLimitError( - response=Response( - status_code=417, request=Request(method="GET", url="") + response=httpx.Response( + status_code=417, request=httpx.Request(method="GET", url="") ), body=None, message="Reason", From 2424d1c615274ed3fe49485f64cb336dda1cd8f9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 13:19:32 -0400 Subject: [PATCH 1716/1941] bump Python-Roborock to 2.14.0 (#140727) bump Python Roborock to 2.14.0 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 1b143591203..45cfe4e12d8 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.12.2", + "python-roborock==2.14.0", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 758456c5e9b..e5840c757bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2465,7 +2465,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.12.2 +python-roborock==2.14.0 # homeassistant.components.smarttub python-smarttub==0.0.39 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 562ccd14163..4da33240d7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1995,7 +1995,7 @@ python-picnic-api2==1.2.2 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.12.2 +python-roborock==2.14.0 # homeassistant.components.smarttub python-smarttub==0.0.39 From 2ece7fbc112ba65071527e2617f9480328f05dab Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 16 Mar 2025 19:32:59 +0100 Subject: [PATCH 1717/1941] Add strict typing to remote_calendar (#140734) --- .strict-typing | 1 + .../components/remote_calendar/quality_scale.yaml | 2 +- mypy.ini | 10 ++++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.strict-typing b/.strict-typing index 56d3e299281..0e00c2e9e07 100644 --- a/.strict-typing +++ b/.strict-typing @@ -412,6 +412,7 @@ homeassistant.components.recollect_waste.* homeassistant.components.recorder.* homeassistant.components.remember_the_milk.* homeassistant.components.remote.* +homeassistant.components.remote_calendar.* homeassistant.components.renault.* homeassistant.components.reolink.* homeassistant.components.repairs.* diff --git a/homeassistant/components/remote_calendar/quality_scale.yaml b/homeassistant/components/remote_calendar/quality_scale.yaml index 3693d75f2cf..05dc32e5da9 100644 --- a/homeassistant/components/remote_calendar/quality_scale.yaml +++ b/homeassistant/components/remote_calendar/quality_scale.yaml @@ -97,4 +97,4 @@ rules: # Platinum async-dependency: todo inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/mypy.ini b/mypy.ini index 520fad7d738..852678677bb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3876,6 +3876,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.remote_calendar.*] +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.renault.*] check_untyped_defs = true disallow_incomplete_defs = true From 8a552aef9dc67be53c60c01652439b16452cb383 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 16 Mar 2025 19:33:28 +0100 Subject: [PATCH 1718/1941] Adjusts strings in create actions in Habitica integration (#140742) Adjusts strings in create actions --- homeassistant/components/habitica/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index cc67b767519..fac0fdf3868 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -768,7 +768,7 @@ "description": "[%key:component::habitica::common::notes_description%]" }, "tag": { - "name": "[%key:component::habitica::common::tag_name%]", + "name": "[%key:component::habitica::common::tag_options_name%]", "description": "[%key:component::habitica::common::tag_description%]" }, "alias": { @@ -868,7 +868,7 @@ "description": "[%key:component::habitica::common::notes_description%]" }, "tag": { - "name": "[%key:component::habitica::common::tag_name%]", + "name": "[%key:component::habitica::common::tag_options_name%]", "description": "[%key:component::habitica::common::tag_description%]" }, "alias": { @@ -1008,7 +1008,7 @@ "description": "[%key:component::habitica::common::notes_description%]" }, "tag": { - "name": "[%key:component::habitica::common::tag_name%]", + "name": "[%key:component::habitica::common::tag_options_name%]", "description": "[%key:component::habitica::common::tag_description%]" }, "alias": { @@ -1024,11 +1024,11 @@ "description": "[%key:component::habitica::common::date_description%]" }, "reminder": { - "name": "[%key:component::habitica::common::reminder_name%]", + "name": "[%key:component::habitica::common::reminder_options_name%]", "description": "[%key:component::habitica::common::reminder_description%]" }, "add_checklist_item": { - "name": "[%key:component::habitica::common::add_checklist_item_name%]", + "name": "[%key:component::habitica::common::checklist_options_name%]", "description": "[%key:component::habitica::common::add_checklist_item_description%]" } }, From b5fa3e74c0b7c6c25cbb43fb9f53aeda4af81412 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 16 Mar 2025 19:51:06 +0100 Subject: [PATCH 1719/1941] Add option to specify Reolink Basic Service Port (#137603) * Allow changing the baichuan port * styling * Add description * Add tests * Review feedback * capital letters Co-authored-by: Robert Resch --------- Co-authored-by: Robert Resch --- homeassistant/components/reolink/__init__.py | 14 +++++++++++++- .../components/reolink/config_flow.py | 7 +++++-- homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 4 +++- homeassistant/components/reolink/strings.json | 4 +++- tests/components/reolink/conftest.py | 4 ++++ tests/components/reolink/test_config_flow.py | 18 ++++++++++++++++++ tests/components/reolink/test_init.py | 18 +++++++++++++++++- tests/components/reolink/test_media_source.py | 4 +++- 9 files changed, 67 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 71ca5428740..2489133841a 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,7 +28,7 @@ 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_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services @@ -100,6 +100,7 @@ async def async_setup_entry( 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) + or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT) ): if host.api.port != config_entry.data[CONF_PORT]: _LOGGER.warning( @@ -108,10 +109,21 @@ async def async_setup_entry( config_entry.data[CONF_PORT], host.api.port, ) + if ( + config_entry.data.get(CONF_BC_PORT, host.api.baichuan.port) + != host.api.baichuan.port + ): + _LOGGER.warning( + "Baichuan port of Reolink %s, changed from %s to %s", + host.api.nvr_name, + config_entry.data.get(CONF_BC_PORT), + host.api.baichuan.port, + ) data = { **config_entry.data, CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, + CONF_BC_PORT: host.api.baichuan.port, CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 7943cadef21..12ccd455be3 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -8,6 +8,7 @@ import logging from typing import Any from reolink_aio.api import ALLOWED_SPECIAL_CHARS +from reolink_aio.baichuan import DEFAULT_BC_PORT from reolink_aio.exceptions import ( ApiError, CredentialsInvalidError, @@ -37,7 +38,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_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -287,6 +288,7 @@ 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_BC_PORT] = host.api.baichuan.port user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( None, "privacy_mode" ) @@ -326,8 +328,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if errors: data_schema = data_schema.extend( { - vol.Optional(CONF_PORT): cv.positive_int, + vol.Optional(CONF_PORT): cv.port, vol.Required(CONF_USE_HTTPS, default=False): bool, + vol.Required(CONF_BC_PORT, default=DEFAULT_BC_PORT): cv.port, } ) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 7bd93337c46..026d1219881 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,4 +3,5 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" +CONF_BC_PORT = "baichuan_port" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 2f646ba9090..53061500e32 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -12,6 +12,7 @@ from typing import Any, Literal import aiohttp from aiohttp.web import Request from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host +from reolink_aio.baichuan import DEFAULT_BC_PORT from reolink_aio.enums import SubType from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError @@ -33,7 +34,7 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .exceptions import ( PasswordIncompatible, ReolinkSetupException, @@ -91,6 +92,7 @@ class ReolinkHost: protocol=options[CONF_PROTOCOL], timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=get_aiohttp_session, + bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), ) self.last_wake: float = 0 diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 335ed92d32e..daa87fb401c 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -8,13 +8,15 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "use_https": "Enable HTTPS", + "baichuan_port": "Basic service port", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, "data_description": { "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", - "port": "The port to connect to the Reolink device. For HTTP normally: '80', for HTTPS normally '443'.", + "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.", "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.", "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 5af55b48dda..293103e7eb2 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -10,6 +10,7 @@ from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN, @@ -48,6 +49,7 @@ TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" TEST_DUO_MODEL = "Reolink Duo PoE" TEST_PRIVACY = True +TEST_BC_PORT = 5678 @pytest.fixture @@ -136,6 +138,7 @@ def reolink_connect_class() -> Generator[MagicMock]: # Baichuan host_mock.baichuan = create_autospec(Baichuan) # Disable tcp push by default for tests + host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") @@ -175,6 +178,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4fe671f8cca..e706af0d067 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -19,6 +19,7 @@ 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_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN, @@ -40,6 +41,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .conftest import ( DHCP_FORMATTED_MAC, + TEST_BC_PORT, TEST_HOST, TEST_HOST2, TEST_MAC, @@ -88,6 +90,7 @@ async def test_config_flow_manual_success( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -140,6 +143,7 @@ async def test_config_flow_privacy_success( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -290,6 +294,7 @@ async def test_config_flow_errors( CONF_HOST: TEST_HOST, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, ) @@ -302,6 +307,7 @@ async def test_config_flow_errors( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -322,6 +328,7 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: "rtsp", @@ -360,6 +367,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -405,6 +413,7 @@ async def test_reauth_abort_unique_id_mismatch( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -474,6 +483,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -496,6 +506,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -536,6 +547,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, + bc_port=TEST_BC_PORT, ) assert expected_call in reolink_connect_class.call_args_list @@ -593,6 +605,7 @@ async def test_dhcp_ip_update( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -635,6 +648,7 @@ async def test_dhcp_ip_update( protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, + bc_port=TEST_BC_PORT, ) assert expected_call in reolink_connect_class.call_args_list @@ -671,6 +685,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -702,6 +717,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, + bc_port=TEST_BC_PORT, ) assert expected_call in reolink_connect_class.call_args_list @@ -731,6 +747,7 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -777,6 +794,7 @@ async def test_reconfig_abort_unique_id_mismatch( CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 28d8c542f4f..ad7f5540b04 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -19,7 +19,7 @@ from homeassistant.components.reolink import ( FIRMWARE_UPDATE_INTERVAL, NUM_CRED_ERRORS, ) -from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.reolink.const import CONF_BC_PORT, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_PORT, @@ -38,6 +38,7 @@ from homeassistant.helpers import ( from homeassistant.setup import async_setup_component from .conftest import ( + TEST_BC_PORT, TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_MAC, @@ -762,6 +763,21 @@ async def test_port_changed( assert config_entry.data[CONF_PORT] == 4567 +async def test_baichuan_port_changed( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test config_entry baichuan port update when it has changed during initial login.""" + assert config_entry.data[CONF_BC_PORT] == TEST_BC_PORT + reolink_connect.baichuan.port = 8901 + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.data[CONF_BC_PORT] == 8901 + + async def test_privacy_mode_on( hass: HomeAssistant, freezer: FrozenDateTimeFactory, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index a5a34514598..7044ea53671 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -15,7 +15,7 @@ from homeassistant.components.media_source import ( async_resolve_media, ) 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_BC_PORT, CONF_USE_HTTPS, DOMAIN from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN from homeassistant.const import ( CONF_HOST, @@ -31,6 +31,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.setup import async_setup_component from .conftest import ( + TEST_BC_PORT, TEST_HOST2, TEST_HOST_MODEL, TEST_MAC2, @@ -348,6 +349,7 @@ async def test_browsing_not_loaded( CONF_PASSWORD: TEST_PASSWORD2, CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_BC_PORT: TEST_BC_PORT, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, From 735c98cb861c0cb647e998044fbdd29f58e0ad6e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 16 Mar 2025 19:54:00 +0100 Subject: [PATCH 1720/1941] Set Home Connect button unique id to shorthand attribute (#140745) --- homeassistant/components/home_connect/button.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 0a5538ec588..726ca8cf670 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -102,7 +102,7 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): ) self.entity_description = desc self.appliance = appliance - self.unique_id = f"{appliance.info.ha_id}-{desc.key}" + self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" def update_native_value(self) -> None: """Set the value of the entity.""" From 46973f0446cc2d814afdb7e2d58ebb73e98abc02 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 16 Mar 2025 20:00:10 +0100 Subject: [PATCH 1721/1941] Redact emails and names in Bring! diagnostics (#140746) --- homeassistant/components/bring/diagnostics.py | 9 ++++++++- .../bring/snapshots/test_diagnostics.ambr | 20 +++++++++---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index 6c2f779ef05..e5cafd30ab5 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -4,10 +4,14 @@ from __future__ import annotations from typing import Any +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_EMAIL, CONF_NAME from homeassistant.core import HomeAssistant from .coordinator import BringConfigEntry +TO_REDACT = {CONF_NAME, CONF_EMAIL} + async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: BringConfigEntry @@ -15,7 +19,10 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" return { - "data": {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()}, + "data": { + k: async_redact_data(v.to_dict(), TO_REDACT) + for k, v in config_entry.runtime_data.data.items() + }, "lists": [lst.to_dict() for lst in config_entry.runtime_data.lists], "user_settings": config_entry.runtime_data.user_settings.to_dict(), } diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 951c3d3f808..8570bc0410f 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -128,16 +128,16 @@ }), 'lst': dict({ 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', - 'name': 'Baumarkt', + 'name': '**REDACTED**', 'theme': 'ch.publisheria.bring.theme.home', }), 'users': dict({ 'users': list([ dict({ 'country': 'DE', - 'email': 'test-email', + 'email': '**REDACTED**', 'language': 'de', - 'name': 'Bring', + 'name': '**REDACTED**', 'photoPath': '', 'plusTryOut': False, 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', @@ -145,9 +145,9 @@ }), dict({ 'country': 'US', - 'email': 'EMAIL', + 'email': '**REDACTED**', 'language': 'en', - 'name': 'NAME', + 'name': '**REDACTED**', 'photoPath': '', 'plusTryOut': False, 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', @@ -292,16 +292,16 @@ }), 'lst': dict({ 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', - 'name': 'Einkauf', + 'name': '**REDACTED**', 'theme': 'ch.publisheria.bring.theme.home', }), 'users': dict({ 'users': list([ dict({ 'country': 'DE', - 'email': 'test-email', + 'email': '**REDACTED**', 'language': 'de', - 'name': 'Bring', + 'name': '**REDACTED**', 'photoPath': '', 'plusTryOut': False, 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', @@ -309,9 +309,9 @@ }), dict({ 'country': 'US', - 'email': 'EMAIL', + 'email': '**REDACTED**', 'language': 'en', - 'name': 'NAME', + 'name': '**REDACTED**', 'photoPath': '', 'plusTryOut': False, 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', From a7b6bcf1d6aba99ad03ac9c36256d3e45465dcf1 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 16 Mar 2025 20:03:02 +0100 Subject: [PATCH 1722/1941] Address post merge comments for remote calendar (#140735) --- homeassistant/components/remote_calendar/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index c833676a410..1ad62821818 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -5,11 +5,11 @@ "user": { "description": "Please choose a name for the calendar to be imported", "data": { - "calendar_name": "Calendar Name", + "calendar_name": "Calendar name", "url": "Calendar URL" }, "data_description": { - "calendar_name": "The name of the calendar shown in th UI.", + "calendar_name": "The name of the calendar shown in the UI.", "url": "The URL of the remote calendar." } } From 56fe4319a07a51707f886335e1a326b09a0e0457 Mon Sep 17 00:00:00 2001 From: MarkGodwin <10632972+MarkGodwin@users.noreply.github.com> Date: Sun, 16 Mar 2025 19:04:58 +0000 Subject: [PATCH 1723/1941] Bump TP-Link Omada API to 1.4.4 (#140738) --- homeassistant/components/tplink_omada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index af20b54675b..274f2815330 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.4.3"] + "requirements": ["tplink-omada-client==1.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index e5840c757bd..e86de5d2f71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2930,7 +2930,7 @@ total-connect-client==2025.1.4 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.3 +tplink-omada-client==1.4.4 # homeassistant.components.transmission transmission-rpc==7.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4da33240d7a..5ce29dff3ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2352,7 +2352,7 @@ toonapi==0.3.0 total-connect-client==2025.1.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.3 +tplink-omada-client==1.4.4 # homeassistant.components.transmission transmission-rpc==7.0.3 From d061f4ee05e2c1560a02029903b09f456e8d70fa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 16 Mar 2025 20:06:50 +0100 Subject: [PATCH 1724/1941] Fix SmartThings ACs without supported AC modes (#140744) --- .../components/smartthings/climate.py | 15 ++-- tests/components/smartthings/conftest.py | 1 + .../fixtures/device_status/aux_ac.json | 69 ++++++++++++++++ .../smartthings/fixtures/devices/aux_ac.json | 81 +++++++++++++++++++ .../smartthings/snapshots/test_climate.ambr | 64 +++++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++++++++ .../smartthings/snapshots/test_sensor.ambr | 52 ++++++++++++ 7 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/aux_ac.json create mode 100644 tests/components/smartthings/fixtures/devices/aux_ac.json diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index a95105efaa6..c6dee3e2be4 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -571,12 +571,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): 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( + if ( + ac_modes := self.get_attribute_value( Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES ) - if (state := AC_MODE_TO_STATE.get(mode)) is not None - if state not in modes - ) + ) is not None: + modes.extend( + state + for mode in ac_modes + if (state := AC_MODE_TO_STATE.get(mode)) is not None + if state not in modes + ) return modes diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 3e0047e255a..d26805eb04b 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -137,6 +137,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "abl_light_b_001", "tplink_p110", "ikea_kadrilj", + "aux_ac", ] ) def device_fixture( diff --git a/tests/components/smartthings/fixtures/device_status/aux_ac.json b/tests/components/smartthings/fixtures/device_status/aux_ac.json new file mode 100644 index 00000000000..a3ebede7a10 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aux_ac.json @@ -0,0 +1,69 @@ +{ + "components": { + "main": { + "partyvoice23922.vtempset": { + "vtemp": { + "value": 20, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.161Z" + } + }, + "airConditionerFanMode": { + "fanMode": { + "value": "auto", + "timestamp": "2024-12-05T20:03:32.930Z" + }, + "supportedAcFanModes": { + "value": null + }, + "availableAcFanModes": { + "value": null + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 20.0, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.066Z" + } + }, + "airConditionerMode": { + "availableAcModes": { + "value": null + }, + "supportedAcModes": { + "value": null + }, + "airConditionerMode": { + "value": "cool", + "timestamp": "2024-12-05T20:03:32.845Z" + } + }, + "fanSpeed": { + "fanSpeed": { + "value": 0, + "timestamp": "2024-12-05T20:03:33.334Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": 20.0, + "unit": "C", + "timestamp": "2024-12-05T20:03:33.243Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2024-12-05T20:03:32.662Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aux_ac.json b/tests/components/smartthings/fixtures/devices/aux_ac.json new file mode 100644 index 00000000000..fcdb581748c --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aux_ac.json @@ -0,0 +1,81 @@ +{ + "items": [ + { + "deviceId": "bf53a150-f8a4-45d1-aac4-86252475d551", + "name": "vedgeaircon.v1", + "label": "AUX A/C on-off", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "ab252042-5669-3c2c-8b1b-d606bbcc9e04", + "deviceManufacturerCode": "SmartThings Community", + "locationId": "5db1e3d8-ea26-44b4-8ed0-1ba9c841fd57", + "ownerId": "5404aa57-6a68-4fe2-83ff-168ef769d1c7", + "roomId": "564cdd9a-fa9f-4187-902f-95656ef22989", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "airConditionerMode", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "airConditionerFanMode", + "version": 1 + }, + { + "id": "fanSpeed", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "partyvoice23922.vtempset", + "version": 1 + } + ], + "categories": [ + { + "name": "AirConditioner", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2024-06-19T20:18:45.407Z", + "parentDeviceId": "e699599d-30f8-4cf0-8de7-6dbdba6a665f", + "profile": { + "id": "87f0ac35-e024-3c0a-8153-78ca27a6fe0c" + }, + "lan": { + "networkId": "vEdge_A/C_1718828324.999", + "driverId": "0fd9a9a4-8863-4a83-97a7-5a288ff0f5a6", + "executingLocally": true, + "hubId": "e699599d-30f8-4cf0-8de7-6dbdba6a665f", + "provisioningState": "TYPED" + }, + "type": "LAN", + "restrictionTier": 0, + "allowed": null, + "indoorMap": { + "coordinates": [130.0, 36.0, 378.0], + "rotation": [270.0, 0.0, 0.0], + "visible": true, + "data": null + }, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 20389f38a46..893093ee2aa 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -1,4 +1,68 @@ # serializer version: 1 +# name: test_all_entities[aux_ac][climate.aux_a_c_on_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': None, + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.aux_a_c_on_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aux_ac][climate.aux_a_c_on_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.0, + 'fan_mode': 'auto', + 'fan_modes': None, + 'friendly_name': 'AUX A/C on-off', + 'hvac_modes': list([ + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'temperature': 20.0, + }), + 'context': , + 'entity_id': 'climate.aux_a_c_on_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[bosch_radiator_thermostat_ii][climate.radiator_thermostat_ii_m_wohnzimmer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 5a3ba833cf5..e62c34cd11c 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -68,6 +68,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[aux_ac] + 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', + 'bf53a150-f8a4-45d1-aac4-86252475d551', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'AUX A/C on-off', + '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': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index d5ee2ffad22..954bcc5c281 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -154,6 +154,58 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_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.aux_a_c_on_off_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': 'bf53a150-f8a4-45d1-aac4-86252475d551.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[aux_ac][sensor.aux_a_c_on_off_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AUX A/C on-off Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aux_a_c_on_off_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- # name: test_all_entities[base_electric_meter][sensor.aeon_energy_monitor_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1ee4f02e7089480ed11f57679bcace96b753ed1f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 16 Mar 2025 12:10:40 -0700 Subject: [PATCH 1725/1941] Bump ical to 9.0.1 (#140726) --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index bd04597e513..81fd2b07de4 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==8.3.0"] + "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.1"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 21a4134a8b6..fc6d0bc00c7 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==8.3.0"] + "requirements": ["ical==9.0.1"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 68154f10885..27d3ccce4a7 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==8.3.0"] + "requirements": ["ical==9.0.1"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 260f465f993..fe17a3d2c34 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["ical"], "quality_scale": "silver", - "requirements": ["ical==8.3.0"] + "requirements": ["ical==9.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e86de5d2f71..825983be33b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1194,7 +1194,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==8.3.0 +ical==9.0.1 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ce29dff3ba..c73a3c8e9e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1011,7 +1011,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==8.3.0 +ical==9.0.1 # homeassistant.components.caldav icalendar==6.1.0 From 42f0e70cde924b2c9087fc164e33d01d61348e66 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 16 Mar 2025 20:13:36 +0100 Subject: [PATCH 1726/1941] Add Homee binary sensor platform (#140088) * binary-sensor initial * Add binary sensor tests * small string changes * fix review comments * review change 1 --- homeassistant/components/homee/__init__.py | 1 + .../components/homee/binary_sensor.py | 190 +++ homeassistant/components/homee/strings.json | 70 + .../homee/fixtures/binary_sensors.json | 891 +++++++++++ .../homee/snapshots/test_binary_sensor.ambr | 1392 +++++++++++++++++ tests/components/homee/test_binary_sensor.py | 29 + 6 files changed, 2573 insertions(+) create mode 100644 homeassistant/components/homee/binary_sensor.py create mode 100644 tests/components/homee/fixtures/binary_sensors.json create mode 100644 tests/components/homee/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/homee/test_binary_sensor.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 92773dae656..6158a699302 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -15,6 +15,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.LIGHT, diff --git a/homeassistant/components/homee/binary_sensor.py b/homeassistant/components/homee/binary_sensor.py new file mode 100644 index 00000000000..3f5f5c46a29 --- /dev/null +++ b/homeassistant/components/homee/binary_sensor.py @@ -0,0 +1,190 @@ +"""The Homee binary sensor platform.""" + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HomeeConfigEntry +from .entity import HomeeEntity + +PARALLEL_UPDATES = 0 + +BINARY_SENSOR_DESCRIPTIONS: dict[AttributeType, BinarySensorEntityDescription] = { + AttributeType.BATTERY_LOW_ALARM: BinarySensorEntityDescription( + key="battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.BLACKOUT_ALARM: BinarySensorEntityDescription( + key="blackout_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.COALARM: BinarySensorEntityDescription( + key="carbon_monoxide", device_class=BinarySensorDeviceClass.CO + ), + AttributeType.CO2ALARM: BinarySensorEntityDescription( + key="carbon_dioxide", device_class=BinarySensorDeviceClass.PROBLEM + ), + AttributeType.FLOOD_ALARM: BinarySensorEntityDescription( + key="flood", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + AttributeType.HIGH_TEMPERATURE_ALARM: BinarySensorEntityDescription( + key="high_temperature", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.LEAK_ALARM: BinarySensorEntityDescription( + key="leak_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + ), + AttributeType.LOAD_ALARM: BinarySensorEntityDescription( + key="load_alarm", + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.LOCK_STATE: BinarySensorEntityDescription( + key="lock", + device_class=BinarySensorDeviceClass.LOCK, + ), + AttributeType.LOW_TEMPERATURE_ALARM: BinarySensorEntityDescription( + key="low_temperature", + device_class=BinarySensorDeviceClass.COLD, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MALFUNCTION_ALARM: BinarySensorEntityDescription( + key="malfunction", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MAXIMUM_ALARM: BinarySensorEntityDescription( + key="maximum", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MINIMUM_ALARM: BinarySensorEntityDescription( + key="minimum", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.MOTION_ALARM: BinarySensorEntityDescription( + key="motion", + device_class=BinarySensorDeviceClass.MOTION, + ), + AttributeType.MOTOR_BLOCKED_ALARM: BinarySensorEntityDescription( + key="motor_blocked", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.ON_OFF: BinarySensorEntityDescription( + key="plug", + device_class=BinarySensorDeviceClass.PLUG, + ), + AttributeType.OPEN_CLOSE: BinarySensorEntityDescription( + key="opening", + device_class=BinarySensorDeviceClass.OPENING, + ), + AttributeType.OVER_CURRENT_ALARM: BinarySensorEntityDescription( + key="overcurrent", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.OVERLOAD_ALARM: BinarySensorEntityDescription( + key="overload", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.PRESENCE_ALARM: BinarySensorEntityDescription( + key="presence", + device_class=BinarySensorDeviceClass.PRESENCE, + ), + AttributeType.POWER_SUPPLY_ALARM: BinarySensorEntityDescription( + key="power", + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.RAIN_FALL: BinarySensorEntityDescription( + key="rain", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + AttributeType.REPLACE_FILTER_ALARM: BinarySensorEntityDescription( + key="replace_filter", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.SMOKE_ALARM: BinarySensorEntityDescription( + key="smoke", + device_class=BinarySensorDeviceClass.SMOKE, + ), + AttributeType.STORAGE_ALARM: BinarySensorEntityDescription( + key="storage", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.SURGE_ALARM: BinarySensorEntityDescription( + key="surge", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.TAMPER_ALARM: BinarySensorEntityDescription( + key="tamper", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.VOLTAGE_DROP_ALARM: BinarySensorEntityDescription( + key="voltage_drop", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AttributeType.WATER_ALARM: BinarySensorEntityDescription( + key="water", + device_class=BinarySensorDeviceClass.MOISTURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the binary sensor component.""" + + async_add_devices( + HomeeBinarySensor( + attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type] + ) + for node in config_entry.runtime_data.nodes + for attribute in node.attributes + if attribute.type in BINARY_SENSOR_DESCRIPTIONS and not attribute.editable + ) + + +class HomeeBinarySensor(HomeeEntity, BinarySensorEntity): + """Representation of a Homee binary sensor.""" + + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize a Homee binary sensor entity.""" + super().__init__(attribute, entry) + + self.entity_description = description + self._attr_translation_key = description.key + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return bool(self._attribute.current_value) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 8b61cc6d28c..050ed13bcad 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -26,6 +26,76 @@ } }, "entity": { + "binary_sensor": { + "blackout_alarm": { + "name": "Blackout" + }, + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "flood": { + "name": "Flood" + }, + "high_temperature": { + "name": "High temperature" + }, + "leak_alarm": { + "name": "Leak" + }, + "load_alarm": { + "name": "Load", + "state": { + "off": "Normal", + "on": "Overload" + } + }, + "low_temperature": { + "name": "Low temperature" + }, + "malfunction": { + "name": "Malfunction" + }, + "maximum": { + "name": "Maximumn level" + }, + "minimum": { + "name": "Minumum level" + }, + "motor_blocked": { + "name": "Motor blocked" + }, + "overcurrent": { + "name": "Overcurrent" + }, + "overload": { + "name": "Overload" + }, + "rain": { + "name": "Rain" + }, + "replace_filter": { + "name": "Replace filter", + "state": { + "on": "Replace" + } + }, + "storage": { + "name": "Storage", + "state": { + "off": "Space available", + "on": "Storage full" + } + }, + "surge": { + "name": "Surge" + }, + "voltage_drop": { + "name": "Voltage drop" + }, + "water": { + "name": "Water" + } + }, "button": { "automatic_mode": { "name": "Automatic mode" diff --git a/tests/components/homee/fixtures/binary_sensors.json b/tests/components/homee/fixtures/binary_sensors.json new file mode 100644 index 00000000000..5ced5dc51da --- /dev/null +++ b/tests/components/homee/fixtures/binary_sensors.json @@ -0,0 +1,891 @@ +{ + "id": 1, + "name": "Test Binary Sensor", + "profile": 4026, + "image": "default", + "favorite": 0, + "order": 20, + "protocol": 1, + "routing": 0, + "state": 1, + "state_changed": 1709379826, + "added": 1676199446, + "history": 1, + "cube_type": 1, + "note": "", + "services": 5, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 69, + "state": 1, + "last_changed": 1706461181, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 17, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 3, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 132, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 4, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 228, + "state": 1, + "last_changed": 1691668428, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 5, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 12, + "state": 1, + "last_changed": 1699456267, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 6, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 52, + "state": 1, + "last_changed": 1694176210, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 7, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 68, + "state": 1, + "last_changed": 1694176210, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 8, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 139, + "state": 1, + "last_changed": 1650402359, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 9, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 232, + "state": 1, + "last_changed": 1711897362, + "changed_by": 4, + "changed_by_id": 5, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 10, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 54, + "state": 1, + "last_changed": 1650402359, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 11, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 70, + "state": 1, + "last_changed": 1738231378, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 12, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 78, + "state": 1, + "last_changed": 1738231378, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 4, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 13, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 77, + "state": 1, + "last_changed": 1735964135, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 14, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 25, + "state": 1, + "last_changed": 1709933563, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 15, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 330, + "state": 1, + "last_changed": 1709933563, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 16, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["reset"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 17, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 14, + "state": 1, + "last_changed": 1739320320, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 18, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 143, + "state": 1, + "last_changed": 1694992768, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 19, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 140, + "state": 1, + "last_changed": 1718900928, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 20, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 76, + "state": 1, + "last_changed": 1718900928, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 21, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 182, + "state": 1, + "last_changed": 1718900928, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 22, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 101, + "state": 1, + "last_changed": 1700056646, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 23, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "n%2Fa", + "step_value": 1.0, + "editable": 0, + "type": 289, + "state": 1, + "last_changed": 1736106312, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 24, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 16, + "state": 1, + "last_changed": 1616314530, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 25, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 2, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 181, + "state": 1, + "last_changed": 1616314530, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 26, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 138, + "state": 1, + "last_changed": 1700747644, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 27, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 30, + "state": 1, + "last_changed": 1709933563, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 28, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 141, + "state": 1, + "last_changed": 1700747644, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + }, + { + "id": 29, + "node_id": 1, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 0, + "type": 80, + "state": 1, + "last_changed": 1700747644, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["reset"], + "history": { + "day": 182, + "week": 26, + "month": 6, + "stepped": true + } + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_binary_sensor.ambr b/tests/components/homee/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..01f1f1e42ba --- /dev/null +++ b/tests/components/homee/snapshots/test_binary_sensor.ambr @@ -0,0 +1,1392 @@ +# serializer version: 1 +# name: test_sensor_snapshot[binary_sensor.test_binary_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': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_binary_sensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Binary Sensor Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_blackout-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_blackout', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Blackout', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'blackout_alarm', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_blackout-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Blackout', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_blackout', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_dioxide-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.test_binary_sensor_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_dioxide', + 'unique_id': '00055511EECC-1-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Carbon dioxide', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_monoxide-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.test_binary_sensor_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Test Binary Sensor Carbon monoxide', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_carbon_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_flood-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.test_binary_sensor_flood', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flood', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flood', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_flood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Flood', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_flood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_high_temperature-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_high_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': 'High temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'high_temperature', + 'unique_id': '00055511EECC-1-6', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_high_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'heat', + 'friendly_name': 'Test Binary Sensor High temperature', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_high_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_leak-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.test_binary_sensor_leak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Leak', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leak_alarm', + 'unique_id': '00055511EECC-1-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_leak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Leak', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_leak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_load-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_load', + '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': 'Load', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'load_alarm', + 'unique_id': '00055511EECC-1-8', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Binary Sensor Load', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_binary_sensor_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lock', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'Test Binary Sensor Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_low_temperature-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_low_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': 'Low temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_temperature', + 'unique_id': '00055511EECC-1-10', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_low_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'cold', + 'friendly_name': 'Test Binary Sensor Low temperature', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_low_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_malfunction-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_malfunction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Malfunction', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'malfunction', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_malfunction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Malfunction', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_malfunction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximumn_level-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_maximumn_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximumn level', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'maximum', + 'unique_id': '00055511EECC-1-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximumn_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Maximumn level', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_maximumn_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minumum_level-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_minumum_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Minumum level', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'minimum', + 'unique_id': '00055511EECC-1-13', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minumum_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Minumum level', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_minumum_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_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.test_binary_sensor_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': 'Motion', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '00055511EECC-1-14', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': 'Test Binary Sensor Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motor_blocked-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_motor_blocked', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motor blocked', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motor_blocked', + 'unique_id': '00055511EECC-1-15', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_motor_blocked-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Motor blocked', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_motor_blocked', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_opening-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.test_binary_sensor_opening', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Opening', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'opening', + 'unique_id': '00055511EECC-1-17', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_opening-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Test Binary Sensor Opening', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_opening', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overcurrent-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overcurrent', + 'unique_id': '00055511EECC-1-18', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overload-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_overload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overload', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overload', + 'unique_id': '00055511EECC-1-19', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_overload-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Overload', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_overload', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_plug-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.test_binary_sensor_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plug', + 'unique_id': '00055511EECC-1-16', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_plug-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'Test Binary Sensor Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_plug', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_power-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': '00055511EECC-1-21', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Binary Sensor Power', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_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.test_binary_sensor_presence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Presence', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'presence', + 'unique_id': '00055511EECC-1-20', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_presence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'presence', + 'friendly_name': 'Test Binary Sensor Presence', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_presence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_rain-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.test_binary_sensor_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rain', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rain', + 'unique_id': '00055511EECC-1-22', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Rain', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_replace_filter-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_replace_filter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Replace filter', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'replace_filter', + 'unique_id': '00055511EECC-1-23', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_replace_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Replace filter', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_replace_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_smoke-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.test_binary_sensor_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smoke', + 'unique_id': '00055511EECC-1-24', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Test Binary Sensor Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_storage-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage', + 'unique_id': '00055511EECC-1-25', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Storage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_surge-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_surge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Surge', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'surge', + 'unique_id': '00055511EECC-1-26', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_surge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Surge', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_surge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_tamper-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tamper', + 'unique_id': '00055511EECC-1-27', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': 'Test Binary Sensor Tamper', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_voltage_drop-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': , + 'entity_id': 'binary_sensor.test_binary_sensor_voltage_drop', + '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 drop', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voltage_drop', + 'unique_id': '00055511EECC-1-28', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_voltage_drop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test Binary Sensor Voltage drop', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_voltage_drop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_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': , + 'entity_id': 'binary_sensor.test_binary_sensor_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water', + 'unique_id': '00055511EECC-1-29', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test Binary Sensor Water', + }), + 'context': , + 'entity_id': 'binary_sensor.test_binary_sensor_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/homee/test_binary_sensor.py b/tests/components/homee/test_binary_sensor.py new file mode 100644 index 00000000000..50662616379 --- /dev/null +++ b/tests/components/homee/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""Test homee binary sensors.""" + +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import 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_sensor_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("binary_sensors.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + with patch("homeassistant.components.homee.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 784381a25f1ce0ec23665100f9c560583277e5fe Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 16 Mar 2025 20:45:46 +0100 Subject: [PATCH 1727/1941] Deprecate SmartThings cover battery state attribute (#140752) --- homeassistant/components/smartthings/cover.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 0b0817d7c56..84bf0412ab4 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -130,6 +130,7 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): Capability.WINDOW_SHADE_LEVEL, Attribute.SHADE_LEVEL ) + # Deprecated, remove in 2025.10 self._attr_extra_state_attributes = {} if self.supports_capability(Capability.BATTERY): self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = ( From b0db7b432e2c9590a51004b881608fc7d9dfe386 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 15:55:00 -0400 Subject: [PATCH 1728/1941] Move Roborock MapParser to coordinator (#140750) Move MapParser to coordinator --- .../components/roborock/coordinator.py | 34 ++++++++++++++ homeassistant/components/roborock/image.py | 46 +------------------ homeassistant/components/roborock/vacuum.py | 5 +- tests/components/roborock/conftest.py | 2 +- tests/components/roborock/test_image.py | 10 ++-- tests/components/roborock/test_vacuum.py | 4 +- 6 files changed, 48 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index bf06387b377..2f156545929 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import timedelta +import io import logging from propcache.api import cached_property @@ -25,6 +26,10 @@ from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClient 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 vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CONNECTIONS @@ -38,7 +43,11 @@ from homeassistant.util import slugify from .const import ( A01_UPDATE_INTERVAL, + DEFAULT_DRAWABLES, DOMAIN, + DRAWABLES, + MAP_FILE_FORMAT, + MAP_SCALE, V1_CLOUD_IN_CLEANING_INTERVAL, V1_CLOUD_NOT_CLEANING_INTERVAL, V1_LOCAL_IN_CLEANING_INTERVAL, @@ -127,6 +136,18 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self._user_data = user_data self._api_client = api_client self._is_cloud_api = False + drawables = [ + drawable + for drawable, default_value in DEFAULT_DRAWABLES.items() + if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) + ] + self.map_parser = RoborockMapDataParser( + ColorsPalette(), + Sizes({k: v * MAP_SCALE for k, v in Sizes.SIZES.items()}), + drawables, + ImageConfig(scale=MAP_SCALE), + [], + ) @cached_property def dock_device_info(self) -> DeviceInfo: @@ -145,6 +166,19 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): sw_version=self.roborock_device_info.device.fv, ) + def parse_image(self, map_bytes: bytes) -> bytes | None: + """Parse map_bytes and store it as image bytes.""" + try: + parsed_map = self.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() + parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) + return img_byte_arr.getvalue() + async def _async_setup(self) -> None: """Set up the coordinator.""" # Verify we can communicate locally - if we can't, switch to cloud api diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 2fb5d644826..b56abaeebdb 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -1,16 +1,10 @@ """Support for Roborock image.""" 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 -from vacuum_map_parser_base.config.image_config import ImageConfig -from vacuum_map_parser_base.config.size import Sizes -from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry @@ -20,15 +14,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import ( - DEFAULT_DRAWABLES, - DOMAIN, - DRAWABLES, - IMAGE_CACHE_INTERVAL, - MAP_FILE_FORMAT, - MAP_SCALE, - MAP_SLEEP, -) +from .const import DOMAIN, IMAGE_CACHE_INTERVAL, MAP_SLEEP from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -42,31 +28,6 @@ async def async_setup_entry( ) -> None: """Set up Roborock image platform.""" - drawables = [ - drawable - for drawable, default_value in DEFAULT_DRAWABLES.items() - if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value) - ] - parser = RoborockMapDataParser( - ColorsPalette(), - Sizes({k: v * MAP_SCALE for k, v in Sizes.SIZES.items()}), - drawables, - ImageConfig(scale=MAP_SCALE), - [], - ) - - def parse_image(map_bytes: bytes) -> bytes | None: - 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() - parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) - return img_byte_arr.getvalue() - await asyncio.gather( *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1) ) @@ -78,7 +39,6 @@ async def async_setup_entry( coord, map_info.flag, map_info.name, - parse_image, ) for coord in config_entry.runtime_data.v1 for map_info in coord.maps.values() @@ -100,14 +60,12 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): coordinator: RoborockDataUpdateCoordinator, map_flag: int, map_name: str, - parser: Callable[[bytes], bytes | None], ) -> None: """Initialize a Roborock map.""" RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator) ImageEntity.__init__(self, coordinator.hass) self.config_entry = config_entry self._attr_name = map_name - self.parser = parser self.map_flag = map_flag self.cached_map = b"" self._attr_entity_category = EntityCategory.DIAGNOSTIC @@ -154,7 +112,7 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): ) if ( not isinstance(response[0], bytes) - or (content := self.parser(response[0])) is None + or (content := self.coordinator.parse_image(response[0])) is None ): _LOGGER.debug("Failed to parse map contents: %s", response[0]) raise HomeAssistantError( diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 59abc888673..db201ff06d2 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -6,6 +6,10 @@ from typing import Any from roborock.code_mappings import RoborockStateCode from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand +from vacuum_map_parser_base.config.color import ColorsPalette +from vacuum_map_parser_base.config.image_config import ImageConfig +from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser import voluptuous as vol from homeassistant.components.vacuum import ( @@ -26,7 +30,6 @@ from .const import ( ) from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 -from .image import ColorsPalette, ImageConfig, RoborockMapDataParser, Sizes STATE_CODE_TO_STATE = { RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting" diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 9b3a6633c62..cafac280620 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -110,7 +110,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: return_value=MULTI_MAP_LIST, ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=MAP_DATA, ), patch( diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 7d79cf4f6ab..d81f1289fe3 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -65,7 +65,7 @@ async def test_floorplan_image( "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=new_map_data, ) as parse_map, ): @@ -94,7 +94,7 @@ async def test_floorplan_image_failed_parse( # Update image, but get none for parse image. with ( patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), patch( @@ -148,7 +148,7 @@ async def test_fail_to_load_image( """Test that we gracefully handle failing to load an image.""" with ( patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", ) as parse_map, patch( "homeassistant.components.roborock.roborock_storage.Path.exists", @@ -178,7 +178,7 @@ async def test_fail_parse_on_startup( map_data = copy.deepcopy(MAP_DATA) map_data.image = None with patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ): await async_setup_component(hass, DOMAIN, {}) @@ -226,7 +226,7 @@ async def test_fail_updating_image( # Update image, but get none for parse image. with ( patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), patch( diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 15fdeb4767c..2a2d9f210ed 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -261,7 +261,7 @@ async def test_get_current_position( return_value=b"", ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), ): @@ -316,7 +316,7 @@ async def test_get_current_position_no_robot_position( return_value=b"", ), patch( - "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", return_value=map_data, ), pytest.raises(HomeAssistantError, match="Robot position not found"), From 5351fe3f9bd6620b69b6c206f0521a4ce639b298 Mon Sep 17 00:00:00 2001 From: mbraem Date: Sun, 16 Mar 2025 21:06:49 +0100 Subject: [PATCH 1729/1941] Add specific sensor device_class, state_class and unit_of_measurement (#137038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support additional units in the coil unit descriptions: min, s, Pa, kPa, bar, l/m, m³/h and %RH. Co-authored-by: Joost Lekkerkerker --- .../components/nibe_heatpump/sensor.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index ac4f9eba308..54cd0f7ea34 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -13,14 +13,17 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + PERCENTAGE, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, + UnitOfPressure, UnitOfTemperature, UnitOfTime, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -114,6 +117,20 @@ UNIT_DESCRIPTIONS = { state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.HOURS, ), + "min": SensorEntityDescription( + key="min", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), + "s": SensorEntityDescription( + key="s", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.SECONDS, + ), "Hz": SensorEntityDescription( key="Hz", entity_category=EntityCategory.DIAGNOSTIC, @@ -121,6 +138,48 @@ UNIT_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), + "Pa": SensorEntityDescription( + key="Pa", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.PA, + ), + "kPa": SensorEntityDescription( + key="kPa", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.KPA, + ), + "bar": SensorEntityDescription( + key="bar", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + ), + "l/m": SensorEntityDescription( + key="l/m", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + ), + "m³/h": SensorEntityDescription( + key="m³/h", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + ), + "%RH": SensorEntityDescription( + key="%RH", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + ), } From bbe2a95b3d1f871810fe92b856afcaac5b2af231 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 16 Mar 2025 21:29:03 +0100 Subject: [PATCH 1730/1941] Deprecate Valve binary sensor in SmartThings (#140751) Deprecate Valve binary sensor --- .../components/smartthings/binary_sensor.py | 62 ++++++++++++++++- .../components/smartthings/strings.json | 6 ++ .../smartthings/test_binary_sensor.py | 69 ++++++++++++++++++- 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 080a90440be..25b9cbefb6f 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -6,17 +6,25 @@ from dataclasses import dataclass from pysmartthings import Attribute, Capability, SmartThings +from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from . import FullDevice, SmartThingsConfigEntry -from .const import MAIN +from .const import DOMAIN, MAIN from .entity import SmartThingsEntity @@ -151,3 +159,55 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self.get_attribute_value(self.capability, self._attribute) == self.entity_description.is_on_key ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + if self.capability is not Capability.VALVE: + return + automations = automations_with_entity(self.hass, self.entity_id) + scripts = scripts_with_entity(self.hass, self.entity_id) + items = automations + scripts + if not items: + return + + entity_reg: er.EntityRegistry = er.async_get(self.hass) + entity_automations = [ + automation_entity + for automation_id in automations + if (automation_entity := entity_reg.async_get(automation_id)) + ] + entity_scripts = [ + script_entity + for script_id in scripts + if (script_entity := entity_reg.async_get(script_id)) + ] + + items_list = [ + f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" + for item in entity_automations + ] + [ + f"- [{item.original_name}](/config/script/edit/{item.unique_id})" + for item in entity_scripts + ] + + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_binary_valve_{self.entity_id}", + breaks_in_ha_version="2025.10.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_binary_valve", + translation_placeholders={ + "entity": self.entity_id, + "items": "\n".join(items_list), + }, + ) + + 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_valve_{self.entity_id}" + ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 844ebd12004..99e1550caba 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -390,5 +390,11 @@ } } } + }, + "issues": { + "deprecated_binary_valve": { + "title": "Deprecated valve binary sensor detected in some automations or scripts", + "description": "The valve binary sensor `{entity}` is deprecated and is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." + } } } diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index f46be2edc89..4d58b5ddd48 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -6,9 +6,14 @@ from pysmartthings import Attribute, Capability import pytest from syrupy import SnapshotAssertion +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.smartthings import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from . import setup_integration, snapshot_smartthings_entities, trigger_update @@ -51,3 +56,65 @@ async def test_state_update( ) assert hass.states.get("binary_sensor.refrigerator_door").state == STATE_ON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("device_fixture", ["virtual_valve"]) +async def test_create_issue( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + entity_id = "binary_sensor.volvo_valve" + issue_id = f"deprecated_binary_valve_{entity_id}" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": { + "entity_id": "automation.test", + }, + }, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "condition": "state", + "entity_id": entity_id, + "state": "on", + }, + ], + } + } + }, + ) + + await setup_integration(hass, mock_config_entry) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 From 1b91240d54540777aea69f8685facffe8f38b1e0 Mon Sep 17 00:00:00 2001 From: Ivaylo Iliev <43753631+iiliev-nemetschek@users.noreply.github.com> Date: Sun, 16 Mar 2025 22:31:34 +0200 Subject: [PATCH 1731/1941] Bump nibe_heatpump component version to add S332/S330 model (#140741) --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 049ba905f04..a8441fb90d8 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.14.0"] + "requirements": ["nibe==2.17.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 825983be33b..de3e2146a6f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1502,7 +1502,7 @@ nextdns==4.0.0 nhc==0.4.10 # homeassistant.components.nibe_heatpump -nibe==2.14.0 +nibe==2.17.0 # homeassistant.components.nice_go nice-go==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c73a3c8e9e9..63eb1780956 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1262,7 +1262,7 @@ nextdns==4.0.0 nhc==0.4.10 # homeassistant.components.nibe_heatpump -nibe==2.14.0 +nibe==2.17.0 # homeassistant.components.nice_go nice-go==1.0.1 From a40bb2790ebb5f652b5a7112cb455e50d55ecd65 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 17:15:04 -0400 Subject: [PATCH 1732/1941] Move Roborock map refresh to coordinator (#140758) Move refresh coordinator to coordinator --- homeassistant/components/roborock/__init__.py | 3 ++ .../components/roborock/coordinator.py | 31 ++++++++++++++++ homeassistant/components/roborock/image.py | 37 +------------------ tests/components/roborock/conftest.py | 5 ++- tests/components/roborock/test_image.py | 30 +++++++++++++++ 5 files changed, 69 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 955e50cd15b..1b90adaf6ec 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -111,6 +111,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> translation_key="no_coordinators", ) valid_coordinators = RoborockCoordinators(v1_coords, a01_coords) + await asyncio.gather( + *(coord.refresh_coordinator_map() for coord in valid_coordinators.v1) + ) async def on_stop(_: Any) -> None: _LOGGER.debug("Shutting down roborock") diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 2f156545929..cbfd5e95a90 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -48,6 +48,7 @@ from .const import ( DRAWABLES, MAP_FILE_FORMAT, MAP_SCALE, + MAP_SLEEP, V1_CLOUD_IN_CLEANING_INTERVAL, V1_CLOUD_NOT_CLEANING_INTERVAL, V1_LOCAL_IN_CLEANING_INTERVAL, @@ -316,6 +317,36 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Get the slug of the duid.""" return slugify(self.duid) + async def refresh_coordinator_map(self) -> None: + """Get the starting map information for all maps for this device. + + The following steps must be done synchronously. + Only one map can be loaded at a time per device. + """ + cur_map = self.current_map + # This won't be None at this point as the coordinator will have run first. + if cur_map is None: + # If we don't have a cur map(shouldn't happen) just + # return as we can't do anything. + return + map_flags = sorted(self.maps, key=lambda data: data == cur_map, reverse=True) + for map_flag in map_flags: + if map_flag != cur_map: + # Only change the map and sleep if we have multiple maps. + await self.api.load_multi_map(map_flag) + self.current_map = map_flag + # We cannot get the map until the roborock servers fully process the + # map change. + await asyncio.sleep(MAP_SLEEP) + await self.set_current_map_rooms() + + if len(self.maps) != 1: + # Set the map back to the map the user previously had selected so that it + # does not change the end user's app. + # Only needs to happen when we changed maps above. + await self.api.load_multi_map(cur_map) + self.current_map = cur_map + class RoborockDataUpdateCoordinatorA01( DataUpdateCoordinator[ diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b56abaeebdb..382edbca744 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -4,8 +4,6 @@ import asyncio from datetime import datetime import logging -from roborock import RoborockCommand - from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -14,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, IMAGE_CACHE_INTERVAL, MAP_SLEEP +from .const import DOMAIN, IMAGE_CACHE_INTERVAL from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -28,9 +26,6 @@ async def async_setup_entry( ) -> None: """Set up Roborock image platform.""" - await asyncio.gather( - *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1) - ) async_add_entities( ( RoborockMap( @@ -126,33 +121,3 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): content, ) return self.cached_map - - -async def refresh_coordinators( - hass: HomeAssistant, coord: RoborockDataUpdateCoordinator -) -> None: - """Get the starting map information for all maps for this device. - - The following steps must be done synchronously. - Only one map can be loaded at a time per device. - """ - cur_map = coord.current_map - # This won't be None at this point as the coordinator will have run first. - assert cur_map is not None - map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True) - for map_flag in map_flags: - if map_flag != cur_map: - # Only change the map and sleep if we have multiple maps. - await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag]) - coord.current_map = map_flag - # We cannot get the map until the roborock servers fully process the - # map change. - await asyncio.sleep(MAP_SLEEP) - await coord.set_current_map_rooms() - - if len(coord.maps) != 1: - # Set the map back to the map the user previously had selected so that it - # does not change the end user's app. - # Only needs to happen when we changed maps above. - await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map]) - coord.current_map = cur_map diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index cafac280620..b4fde5cc513 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -80,6 +80,9 @@ def bypass_api_client_fixture() -> None: "homeassistant.components.roborock.RoborockApiClient.get_scenes", return_value=SCENES, ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.load_multi_map" + ), ): yield @@ -127,7 +130,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: "roborock.version_1_apis.AttributeCache.value", ), patch( - "homeassistant.components.roborock.image.MAP_SLEEP", + "homeassistant.components.roborock.coordinator.MAP_SLEEP", 0, ), patch( diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index d81f1289fe3..08f8ac504bf 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -244,3 +244,33 @@ async def test_fail_updating_image( async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert not resp.ok + + +async def test_index_error_map( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that we handle failing getting the image after it has already been setup with a indexerror.""" + client = await hass_client() + now = dt_util.utcnow() + timedelta(seconds=91) + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = 1 + # Update image, but get IndexError for image. + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", + side_effect=IndexError, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + ), + ): + async_fire_time_changed(hass, now) + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert not resp.ok From 15e983e9972be5d9e8162e8d3baf6475e8b9031b Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 16 Mar 2025 17:24:49 -0400 Subject: [PATCH 1733/1941] Add snoo switches (#140748) * Add snoo switches * change naming * change wording --- homeassistant/components/snoo/__init__.py | 7 +- homeassistant/components/snoo/strings.json | 14 +++ homeassistant/components/snoo/switch.py | 105 +++++++++++++++++++++ tests/components/snoo/test_switch.py | 88 +++++++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/snoo/switch.py create mode 100644 tests/components/snoo/test_switch.py diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index 23b5d5201db..1934a2607a0 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -17,7 +17,12 @@ from .coordinator import SnooConfigEntry, SnooCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool: diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index 8211480f771..ddeab83b6d4 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -24,6 +24,12 @@ "exceptions": { "select_failed": { "message": "Error while updating {name} to {option}" + }, + "switch_on_failed": { + "message": "Turning {name} on failed" + }, + "switch_off_failed": { + "message": "Turning {name} off failed" } }, "entity": { @@ -66,6 +72,14 @@ "stop": "[%key:component::snoo::entity::sensor::state::state::stop%]" } } + }, + "switch": { + "sticky_white_noise": { + "name": "Sleepytime sounds" + }, + "hold": { + "name": "Level lock" + } } } } diff --git a/homeassistant/components/snoo/switch.py b/homeassistant/components/snoo/switch.py new file mode 100644 index 00000000000..2ed322d5f6b --- /dev/null +++ b/homeassistant/components/snoo/switch.py @@ -0,0 +1,105 @@ +"""Support for Snoo Switches.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from python_snoo.containers import SnooData, SnooDevice +from python_snoo.exceptions import SnooCommandException +from python_snoo.snoo import Snoo + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +@dataclass(frozen=True, kw_only=True) +class SnooSwitchEntityDescription(SwitchEntityDescription): + """Describes a Snoo sensor.""" + + value_fn: Callable[[SnooData], bool] + set_value_fn: Callable[[Snoo, SnooDevice, SnooData, bool], Awaitable[None]] + + +BINARY_SENSOR_DESCRIPTIONS: list[SnooSwitchEntityDescription] = [ + SnooSwitchEntityDescription( + key="sticky_white_noise", + translation_key="sticky_white_noise", + value_fn=lambda data: data.state_machine.sticky_white_noise == "on", + set_value_fn=lambda snoo_api, device, _, state: snoo_api.set_sticky_white_noise( + device, state + ), + ), + SnooSwitchEntityDescription( + key="hold", + translation_key="hold", + value_fn=lambda data: data.state_machine.hold == "on", + set_value_fn=lambda snoo_api, device, data, state: snoo_api.set_level( + device, data.state_machine.level, state + ), + ), +] + + +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( + SnooSwitch(coordinator, description) + for coordinator in coordinators.values() + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class SnooSwitch(SnooDescriptionEntity, SwitchEntity): + """A switch using Snoo coordinator.""" + + entity_description: SnooSwitchEntityDescription + + @property + def is_on(self) -> bool | None: + """Return True if entity is on.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.snoo, + self.coordinator.device, + self.coordinator.data, + True, + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_on_failed", + translation_placeholders={"name": str(self.name), "status": "on"}, + ) from err + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + try: + await self.entity_description.set_value_fn( + self.coordinator.snoo, + self.coordinator.device, + self.coordinator.data, + False, + ) + except SnooCommandException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="switch_off_failed", + translation_placeholders={"name": str(self.name), "status": "off"}, + ) from err diff --git a/tests/components/snoo/test_switch.py b/tests/components/snoo/test_switch.py new file mode 100644 index 00000000000..2343ff6c0d8 --- /dev/null +++ b/tests/components/snoo/test_switch.py @@ -0,0 +1,88 @@ +"""Test Snoo Switches.""" + +import copy +from unittest.mock import AsyncMock + +import pytest +from python_snoo.containers import SnooDevice +from python_snoo.exceptions import SnooCommandException + +from homeassistant.components.switch import ( + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +async def test_switch(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test switch and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("switch")) == 2 + assert hass.states.get("switch.test_snoo_level_lock").state == STATE_UNAVAILABLE + assert ( + hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_UNAVAILABLE + ) + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("switch")) == 2 + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF + assert hass.states.get("switch.test_snoo_level_lock").state == STATE_OFF + + +async def test_update_success(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test changing values for switch entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF + + async def set_sticky_white_noise(device: SnooDevice, state: bool): + new_data = copy.deepcopy(MOCK_SNOO_DATA) + new_data.state_machine.sticky_white_noise = "off" if not state else "on" + find_update_callback(bypass_api, device.serialNumber)(new_data) + + bypass_api.set_sticky_white_noise.side_effect = set_sticky_white_noise + await hass.services.async_call( + "switch", + SERVICE_TOGGLE, + blocking=True, + target={"entity_id": "switch.test_snoo_sleepytime_sounds"}, + ) + + assert bypass_api.set_sticky_white_noise.assert_called_once + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_ON + + +@pytest.mark.parametrize( + ("command", "error_str"), + [ + (SERVICE_TURN_ON, "Turning Sleepytime sounds on failed"), + (SERVICE_TURN_OFF, "Turning Sleepytime sounds off failed"), + ], +) +async def test_update_failed( + hass: HomeAssistant, bypass_api: AsyncMock, command: str, error_str: str +) -> None: + """Test failing to change values for switch entities.""" + await async_init_integration(hass) + + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF + + bypass_api.set_sticky_white_noise.side_effect = SnooCommandException + with pytest.raises(HomeAssistantError, match=error_str): + await hass.services.async_call( + "switch", + command, + blocking=True, + target={"entity_id": "switch.test_snoo_sleepytime_sounds"}, + ) + + assert bypass_api.set_level.assert_called_once + assert hass.states.get("switch.test_snoo_sleepytime_sounds").state == STATE_OFF From a9949aece0c1eedd7b8da3957782e1265150c162 Mon Sep 17 00:00:00 2001 From: Johnny Willemsen Date: Sun, 16 Mar 2025 22:27:35 +0100 Subject: [PATCH 1734/1941] Fix typo in Homee (#140759) * Update strings.json Fixed typo * Update homeassistant/components/homee/strings.json * Fix --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/homee/strings.json | 4 ++-- .../homee/snapshots/test_binary_sensor.ambr | 24 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 050ed13bcad..da8357d16bc 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -56,10 +56,10 @@ "name": "Malfunction" }, "maximum": { - "name": "Maximumn level" + "name": "Maximum level" }, "minimum": { - "name": "Minumum level" + "name": "Minimum level" }, "motor_blocked": { "name": "Motor blocked" diff --git a/tests/components/homee/snapshots/test_binary_sensor.ambr b/tests/components/homee/snapshots/test_binary_sensor.ambr index 01f1f1e42ba..4926c048f5b 100644 --- a/tests/components/homee/snapshots/test_binary_sensor.ambr +++ b/tests/components/homee/snapshots/test_binary_sensor.ambr @@ -526,7 +526,7 @@ 'state': 'off', }) # --- -# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximumn_level-entry] +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximum_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -539,7 +539,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_binary_sensor_maximumn_level', + 'entity_id': 'binary_sensor.test_binary_sensor_maximum_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -551,7 +551,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Maximumn level', + 'original_name': 'Maximum level', 'platform': 'homee', 'previous_unique_id': None, 'supported_features': 0, @@ -560,21 +560,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximumn_level-state] +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_maximum_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Test Binary Sensor Maximumn level', + 'friendly_name': 'Test Binary Sensor Maximum level', }), 'context': , - 'entity_id': 'binary_sensor.test_binary_sensor_maximumn_level', + 'entity_id': 'binary_sensor.test_binary_sensor_maximum_level', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'off', }) # --- -# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minumum_level-entry] +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minimum_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -587,7 +587,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': , - 'entity_id': 'binary_sensor.test_binary_sensor_minumum_level', + 'entity_id': 'binary_sensor.test_binary_sensor_minimum_level', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -599,7 +599,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Minumum level', + 'original_name': 'Minimum level', 'platform': 'homee', 'previous_unique_id': None, 'supported_features': 0, @@ -608,14 +608,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minumum_level-state] +# name: test_sensor_snapshot[binary_sensor.test_binary_sensor_minimum_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Test Binary Sensor Minumum level', + 'friendly_name': 'Test Binary Sensor Minimum level', }), 'context': , - 'entity_id': 'binary_sensor.test_binary_sensor_minumum_level', + 'entity_id': 'binary_sensor.test_binary_sensor_minimum_level', 'last_changed': , 'last_reported': , 'last_updated': , From f19a5b28f7bdfd882b0bf2bcf0e238f8c7d2dd5c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 16 Mar 2025 22:38:25 +0100 Subject: [PATCH 1735/1941] Update description of `evaluate_payload` to use friendly name (#140736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update description of `evaluate_payload` to use friendly name For the graphical UI the action descriptions need to refer to the friendly names of other fields so these can be translated to match. Small change from `payload` to 'Payload'. * Replace "When …" with "If …" --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index c3338948ff5..f0112097f4e 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -362,7 +362,7 @@ "fields": { "evaluate_payload": { "name": "Evaluate payload", - "description": "When `payload` is a Python bytes literal, evaluate the bytes literal and publish the raw data." + "description": "If 'Payload' is a Python bytes literal, evaluate the bytes literal and publish the raw data." }, "topic": { "name": "Topic", From bddec1168b1017e9a19911f3e1fcb94305419aae Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 17 Mar 2025 01:38:05 +0100 Subject: [PATCH 1736/1941] Bump ci cache version (#140767) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3f970ce5874..49cb7ae019c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 11 + CACHE_VERSION: 12 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 9 HA_SHORT_VERSION: "2025.4" From 5fb03114b5e4b1da8b9c3f46e0d4bc5769d7ace1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Mar 2025 20:35:49 -1000 Subject: [PATCH 1737/1941] Bump dbus-fast to 2.39.6 (#140775) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.39.5...v2.39.6 --- 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 ff8de8509a3..a0679f8e842 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", - "dbus-fast==2.39.5", + "dbus-fast==2.39.6", "habluetooth==3.32.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index af437c4b079..21bb2dc7612 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.39.5 +dbus-fast==2.39.6 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index de3e2146a6f..c5d27e38a49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.5 +dbus-fast==2.39.6 # homeassistant.components.debugpy debugpy==1.8.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63eb1780956..33fc90c307b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.5 +dbus-fast==2.39.6 # homeassistant.components.debugpy debugpy==1.8.13 From ab6c5af374e367247a18253493a4053f55b25321 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 16 Mar 2025 20:36:43 -1000 Subject: [PATCH 1738/1941] Bump aiohttp to 3.11.14 (#140773) changelog: https://github.com/aio-libs/aiohttp/compare/v3.11.13...v3.11.14 --- 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 21bb2dc7612..f63492a8b3f 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.13 +aiohttp==3.11.14 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 31d0ce4e42d..1879a2544c3 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.13", + "aiohttp==3.11.14", "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 22ffcfb54e1..176b1ae0c24 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.13 +aiohttp==3.11.14 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 4baf72d80b3969ffa2e79c45f5e0ecf6c3ee9f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 17 Mar 2025 07:43:02 +0100 Subject: [PATCH 1739/1941] Call only required listeners on CONNECT/PAIRED in Home Connect (#140765) Call only to the required listeners on CONNECT/PAIRED --- homeassistant/components/home_connect/coordinator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index dfac68084d1..e877dc7bfe4 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -231,15 +231,15 @@ class HomeConnectCoordinator( self.data[event_message_ha_id].update(appliance_data) else: self.data[event_message_ha_id] = appliance_data - for listener, context in list( - self._special_listeners.values() - ) + list(self._listeners.values()): - assert isinstance(context, tuple) + for listener, context in self._special_listeners.values(): if ( EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context ): listener() + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) case EventType.DISCONNECTED: self.data[event_message_ha_id].info.connected = False From 74ce703755dfd10f5455e065d2f8dfcc6b8e280a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 09:08:47 +0100 Subject: [PATCH 1740/1941] Bump docker/login-action from 3.3.0 to 3.4.0 (#140780) Bumps [docker/login-action](https://github.com/docker/login-action) from 3.3.0 to 3.4.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v3.3.0...v3.4.0) --- updated-dependencies: - dependency-name: docker/login-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 346f90fbe4f..ab64f1f3e7e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -256,7 +256,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -330,14 +330,14 @@ jobs: - name: Login to DockerHub if: matrix.registry == 'docker.io/homeassistant' - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: matrix.registry == 'ghcr.io/home-assistant' - uses: docker/login-action@v3.3.0 + uses: docker/login-action@v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -502,7 +502,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Login to GitHub Container Registry - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From 110e827edef8f84713ce3e9a2af9be5edbfc82c7 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Mon, 17 Mar 2025 01:12:22 -0700 Subject: [PATCH 1741/1941] Add @IvanLH to owners of google_generative_ai_conversation (#140764) Update CODEOWNERS --- CODEOWNERS | 4 ++-- .../google_generative_ai_conversation/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index cfc37f6f908..1835e6d0be4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -570,8 +570,8 @@ build.json @home-assistant/supervisor /tests/components/google_cloud/ @lufton @tronikos /homeassistant/components/google_drive/ @tronikos /tests/components/google_drive/ @tronikos -/homeassistant/components/google_generative_ai_conversation/ @tronikos -/tests/components/google_generative_ai_conversation/ @tronikos +/homeassistant/components/google_generative_ai_conversation/ @tronikos @ivanlh +/tests/components/google_generative_ai_conversation/ @tronikos @ivanlh /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob /homeassistant/components/google_photos/ @allenporter diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index cc381532c6f..ed215970d7f 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -2,7 +2,7 @@ "domain": "google_generative_ai_conversation", "name": "Google Generative AI", "after_dependencies": ["assist_pipeline", "intent"], - "codeowners": ["@tronikos"], + "codeowners": ["@tronikos", "@ivanlh"], "config_flow": true, "dependencies": ["conversation"], "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", From a5913147e74f3f4675ed771222418555e82b477d Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Mon, 17 Mar 2025 04:32:52 -0500 Subject: [PATCH 1742/1941] Add support for fan night light in VeSync (#140637) * style: rename humidifier night const * fix: separate night light for fan and humidifier Check for the presence of set_night_light_brightness and set_night_light to indentify humidifier and fan devices. set_night_light is defined on VeSyncAirBypass and set_night_light_brightness is defined on VeSyncHumid200300S. update test --- homeassistant/components/vesync/const.py | 10 ++-- homeassistant/components/vesync/select.py | 50 ++++++++++++++------ homeassistant/components/vesync/strings.json | 3 +- tests/components/vesync/test_select.py | 22 +++++---- 4 files changed, 58 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 1273ab914f8..4e39fe40f2d 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -30,9 +30,13 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" VS_HUMIDIFIER_MODE_MANUAL = "manual" VS_HUMIDIFIER_MODE_SLEEP = "sleep" -NIGHT_LIGHT_LEVEL_BRIGHT = "bright" -NIGHT_LIGHT_LEVEL_DIM = "dim" -NIGHT_LIGHT_LEVEL_OFF = "off" +FAN_NIGHT_LIGHT_LEVEL_DIM = "dim" +FAN_NIGHT_LIGHT_LEVEL_OFF = "off" +FAN_NIGHT_LIGHT_LEVEL_ON = "on" + +HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT = "bright" +HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim" +HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF = "off" VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S """Humidifier device types""" diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py index c266985fc2b..a9d2e1b533a 100644 --- a/homeassistant/components/vesync/select.py +++ b/homeassistant/components/vesync/select.py @@ -15,9 +15,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import rgetattr from .const import ( DOMAIN, - NIGHT_LIGHT_LEVEL_BRIGHT, - NIGHT_LIGHT_LEVEL_DIM, - NIGHT_LIGHT_LEVEL_OFF, + FAN_NIGHT_LIGHT_LEVEL_DIM, + FAN_NIGHT_LIGHT_LEVEL_OFF, + FAN_NIGHT_LIGHT_LEVEL_ON, + HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT, + HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, + HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, @@ -27,14 +30,14 @@ from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) -VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP = { - 100: NIGHT_LIGHT_LEVEL_BRIGHT, - 50: NIGHT_LIGHT_LEVEL_DIM, - 0: NIGHT_LIGHT_LEVEL_OFF, +VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP = { + 100: HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT, + 50: HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, + 0: HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, } -HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP = { - v: k for k, v in VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.items() +HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP = { + v: k for k, v in VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.items() } @@ -48,20 +51,39 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [ + # night_light for humidifier VeSyncSelectEntityDescription( key="night_light_level", translation_key="night_light_level", - options=list(VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.values()), + options=list(VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.values()), icon="mdi:brightness-6", - exists_fn=lambda device: rgetattr(device, "night_light"), + exists_fn=lambda device: rgetattr(device, "set_night_light_brightness"), # The select_option service framework ensures that only options specified are # accepted. ServiceValidationError gets raised for invalid value. select_option_fn=lambda device, value: device.set_night_light_brightness( - HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) + HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) ), # Reporting "off" as the choice for unhandled level. - current_option_fn=lambda device: VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.get( - device.details.get("night_light_brightness"), NIGHT_LIGHT_LEVEL_OFF + current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light_brightness"), + HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, + ), + ), + # night_light for fan devices based on pyvesync.VeSyncAirBypass + VeSyncSelectEntityDescription( + key="night_light_level", + translation_key="night_light_level", + options=[ + FAN_NIGHT_LIGHT_LEVEL_OFF, + FAN_NIGHT_LIGHT_LEVEL_DIM, + FAN_NIGHT_LIGHT_LEVEL_ON, + ], + icon="mdi:brightness-6", + exists_fn=lambda device: rgetattr(device, "set_night_light"), + select_option_fn=lambda device, value: device.set_night_light(value), + current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light"), + FAN_NIGHT_LIGHT_LEVEL_OFF, ), ), ] diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index eabb2969580..9b63bf3e614 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -71,7 +71,8 @@ "state": { "bright": "Bright", "dim": "Dim", - "off": "[%key:common::state::off%]" + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" } } }, diff --git a/tests/components/vesync/test_select.py b/tests/components/vesync/test_select.py index 30c83c89e0e..c96d687dfd2 100644 --- a/tests/components/vesync/test_select.py +++ b/tests/components/vesync/test_select.py @@ -7,8 +7,10 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.vesync.const import NIGHT_LIGHT_LEVEL_DIM -from homeassistant.components.vesync.select import HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP +from homeassistant.components.vesync.const import HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM +from homeassistant.components.vesync.select import ( + HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP, +) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -18,24 +20,24 @@ from .common import ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT @pytest.mark.parametrize( "install_humidifier_device", ["humidifier_300s"], indirect=True ) -async def test_set_nightlight_level( +async def test_humidifier_set_nightlight_level( hass: HomeAssistant, manager, humidifier_300s, install_humidifier_device ) -> None: - """Test set of night light level.""" + """Test set of humidifier night light level.""" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT, - ATTR_OPTION: NIGHT_LIGHT_LEVEL_DIM, + ATTR_OPTION: HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, }, blocking=True, ) # Assert that setter API was invoked with the expected translated value humidifier_300s.set_night_light_brightness.assert_called_once_with( - HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP[NIGHT_LIGHT_LEVEL_DIM] + HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP[HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM] ) # Assert that devices were refreshed manager.update_all_devices.assert_called_once() @@ -44,11 +46,13 @@ async def test_set_nightlight_level( @pytest.mark.parametrize( "install_humidifier_device", ["humidifier_300s"], indirect=True ) -async def test_nightlight_level(hass: HomeAssistant, install_humidifier_device) -> None: - """Test the state of night light level select entity.""" +async def test_humidifier_nightlight_level( + hass: HomeAssistant, install_humidifier_device +) -> None: + """Test the state of humidifier night light level select entity.""" # The mocked device has night_light_brightness=50 which is "dim" assert ( hass.states.get(ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT).state - == NIGHT_LIGHT_LEVEL_DIM + == HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM ) From 0d1c79b427ff91db3036672b87068f570d96cde7 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 17 Mar 2025 14:18:15 +0200 Subject: [PATCH 1743/1941] Bump zwave-js-server-python to 0.62.0 (#140796) * Bump zwave-js-server-python to 0.62.0 * fix breaking change --- homeassistant/components/zwave_js/helpers.py | 2 +- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 904a26acc78..8a90ebf6f88 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -187,7 +187,7 @@ async def async_disable_server_logging_if_needed( old_server_log_level, ) await driver.async_update_log_config(LogConfig(level=old_server_log_level)) - await driver.client.disable_server_logging() + driver.client.disable_server_logging() LOGGER.info("Zwave-js-server logging is enabled") diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 16831853290..7e8b473922f 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.61.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.62.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index c5d27e38a49..41f0462f558 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3165,7 +3165,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.61.0 +zwave-js-server-python==0.62.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33fc90c307b..7876f567064 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2542,7 +2542,7 @@ zeversolar==0.3.2 zha==0.0.52 # homeassistant.components.zwave_js -zwave-js-server-python==0.61.0 +zwave-js-server-python==0.62.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From fb2b3ce7d21998e216567dc6fc81ccd95553bdc0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 17 Mar 2025 13:19:27 +0100 Subject: [PATCH 1744/1941] Bump pychromecast to 14.0.6 (#140794) --- homeassistant/components/cast/helpers.py | 10 +++++++--- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 8f4af197b8e..7f46100afca 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -7,6 +7,7 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, ClassVar from urllib.parse import urlparse +from uuid import UUID import aiohttp import attr @@ -40,7 +41,7 @@ class ChromecastInfo: is_dynamic_group = attr.ib(type=bool | None, default=None) @property - def friendly_name(self) -> str: + def friendly_name(self) -> str | None: """Return the Friendly Name.""" return self.cast_info.friendly_name @@ -50,7 +51,7 @@ class ChromecastInfo: return self.cast_info.cast_type == CAST_TYPE_GROUP @property - def uuid(self) -> bool: + def uuid(self) -> UUID: """Return the UUID.""" return self.cast_info.uuid @@ -111,7 +112,10 @@ class ChromecastInfo: is_dynamic_group = False http_group_status = None http_group_status = dial.get_multizone_status( - None, + # We pass services which will be used for the HTTP request, and we + # don't care about the host in http_group_status.dynamic_groups so + # we pass an empty string to simplify the code. + "", services=self.cast_info.services, zconf=ChromeCastZeroconf.get_zeroconf(), ) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 0650f267544..feb613f4765 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,7 +14,7 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==14.0.5"], + "requirements": ["PyChromecast==14.0.6"], "single_config_entry": true, "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 41f0462f558..ae0f6114b0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==14.0.5 +PyChromecast==14.0.6 # homeassistant.components.flick_electric PyFlick==1.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7876f567064..48dbb5deae6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ PlexAPI==4.15.16 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==14.0.5 +PyChromecast==14.0.6 # homeassistant.components.flick_electric PyFlick==1.1.3 From 76aef5be9f0f68cf6d7a7a400d21adc4956613b2 Mon Sep 17 00:00:00 2001 From: Stephan van Rooij <1292510+svrooij@users.noreply.github.com> Date: Mon, 17 Mar 2025 14:16:52 +0100 Subject: [PATCH 1745/1941] Add PKCE implementation in oauth2 helper (#139509) * Update config_entry_oauth2_flow.py * Specify type on request_data * Added LocalOAuth2ImplementationWithPkce * LocalOAuth2ImplementationWithPkce works more like specs * fix: Adding tests for pkce flow and feedback applied * fix last test for pkce * Clean test_abort_if_oauth_with_pkce_rejected * Improve assertion of code verifier and code challenge * Break long docstrings * Shorten docstring --------- Co-authored-by: Martin Hjelmare --- .../helpers/config_entry_oauth2_flow.py | 117 +++++++++++- .../helpers/test_config_entry_oauth2_flow.py | 167 +++++++++++++++++- 2 files changed, 273 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 24a9de5b562..84728978ede 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -11,7 +11,9 @@ from __future__ import annotations from abc import ABC, ABCMeta, abstractmethod import asyncio from asyncio import Lock +import base64 from collections.abc import Awaitable, Callable +import hashlib from http import HTTPStatus from json import JSONDecodeError import logging @@ -166,6 +168,11 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): """Extra data that needs to be appended to the authorize url.""" return {} + @property + def extra_token_resolve_data(self) -> dict: + """Extra data for the token resolve request.""" + return {} + async def async_generate_authorize_url(self, flow_id: str) -> str: """Generate a url for the user to authorize.""" redirect_uri = self.redirect_uri @@ -186,13 +193,13 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve the authorization code to tokens.""" - return await self._token_request( - { - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - } - ) + request_data: dict = { + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + request_data.update(self.extra_token_resolve_data) + return await self._token_request(request_data) async def _async_refresh_token(self, token: dict) -> dict: """Refresh tokens.""" @@ -211,7 +218,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): data["client_id"] = self.client_id - if self.client_secret is not None: + if self.client_secret: data["client_secret"] = self.client_secret _LOGGER.debug("Sending token request to %s", self.token_url) @@ -233,6 +240,100 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): return cast(dict, await resp.json()) +class LocalOAuth2ImplementationWithPkce(LocalOAuth2Implementation): + """Local OAuth2 implementation with PKCE.""" + + def __init__( + self, + hass: HomeAssistant, + domain: str, + client_id: str, + authorize_url: str, + token_url: str, + client_secret: str = "", + code_verifier_length: int = 128, + ) -> None: + """Initialize local auth implementation.""" + super().__init__( + hass, + domain, + client_id, + client_secret, + authorize_url, + token_url, + ) + + # Generate code verifier + self.code_verifier = LocalOAuth2ImplementationWithPkce.generate_code_verifier( + code_verifier_length + ) + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url. + + If you want to override this method, + calling super is mandatory (for adding scopes): + ``` + @def extra_authorize_data(self) -> dict: + data: dict = { + "scope": "openid profile email", + } + data.update(super().extra_authorize_data) + return data + ``` + """ + return { + "code_challenge": LocalOAuth2ImplementationWithPkce.compute_code_challenge( + self.code_verifier + ), + "code_challenge_method": "S256", + } + + @property + def extra_token_resolve_data(self) -> dict: + """Extra data that needs to be included in the token resolve request. + + If you want to override this method, + calling super is mandatory (for adding `someKey`): + ``` + @def extra_token_resolve_data(self) -> dict: + data: dict = { + "someKey": "someValue", + } + data.update(super().extra_token_resolve_data) + return data + ``` + """ + + return {"code_verifier": self.code_verifier} + + @staticmethod + def generate_code_verifier(code_verifier_length: int = 128) -> str: + """Generate a code verifier.""" + if not 43 <= code_verifier_length <= 128: + msg = ( + "Parameter `code_verifier_length` must validate" + "`43 <= code_verifier_length <= 128`." + ) + raise ValueError(msg) + return secrets.token_urlsafe(96)[:code_verifier_length] + + @staticmethod + def compute_code_challenge(code_verifier: str) -> str: + """Compute the code challenge.""" + if not 43 <= len(code_verifier) <= 128: + msg = ( + "Parameter `code_verifier` must validate " + "`43 <= len(code_verifier) <= 128`." + ) + raise ValueError(msg) + + hashed = hashlib.sha256(code_verifier.encode("ascii")).digest() + encoded = base64.urlsafe_b64encode(hashed) + return encoded.decode("ascii").replace("=", "") + + class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): """Handle a config flow.""" diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 0fc6b582bb5..5d16a9a62fd 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -1,11 +1,11 @@ """Tests for the Somfy config flow.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from http import HTTPStatus import logging import time from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import aiohttp import pytest @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.network import NoURLAvailableError -from tests.common import MockConfigEntry, mock_platform +from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -27,6 +27,11 @@ ACCESS_TOKEN_1 = "mock-access-token-1" ACCESS_TOKEN_2 = "mock-access-token-2" AUTHORIZE_URL = "https://example.como/auth/authorize" TOKEN_URL = "https://example.como/auth/token" +MOCK_SECRET_TOKEN_URLSAFE = ( + "token-" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) @pytest.fixture @@ -40,6 +45,22 @@ async def local_impl( ) +@pytest.fixture +async def local_impl_pkce( + hass: HomeAssistant, +) -> AsyncGenerator[config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce]: + """Local implementation.""" + assert await setup.async_setup_component(hass, "auth", {}) + with patch( + "homeassistant.helpers.config_entry_oauth2_flow.secrets.token_urlsafe", + return_value=MOCK_SECRET_TOKEN_URLSAFE + + "bbbbbb", # Add some characters that should be removed by the logic. + ): + yield config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce( + hass, TEST_DOMAIN, CLIENT_ID, AUTHORIZE_URL, TOKEN_URL + ) + + @pytest.fixture def flow_handler( hass: HomeAssistant, @@ -963,3 +984,143 @@ async def test_oauth2_without_secret_init( client = await hass_client_no_auth() resp = await client.get("/auth/external/callback?code=abcd&state=qwer") assert resp.status == 400 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_abort_oauth_with_pkce_rejected( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl_pkce: config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Check bad oauth token.""" + flow_handler.async_register_implementation(hass, local_impl_pkce) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + code_challenge = local_impl_pkce.compute_code_challenge(MOCK_SECRET_TOKEN_URLSAFE) + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + + assert result["url"].startswith(f"{AUTHORIZE_URL}?") + assert f"client_id={CLIENT_ID}" in result["url"] + assert "redirect_uri=https://example.com/auth/external/callback" in result["url"] + assert f"state={state}" in result["url"] + assert "scope=read+write" in result["url"] + assert "response_type=code" in result["url"] + assert f"code_challenge={code_challenge}" in result["url"] + assert "code_challenge_method=S256" in result["url"] + + client = await hass_client_no_auth() + resp = await client.get( + f"/auth/external/callback?error=access_denied&state={state}" + ) + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "user_rejected_authorize" + assert result["description_placeholders"] == {"error": "access_denied"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_oauth_with_pkce_adds_code_verifier_to_token_resolve( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl_pkce: config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check pkce flow.""" + + mock_integration( + hass, + MockModule( + domain=TEST_DOMAIN, + async_setup_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, f"{TEST_DOMAIN}.config_flow", None) + flow_handler.async_register_implementation(hass, local_impl_pkce) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + code_challenge = local_impl_pkce.compute_code_challenge(MOCK_SECRET_TOKEN_URLSAFE) + assert result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP + + assert result["url"].startswith(f"{AUTHORIZE_URL}?") + assert f"client_id={CLIENT_ID}" in result["url"] + assert "redirect_uri=https://example.com/auth/external/callback" in result["url"] + assert f"state={state}" in result["url"] + assert "scope=read+write" in result["url"] + assert "response_type=code" in result["url"] + assert f"code_challenge={code_challenge}" in result["url"] + assert "code_challenge_method=S256" in result["url"] + + # Setup the response when HA tries to fetch a token with the code + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "type": "bearer", + "expires_in": 60, + }, + ) + + client = await hass_client_no_auth() + # trigger the callback + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Verify the token resolve request occurred + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "client_id": CLIENT_ID, + "grant_type": "authorization_code", + "code": "abcd", + "redirect_uri": "https://example.com/auth/external/callback", + "code_verifier": MOCK_SECRET_TOKEN_URLSAFE, + } + + +@pytest.mark.parametrize("code_verifier_length", [40, 129]) +def test_generate_code_verifier_invalid_length(code_verifier_length: int) -> None: + """Test generate_code_verifier with an invalid length.""" + with pytest.raises(ValueError): + config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce.generate_code_verifier( + code_verifier_length + ) + + +@pytest.mark.parametrize("code_verifier", ["", "yyy", "a" * 129]) +def test_compute_code_challenge_invalid_code_verifier(code_verifier: str) -> None: + """Test compute_code_challenge with an invalid code_verifier.""" + with pytest.raises(ValueError): + config_entry_oauth2_flow.LocalOAuth2ImplementationWithPkce.compute_code_challenge( + code_verifier + ) From 18bd8b561ab4d228a24662d115cee2fa49b52408 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 17 Mar 2025 15:49:13 +0100 Subject: [PATCH 1746/1941] Add Reolink smart ai binary sensors (#140408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Crossline smart AI binary sensor * Add intrusion, lingering, forgotten item, item taken detection * Use unique_index instead of location for unique_id * Add test * Apply suggestions from code review Co-authored-by: Abílio Costa * Name changes * Update homeassistant/components/reolink/binary_sensor.py Co-authored-by: Abílio Costa * Use smart_type instead of key * Use occupancy translation instead of gas (point to the same thing). * Revert "Use occupancy translation instead of gas (point to the same thing)." This reverts commit 9caf796585e1cffdea6e66f16824fe8e34d03276. * fix styling --------- Co-authored-by: Abílio Costa --- .../components/reolink/binary_sensor.py | 210 +++++++++++++++++- homeassistant/components/reolink/icons.json | 66 ++++++ homeassistant/components/reolink/strings.json | 77 +++++++ tests/components/reolink/conftest.py | 4 + .../components/reolink/test_binary_sensor.py | 26 +++ 5 files changed, 378 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 4e90bfc9eef..39910bbc52a 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -25,7 +25,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkEntityDescription, +) from .util import ReolinkConfigEntry, ReolinkData PARALLEL_UPDATES = 0 @@ -41,6 +45,18 @@ class ReolinkBinarySensorEntityDescription( value: Callable[[Host, int], bool] +@dataclass(frozen=True, kw_only=True) +class ReolinkSmartAIBinarySensorEntityDescription( + BinarySensorEntityDescription, + ReolinkEntityDescription, +): + """A class that describes Smart AI binary sensor entities.""" + + smart_type: str + value: Callable[[Host, int, int], bool] + supported: Callable[[Host, int, int], bool] = lambda api, ch, loc: True + + BINARY_PUSH_SENSORS = ( ReolinkBinarySensorEntityDescription( key="motion", @@ -121,6 +137,142 @@ BINARY_SENSORS = ( ), ) +BINARY_SMART_AI_SENSORS = ( + ReolinkSmartAIBinarySensorEntityDescription( + key="crossline_person", + smart_type="crossline", + cmd_id=33, + translation_key="crossline_person", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "crossline", loc, "people") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_crossline") + and "people" in api.baichuan.smart_ai_type_list(ch, "crossline", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="crossline_vehicle", + smart_type="crossline", + cmd_id=33, + translation_key="crossline_vehicle", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "crossline", loc, "vehicle") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_crossline") + and "vehicle" in api.baichuan.smart_ai_type_list(ch, "crossline", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="crossline_dog_cat", + smart_type="crossline", + cmd_id=33, + translation_key="crossline_dog_cat", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "crossline", loc, "dog_cat") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_crossline") + and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "crossline", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="intrusion_person", + smart_type="intrusion", + cmd_id=33, + translation_key="intrusion_person", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "intrusion", loc, "people") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_intrusion") + and "people" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="intrusion_vehicle", + smart_type="intrusion", + cmd_id=33, + translation_key="intrusion_vehicle", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "intrusion", loc, "vehicle") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_intrusion") + and "vehicle" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="intrusion_dog_cat", + smart_type="intrusion", + cmd_id=33, + translation_key="intrusion_dog_cat", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "intrusion", loc, "dog_cat") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_intrusion") + and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="linger_person", + smart_type="loitering", + cmd_id=33, + translation_key="linger_person", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "loitering", loc, "people") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_linger") + and "people" in api.baichuan.smart_ai_type_list(ch, "loitering", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="linger_vehicle", + smart_type="loitering", + cmd_id=33, + translation_key="linger_vehicle", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "loitering", loc, "vehicle") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_linger") + and "vehicle" in api.baichuan.smart_ai_type_list(ch, "loitering", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="linger_dog_cat", + smart_type="loitering", + cmd_id=33, + translation_key="linger_dog_cat", + value=lambda api, ch, loc: ( + api.baichuan.smart_ai_state(ch, "loitering", loc, "dog_cat") + ), + supported=lambda api, ch, loc: ( + api.supported(ch, "ai_linger") + and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "loitering", loc) + ), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="forgotten_item", + smart_type="legacy", + cmd_id=33, + translation_key="forgotten_item", + value=lambda api, ch, loc: (api.baichuan.smart_ai_state(ch, "legacy", loc)), + supported=lambda api, ch, loc: api.supported(ch, "ai_forgotten_item"), + ), + ReolinkSmartAIBinarySensorEntityDescription( + key="taken_item", + smart_type="loss", + cmd_id=33, + translation_key="taken_item", + value=lambda api, ch, loc: (api.baichuan.smart_ai_state(ch, "loss", loc)), + supported=lambda api, ch, loc: api.supported(ch, "ai_taken_item"), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -129,18 +281,29 @@ async def async_setup_entry( ) -> None: """Set up a Reolink IP Camera.""" reolink_data: ReolinkData = config_entry.runtime_data + api = reolink_data.host.api - entities: list[ReolinkBinarySensorEntity] = [] - for channel in reolink_data.host.api.channels: + entities: list[ReolinkBinarySensorEntity | ReolinkSmartAIBinarySensorEntity] = [] + for channel in api.channels: entities.extend( ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description) for entity_description in BINARY_PUSH_SENSORS - if entity_description.supported(reolink_data.host.api, channel) + if entity_description.supported(api, channel) ) entities.extend( ReolinkBinarySensorEntity(reolink_data, channel, entity_description) for entity_description in BINARY_SENSORS - if entity_description.supported(reolink_data.host.api, channel) + if entity_description.supported(api, channel) + ) + entities.extend( + ReolinkSmartAIBinarySensorEntity( + reolink_data, channel, location, entity_description + ) + for entity_description in BINARY_SMART_AI_SENSORS + for location in api.baichuan.smart_location_list( + channel, entity_description.key + ) + if entity_description.supported(api, channel, location) ) async_add_entities(entities) @@ -198,3 +361,40 @@ class ReolinkPushBinarySensorEntity(ReolinkBinarySensorEntity): async def _async_handle_event(self, event: str) -> None: """Handle incoming event for motion detection.""" self.async_write_ha_state() + + +class ReolinkSmartAIBinarySensorEntity( + ReolinkChannelCoordinatorEntity, BinarySensorEntity +): + """Binary-sensor class for Reolink IP camera Smart AI sensors.""" + + entity_description: ReolinkSmartAIBinarySensorEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + location: int, + entity_description: ReolinkSmartAIBinarySensorEntityDescription, + ) -> None: + """Initialize Reolink binary sensor.""" + self.entity_description = entity_description + super().__init__(reolink_data, channel) + unique_index = self._host.api.baichuan.smart_ai_index( + channel, entity_description.smart_type, location + ) + self._attr_unique_id = f"{self._attr_unique_id}_{unique_index}" + + self._location = location + self._attr_translation_placeholders = { + "zone_name": self._host.api.baichuan.smart_ai_name( + channel, entity_description.smart_type, location + ) + } + + @property + def is_on(self) -> bool: + """State of the sensor.""" + return self.entity_description.value( + self._host.api, self._channel, self._location + ) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 26198a11594..0b019277a77 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -54,6 +54,72 @@ "state": { "on": "mdi:sleep" } + }, + "crossline_person": { + "default": "mdi:fence", + "state": { + "on": "mdi:fence-electric" + } + }, + "crossline_vehicle": { + "default": "mdi:fence", + "state": { + "on": "mdi:fence-electric" + } + }, + "crossline_dog_cat": { + "default": "mdi:fence", + "state": { + "on": "mdi:fence-electric" + } + }, + "intrusion_person": { + "default": "mdi:location-enter", + "state": { + "on": "mdi:alert-circle-outline" + } + }, + "intrusion_vehicle": { + "default": "mdi:location-enter", + "state": { + "on": "mdi:alert-circle-outline" + } + }, + "intrusion_dog_cat": { + "default": "mdi:location-enter", + "state": { + "on": "mdi:alert-circle-outline" + } + }, + "linger_person": { + "default": "mdi:account-switch", + "state": { + "on": "mdi:account-alert" + } + }, + "linger_vehicle": { + "default": "mdi:account-switch", + "state": { + "on": "mdi:account-alert" + } + }, + "linger_dog_cat": { + "default": "mdi:account-switch", + "state": { + "on": "mdi:account-alert" + } + }, + "forgotten_item": { + "default": "mdi:package-variant-closed-plus", + "state": { + "on": "mdi:package-variant-closed-check" + } + }, + "taken_item": { + "default": "mdi:package-variant-closed-minus", + "state": { + "on": "mdi:package-variant-closed-check" + } } }, "button": { diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index daa87fb401c..a22c93611b6 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -337,6 +337,83 @@ "off": "Awake", "on": "Sleeping" } + }, + "crossline_person": { + "name": "Crossline {zone_name} person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "crossline_vehicle": { + "name": "Crossline {zone_name} vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "crossline_dog_cat": { + "name": "Crossline {zone_name} animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "intrusion_person": { + "name": "Intrusion {zone_name} person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "intrusion_vehicle": { + "name": "Intrusion {zone_name} vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "intrusion_dog_cat": { + "name": "Intrusion {zone_name} animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "linger_person": { + "name": "Linger {zone_name} person", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "linger_vehicle": { + "name": "Linger {zone_name} vehicle", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "linger_dog_cat": { + "name": "Linger {zone_name} animal", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "forgotten_item": { + "name": "Item forgotten {zone_name}", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, + "taken_item": { + "name": "Item taken {zone_name}", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } } }, "button": { diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 293103e7eb2..cd793b9b620 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -146,6 +146,10 @@ def reolink_connect_class() -> Generator[MagicMock]: 0: {"chnID": 0, "aitype": 34615}, "Host": {"pushAlarm": 7}, } + host_mock.baichuan.smart_location_list.return_value = [0] + host_mock.baichuan.smart_ai_type_list.return_value = ["people"] + host_mock.baichuan.smart_ai_index.return_value = 1 + host_mock.baichuan.smart_ai_name.return_value = "zone1" yield host_mock_class diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 71318c27b25..99c9efba002 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -51,6 +51,32 @@ async def test_motion_sensor( assert hass.states.get(entity_id).state == STATE_ON +async def test_smart_ai_sensor( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test smart ai binary sensor entity.""" + reolink_connect.model = TEST_HOST_MODEL + reolink_connect.baichuan.smart_ai_state.return_value = True + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_crossline_zone1_person" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.baichuan.smart_ai_state.return_value = False + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + async def test_tcp_callback( hass: HomeAssistant, config_entry: MockConfigEntry, From 9b57a831f78a22a4df3e3d923045c456a320e1e1 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 17 Mar 2025 17:33:11 +0200 Subject: [PATCH 1747/1941] Fix Shelly Air lamp life sensor (#140799) --- homeassistant/components/shelly/sensor.py | 5 +++-- homeassistant/components/shelly/utils.py | 9 ++++++++ tests/components/shelly/conftest.py | 2 ++ tests/components/shelly/test_sensor.py | 27 +++++++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 0020c6e0614..f2c858aeb84 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -39,7 +39,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType -from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP, SHAIR_MAX_WORK_HOURS +from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -58,6 +58,7 @@ from .utils import ( async_remove_orphaned_entities, get_device_entry_gen, get_device_uptime, + get_shelly_air_lamp_life, get_virtual_component_ids, is_rpc_wifi_stations_disabled, ) @@ -355,7 +356,7 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { name="Lamp life", native_unit_of_measurement=PERCENTAGE, translation_key="lamp_life", - value=lambda value: 100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), + value=get_shelly_air_lamp_life, suggested_display_precision=1, extra_state_attributes=lambda block: { "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 626cb287f64..19897dbb185 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -59,6 +59,7 @@ from .const import ( GEN2_RELEASE_URL, LOGGER, RPC_INPUTS_EVENTS_TYPES, + SHAIR_MAX_WORK_HOURS, SHBTN_INPUTS_EVENTS_TYPES, SHBTN_MODELS, SHELLY_EMIT_EVENT_PATTERN, @@ -655,3 +656,11 @@ def is_rpc_exclude_from_relay( return True return is_rpc_channel_type_light(settings, ch) + + +def get_shelly_air_lamp_life(lamp_seconds: int) -> float: + """Return Shelly Air lamp life in percentage.""" + lamp_hours = lamp_seconds / 3600 + if lamp_hours >= SHAIR_MAX_WORK_HOURS: + return 0.0 + return 100 * (1 - lamp_hours / SHAIR_MAX_WORK_HOURS) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 5c0f912b72d..c68d52526c5 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -102,12 +102,14 @@ MOCK_BLOCKS = [ "power": 53.4, "energy": 1234567.89, "output": True, + "totalWorkTime": 3600, }, channel="0", type="relay", overpower=0, power=53.4, energy=1234567.89, + totalWorkTime=3600, description="relay_0", set_state=AsyncMock(side_effect=lambda turn: {"ison": turn == "on"}), ), diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index d37a146e314..00db4ade8ac 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -374,6 +374,33 @@ async def test_block_sensor_unknown_value( assert hass.states.get(entity_id).state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("lamp_life_seconds", "percentage"), + [ + (0 * 3600, "100.0"), # 0 hours, 100% remaining + (16 * 3600, "99.8222222222222"), + (4500 * 3600, "50.0"), # 4500 hours, 50% remaining + (9000 * 3600, "0.0"), # 9000 hours, 0% remaining + (10000 * 3600, "0.0"), # > 9000 hours, 0% remaining + ], +) +async def test_block_shelly_air_lamp_life( + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + lamp_life_seconds: int, + percentage: float, +) -> None: + """Test block Shelly Air lamp life percentage sensor.""" + entity_id = f"{SENSOR_DOMAIN}.{'test_name_channel_1_lamp_life'}" + monkeypatch.setattr( + mock_block_device.blocks[RELAY_BLOCK_ID], "totalWorkTime", lamp_life_seconds + ) + await init_integration(hass, 1) + + assert hass.states.get(entity_id).state == percentage + + async def test_rpc_sensor( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: From a252c19e7c26c80ee24d3c1e1b09c3f5c231f4bf Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:57:03 +0100 Subject: [PATCH 1748/1941] Use MowerDictionary in Husqvarna Automower (#140805) --- .../husqvarna_automower/coordinator.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 819ee41a43d..9456074596a 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -13,7 +13,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerAttributes +from aioautomower.model import MowerDictionary from aioautomower.session import AutomowerSession from homeassistant.config_entries import ConfigEntry @@ -32,7 +32,7 @@ DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator] -class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): +class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): """Class to manage fetching Husqvarna data.""" config_entry: AutomowerConfigEntry @@ -61,7 +61,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} - async def _async_update_data(self) -> dict[str, MowerAttributes]: + async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" if not self.ws_connected: await self.api.connect() @@ -84,7 +84,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib return data @callback - def callback(self, ws_data: dict[str, MowerAttributes]) -> None: + def callback(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" self.async_set_updated_data(ws_data) @@ -119,7 +119,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib "reconnect_task", ) - def _async_add_remove_devices(self, data: dict[str, MowerAttributes]) -> None: + def _async_add_remove_devices(self, data: MowerDictionary) -> None: """Add new device, remove non-existing device.""" current_devices = set(data) @@ -159,9 +159,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib for mower_callback in self.new_devices_callbacks: mower_callback(new_devices) - def _async_add_remove_stay_out_zones( - self, data: dict[str, MowerAttributes] - ) -> None: + def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" current_zones = { mower_id: set(mower_data.stay_out_zones.zones) @@ -207,7 +205,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib return current_zones - def _async_add_remove_work_areas(self, data: dict[str, MowerAttributes]) -> None: + def _async_add_remove_work_areas(self, data: MowerDictionary) -> None: """Add new work areas, remove non-existing work areas.""" current_areas = { mower_id: set(mower_data.work_areas) From f4787d469a9559a2be3b74ffce85bf25e4eae4bf Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 17 Mar 2025 17:27:01 +0100 Subject: [PATCH 1749/1941] Remove Shelly extra_attributes for RPC & REST devices (#140792) * Remove Shelly extra_attributes for RPC devices * apply review comment --- homeassistant/components/shelly/entity.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 001727c74b3..58ac34fc5ca 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -296,7 +296,6 @@ class RpcEntityDescription(EntityDescription): value: Callable[[Any, Any], Any] | None = None available: Callable[[dict], bool] | None = None removal_condition: Callable[[dict, dict, str], bool] | None = None - extra_state_attributes: Callable[[dict, dict], dict | None] | None = None use_polling_coordinator: bool = False supported: Callable = lambda _: False unit: Callable[[dict], str | None] | None = None @@ -313,7 +312,6 @@ class RestEntityDescription(EntityDescription): name: str = "" value: Callable[[dict, Any], Any] | None = None - extra_state_attributes: Callable[[dict], dict | None] | None = None class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): From 9a0837593a452d32584f2309cf8328b6e15d0730 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:38:40 +0100 Subject: [PATCH 1750/1941] Improve test coverage and add comment for loading in executor for remote calendar (#140807) Improve calendar loading by executing in a separate thread and add test for CalendarParseError --- .../components/remote_calendar/coordinator.py | 3 +++ tests/components/remote_calendar/test_init.py | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 7ee95695e61..7f29f7e2ea8 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -56,6 +56,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): translation_placeholders={"err": str(err)}, ) from err try: + # calendar_from_ics will dynamically load packages + # the first time it is called, so we need to do it + # in a separate thread to avoid blocking the event loop return await self.hass.async_add_executor_job( IcsCalendarStream.calendar_from_ics, res.text ) diff --git a/tests/components/remote_calendar/test_init.py b/tests/components/remote_calendar/test_init.py index 08f5c8b45c0..f4ca500b2e1 100644 --- a/tests/components/remote_calendar/test_init.py +++ b/tests/components/remote_calendar/test_init.py @@ -71,3 +71,16 @@ async def test_update_failed( respx.get(CALENDER_URL).mock(side_effect=side_effect) await setup_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@respx.mock +async def test_calendar_parse_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test CalendarParseError using respx.""" + respx.get(CALENDER_URL).mock( + return_value=Response(status_code=200, text="not a calendar") + ) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.SETUP_RETRY From a2fec8c2ce7949c7aecfe02f9e2706fc50a96b12 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Mar 2025 18:21:56 +0100 Subject: [PATCH 1751/1941] Fix inconsistent capitalization in `growatt_server` entities (#140803) * Fix inconsistent capitalization in `growatt_server` entities * Makes "amperage" and "wattage" consistent (with "voltage") --- .../components/growatt_server/strings.json | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index 9a985d98034..758428d7a55 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -38,28 +38,28 @@ "name": "Input 1 voltage" }, "inverter_amperage_input_1": { - "name": "Input 1 Amperage" + "name": "Input 1 amperage" }, "inverter_wattage_input_1": { - "name": "Input 1 Wattage" + "name": "Input 1 wattage" }, "inverter_voltage_input_2": { "name": "Input 2 voltage" }, "inverter_amperage_input_2": { - "name": "Input 2 Amperage" + "name": "Input 2 amperage" }, "inverter_wattage_input_2": { - "name": "Input 2 Wattage" + "name": "Input 2 wattage" }, "inverter_voltage_input_3": { "name": "Input 3 voltage" }, "inverter_amperage_input_3": { - "name": "Input 3 Amperage" + "name": "Input 3 amperage" }, "inverter_wattage_input_3": { - "name": "Input 3 Wattage" + "name": "Input 3 wattage" }, "inverter_internal_wattage": { "name": "Internal wattage" @@ -137,13 +137,13 @@ "name": "Load consumption" }, "mix_wattage_pv_1": { - "name": "PV1 Wattage" + "name": "PV1 wattage" }, "mix_wattage_pv_2": { - "name": "PV2 Wattage" + "name": "PV2 wattage" }, "mix_wattage_pv_all": { - "name": "All PV Wattage" + "name": "All PV wattage" }, "mix_export_to_grid": { "name": "Export to grid" @@ -182,7 +182,7 @@ "name": "Storage production today" }, "storage_storage_production_lifetime": { - "name": "Lifetime Storage production" + "name": "Lifetime storage production" }, "storage_grid_discharge_today": { "name": "Grid discharged today" @@ -224,7 +224,7 @@ "name": "Storage charging/ discharging(-ve)" }, "storage_load_consumption_solar_storage": { - "name": "Load consumption (Solar + Storage)" + "name": "Load consumption (solar + storage)" }, "storage_charge_today": { "name": "Charge today" @@ -257,7 +257,7 @@ "name": "Output voltage" }, "storage_ac_output_frequency": { - "name": "Ac output frequency" + "name": "AC output frequency" }, "storage_current_pv": { "name": "Solar charge current" @@ -290,7 +290,7 @@ "name": "Lifetime total energy input 1" }, "tlx_energy_today_input_1": { - "name": "Energy Today Input 1" + "name": "Energy today input 1" }, "tlx_voltage_input_1": { "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_1::name%]" @@ -305,7 +305,7 @@ "name": "Lifetime total energy input 2" }, "tlx_energy_today_input_2": { - "name": "Energy Today Input 2" + "name": "Energy today input 2" }, "tlx_voltage_input_2": { "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_2::name%]" @@ -320,7 +320,7 @@ "name": "Lifetime total energy input 3" }, "tlx_energy_today_input_3": { - "name": "Energy Today Input 3" + "name": "Energy today input 3" }, "tlx_voltage_input_3": { "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_3::name%]" @@ -335,16 +335,16 @@ "name": "Lifetime total energy input 4" }, "tlx_energy_today_input_4": { - "name": "Energy Today Input 4" + "name": "Energy today input 4" }, "tlx_voltage_input_4": { "name": "Input 4 voltage" }, "tlx_amperage_input_4": { - "name": "Input 4 Amperage" + "name": "Input 4 amperage" }, "tlx_wattage_input_4": { - "name": "Input 4 Wattage" + "name": "Input 4 wattage" }, "tlx_solar_generation_total": { "name": "Lifetime total solar energy" @@ -434,10 +434,10 @@ "name": "Money lifetime" }, "total_energy_today": { - "name": "Energy Today" + "name": "Energy today" }, "total_output_power": { - "name": "Output Power" + "name": "Output power" }, "total_energy_output": { "name": "[%key:component::growatt_server::entity::sensor::inverter_energy_total::name%]" From e16f0e9af3de4ac8fa374bd39c4672c8888d4a95 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Mar 2025 19:03:05 +0100 Subject: [PATCH 1752/1941] Clarify action descriptions of `smarttub.snooze_reminder` / `reset_reminder` (#140810) - change both descriptions to descriptive HA style - change "reminder" to "maintenance reminder" (helps translators a lot) - use more of the wording from the online documentation --- homeassistant/components/smarttub/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json index 974e5fb7d37..79fa7a4820f 100644 --- a/homeassistant/components/smarttub/strings.json +++ b/homeassistant/components/smarttub/strings.json @@ -49,17 +49,17 @@ }, "snooze_reminder": { "name": "Snooze a reminder", - "description": "Delay a reminder, so that it won't trigger again for a period of time.", + "description": "Temporarily suppresses the maintenance reminder on a hot tub.", "fields": { "days": { "name": "Days", - "description": "The number of days to delay the reminder." + "description": "The number of days to snooze the reminder." } } }, "reset_reminder": { "name": "Reset a reminder", - "description": "Reset a reminder, and set the next time it will be triggered.", + "description": "Resets the maintenance reminder on a hot tub.", "fields": { "days": { "name": "[%key:component::smarttub::services::snooze_reminder::fields::days::name%]", From 290dab25bf1a256ed972609ca000e2ac8b21c942 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 17 Mar 2025 20:04:30 +0100 Subject: [PATCH 1753/1941] Don't raise in ConfigFlow.async_set_unique_id if the other flow is a reauth flow (#140723) * Don't raise in ConfigFlow.async_set_unique_id if the other flow is a reauth flow * Improve test --- homeassistant/config_entries.py | 7 +++- tests/test_config_entries.py | 70 ++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index bfea2c29eac..9336ead633a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2986,8 +2986,11 @@ class ConfigFlow(ConfigEntryBaseFlow): return None if raise_on_progress: - if self._async_in_progress( - include_uninitialized=True, match_context={"unique_id": unique_id} + if any( + flow["context"]["source"] != SOURCE_REAUTH + for flow in self._async_in_progress( + include_uninitialized=True, match_context={"unique_id": unique_id} + ) ): raise data_entry_flow.AbortFlow("already_in_progress") diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d19c3b38650..788225365e0 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3566,37 +3566,97 @@ async def test_unique_id_not_update_existing_entry( assert len(async_reload.mock_calls) == 0 +ABORT_IN_PROGRESS = { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "already_in_progress", +} + + +@pytest.mark.parametrize( + ("existing_flow_source", "expected_result"), + # Test all sources except SOURCE_IGNORE + [ + (config_entries.SOURCE_BLUETOOTH, ABORT_IN_PROGRESS), + (config_entries.SOURCE_DHCP, ABORT_IN_PROGRESS), + (config_entries.SOURCE_DISCOVERY, ABORT_IN_PROGRESS), + (config_entries.SOURCE_HARDWARE, ABORT_IN_PROGRESS), + (config_entries.SOURCE_HASSIO, ABORT_IN_PROGRESS), + (config_entries.SOURCE_HOMEKIT, ABORT_IN_PROGRESS), + (config_entries.SOURCE_IMPORT, ABORT_IN_PROGRESS), + (config_entries.SOURCE_INTEGRATION_DISCOVERY, ABORT_IN_PROGRESS), + (config_entries.SOURCE_MQTT, ABORT_IN_PROGRESS), + (config_entries.SOURCE_REAUTH, {"type": data_entry_flow.FlowResultType.FORM}), + (config_entries.SOURCE_RECONFIGURE, ABORT_IN_PROGRESS), + (config_entries.SOURCE_SSDP, ABORT_IN_PROGRESS), + (config_entries.SOURCE_SYSTEM, ABORT_IN_PROGRESS), + (config_entries.SOURCE_USB, ABORT_IN_PROGRESS), + (config_entries.SOURCE_USER, ABORT_IN_PROGRESS), + (config_entries.SOURCE_ZEROCONF, ABORT_IN_PROGRESS), + ], +) async def test_unique_id_in_progress( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + existing_flow_source: str, + expected_result: dict, ) -> None: """Test that we abort if there is already a flow in progress with same unique id.""" mock_integration(hass, MockModule("comp")) mock_platform(hass, "comp.config_flow", None) + entry = MockConfigEntry(domain="comp") + entry.add_to_hass(hass) class TestFlow(config_entries.ConfigFlow): """Test flow.""" VERSION = 1 + async def _async_step_discovery_without_unique_id(self): + """Handle a flow initialized by discovery.""" + return await self._async_step() + + async def async_step_hardware(self, user_input=None): + """Test hardware step.""" + return await self._async_step() + + async def async_step_import(self, user_input=None): + """Test import step.""" + return await self._async_step() + + async def async_step_reauth(self, user_input=None): + """Test reauth step.""" + return await self._async_step() + + async def async_step_reconfigure(self, user_input=None): + """Test reconfigure step.""" + return await self._async_step() + + async def async_step_system(self, user_input=None): + """Test system step.""" + return await self._async_step() + async def async_step_user(self, user_input=None): """Test user step.""" + return await self._async_step() + + async def _async_step(self, user_input=None): + """Test step.""" await self.async_set_unique_id("mock-unique-id") return self.async_show_form(step_id="discovery") with mock_config_flow("comp", TestFlow): # Create one to be in progress result = await manager.flow.async_init( - "comp", context={"source": config_entries.SOURCE_USER} + "comp", context={"source": existing_flow_source, "entry_id": entry.entry_id} ) assert result["type"] == data_entry_flow.FlowResultType.FORM - # Will be canceled result2 = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} ) - assert result2["type"] == data_entry_flow.FlowResultType.ABORT - assert result2["reason"] == "already_in_progress" + for k, v in expected_result.items(): + assert result2[k] == v async def test_finish_flow_aborts_progress( From 4dfb56a2f74d16554cb2ab11ae5686e979b6b808 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 17 Mar 2025 20:06:49 +0100 Subject: [PATCH 1754/1941] Bump reolink-aio to 0.12.3b1 (#140811) --- 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 c07d63c184c..0cb5eb3e13c 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.2"] + "requirements": ["reolink-aio==0.12.3b1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ae0f6114b0e..76f8cbb46dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2622,7 +2622,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.2 +reolink-aio==0.12.3b1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48dbb5deae6..b8e265df455 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2122,7 +2122,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.2 +reolink-aio==0.12.3b1 # homeassistant.components.rflink rflink==0.0.66 From 52d86ede3ecb4311a01bfbe68f2a0f437fd9202f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 17 Mar 2025 14:10:56 -0500 Subject: [PATCH 1755/1941] Add ability to browse (and play) HEOS media (#140433) * Add browse and play * Tests * Add tests involving media source --- homeassistant/components/heos/media_player.py | 133 ++++++++- homeassistant/components/heos/strings.json | 3 + tests/components/heos/__init__.py | 21 +- tests/components/heos/conftest.py | 48 +++- .../heos/snapshots/test_media_player.ambr | 140 +++++++++ tests/components/heos/test_media_player.py | 267 ++++++++++++++++++ 6 files changed, 602 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 9edc674d1cf..5c0a66a02fa 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -3,27 +3,35 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine, Sequence +from contextlib import suppress from datetime import datetime from functools import reduce, wraps +import logging from operator import ior -from typing import Any +from typing import Any, Final from pyheos import ( AddCriteriaType, ControlType, HeosError, HeosPlayer, + MediaItem, + MediaMusicSource, + MediaType as HeosMediaType, PlayState, RepeatType, const as heos_const, ) +from pyheos.util import mediauri as heos_source import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_VOLUME_LEVEL, + BrowseError, BrowseMedia, + MediaClass, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -32,6 +40,7 @@ from homeassistant.components.media_player import ( RepeatMode, async_process_play_media_url, ) +from homeassistant.components.media_source import BrowseMediaSource from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -55,6 +64,8 @@ from .coordinator import HeosConfigEntry, HeosCoordinator PARALLEL_UPDATES = 0 +BROWSE_ROOT: Final = "heos://media" + BASE_SUPPORTED_FEATURES = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET @@ -97,6 +108,21 @@ HEOS_HA_REPEAT_TYPE_MAP = { } HA_HEOS_REPEAT_TYPE_MAP = {v: k for k, v in HEOS_HA_REPEAT_TYPE_MAP.items()} +HEOS_MEDIA_TYPE_TO_MEDIA_CLASS = { + HeosMediaType.ALBUM: MediaClass.ALBUM, + HeosMediaType.ARTIST: MediaClass.ARTIST, + HeosMediaType.CONTAINER: MediaClass.DIRECTORY, + HeosMediaType.GENRE: MediaClass.GENRE, + HeosMediaType.HEOS_SERVER: MediaClass.DIRECTORY, + HeosMediaType.HEOS_SERVICE: MediaClass.DIRECTORY, + HeosMediaType.MUSIC_SERVICE: MediaClass.DIRECTORY, + HeosMediaType.PLAYLIST: MediaClass.PLAYLIST, + HeosMediaType.SONG: MediaClass.TRACK, + HeosMediaType.STATION: MediaClass.TRACK, +} + +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -282,6 +308,16 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" + if heos_source.is_media_uri(media_id): + media, data = heos_source.from_media_uri(media_id) + if not isinstance(media, MediaItem): + raise ValueError(f"Invalid media id '{media_id}'") + await self._player.play_media( + media, + HA_HEOS_ENQUEUE_MAP[kwargs.get(ATTR_MEDIA_ENQUEUE)], + ) + return + if media_source.is_media_source_id(media_id): media_type = MediaType.URL play_item = await media_source.async_resolve_media( @@ -534,14 +570,101 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): """Volume level of the media player (0..1).""" return self._player.volume / 100 + async def _async_browse_media_root(self) -> BrowseMedia: + """Return media browsing root.""" + if not self.coordinator.heos.music_sources: + try: + await self.coordinator.heos.get_music_sources() + except HeosError as error: + _LOGGER.debug("Unable to load music sources: %s", error) + children: list[BrowseMedia] = [ + _media_to_browse_media(source) + for source in self.coordinator.heos.music_sources.values() + if source.available + ] + root = BrowseMedia( + title="Music Sources", + media_class=MediaClass.DIRECTORY, + children_media_class=MediaClass.DIRECTORY, + media_content_type="", + media_content_id=BROWSE_ROOT, + can_expand=True, + can_play=False, + children=children, + ) + # Append media source items + with suppress(BrowseError): + browse = await self._async_browse_media_source() + # If domain is None, it's an overview of available sources + if browse.domain is None and browse.children: + children.extend(browse.children) + else: + children.append(browse) + return root + + async def _async_browse_heos_media(self, media_content_id: str) -> BrowseMedia: + """Browse a HEOS media item.""" + media, data = heos_source.from_media_uri(media_content_id) + browse_media = _media_to_browse_media(media) + try: + browse_result = await self.coordinator.heos.browse_media(media) + except HeosError as error: + _LOGGER.debug("Unable to browse media %s: %s", media, error) + else: + browse_media.children = [ + _media_to_browse_media(item) + for item in browse_result.items + if item.browsable or item.playable + ] + return browse_media + + async def _async_browse_media_source( + self, media_content_id: str | None = None + ) -> BrowseMediaSource: + """Browse a media source item.""" + return await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda item: item.media_content_type.startswith("audio/"), + ) + async def async_browse_media( self, media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" - return await media_source.async_browse_media( - self.hass, - media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), + if media_content_id in (None, BROWSE_ROOT): + return await self._async_browse_media_root() + assert media_content_id is not None + if heos_source.is_media_uri(media_content_id): + return await self._async_browse_heos_media(media_content_id) + if media_source.is_media_source_id(media_content_id): + return await self._async_browse_media_source(media_content_id) + raise ServiceValidationError( + translation_domain=HEOS_DOMAIN, + translation_key="unsupported_media_content_id", + translation_placeholders={"media_content_id": media_content_id}, ) + + +def _media_to_browse_media(media: MediaItem | MediaMusicSource) -> BrowseMedia: + """Convert a HEOS media item to a browse media item.""" + can_expand = False + can_play = False + + if isinstance(media, MediaMusicSource): + can_expand = media.available + else: + can_expand = media.browsable + can_play = media.playable + + return BrowseMedia( + can_expand=can_expand, + can_play=can_play, + media_content_id=heos_source.to_media_uri(media), + media_content_type="", + media_class=HEOS_MEDIA_TYPE_TO_MEDIA_CLASS[media.type], + title=media.name, + thumbnail=media.image_url, + ) diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 340eecb9f8b..593c437accc 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -146,6 +146,9 @@ }, "unknown_source": { "message": "Unknown source: {source}" + }, + "unsupported_media_content_id": { + "message": "Unsupported media_content_id: {media_content_id}" } }, "issues": { diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py index 862b1e5ffab..cb4313bbd10 100644 --- a/tests/components/heos/__init__.py +++ b/tests/components/heos/__init__.py @@ -2,7 +2,14 @@ from unittest.mock import AsyncMock -from pyheos import ConnectionState, Heos, HeosGroup, HeosOptions, HeosPlayer +from pyheos import ( + ConnectionState, + Heos, + HeosGroup, + HeosOptions, + HeosPlayer, + MediaMusicSource, +) class MockHeos(Heos): @@ -13,6 +20,7 @@ class MockHeos(Heos): super().__init__(options) # Overwrite the methods with async mocks, changing type self.add_to_queue: AsyncMock = AsyncMock() + self.browse_media: AsyncMock = AsyncMock() self.connect: AsyncMock = AsyncMock() self.disconnect: AsyncMock = AsyncMock() self.get_favorites: AsyncMock = AsyncMock() @@ -20,6 +28,7 @@ class MockHeos(Heos): self.get_input_sources: AsyncMock = AsyncMock() self.get_playlists: AsyncMock = AsyncMock() self.get_players: AsyncMock = AsyncMock() + self.get_music_sources: AsyncMock = AsyncMock() self.group_volume_down: AsyncMock = AsyncMock() self.group_volume_up: AsyncMock = AsyncMock() self.get_system_info: AsyncMock = AsyncMock() @@ -68,3 +77,13 @@ class MockHeos(Heos): def mock_set_current_host(self, host: str) -> None: """Set the current host on the mock instance.""" self._connection._host = host + + def mock_set_music_sources( + self, music_sources: dict[int, MediaMusicSource] + ) -> None: + """Set the music sources on the mock instance.""" + for music_source in music_sources.values(): + music_source.heos = self + self._music_sources = music_sources + self._music_sources_loaded = bool(music_sources) + self.get_music_sources.return_value = music_sources diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 7bed05a0289..5d06d1812ea 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Iterator from unittest.mock import Mock, patch from pyheos import ( + BrowseResult, HeosGroup, HeosHost, HeosNowPlayingMedia, @@ -14,6 +15,7 @@ from pyheos import ( HeosSystem, LineOutLevelType, MediaItem, + MediaMusicSource, MediaType, NetworkType, PlayerUpdateResult, @@ -294,10 +296,10 @@ def quick_selects_fixture() -> dict[int, str]: } -@pytest.fixture(name="playlists") -def playlists_fixture() -> list[MediaItem]: - """Create favorites fixture.""" - playlist = MediaItem( +@pytest.fixture(name="playlist") +def playlist_fixture() -> MediaItem: + """Create playlist fixture.""" + return MediaItem( source_id=const.MUSIC_SOURCE_PLAYLISTS, name="Awesome Music", type=MediaType.PLAYLIST, @@ -306,6 +308,44 @@ def playlists_fixture() -> list[MediaItem]: image_url="", heos=None, ) + + +@pytest.fixture(name="music_sources") +def music_sources_fixture() -> dict[int, MediaMusicSource]: + """Create music sources fixture.""" + return { + const.MUSIC_SOURCE_PANDORA: MediaMusicSource( + source_id=const.MUSIC_SOURCE_PANDORA, + name="Pandora", + type=MediaType.MUSIC_SERVICE, + available=True, + service_username="user", + image_url="", + heos=None, + ), + const.MUSIC_SOURCE_TUNEIN: MediaMusicSource( + source_id=const.MUSIC_SOURCE_TUNEIN, + name="TuneIn", + type=MediaType.MUSIC_SERVICE, + available=False, + service_username=None, + image_url="", + heos=None, + ), + } + + +@pytest.fixture(name="pandora_browse_result") +def pandora_browse_response_fixture(favorites: dict[int, MediaItem]) -> BrowseResult: + """Create a mock response for browsing Pandora.""" + return BrowseResult( + 1, 1, const.MUSIC_SOURCE_PANDORA, items=[favorites[1]], options=[] + ) + + +@pytest.fixture(name="playlists") +def playlists_fixture(playlist: MediaItem) -> list[MediaItem]: + """Create playlists fixture.""" return [playlist] diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index 88d27f2073a..d2cd8b3e12a 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -1,4 +1,144 @@ # serializer version: 1 +# name: test_browse_media_heos_media + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'track', + 'media_content_id': 'heos://media/1/station?name=Today%27s+Hits+Radio&image_url=&playable=True&browsable=False&media_id=123456789', + 'media_content_type': '', + 'thumbnail': '', + 'title': "Today's Hits Radio", + }), + ]), + 'children_media_class': 'track', + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': '', + 'title': 'Pandora', + }) +# --- +# name: test_browse_media_heos_media_error_returns_empty + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + ]), + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': '', + 'title': 'Pandora', + }) +# --- +# name: test_browse_media_media_source + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': False, + 'can_play': True, + 'children_media_class': None, + 'media_class': 'music', + 'media_content_id': 'media-source://media_source/local/test.mp3', + 'media_content_type': 'audio/mpeg', + 'thumbnail': None, + 'title': 'test.mp3', + }), + ]), + 'children_media_class': 'music', + 'media_class': 'directory', + 'media_content_id': 'media-source://media_source/local/.', + 'media_content_type': '', + 'not_shown': 1, + 'thumbnail': None, + 'title': 'media', + }) +# --- +# name: test_browse_media_root + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'Pandora', + }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': 'music', + 'media_class': 'directory', + 'media_content_id': 'media-source://media_source/local/.', + 'media_content_type': '', + 'thumbnail': None, + 'title': 'media', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'heos://media', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Music Sources', + }) +# --- +# name: test_browse_media_root_no_media_source + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'Pandora', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'heos://media', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Music Sources', + }) +# --- +# name: test_browse_media_root_source_error_continues + dict({ + 'can_expand': True, + 'can_play': False, + 'children': list([ + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'heos://media', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Music Sources', + }) +# --- # name: test_state_attributes StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index debfe31f427..d5bc8cab488 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -7,9 +7,11 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory from pyheos import ( AddCriteriaType, + BrowseResult, CommandFailedError, HeosError, MediaItem, + MediaMusicSource, MediaType as HeosMediaType, PlayerUpdateResult, PlayState, @@ -18,6 +20,7 @@ from pyheos import ( SignalType, const, ) +from pyheos.util import mediauri import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props @@ -51,6 +54,7 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) +from homeassistant.components.media_source import DOMAIN as MS_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, @@ -73,6 +77,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from . import MockHeos from tests.common import MockConfigEntry, async_fire_time_changed +from tests.conftest import async_setup_component +from tests.typing import WebSocketGenerator async def test_state_attributes( @@ -1239,6 +1245,267 @@ async def test_play_media_invalid_type( ) +async def test_play_media_media_uri( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + playlist: MediaItem, +) -> None: + """Test the play media service with HEOS media uri.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + media_content_id = mediauri.to_media_uri(playlist) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: media_content_id, + ATTR_MEDIA_CONTENT_TYPE: "", + }, + blocking=True, + ) + controller.play_media.assert_called_once() + + +async def test_play_media_media_uri_invalid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with an invalid HEOS media uri raises.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + media_id = "heos://media/1/music_service?name=Pandora&available=False&image_url=" + + with pytest.raises( + HomeAssistantError, + match=re.escape(f"Unable to play media: Invalid media id '{media_id}'"), + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: media_id, + ATTR_MEDIA_CONTENT_TYPE: "", + }, + blocking=True, + ) + controller.play_media.assert_not_called() + + +async def test_play_media_music_source_url( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test the play media service with a music source url.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/test.mp3", + ATTR_MEDIA_CONTENT_TYPE: "", + }, + blocking=True, + ) + controller.play_url.assert_called_once() + + +async def test_browse_media_root( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + music_sources: dict[int, MediaMusicSource], + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the root.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + + controller.mock_set_music_sources(music_sources) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_root_no_media_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + music_sources: dict[int, MediaMusicSource], + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the root.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.mock_set_music_sources(music_sources) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_root_source_error_continues( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the root with an error getting sources continues.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.get_music_sources.side_effect = HeosError("error") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + assert "Unable to load music sources" in caplog.text + + +async def test_browse_media_heos_media( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + hass_ws_client: WebSocketGenerator, + pandora_browse_result: BrowseResult, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a heos media item.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.browse_media.return_value = pandora_browse_result + + 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": "heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_heos_media_error_returns_empty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + controller: MockHeos, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a heos media item results in an error, returns empty children.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + controller.browse_media.side_effect = HeosError("error") + + 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": "heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + assert "Unable to browse media" in caplog.text + + +async def test_browse_media_media_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a media source.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + + 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-source://media_source/local/.", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + + +async def test_browse_media_invalid_content_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test browsing an invalid content id fails.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + + 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": "invalid", + "media_content_type": "", + } + ) + response = await client.receive_json() + assert not response["success"] + + @pytest.mark.parametrize( ("members", "expected"), [ From 539a28dcba6921d0acd5005b51f91a980575e237 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Mar 2025 20:19:38 +0100 Subject: [PATCH 1756/1941] Make all action descriptions in `rachio` consistent (#140816) Changes 4 of the 6 action descriptions in the `rachio` integration to also use the descriptive style of Home Assistant. In addition "API key" is sentence-cased to match the common string used in the same dialog. --- homeassistant/components/rachio/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index 308403d805d..d51a1d5f920 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Connect to your Rachio device", - "description": "You will need the API Key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.", + "description": "You will need the API key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } @@ -70,7 +70,7 @@ }, "start_watering": { "name": "Start watering", - "description": "Start a single zone, a schedule or any number of smart hose timers.", + "description": "Starts a single zone, a schedule or any number of smart hose timers.", "fields": { "duration": { "name": "Duration", @@ -80,7 +80,7 @@ }, "pause_watering": { "name": "Pause watering", - "description": "Pause any currently running zones or schedules.", + "description": "Pauses any currently running zones or schedules.", "fields": { "devices": { "name": "Devices", @@ -94,7 +94,7 @@ }, "resume_watering": { "name": "Resume watering", - "description": "Resume any paused zone runs or schedules.", + "description": "Resumes any paused zone runs or schedules.", "fields": { "devices": { "name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]", @@ -104,7 +104,7 @@ }, "stop_watering": { "name": "Stop watering", - "description": "Stop any currently running zones or schedules.", + "description": "Stops any currently running zones or schedules.", "fields": { "devices": { "name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]", From eafea6070d92ab6bda8d815b05badf17ddd1bdd3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 17 Mar 2025 20:45:17 +0100 Subject: [PATCH 1757/1941] Improve action description in `mealie` integration (#140817) - change all action descriptions to third-person singular - use neutral wording for the description of `config_entry_id` so it works with all the different action contexts. --- homeassistant/components/mealie/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json index fa63252e837..186fc4c4ac0 100644 --- a/homeassistant/components/mealie/strings.json +++ b/homeassistant/components/mealie/strings.json @@ -146,11 +146,11 @@ "services": { "get_mealplan": { "name": "Get mealplan", - "description": "Get mealplan from Mealie", + "description": "Gets a mealplan from Mealie", "fields": { "config_entry_id": { "name": "Mealie instance", - "description": "Select the Mealie instance to get mealplan from" + "description": "The Mealie instance to use for this action." }, "start_date": { "name": "Start date", @@ -164,7 +164,7 @@ }, "get_recipe": { "name": "Get recipe", - "description": "Get recipe from Mealie", + "description": "Gets a recipe from Mealie", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", @@ -178,7 +178,7 @@ }, "import_recipe": { "name": "Import recipe", - "description": "Import recipe from an URL", + "description": "Imports a recipe from an URL", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", @@ -196,7 +196,7 @@ }, "set_random_mealplan": { "name": "Set random mealplan", - "description": "Set a random mealplan for a specific date", + "description": "Sets a random mealplan for a specific date", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", @@ -214,7 +214,7 @@ }, "set_mealplan": { "name": "Set a mealplan", - "description": "Set a mealplan for a specific date", + "description": "Sets a mealplan for a specific date", "fields": { "config_entry_id": { "name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]", From c9276aedde098fae761f60f7b0e082a955f69501 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 18 Mar 2025 05:38:37 +0900 Subject: [PATCH 1758/1941] Bump thinqconnect to 1.0.5 (#140577) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index b00d28c1d4f..cffc61cb1c4 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.4"] + "requirements": ["thinqconnect==1.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 76f8cbb46dc..57f40b4c018 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2903,7 +2903,7 @@ thermopro-ble==0.11.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.4 +thinqconnect==1.0.5 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b8e265df455..65a64a8b2ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2334,7 +2334,7 @@ thermobeacon-ble==0.8.1 thermopro-ble==0.11.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.4 +thinqconnect==1.0.5 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 412705302dab5bf069fcb0f367211aa752dd28c1 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 17 Mar 2025 14:38:21 -0700 Subject: [PATCH 1759/1941] Update MCP server to make the stateless API implicit (#140753) * Update MCP server to not register the stateless API, but use it implicitly as an Assist API replacement * Ensure backwards compatibility with old registration --- .../components/mcp_server/__init__.py | 3 +-- .../components/mcp_server/config_flow.py | 9 +------- homeassistant/components/mcp_server/const.py | 5 ++-- .../components/mcp_server/llm_api.py | 23 +++++++------------ homeassistant/components/mcp_server/server.py | 23 ++++++++++++++----- tests/components/mcp_server/conftest.py | 13 ++++++++--- tests/components/mcp_server/test_http.py | 20 ++++++++-------- 7 files changed, 50 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py index 941eccbe528..e523f46228f 100644 --- a/homeassistant/components/mcp_server/__init__.py +++ b/homeassistant/components/mcp_server/__init__.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from . import http, llm_api +from . import http from .const import DOMAIN from .session import SessionManager from .types import MCPServerConfigEntry @@ -25,7 +25,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Model Context Protocol component.""" http.async_register(hass) - llm_api.async_register_api(hass) return True diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py index 8d8d311b874..e8df68de5e2 100644 --- a/homeassistant/components/mcp_server/config_flow.py +++ b/homeassistant/components/mcp_server/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .const import DOMAIN, LLM_API, LLM_API_NAME +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,13 +33,6 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)} - if LLM_API not in llm_apis: - # MCP server component is not loaded yet, so make the LLM API a choice. - llm_apis = { - LLM_API: LLM_API_NAME, - **llm_apis, - } - if user_input is not None: return self.async_create_entry( title=llm_apis[user_input[CONF_LLM_HASS_API]], data=user_input diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py index 8958ac36616..3f2e12cbb6a 100644 --- a/homeassistant/components/mcp_server/const.py +++ b/homeassistant/components/mcp_server/const.py @@ -2,5 +2,6 @@ DOMAIN = "mcp_server" TITLE = "Model Context Protocol Server" -LLM_API = "stateless_assist" -LLM_API_NAME = "Stateless Assist" +# The Stateless API is no longer registered explicitly, but this name may still exist in the +# users config entry. +STATELESS_LLM_API = "stateless_assist" diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py index 5c29b29153e..f7dd4421480 100644 --- a/homeassistant/components/mcp_server/llm_api.py +++ b/homeassistant/components/mcp_server/llm_api.py @@ -1,19 +1,18 @@ -"""LLM API for MCP Server.""" +"""LLM API for MCP Server. -from homeassistant.core import HomeAssistant, callback +This is a modified version of the AssistAPI that does not include the home state +in the prompt. This API is not registered with the LLM API registry since it is +only used by the MCP Server. The MCP server will substitute this API when the +user selects the Assist API. +""" + +from homeassistant.core import callback from homeassistant.helpers import llm from homeassistant.util import yaml as yaml_util -from .const import LLM_API, LLM_API_NAME - EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"} -def async_register_api(hass: HomeAssistant) -> None: - """Register the LLM API.""" - llm.async_register_api(hass, StatelessAssistAPI(hass)) - - class StatelessAssistAPI(llm.AssistAPI): """LLM API for MCP Server that provides the Assist API without state information in the prompt. @@ -22,12 +21,6 @@ class StatelessAssistAPI(llm.AssistAPI): actions don't care about the current state, there is little quality loss. """ - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the StatelessAssistAPI.""" - super().__init__(hass) - self.id = LLM_API - self.name = LLM_API_NAME - @callback def _async_get_exposed_entities_prompt( self, llm_context: llm.LLMContext, exposed_entities: dict | None diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index ba21abd722c..307fcdda8f3 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -21,6 +21,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm +from .const import STATELESS_LLM_API +from .llm_api import StatelessAssistAPI + _LOGGER = logging.getLogger(__name__) @@ -50,13 +53,21 @@ async def create_server( server = Server("home-assistant") + async def get_api_instance() -> llm.APIInstance: + """Substitute the StatelessAssistAPI for the Assist API if selected.""" + if llm_api_id in (STATELESS_LLM_API, llm.LLM_API_ASSIST): + api = StatelessAssistAPI(hass) + return await api.async_get_api_instance(llm_context) + + return await llm.async_get_api(hass, llm_api_id, llm_context) + @server.list_prompts() # type: ignore[no-untyped-call, misc] async def handle_list_prompts() -> list[types.Prompt]: - llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + llm_api = await get_api_instance() return [ types.Prompt( name=llm_api.api.name, - description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}", + description=f"Default prompt for Home Assistant {llm_api.api.name} API", ) ] @@ -64,12 +75,12 @@ async def create_server( async def handle_get_prompt( name: str, arguments: dict[str, str] | None ) -> types.GetPromptResult: - llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + llm_api = await get_api_instance() if name != llm_api.api.name: raise ValueError(f"Unknown prompt: {name}") return types.GetPromptResult( - description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}", + description=f"Default prompt for Home Assistant {llm_api.api.name} API", messages=[ types.PromptMessage( role="assistant", @@ -84,13 +95,13 @@ async def create_server( @server.list_tools() # type: ignore[no-untyped-call, misc] async def list_tools() -> list[types.Tool]: """List available time tools.""" - llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + llm_api = await get_api_instance() return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools] @server.call_tool() # type: ignore[no-untyped-call, misc] async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]: """Handle calling tools.""" - llm_api = await llm.async_get_api(hass, llm_api_id, llm_context) + llm_api = await get_api_instance() tool_input = llm.ToolInput(tool_name=name, tool_args=arguments) _LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args) diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index 5ec67fb6ce3..b5e25d9fe50 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -5,9 +5,10 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.mcp_server.const import DOMAIN, LLM_API +from homeassistant.components.mcp_server.const import DOMAIN from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm from tests.common import MockConfigEntry @@ -21,13 +22,19 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture(name="llm_hass_api") +def llm_hass_api_fixture() -> str: + """Fixture for the config entry llm_hass_api.""" + return llm.LLM_API_ASSIST + + @pytest.fixture(name="config_entry") -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant, llm_hass_api: str) -> MockConfigEntry: """Fixture to load the integration.""" config_entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_LLM_HASS_API: LLM_API, + CONF_LLM_HASS_API: llm_hass_api, }, ) config_entry.add_to_hass(hass) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 905bfaa11d7..70efd211b57 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -16,6 +16,7 @@ import pytest from homeassistant.components.conversation import DOMAIN as CONVERSATION_DOMAIN from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.mcp_server.const import STATELESS_LLM_API from homeassistant.components.mcp_server.http import MESSAGES_API, SSE_API from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LLM_HASS_API, STATE_OFF, STATE_ON @@ -24,6 +25,7 @@ from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, + llm, ) from homeassistant.setup import async_setup_component @@ -297,6 +299,7 @@ async def mcp_session( yield session +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tools_list( hass: HomeAssistant, setup_integration: None, @@ -319,6 +322,7 @@ async def test_mcp_tools_list( assert properties.get("name") == {"type": "string"} +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_mcp_tool_call( hass: HomeAssistant, setup_integration: None, @@ -371,6 +375,7 @@ async def test_mcp_tool_call_failed( assert "Error calling tool" in result.content[0].text +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_prompt_list( hass: HomeAssistant, setup_integration: None, @@ -384,13 +389,11 @@ async def test_prompt_list( assert len(result.prompts) == 1 prompt = result.prompts[0] - assert prompt.name == "Stateless Assist" - assert ( - prompt.description - == "Default prompt for the Home Assistant LLM API Stateless Assist" - ) + assert prompt.name == "Assist" + assert prompt.description == "Default prompt for Home Assistant Assist API" +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API]) async def test_prompt_get( hass: HomeAssistant, setup_integration: None, @@ -400,12 +403,9 @@ async def test_prompt_get( """Test the get prompt endpoint.""" async with mcp_session(mcp_sse_url, hass_supervisor_access_token) as session: - result = await session.get_prompt(name="Stateless Assist") + result = await session.get_prompt(name="Assist") - assert ( - result.description - == "Default prompt for the Home Assistant LLM API Stateless Assist" - ) + assert result.description == "Default prompt for Home Assistant Assist API" assert len(result.messages) == 1 assert result.messages[0].role == "assistant" assert result.messages[0].content.type == "text" From 73a24bf79987340115004cb0960d2da657cfb47b Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 17 Mar 2025 21:39:48 -0400 Subject: [PATCH 1760/1941] Set Parallel updates to 0 in Roborock (#140837) roborock set parallel updates to 0 --- homeassistant/components/roborock/binary_sensor.py | 2 ++ homeassistant/components/roborock/button.py | 2 ++ homeassistant/components/roborock/image.py | 2 ++ homeassistant/components/roborock/number.py | 2 ++ homeassistant/components/roborock/quality_scale.yaml | 2 +- homeassistant/components/roborock/select.py | 2 ++ homeassistant/components/roborock/sensor.py | 2 ++ homeassistant/components/roborock/switch.py | 2 ++ homeassistant/components/roborock/time.py | 2 ++ homeassistant/components/roborock/vacuum.py | 2 ++ 10 files changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 95640812b11..a2c34f5c59d 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -20,6 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockBinarySensorDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index f0f0d7beea2..fea38524fe0 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -17,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockEntity, RoborockEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockButtonDescription(ButtonEntityDescription): diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 382edbca744..79d6dafdc7a 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -18,6 +18,8 @@ from .entity import RoborockCoordinatedEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index a710eeefb90..73ac14fca71 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -22,6 +22,8 @@ from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockNumberDescription(NumberEntityDescription): diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 2cf664beb40..c7675ef96d1 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -28,7 +28,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: todo test-coverage: done # Gold diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 42245c458eb..c79bf817d09 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -17,6 +17,8 @@ from .const import MAP_SLEEP from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockSelectDescription(SelectEntityDescription): diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 7b019acb39b..556d8443669 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -38,6 +38,8 @@ from .coordinator import ( ) from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockSensorDescription(SensorEntityDescription): diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 636066c1ed5..44feccdebac 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -24,6 +24,8 @@ from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockSwitchDescription(SwitchEntityDescription): diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 6aa70e300e5..83d341fa2dd 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -24,6 +24,8 @@ from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class RoborockTimeDescription(TimeEntityDescription): diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index db201ff06d2..f17cab7e922 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -57,6 +57,8 @@ STATE_CODE_TO_STATE = { RoborockStateCode.device_offline: VacuumActivity.ERROR, # "Device offline" } +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From 0eac679a5a2d5e884f6771e9df7f6dd2f4072822 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 17 Mar 2025 22:34:47 -0400 Subject: [PATCH 1761/1941] Move MapData to Coordinator for Roborock (#140766) * Move MapData to Coordinator * seeing if mypy likes this * delete dead code * Some MR comments * remove MapData and always update on startup if we don't have a stored map. * don't do on demand updates * remove unneeded logic and pull out map save * Apply suggestions from code review Co-authored-by: Allen Porter * see if mypy is happy --------- Co-authored-by: Allen Porter --- homeassistant/components/roborock/const.py | 2 +- .../components/roborock/coordinator.py | 71 ++++++++++++++++++- homeassistant/components/roborock/image.py | 56 +++------------ homeassistant/components/roborock/models.py | 3 + homeassistant/components/roborock/vacuum.py | 10 ++- tests/components/roborock/conftest.py | 2 +- tests/components/roborock/test_config_flow.py | 42 +++++++---- tests/components/roborock/test_image.py | 44 +++++++++--- tests/components/roborock/test_init.py | 6 +- 9 files changed, 155 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 4e2588c9478..e56fade7078 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -46,7 +46,7 @@ PLATFORMS = [ ] # This can be lowered in the future if we do not receive rate limiting issues. -IMAGE_CACHE_INTERVAL = 30 +IMAGE_CACHE_INTERVAL = timedelta(seconds=30) MAP_SLEEP = 3 diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index cbfd5e95a90..e430e2f6301 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -39,13 +39,14 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify from .const import ( A01_UPDATE_INTERVAL, DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, + IMAGE_CACHE_INTERVAL, MAP_FILE_FORMAT, MAP_SCALE, MAP_SLEEP, @@ -191,15 +192,59 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): except RoborockException as err: raise UpdateFailed("Failed to get map data: {err}") from err # Rooms names populated later with calls to `set_current_map_rooms` for each map + roborock_maps = maps.map_info if (maps and maps.map_info) else () + stored_images = await asyncio.gather( + *[ + self.map_storage.async_load_map(roborock_map.mapFlag) + for roborock_map in roborock_maps + ] + ) self.maps = { roborock_map.mapFlag: RoborockMapInfo( flag=roborock_map.mapFlag, name=roborock_map.name or f"Map {roborock_map.mapFlag}", rooms={}, + image=image, + last_updated=dt_util.utcnow() - IMAGE_CACHE_INTERVAL, ) - for roborock_map in (maps.map_info if (maps and maps.map_info) else ()) + for image, roborock_map in zip(stored_images, roborock_maps, strict=False) } + async def update_map(self) -> None: + """Update the currently selected map.""" + # The current map was set in the props update, so these can be done without + # worry of applying them to the wrong map. + if self.current_map is None: + # This exists as a safeguard/ to keep mypy happy. + return + try: + response = await self.cloud_api.get_map_v1() + except RoborockException as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) from ex + if not isinstance(response, bytes): + _LOGGER.debug("Failed to parse map contents: %s", response) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) + parsed_image = self.parse_image(response) + if parsed_image is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) + if parsed_image != self.maps[self.current_map].image: + await self.map_storage.async_save_map( + self.current_map, + parsed_image, + ) + current_roborock_map_info = self.maps[self.current_map] + current_roborock_map_info.image = parsed_image + current_roborock_map_info.last_updated = dt_util.utcnow() + async def _verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" if isinstance(self.api, RoborockLocalClientV1): @@ -240,6 +285,19 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # Set the new map id from the updated device props self._set_current_map() # Get the rooms for that map id. + + # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL + # since the last map update, you can update the map. + if ( + self.current_map is not None + and self.roborock_device_info.props.status.in_cleaning + and (dt_util.utcnow() - self.maps[self.current_map].last_updated) + > IMAGE_CACHE_INTERVAL + ): + try: + await self.update_map() + except HomeAssistantError as err: + _LOGGER.debug("Failed to update map: %s", err) await self.set_current_map_rooms() except RoborockException as ex: _LOGGER.debug("Failed to update data: %s", ex) @@ -338,7 +396,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # We cannot get the map until the roborock servers fully process the # map change. await asyncio.sleep(MAP_SLEEP) - await self.set_current_map_rooms() + tasks = [self.set_current_map_rooms()] + # The image is set within async_setup, so if it exists, we have it here. + if self.maps[map_flag].image is None: + # If we don't have a cached map, let's update it here so that it can be + # cached in the future. + tasks.append(self.update_map()) + # If either of these fail, we don't care, and we want to continue. + await asyncio.gather(*tasks, return_exceptions=True) if len(self.maps) != 1: # Set the map back to the map the user previously had selected so that it diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 79d6dafdc7a..d1c19331ba4 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -1,6 +1,5 @@ """Support for Roborock image.""" -import asyncio from datetime import datetime import logging @@ -8,11 +7,8 @@ from homeassistant.components.image import ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import dt as dt_util -from .const import DOMAIN, IMAGE_CACHE_INTERVAL from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator from .entity import RoborockCoordinatedEntityV1 @@ -75,51 +71,19 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity): async def async_added_to_hass(self) -> None: """When entity is added to hass load any previously cached maps from disk.""" await super().async_added_to_hass() - content = await self.coordinator.map_storage.async_load_map(self.map_flag) - self.cached_map = content or b"" - self._attr_image_last_updated = dt_util.utcnow() + self._attr_image_last_updated = self.coordinator.maps[ + self.map_flag + ].last_updated self.async_write_ha_state() def _handle_coordinator_update(self) -> None: - # Bump last updated every third time the coordinator runs, so that async_image - # will be called and we will evaluate on the new coordinator data if we should - # update the cache. - if self.is_selected and ( - ( - (dt_util.utcnow() - self.image_last_updated).total_seconds() - > IMAGE_CACHE_INTERVAL - and self.coordinator.roborock_device_info.props.status is not None - and bool(self.coordinator.roborock_device_info.props.status.in_cleaning) - ) - or self.cached_map == b"" - ): - # This will tell async_image it should update. - self._attr_image_last_updated = dt_util.utcnow() + # If the coordinator has updated the map, we can update the image. + self._attr_image_last_updated = self.coordinator.maps[ + self.map_flag + ].last_updated + super()._handle_coordinator_update() async def async_image(self) -> bytes | None: - """Update the image if it is not cached.""" - if self.is_selected: - response = await asyncio.gather( - *( - self.cloud_api.get_map_v1(), - self.coordinator.set_current_map_rooms(), - ), - return_exceptions=True, - ) - if ( - not isinstance(response[0], bytes) - or (content := self.coordinator.parse_image(response[0])) is None - ): - _LOGGER.debug("Failed to parse map contents: %s", response[0]) - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="map_failure", - ) - if self.cached_map != content: - self.cached_map = content - await self.coordinator.map_storage.async_save_map( - self.map_flag, - content, - ) - return self.cached_map + """Get the cached image.""" + return self.coordinator.maps[self.map_flag].image diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 4b8ab43b4a1..113f99d9474 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -1,6 +1,7 @@ """Roborock Models.""" from dataclasses import dataclass +from datetime import datetime from typing import Any from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo @@ -48,3 +49,5 @@ class RoborockMapInfo: flag: int name: str rooms: dict[int, str] + image: bytes | None + last_updated: datetime diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index f17cab7e922..c5357597527 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -1,6 +1,5 @@ """Support for Roborock vacuum class.""" -from dataclasses import asdict from typing import Any from roborock.code_mappings import RoborockStateCode @@ -206,7 +205,14 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): """Get map information such as map id and room ids.""" return { "maps": [ - asdict(vacuum_map) for vacuum_map in self.coordinator.maps.values() + { + "flag": vacuum_map.flag, + "name": vacuum_map.name, + # JsonValueType does not accept a int as a key - was not a + # issue with previous asdict() implementation. + "rooms": vacuum_map.rooms, # type: ignore[dict-item] + } + for vacuum_map in self.coordinator.maps.values() ] } diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index b4fde5cc513..332a9143c51 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -228,7 +228,7 @@ async def setup_entry( yield mock_roborock_entry -@pytest.fixture +@pytest.fixture(autouse=True) async def cleanup_map_storage( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> Generator[pathlib.Path]: diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 13bc23e6e2b..1bcb72c2f5b 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -25,6 +25,12 @@ from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry +@pytest.fixture +def cleanup_map_storage(): + """Override the map storage fixture as it is not relevant here.""" + return + + async def test_config_flow_success( hass: HomeAssistant, bypass_api_fixture, @@ -189,25 +195,31 @@ async def test_config_flow_failures_code_login( async def test_options_flow_drawables( - hass: HomeAssistant, setup_entry: MockConfigEntry + hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> None: """Test that the options flow works.""" - result = await hass.config_entries.options.async_init(setup_entry.entry_id) - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == DRAWABLES - with patch( - "homeassistant.components.roborock.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={Drawable.PREDICTED_PATH: True}, - ) + with patch("homeassistant.components.roborock.roborock_storage"): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert setup_entry.options[DRAWABLES][Drawable.PREDICTED_PATH] is True - assert len(mock_setup.mock_calls) == 1 + result = await hass.config_entries.options.async_init( + mock_roborock_entry.entry_id + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == DRAWABLES + with patch( + "homeassistant.components.roborock.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={Drawable.PREDICTED_PATH: True}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert mock_roborock_entry.options[DRAWABLES][Drawable.PREDICTED_PATH] is True + assert len(mock_setup.mock_calls) == 1 async def test_reauth_flow( diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 08f8ac504bf..0cd9d625920 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -62,20 +62,26 @@ async def test_floorplan_image( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - return_value=new_map_data, + return_value=MAP_DATA, ) as parse_map, ): + # This should call parse_map twice as the both devices are in cleaning. async_fire_time_changed(hass, now) - await hass.async_block_till_done() resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + resp = await client.get("/api/image_proxy/image.roborock_s7_2_upstairs") + assert resp.status == HTTPStatus.OK + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_downstairs") assert resp.status == HTTPStatus.OK body = await resp.read() assert body is not None - assert parse_map.call_count == 1 + + assert parse_map.call_count == 2 async def test_floorplan_image_failed_parse( @@ -91,6 +97,7 @@ async def test_floorplan_image_failed_parse( # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state # Update image, but get none for parse image. with ( patch( @@ -102,12 +109,16 @@ async def test_floorplan_image_failed_parse( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert not resp.ok + # The map should load fine from the coordinator, but it should not update the + # last_updated timestamp. + assert resp.ok + assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state async def test_fail_to_save_image( @@ -158,6 +169,9 @@ async def test_fail_to_load_image( "homeassistant.components.roborock.roborock_storage.Path.read_bytes", side_effect=OSError, ) as read_bytes, + patch( + "homeassistant.components.roborock.coordinator.RoborockDataUpdateCoordinator.refresh_coordinator_map" + ), ): # Reload the config entry so that the map is saved in storage and entities exist. await hass.config_entries.async_reload(setup_entry.entry_id) @@ -224,6 +238,7 @@ async def test_fail_updating_image( prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 # Update image, but get none for parse image. + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state with ( patch( "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", @@ -234,7 +249,8 @@ async def test_fail_updating_image( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), patch( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", @@ -243,7 +259,10 @@ async def test_fail_updating_image( ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert not resp.ok + # The map should load fine from the coordinator, but it should not update the + # last_updated timestamp. + assert resp.ok + assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state async def test_index_error_map( @@ -257,6 +276,7 @@ async def test_index_error_map( # Copy the device prop so we don't override it prop = copy.deepcopy(PROP) prop.status.in_cleaning = 1 + previous_state = hass.states.get("image.roborock_s7_maxv_upstairs").state # Update image, but get IndexError for image. with ( patch( @@ -268,9 +288,13 @@ async def test_index_error_map( return_value=prop, ), patch( - "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, ), ): async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") - assert not resp.ok + # The map should load fine from the coordinator, but it should not update the + # last_updated timestamp. + assert resp.ok + assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 904a3af89d6..9a749a71e30 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -189,7 +189,7 @@ async def test_remove_from_hass( 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 + assert len(paths) == 4 # Two map image and two directories await hass.config_entries.async_remove(setup_entry.entry_id) # After removal, directories should be empty. @@ -219,7 +219,7 @@ async def test_oserror_remove_image( assert cleanup_map_storage.exists() paths = list(cleanup_map_storage.walk()) - assert len(paths) == 3 # One map image and two directories + assert len(paths) == 4 # Two map image and two directories with patch( "homeassistant.components.roborock.roborock_storage.shutil.rmtree", @@ -242,7 +242,7 @@ async def test_not_supported_protocol( "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", return_value=home_data_copy, ): - await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) await hass.async_block_till_done() assert "because its protocol version random" in caplog.text From a93ab74e402d5fddbf506d004f09cae21ff8123d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 08:21:06 +0100 Subject: [PATCH 1762/1941] Sentence-case "Zip code" in `iqvia` integration strings (#140853) --- homeassistant/components/iqvia/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/iqvia/strings.json b/homeassistant/components/iqvia/strings.json index 5dc0dea53d5..a0697a6c210 100644 --- a/homeassistant/components/iqvia/strings.json +++ b/homeassistant/components/iqvia/strings.json @@ -4,7 +4,7 @@ "user": { "description": "Fill out your U.S. or Canadian ZIP code.", "data": { - "zip_code": "ZIP Code" + "zip_code": "ZIP code" } } }, From 426be3c11b8f170f9ceb1c1693cc79a6b186e36d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 08:21:28 +0100 Subject: [PATCH 1763/1941] Capitalize "ZIP" as abbreviation in `rova` integration strings (#140852) Capitalized "ZIP" as abbreviation in `rova` --- homeassistant/components/rova/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rova/strings.json b/homeassistant/components/rova/strings.json index 3b89fc789ee..21f4146bf78 100644 --- a/homeassistant/components/rova/strings.json +++ b/homeassistant/components/rova/strings.json @@ -4,7 +4,7 @@ "user": { "title": "Provide your address details", "data": { - "zip_code": "Your zip code", + "zip_code": "Your ZIP code", "house_number": "Your house number", "house_number_suffix": "A suffix for your house number" } From 776495dfa2dbf680b2ee50529bc32bded5a735e2 Mon Sep 17 00:00:00 2001 From: Adam Feldman Date: Tue, 18 Mar 2025 03:24:05 -0500 Subject: [PATCH 1764/1941] Fix broken core integration Smart Meter Texas by switching it to use HA's SSL Context (#140694) * Update __init__.py to use HA's SSLContext * Update config_flow.py to use HA's SSLContext * Use default context for config_flow.py * Use default context instead in __init__.py Co-authored-by: Josef Zweck * Fix import in __init__.py * Fix import in config_flow.py --------- Co-authored-by: Josef Zweck --- homeassistant/components/smart_meter_texas/__init__.py | 6 +++--- homeassistant/components/smart_meter_texas/config_flow.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index 1cd7df68e91..ce87b85c322 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -3,7 +3,7 @@ import logging import ssl -from smart_meter_texas import Account, Client, ClientSSLContext +from smart_meter_texas import Account, Client from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -16,6 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import get_default_context from .const import ( DATA_COORDINATOR, @@ -38,8 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: account = Account(username, password) - client_ssl_context = ClientSSLContext() - ssl_context = await client_ssl_context.get_ssl_context() + ssl_context = get_default_context() smart_meter_texas_data = SmartMeterTexasData(hass, entry, account, ssl_context) try: diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py index b60855b62c8..18a3716e1b9 100644 --- a/homeassistant/components/smart_meter_texas/config_flow.py +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -4,7 +4,7 @@ import logging from typing import Any from aiohttp import ClientError -from smart_meter_texas import Account, Client, ClientSSLContext +from smart_meter_texas import Account, Client from smart_meter_texas.exceptions import ( SmartMeterTexasAPIError, SmartMeterTexasAuthError, @@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client +from homeassistant.util.ssl import get_default_context from .const import DOMAIN @@ -31,8 +32,7 @@ async def validate_input(hass: HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - client_ssl_context = ClientSSLContext() - ssl_context = await client_ssl_context.get_ssl_context() + ssl_context = get_default_context() client_session = aiohttp_client.async_get_clientsession(hass) account = Account(data["username"], data["password"]) client = Client(client_session, account, ssl_context) From 74992344d53142a9e353365a8dce0f69a9b96df7 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 18 Mar 2025 08:31:08 +0000 Subject: [PATCH 1765/1941] Add diagnostics for Ohme (#140833) --- homeassistant/components/ohme/diagnostics.py | 24 ++++++++++++++++ .../components/ohme/quality_scale.yaml | 2 +- .../ohme/snapshots/test_diagnostics.ambr | 16 +++++++++++ tests/components/ohme/test_diagnostics.py | 28 +++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ohme/diagnostics.py create mode 100644 tests/components/ohme/snapshots/test_diagnostics.ambr create mode 100644 tests/components/ohme/test_diagnostics.py diff --git a/homeassistant/components/ohme/diagnostics.py b/homeassistant/components/ohme/diagnostics.py new file mode 100644 index 00000000000..a955b3b76e2 --- /dev/null +++ b/homeassistant/components/ohme/diagnostics.py @@ -0,0 +1,24 @@ +"""Provides diagnostics for Ohme.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import OhmeConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: OhmeConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Ohme.""" + coordinators = config_entry.runtime_data + client = coordinators.charge_session_coordinator.client + + return { + "device_info": client.device_info, + "vehicles": client.vehicles, + "ct_connected": client.ct_connected, + "cap_available": client.cap_available, + } diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index 497d5ad32e5..ba814202cdc 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -39,7 +39,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery: status: exempt comment: | diff --git a/tests/components/ohme/snapshots/test_diagnostics.ambr b/tests/components/ohme/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f51c701b71b --- /dev/null +++ b/tests/components/ohme/snapshots/test_diagnostics.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'cap_available': True, + 'ct_connected': True, + 'device_info': dict({ + 'model': 'Home Pro', + 'name': 'Ohme Home Pro', + 'sw_version': 'v2.65', + }), + 'vehicles': list([ + 'Nissan Leaf', + 'Tesla Model 3', + ]), + }) +# --- diff --git a/tests/components/ohme/test_diagnostics.py b/tests/components/ohme/test_diagnostics.py new file mode 100644 index 00000000000..6aab1262189 --- /dev/null +++ b/tests/components/ohme/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for the diagnostics data provided by the Ohme integration.""" + +from unittest.mock import MagicMock + +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, + mock_client: MagicMock, + 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 52054d69c780626e8a804192bdd696023b359c81 Mon Sep 17 00:00:00 2001 From: Jan Schneider Date: Tue, 18 Mar 2025 09:32:28 +0100 Subject: [PATCH 1766/1941] Update moehlenhoff-alpha2 to 1.4.0 (#140829) * Update moehlenhoff-alpha2 to 1.4.0 * Fix test --- homeassistant/components/moehlenhoff_alpha2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/moehlenhoff_alpha2/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/manifest.json b/homeassistant/components/moehlenhoff_alpha2/manifest.json index 14f40991a84..45b7f8c9565 100644 --- a/homeassistant/components/moehlenhoff_alpha2/manifest.json +++ b/homeassistant/components/moehlenhoff_alpha2/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/moehlenhoff_alpha2", "iot_class": "local_push", - "requirements": ["moehlenhoff-alpha2==1.3.1"] + "requirements": ["moehlenhoff-alpha2==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 57f40b4c018..977c8a1f574 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1427,7 +1427,7 @@ minio==7.1.12 moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 -moehlenhoff-alpha2==1.3.1 +moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo monzopy==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65a64a8b2ea..418e030d42f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1196,7 +1196,7 @@ minio==7.1.12 moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 -moehlenhoff-alpha2==1.3.1 +moehlenhoff-alpha2==1.4.0 # homeassistant.components.monzo monzopy==1.4.2 diff --git a/tests/components/moehlenhoff_alpha2/__init__.py b/tests/components/moehlenhoff_alpha2/__init__.py index 50087794560..90d6d88fedc 100644 --- a/tests/components/moehlenhoff_alpha2/__init__.py +++ b/tests/components/moehlenhoff_alpha2/__init__.py @@ -19,7 +19,7 @@ async def mock_update_data(self): for _type in ("HEATAREA", "HEATCTRL", "IODEVICE"): if not isinstance(data["Devices"]["Device"][_type], list): data["Devices"]["Device"][_type] = [data["Devices"]["Device"][_type]] - self.static_data = data + self._static_data = data async def init_integration(hass: HomeAssistant) -> MockConfigEntry: From ea259ffa66db69e31781fd1c4a5efb0d70ff3a94 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 18 Mar 2025 04:35:57 -0400 Subject: [PATCH 1767/1941] Add event to Snoo (#140827) --- homeassistant/components/snoo/__init__.py | 1 + homeassistant/components/snoo/event.py | 62 ++++++++++++++++++++++ homeassistant/components/snoo/strings.json | 21 +++++++- tests/components/snoo/test_event.py | 45 ++++++++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/snoo/event.py create mode 100644 tests/components/snoo/test_event.py diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py index 1934a2607a0..54834bf58ce 100644 --- a/homeassistant/components/snoo/__init__.py +++ b/homeassistant/components/snoo/__init__.py @@ -19,6 +19,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.EVENT, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/snoo/event.py b/homeassistant/components/snoo/event.py new file mode 100644 index 00000000000..5932bfd9862 --- /dev/null +++ b/homeassistant/components/snoo/event.py @@ -0,0 +1,62 @@ +"""Support for Snoo Events.""" + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SnooConfigEntry +from .entity import SnooDescriptionEntity + + +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( + SnooEvent( + coordinator, + EventEntityDescription( + key="event", + translation_key="event", + event_types=[ + "timer", + "cry", + "command", + "safety_clip", + "long_activity_press", + "activity", + "power", + "status_requested", + "sticky_white_noise_updated", + ], + ), + ) + for coordinator in coordinators.values() + ) + + +class SnooEvent(SnooDescriptionEntity, EventEntity): + """A event using Snoo coordinator.""" + + @callback + def _async_handle_event(self) -> None: + """Handle the demo button event.""" + self._trigger_event( + self.coordinator.data.event.value, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add Event.""" + await super().async_added_to_hass() + if self.coordinator.data: + # If we were able to get data on startup - set it + # Otherwise, it will update when the coordinator gets data. + self._async_handle_event() + + def _handle_coordinator_update(self) -> None: + self._async_handle_event() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json index ddeab83b6d4..f7cf6a4820b 100644 --- a/homeassistant/components/snoo/strings.json +++ b/homeassistant/components/snoo/strings.json @@ -41,7 +41,26 @@ "name": "Right safety clip" } }, - + "event": { + "event": { + "name": "Snoo event", + "state_attributes": { + "event_type": { + "state": { + "timer": "Timer", + "cry": "Cry", + "command": "Command sent", + "safety_clip": "Safety clip changed", + "long_activity_press": "Long activity press", + "activity": "Activity press", + "power": "Power button pressed", + "status_requested": "Status requested", + "sticky_white_noise_updated": "Sleepytime sounds updated" + } + } + } + } + }, "sensor": { "state": { "name": "State", diff --git a/tests/components/snoo/test_event.py b/tests/components/snoo/test_event.py new file mode 100644 index 00000000000..41cb386a599 --- /dev/null +++ b/tests/components/snoo/test_event.py @@ -0,0 +1,45 @@ +"""Test Snoo Events.""" + +from unittest.mock import AsyncMock + +from freezegun import freeze_time + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from . import async_init_integration, find_update_callback +from .const import MOCK_SNOO_DATA + + +@freeze_time("2025-01-01 12:00:00") +async def test_events(hass: HomeAssistant, bypass_api: AsyncMock) -> None: + """Test events and check test values are correctly set.""" + await async_init_integration(hass) + assert len(hass.states.async_all("event")) == 1 + assert hass.states.get("event.test_snoo_snoo_event").state == STATE_UNAVAILABLE + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + await hass.async_block_till_done() + assert len(hass.states.async_all("event")) == 1 + assert ( + hass.states.get("event.test_snoo_snoo_event").state + == "2025-01-01T12:00:00.000+00:00" + ) + + +@freeze_time("2025-01-01 12:00:00") +async def test_events_data_on_startup( + hass: HomeAssistant, bypass_api: AsyncMock +) -> None: + """Test events and check test values are correctly set if data exists on first update.""" + + def update_status(_): + find_update_callback(bypass_api, "random_num")(MOCK_SNOO_DATA) + + bypass_api.get_status.side_effect = update_status + await async_init_integration(hass) + await hass.async_block_till_done() + assert len(hass.states.async_all("event")) == 1 + assert ( + hass.states.get("event.test_snoo_snoo_event").state + == "2025-01-01T12:00:00.000+00:00" + ) From 36d42760a436748e781058eb5178d79fdf7128d0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 10:07:05 +0100 Subject: [PATCH 1768/1941] Fix capitalization in `nextcloud` entity names (#140856) * Fix capitalization in `nextcloud` entity names Use uppercase for abbreviations, sentence-case for words. * Update test_sensor.ambr --- homeassistant/components/nextcloud/strings.json | 8 ++++---- .../nextcloud/snapshots/test_sensor.ambr | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 9b22a6924bc..ef4e3de0f62 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -88,7 +88,7 @@ "name": "Cache start time" }, "nextcloud_cache_ttl": { - "name": "Cache ttl" + "name": "Cache TTL" }, "nextcloud_database_size": { "name": "Database size" @@ -268,13 +268,13 @@ "name": "Updates available" }, "nextcloud_system_cpuload_1": { - "name": "CPU Load last 1 minute" + "name": "CPU load last 1 minute" }, "nextcloud_system_cpuload_15": { - "name": "CPU Load last 15 minutes" + "name": "CPU load last 15 minutes" }, "nextcloud_system_cpuload_5": { - "name": "CPU Load last 5 minutes" + "name": "CPU load last 5 minutes" }, "nextcloud_system_freespace": { "name": "Free space" diff --git a/tests/components/nextcloud/snapshots/test_sensor.ambr b/tests/components/nextcloud/snapshots/test_sensor.ambr index 84c1d33f886..e6154841a28 100644 --- a/tests/components/nextcloud/snapshots/test_sensor.ambr +++ b/tests/components/nextcloud/snapshots/test_sensor.ambr @@ -1424,7 +1424,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Cache ttl', + 'original_name': 'Cache TTL', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1436,7 +1436,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cache_ttl-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local Cache ttl', + 'friendly_name': 'my.nc_url.local Cache TTL', }), 'context': , 'entity_id': 'sensor.my_nc_url_local_cache_ttl', @@ -1474,7 +1474,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CPU Load last 15 minutes', + 'original_name': 'CPU load last 15 minutes', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1486,7 +1486,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cpu_load_last_15_minutes-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local CPU Load last 15 minutes', + 'friendly_name': 'my.nc_url.local CPU load last 15 minutes', 'unit_of_measurement': 'load', }), 'context': , @@ -1525,7 +1525,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CPU Load last 1 minute', + 'original_name': 'CPU load last 1 minute', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1537,7 +1537,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cpu_load_last_1_minute-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local CPU Load last 1 minute', + 'friendly_name': 'my.nc_url.local CPU load last 1 minute', 'unit_of_measurement': 'load', }), 'context': , @@ -1576,7 +1576,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CPU Load last 5 minutes', + 'original_name': 'CPU load last 5 minutes', 'platform': 'nextcloud', 'previous_unique_id': None, 'supported_features': 0, @@ -1588,7 +1588,7 @@ # name: test_async_setup_entry[sensor.my_nc_url_local_cpu_load_last_5_minutes-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'my.nc_url.local CPU Load last 5 minutes', + 'friendly_name': 'my.nc_url.local CPU load last 5 minutes', 'unit_of_measurement': 'load', }), 'context': , From 603557af737b992e002d1ba43925129ab3c6edd4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 10:16:21 +0100 Subject: [PATCH 1769/1941] Improve description of `vicare.set_vicare_mode` action (#140826) Add some additional information from the online docs so they get included in translations. --- homeassistant/components/vicare/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 733cda363e5..04049f026bd 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -515,11 +515,11 @@ "services": { "set_vicare_mode": { "name": "Set ViCare mode", - "description": "Set a ViCare mode.", + "description": "Sets the mode of the climate device as defined by Viessmann.", "fields": { "vicare_mode": { "name": "ViCare mode", - "description": "ViCare mode." + "description": "For supported values, see the `vicare_modes` attribute of the climate entity." } } } From fdd36e457d11c2fad3d88feaa2f5aca4c75287c6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 18 Mar 2025 10:19:45 +0100 Subject: [PATCH 1770/1941] Add Reolink day night state sensor (#140825) * Add day night state sensor * Update test_diagnostics.ambr --- homeassistant/components/reolink/icons.json | 3 +++ homeassistant/components/reolink/sensor.py | 11 +++++++++++ homeassistant/components/reolink/strings.json | 8 ++++++++ .../reolink/snapshots/test_diagnostics.ambr | 4 ++++ 4 files changed, 26 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 0b019277a77..bcfea0bebd1 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -365,6 +365,9 @@ "battery_state": { "default": "mdi:battery-charging" }, + "day_night_state": { + "default": "mdi:theme-light-dark" + }, "wifi_signal": { "default": "mdi:wifi" }, diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index ecad555b481..85de03dd1a3 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -107,6 +107,17 @@ SENSORS = ( value=lambda api, ch: BatteryEnum(api.battery_status(ch)).name, supported=lambda api, ch: api.supported(ch, "battery"), ), + ReolinkSensorEntityDescription( + key="day_night_state", + cmd_id=33, + cmd_key="296", + translation_key="day_night_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=["day", "night", "led_day"], + value=lambda api, ch: api.baichuan.day_night_state(ch), + supported=lambda api, ch: api.supported(ch, "day_night_state"), + ), ) HOST_SENSORS = ( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index a22c93611b6..80d9156e420 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -825,6 +825,14 @@ "chargecomplete": "Charge complete" } }, + "day_night_state": { + "name": "Day night state", + "state": { + "day": "Color", + "night": "Black & white", + "led_day": "Color with floodlight" + } + }, "hdd_storage": { "name": "HDD {hdd_index} storage" }, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index f8d5318e9bd..b034122e1fc 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -62,6 +62,10 @@ 0, ]), 'cmd list': dict({ + '296': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, From 5438532780829acdeda80e289844da851bb2dbbe Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+mtielen@users.noreply.github.com> Date: Tue, 18 Mar 2025 10:22:32 +0100 Subject: [PATCH 1771/1941] Bump wolf-comm to 0.0.23 (#140840) * Bump wolf-comm to 0.0.23 * fix test for new lib --- homeassistant/components/wolflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wolflink/conftest.py | 17 ++++++++++------- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 964d192d279..5f3a6366fe1 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wolflink", "iot_class": "cloud_polling", "loggers": ["wolf_comm"], - "requirements": ["wolf-comm==0.0.19"] + "requirements": ["wolf-comm==0.0.23"] } diff --git a/requirements_all.txt b/requirements_all.txt index 977c8a1f574..082c61b6fcb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3080,7 +3080,7 @@ wirelesstagpy==0.8.1 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.19 +wolf-comm==0.0.23 # homeassistant.components.wyoming wyoming==1.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 418e030d42f..9bf1f0979b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2478,7 +2478,7 @@ wiffi==1.1.2 wled==0.21.0 # homeassistant.components.wolflink -wolf-comm==0.0.19 +wolf-comm==0.0.23 # homeassistant.components.wyoming wyoming==1.5.4 diff --git a/tests/components/wolflink/conftest.py b/tests/components/wolflink/conftest.py index 9c69c0d69bb..bfa41c4a4af 100644 --- a/tests/components/wolflink/conftest.py +++ b/tests/components/wolflink/conftest.py @@ -67,22 +67,25 @@ def mock_wolflink() -> Generator[MagicMock]: wolflink = wolflink_mock.return_value wolflink.fetch_parameters.return_value = [ - EnergyParameter(6002800000, "Energy Parameter", "Heating", 6005200000), + EnergyParameter( + 6002800000, "Energy Parameter", "Heating", 6005200000, 2000 + ), ListItemParameter( 8002800000, "List Item Parameter", "Heating", [ListItem("0", "Aus"), ListItem("1", "Ein")], 8005200000, + 3001, ), - PowerParameter(5002800000, "Power Parameter", "Heating", 5005200000), - Pressure(4002800000, "Pressure Parameter", "Heating", 4005200000), - Temperature(3002800000, "Temperature Parameter", "Solar", 3005200000), + PowerParameter(5002800000, "Power Parameter", "Heating", 5005200000, 1000), + Pressure(4002800000, "Pressure Parameter", "Heating", 4005200000, 1000), + Temperature(3002800000, "Temperature Parameter", "Solar", 3005200000, 1000), PercentageParameter( - 2002800000, "Percentage Parameter", "Solar", 2005200000 + 2002800000, "Percentage Parameter", "Solar", 2005200000, 1000 ), - HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000), - SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000), + HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000), + SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000), ] wolflink.fetch_value.return_value = [ From 30c19ec37354b1bc946cb5cbaf90a4c89d52a1d1 Mon Sep 17 00:00:00 2001 From: Dan Raper Date: Tue, 18 Mar 2025 09:36:21 +0000 Subject: [PATCH 1772/1941] Add reconfigure flow to Ohme (#140835) * Add reconfigure flow to Ohme * Remove incorrect unique ID check from ohme reconfig --- homeassistant/components/ohme/config_flow.py | 23 ++++++ .../components/ohme/quality_scale.yaml | 2 +- homeassistant/components/ohme/strings.json | 13 ++- tests/components/ohme/test_config_flow.py | 81 +++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ohme/config_flow.py b/homeassistant/components/ohme/config_flow.py index 748ea558983..1037c3a7c8b 100644 --- a/homeassistant/components/ohme/config_flow.py +++ b/homeassistant/components/ohme/config_flow.py @@ -99,6 +99,29 @@ class OhmeConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-configuration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input: + errors = await self._validate_account( + reconfigure_entry.data[CONF_EMAIL], + user_input[CONF_PASSWORD], + ) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=REAUTH_SCHEMA, + description_placeholders={"email": reconfigure_entry.data[CONF_EMAIL]}, + errors=errors, + ) + async def _validate_account(self, email: str, password: str) -> dict[str, str]: """Validate Ohme account and return dict of errors.""" errors: dict[str, str] = {} diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml index ba814202cdc..f748cf339b4 100644 --- a/homeassistant/components/ohme/quality_scale.yaml +++ b/homeassistant/components/ohme/quality_scale.yaml @@ -62,7 +62,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index 1da17183bb2..4a2170babeb 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -21,6 +21,16 @@ "data_description": { "password": "Enter the password for your Ohme account" } + }, + "reconfigure": { + "description": "Update your password for {email}", + "title": "Reconfigure Ohme Account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Enter the password for your Ohme account" + } } }, "error": { @@ -29,7 +39,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "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%]" } }, "services": { diff --git a/tests/components/ohme/test_config_flow.py b/tests/components/ohme/test_config_flow.py index bb7ecc00bdc..b8754711d76 100644 --- a/tests/components/ohme/test_config_flow.py +++ b/tests/components/ohme/test_config_flow.py @@ -182,3 +182,84 @@ async def test_reauth_fail( await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + + +async def test_reconfigure_form(hass: HomeAssistant, mock_client: MagicMock) -> None: + """Test reconfigure form.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reconfigure", "entry_id": entry.entry_id} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter2"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.parametrize( + ("test_exception", "expected_error"), + [(AuthException, "invalid_auth"), (ApiException, "unknown")], +) +async def test_reconfigure_fail( + hass: HomeAssistant, + mock_client: MagicMock, + test_exception: Exception, + expected_error: str, +) -> None: + """Test reconfigure errors.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "hunter1", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reconfigure", "entry_id": entry.entry_id} + ) + + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + # Simulate failed login attempt + mock_client.async_login.side_effect = test_exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter1"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + # Retry with a successful login + mock_client.async_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "hunter2"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" From 12f5bd2aea2430d2af37d6717a725766408790f8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 18 Mar 2025 11:48:18 +0100 Subject: [PATCH 1773/1941] Add dedicated sensors for extra_state_attributes in Shelly integration (#140793) * Add dedicated sensors for extra_state_attributes in Shelly integration * add tests * apply review comment * fix text syntax * add gas test * update strings * add icons --- homeassistant/components/shelly/icons.json | 6 +++ homeassistant/components/shelly/sensor.py | 22 ++++++++++ homeassistant/components/shelly/strings.json | 36 +++++++++++++++++ tests/components/shelly/conftest.py | 2 + tests/components/shelly/test_sensor.py | 42 +++++++++++++++++--- 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index f93abf6b854..08b269a73c5 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -23,12 +23,18 @@ "gas_concentration": { "default": "mdi:gauge" }, + "gas_detected": { + "default": "mdi:gas-burner" + }, "lamp_life": { "default": "mdi:progress-wrench" }, "operation": { "default": "mdi:cog-transfer" }, + "self_test": { + "default": "mdi:progress-wrench" + }, "tilt": { "default": "mdi:angle-acute" }, diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index f2c858aeb84..b6820921b4f 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -397,6 +397,28 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { entity_category=EntityCategory.DIAGNOSTIC, removal_condition=lambda _, block: block.valve == "not_connected", ), + ("sensor", "gas"): BlockSensorDescription( + key="sensor|gas", + name="Gas detected", + translation_key="gas_detected", + device_class=SensorDeviceClass.ENUM, + options=[ + "none", + "mild", + "heavy", + "test", + ], + value=lambda value: None if value == "unknown" else value, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ("sensor", "selfTest"): BlockSensorDescription( + key="sensor|selfTest", + name="Self test", + translation_key="self_test", + device_class=SensorDeviceClass.ENUM, + options=["not_completed", "completed", "running", "pending"], + entity_category=EntityCategory.DIAGNOSTIC, + ), } REST_SENSORS: Final = { diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index cc511c93afe..ba9a8492194 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -138,6 +138,24 @@ } }, "sensor": { + "gas_detected": { + "state": { + "none": "None", + "mild": "Mild", + "heavy": "Heavy", + "test": "Test" + }, + "state_attributes": { + "options": { + "state": { + "none": "[%key:component::shelly::entity::sensor::gas_detected::state::none%]", + "mild": "[%key:component::shelly::entity::sensor::gas_detected::state::mild%]", + "heavy": "[%key:component::shelly::entity::sensor::gas_detected::state::heavy%]", + "test": "[%key:component::shelly::entity::sensor::gas_detected::state::test%]" + } + } + } + }, "operation": { "state": { "warmup": "Warm-up", @@ -155,6 +173,24 @@ } } }, + "self_test": { + "state": { + "not_completed": "Not completed", + "completed": "Completed", + "running": "Running", + "pending": "Pending" + }, + "state_attributes": { + "options": { + "state": { + "not_completed": "[%key:component::shelly::entity::sensor::self_test::state::not_completed%]", + "completed": "[%key:component::shelly::entity::sensor::self_test::state::completed%]", + "running": "[%key:component::shelly::entity::sensor::self_test::state::running%]", + "pending": "[%key:component::shelly::entity::sensor::self_test::state::pending%]" + } + } + } + }, "valve_status": { "state": { "checking": "Checking", diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index c68d52526c5..8030df6e473 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -142,12 +142,14 @@ MOCK_BLOCKS = [ "gas": "mild", "motionActive": 1, "sensorOp": "normal", + "selfTest": "pending", }, channel="0", motion=0, temp=22.1, gas="mild", sensorOp="normal", + selfTest="pending", targetTemp=4, description="sensor_0", type="sensor", diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 00db4ade8ac..5c1f03de3e8 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -346,13 +346,44 @@ async def test_block_sensor_without_value( @pytest.mark.parametrize( - ("entity", "initial_state", "block_id", "attribute", "value"), + ("entity", "initial_state", "block_id", "attribute", "value", "final_value"), [ - ("test_name_battery", "98", DEVICE_BLOCK_ID, "battery", None), - ("test_name_operation", "normal", SENSOR_BLOCK_ID, "sensorOp", "unknown"), + ("test_name_battery", "98", DEVICE_BLOCK_ID, "battery", None, STATE_UNKNOWN), + ( + "test_name_operation", + "normal", + SENSOR_BLOCK_ID, + "sensorOp", + None, + STATE_UNKNOWN, + ), + ( + "test_name_operation", + "normal", + SENSOR_BLOCK_ID, + "sensorOp", + "normal", + "normal", + ), + ( + "test_name_self_test", + "pending", + SENSOR_BLOCK_ID, + "selfTest", + "completed", + "completed", + ), + ( + "test_name_gas_detected", + "mild", + SENSOR_BLOCK_ID, + "gas", + "heavy", + "heavy", + ), ], ) -async def test_block_sensor_unknown_value( +async def test_block_sensor_values( hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch, @@ -361,6 +392,7 @@ async def test_block_sensor_unknown_value( block_id: int, attribute: str, value: str | None, + final_value: str, ) -> None: """Test block sensor unknown value.""" entity_id = f"{SENSOR_DOMAIN}.{entity}" @@ -371,7 +403,7 @@ async def test_block_sensor_unknown_value( monkeypatch.setattr(mock_block_device.blocks[block_id], attribute, value) mock_block_device.mock_update() - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id).state == final_value @pytest.mark.parametrize( From 516aaa741d3b46c79342abf433facc2b608af362 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 18 Mar 2025 13:05:10 +0200 Subject: [PATCH 1774/1941] Add Z-Wave JS lookup_device API (#140802) * ZwaveJS lookup_device API * add FailedCommand test * test tweak --- homeassistant/components/zwave_js/api.py | 36 +++++++ tests/components/zwave_js/test_api.py | 126 ++++++++++++++++++++++- 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index a3d1416962e..ec164e2b505 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -405,6 +405,7 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command( hass, websocket_try_parse_dsk_from_qr_code_string ) + websocket_api.async_register_command(hass, websocket_lookup_device) websocket_api.async_register_command(hass, websocket_supports_feature) websocket_api.async_register_command(hass, websocket_stop_inclusion) websocket_api.async_register_command(hass, websocket_stop_exclusion) @@ -1138,6 +1139,41 @@ async def websocket_try_parse_dsk_from_qr_code_string( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/lookup_device", + vol.Required(ENTRY_ID): str, + vol.Required(MANUFACTURER_ID): int, + vol.Required(PRODUCT_TYPE): int, + vol.Required(PRODUCT_ID): int, + vol.Optional(APPLICATION_VERSION): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_lookup_device( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Look up the definition of a given device in the configuration DB.""" + device = await driver.config_manager.lookup_device( + msg[MANUFACTURER_ID], + msg[PRODUCT_TYPE], + msg[PRODUCT_ID], + msg.get(APPLICATION_VERSION), + ) + if device is None: + connection.send_error(msg[ID], ERR_NOT_FOUND, "Device not found") + else: + connection.send_result(msg[ID], device.to_dict()) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 07c874197b6..b2741a53a92 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5,7 +5,7 @@ from http import HTTPStatus from io import BytesIO import json from typing import Any -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest from zwave_js_server.const import ( @@ -5577,3 +5577,127 @@ async def test_subscribe_s2_inclusion( msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + + +async def test_lookup_device( + hass: HomeAssistant, + integration: MockConfigEntry, + client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test lookup_device websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + # Create mock device response + mock_device = MagicMock() + mock_device.to_dict.return_value = { + "manufacturer": "Test Manufacturer", + "label": "Test Device", + "description": "Test Device Description", + "devices": [{"productType": 1, "productId": 2}], + "firmwareVersion": {"min": "1.0", "max": "2.0"}, + } + + # Test successful lookup + client.driver.config_manager.lookup_device = AsyncMock(return_value=mock_device) + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 2, + PRODUCT_ID: 3, + APPLICATION_VERSION: "1.5", + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] == mock_device.to_dict.return_value + + client.driver.config_manager.lookup_device.assert_called_once_with(1, 2, 3, "1.5") + + # Reset mock + client.driver.config_manager.lookup_device.reset_mock() + + # Test lookup without optional application_version + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 4, + PRODUCT_TYPE: 5, + PRODUCT_ID: 6, + } + ) + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] == mock_device.to_dict.return_value + + client.driver.config_manager.lookup_device.assert_called_once_with(4, 5, 6, None) + + # Test device not found + with patch.object( + client.driver.config_manager, + "lookup_device", + return_value=None, + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 99, + PRODUCT_TYPE: 99, + PRODUCT_ID: 99, + APPLICATION_VERSION: "9.9", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + assert msg["error"]["message"] == "Device not found" + + # Test sending command with improper entry ID fails + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: "invalid_entry_id", + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "1.0", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + assert msg["error"]["message"] == "Config entry invalid_entry_id not found" + + # Test FailedCommand exception + error_message = "Failed to execute lookup_device command" + with patch.object( + client.driver.config_manager, + "lookup_device", + side_effect=FailedCommand("lookup_device", error_message), + ): + # Send the subscription request + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/lookup_device", + ENTRY_ID: entry.entry_id, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 2, + PRODUCT_ID: 3, + APPLICATION_VERSION: "1.0", + } + ) + + # Verify error response + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == error_message + assert msg["error"]["message"] == f"Command failed: {error_message}" From 29f03f5b875d45aa70557c18a8be0249fe6f463f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 18 Mar 2025 12:23:51 +0100 Subject: [PATCH 1775/1941] Add exception translations for AccuWeather integration (#140863) * Add exception translations * Improve error strings --- homeassistant/components/accuweather/coordinator.py | 12 ++++++++++-- homeassistant/components/accuweather/strings.json | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 67e3e2ad76e..780c977f930 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -75,7 +75,11 @@ class AccuWeatherObservationDataUpdateCoordinator( async with timeout(10): result = await self.accuweather.async_get_current_conditions() except EXCEPTIONS as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="current_conditions_update_error", + translation_placeholders={"error": repr(error)}, + ) from error _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) @@ -121,7 +125,11 @@ class AccuWeatherDailyForecastDataUpdateCoordinator( language=self.hass.config.language ) except EXCEPTIONS as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="forecast_update_error", + translation_placeholders={"error": repr(error)}, + ) from error _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 92428a9d599..e1a71c5e1a5 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -229,6 +229,14 @@ } } }, + "exceptions": { + "current_conditions_update_error": { + "message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}" + }, + "forecast_update_error": { + "message": "An error occurred while retrieving weather forecast data from the AccuWeather API: {error}" + } + }, "system_health": { "info": { "can_reach_server": "Reach AccuWeather server", From de1823070ffddafc9001be97234125a6e08b4611 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 12:55:32 +0100 Subject: [PATCH 1776/1941] Replace unsupported markup of examples in `humidifier.set_mode` action (#140824) Markup language is not supported in the action UI. Thus the underscores for italics are replaced with quote marks. --- homeassistant/components/humidifier/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 753368dc572..436f7df8312 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -89,7 +89,7 @@ "fields": { "mode": { "name": "Mode", - "description": "Operation mode. For example, _normal_, _eco_, or _away_. For a list of possible values, refer to the integration documentation." + "description": "Operation mode. For example, \"normal\", \"eco\", or \"away\". For a list of possible values, refer to the integration documentation." } } }, From 1cae866da968a842a2c2a42110adece71d490079 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 18 Mar 2025 10:34:02 -0400 Subject: [PATCH 1777/1941] Update Roborock Map on status change (#140873) * update map on status change * Update tests/components/roborock/test_image.py Co-authored-by: Allen Porter * update code to handle state logic within async_update_data * Update homeassistant/components/roborock/coordinator.py Co-authored-by: Allen Porter * move previous_state and allow update on None --------- Co-authored-by: Allen Porter --- .../components/roborock/coordinator.py | 14 +++-- tests/components/roborock/test_image.py | 55 +++++++++++++++++-- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index e430e2f6301..c333b143b10 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -279,6 +279,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): async def _async_update_data(self) -> DeviceProp: """Update data via library.""" + previous_state = self.roborock_device_info.props.status.state_name try: # Update device props and standard api information await self._update_device_prop() @@ -288,11 +289,14 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL # since the last map update, you can update the map. - if ( - self.current_map is not None - and self.roborock_device_info.props.status.in_cleaning - and (dt_util.utcnow() - self.maps[self.current_map].last_updated) - > IMAGE_CACHE_INTERVAL + new_status = self.roborock_device_info.props.status + if self.current_map is not None and ( + ( + new_status.in_cleaning + and (dt_util.utcnow() - self.maps[self.current_map].last_updated) + > IMAGE_CACHE_INTERVAL + ) + or previous_state != new_status.state_name ): try: await self.update_map() diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 0cd9d625920..b7c811e0ce2 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -11,6 +11,7 @@ from roborock import RoborockException from vacuum_map_parser_base.map_data import ImageConfig, ImageData from homeassistant.components.roborock import DOMAIN +from homeassistant.components.roborock.const import V1_LOCAL_NOT_CLEANING_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -158,9 +159,6 @@ async def test_fail_to_load_image( ) -> None: """Test that we gracefully handle failing to load an image.""" with ( - patch( - "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", - ) as parse_map, patch( "homeassistant.components.roborock.roborock_storage.Path.exists", return_value=True, @@ -177,8 +175,6 @@ async def test_fail_to_load_image( await hass.config_entries.async_reload(setup_entry.entry_id) await hass.async_block_till_done() assert read_bytes.call_count == 4 - # Ensure that we never updated the map manually since we couldn't load it. - assert parse_map.call_count == 0 assert "Unable to read map file" in caplog.text @@ -298,3 +294,52 @@ async def test_index_error_map( # last_updated timestamp. assert resp.ok assert previous_state == hass.states.get("image.roborock_s7_maxv_upstairs").state + + +async def test_map_status_change( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test floor plan map image is correctly updated on status change.""" + assert len(hass.states.async_all("image")) == 4 + + 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") + assert resp.status == HTTPStatus.OK + old_body = await resp.read() + assert old_body[0:4] == b"\x89PNG" + + # Call a second time. This interval does not directly trigger a map update, but does + # trigger a status update which detects the state has changed and uddates the map + now = dt_util.utcnow() + V1_LOCAL_NOT_CLEANING_INTERVAL + + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.state_name = "testing" + new_map_data = copy.deepcopy(MAP_DATA) + new_map_data.image = ImageData( + 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (2, 2)), lambda p: p + ) + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.coordinator.dt_util.utcnow", + return_value=now, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockMapDataParser.parse", + return_value=new_map_data, + ), + ): + async_fire_time_changed(hass, now) + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body is not None + assert body != old_body From 4176776d70900c6c62fb64164a90bf6f0da77b50 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 18 Mar 2025 15:49:27 +0100 Subject: [PATCH 1778/1941] Fix optional password in Velbus config flow (#140615) * Fix velbusconfigflow * add tests * Paramtize the tests * Removed duplicate test in favor of another case * more comments --- .../components/velbus/config_flow.py | 2 +- tests/components/velbus/test_config_flow.py | 66 ++++++++----------- 2 files changed, 27 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index fc5da92588a..7c93d8784ad 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -63,7 +63,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN): self._device = "tls://" else: self._device = "" - if user_input[CONF_PASSWORD] != "": + if CONF_PASSWORD in user_input and 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}) diff --git a/tests/components/velbus/test_config_flow.py b/tests/components/velbus/test_config_flow.py index ee714624b45..36d658f9633 100644 --- a/tests/components/velbus/test_config_flow.py +++ b/tests/components/velbus/test_config_flow.py @@ -59,43 +59,30 @@ def mock_controller_connection_failed(): @pytest.mark.usefixtures("controller") -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.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.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 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: +@pytest.mark.parametrize( + ("inputParams", "expected"), + [ + ( + { + CONF_TLS: True, + CONF_PASSWORD: "password", + }, + "tls://password@velbus:6000", + ), + ( + { + CONF_TLS: True, + CONF_PASSWORD: "", + }, + "tls://velbus:6000", + ), + ({CONF_TLS: True}, "tls://velbus:6000"), + ({CONF_TLS: False}, "velbus:6000"), + ], +) +async def test_user_network_succes( + hass: HomeAssistant, inputParams: str, expected: str +) -> None: """Test user network config.""" # inttial menu show result = await hass.config_entries.flow.async_init( @@ -116,10 +103,9 @@ async def test_user_network_succes_tls(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result.get("flow_id"), { - CONF_TLS: True, CONF_HOST: "velbus", CONF_PORT: 6000, - CONF_PASSWORD: "password", + **inputParams, }, ) assert result @@ -127,7 +113,7 @@ async def test_user_network_succes_tls(hass: HomeAssistant) -> None: assert result.get("title") == "Velbus Network" data = result.get("data") assert data - assert data[CONF_PORT] == "tls://password@velbus:6000" + assert data[CONF_PORT] == expected @pytest.mark.usefixtures("controller") From a170e328525774036805a4d20bfcf03154c2fbd2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 18 Mar 2025 16:29:21 +0100 Subject: [PATCH 1779/1941] Deprecate Shelly state attributes (#140791) --- homeassistant/components/shelly/binary_sensor.py | 1 + homeassistant/components/shelly/sensor.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index ed2ac68d264..b74578f1fb3 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -130,6 +130,7 @@ SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = { device_class=BinarySensorDeviceClass.GAS, translation_key="gas", value=lambda value: value in ["mild", "heavy"], + # Deprecated, remove in 2025.10 extra_state_attributes=lambda block: {"detected": block.gas}, ), ("sensor", "smoke"): BlockBinarySensorDescription( diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b6820921b4f..79e4c97aead 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -358,6 +358,7 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { translation_key="lamp_life", value=get_shelly_air_lamp_life, suggested_display_precision=1, + # Deprecated, remove in 2025.10 extra_state_attributes=lambda block: { "Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1) }, @@ -378,6 +379,7 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { options=["warmup", "normal", "fault"], translation_key="operation", value=lambda value: None if value == "unknown" else value, + # Deprecated, remove in 2025.10 extra_state_attributes=lambda block: {"self_test": block.selfTest}, ), ("valve", "valve"): BlockSensorDescription( From e2460a43937da12d8bfc50dc458487f9582cf873 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Tue, 18 Mar 2025 16:32:14 +0100 Subject: [PATCH 1780/1941] bump pyHomee to 1.2.8 (#140870) --- homeassistant/components/homee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index e4622222be1..3c2a99c30dc 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.7"] + "requirements": ["pyHomee==1.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 082c61b6fcb..bf8ac5df9ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1783,7 +1783,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.7 +pyHomee==1.2.8 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bf1f0979b3..ffa587ad5cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1469,7 +1469,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.7 +pyHomee==1.2.8 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 From 4564d2537bb0cd756d368c141c58c0a0f0d0ffb8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 18 Mar 2025 16:38:34 +0100 Subject: [PATCH 1781/1941] Fix flakey reolink test (#140877) --- tests/components/reolink/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index cd793b9b620..1fa46271353 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -141,6 +141,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.events_active = False host_mock.baichuan.privacy_mode.return_value = False + host_mock.baichuan.day_night_state.return_value = "day" host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") host_mock.baichuan.abilities = { 0: {"chnID": 0, "aitype": 34615}, From 11e02f89cf4e7388d74b43ed364c93a87b53b960 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 18 Mar 2025 16:40:47 +0100 Subject: [PATCH 1782/1941] Add exception translations for Brother integration (#140868) Add exception translations --- homeassistant/components/brother/__init__.py | 10 +++++++++- homeassistant/components/brother/coordinator.py | 10 +++++++++- homeassistant/components/brother/strings.json | 8 ++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 464e6629224..1c1768b58fd 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -9,6 +9,7 @@ from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from .const import DOMAIN from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -25,7 +26,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b host, printer_type=printer_type, snmp_engine=snmp_engine ) except (ConnectionError, SnmpError, TimeoutError) as error: - raise ConfigEntryNotReady from error + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "device": entry.title, + "error": repr(error), + }, + ) from error coordinator = BrotherDataUpdateCoordinator(hass, entry, brother) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/brother/coordinator.py b/homeassistant/components/brother/coordinator.py index 4f518ba8a25..a3c337f27f7 100644 --- a/homeassistant/components/brother/coordinator.py +++ b/homeassistant/components/brother/coordinator.py @@ -26,6 +26,7 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): ) -> None: """Initialize.""" self.brother = brother + self.device_name = config_entry.title super().__init__( hass, @@ -41,5 +42,12 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]): async with timeout(20): data = await self.brother.async_update() except (ConnectionError, SnmpError, UnsupportedModelError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "device": self.device_name, + "error": repr(error), + }, + ) from error return data diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index b502ed7e3b9..d0714a199c4 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -159,5 +159,13 @@ "name": "Last restart" } } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while connecting to the {device} printer: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the {device} printer: {error}" + } } } From f8ab4d0238d18732842314eea853ef6c7b49e69c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 18 Mar 2025 16:47:33 +0100 Subject: [PATCH 1783/1941] Fix warnings in Reolink tests (#140878) --- tests/components/reolink/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 1fa46271353..672919bc7a9 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -134,6 +134,8 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] host_mock.auto_track_method.return_value = 3 host_mock.daynight_state.return_value = "Black&White" + host_mock.hub_alarm_tone_id.return_value = 1 + host_mock.hub_visitor_tone_id.return_value = 1 # Baichuan host_mock.baichuan = create_autospec(Baichuan) From 2d82a12e0a85742c19408ec8b6e465de7908328a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 19:47:14 +0100 Subject: [PATCH 1784/1941] Make description of `homeassistant.reload_all` action consistent (#140887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change it to "Reloads …" like all other `homeassistant.reload_xyz` actions. --- homeassistant/components/homeassistant/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 4ca56471452..b8b5f77cf52 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -188,7 +188,7 @@ }, "reload_all": { "name": "Reload all", - "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant." + "description": "Reloads all YAML configuration that can be reloaded without restarting Home Assistant." } }, "exceptions": { From 07302ea1788b1dcae2e612383492f11f14f110aa Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 18 Mar 2025 20:27:21 +0100 Subject: [PATCH 1785/1941] =?UTF-8?q?Fix=20duplicate=20descriptions=20of?= =?UTF-8?q?=20`homematicip=5Fcloud.activate=5Feco=5Fmode=5Fwith=5F?= =?UTF-8?q?=E2=80=A6`=20actions=20(#140885)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update strings.json Currently both the `activate_eco_mode_with_duration` and the `activate_eco_mode_with_period` actions have the identical description: "Activates eco mode with period." To resolve this confusing duplicate, both actions get their own descriptions, making the latter consistent with that of the `activate_vacation` action. --- homeassistant/components/homematicip_cloud/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 228ebc7500e..7b1b08ac4e2 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -35,7 +35,7 @@ "services": { "activate_eco_mode_with_duration": { "name": "Activate eco mode with duration", - "description": "Activates eco mode with period.", + "description": "Activates the eco mode for a specified duration.", "fields": { "duration": { "name": "Duration", @@ -49,7 +49,7 @@ }, "activate_eco_mode_with_period": { "name": "Activate eco more with period", - "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::description%]", + "description": "Activates the eco mode until a given time.", "fields": { "endtime": { "name": "Endtime", @@ -63,7 +63,7 @@ }, "activate_vacation": { "name": "Activate vacation", - "description": "Activates the vacation mode until the given time.", + "description": "Activates the vacation mode until a given time.", "fields": { "endtime": { "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_period::fields::endtime::name%]", From 3ce9d47d7dd51aed8c9059562e5ceb774388db51 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 18 Mar 2025 20:27:36 +0100 Subject: [PATCH 1786/1941] Add exception translations for Airly integration (#140864) * Add exception translations * Improve error strings --- homeassistant/components/airly/coordinator.py | 15 +++++++++++++-- homeassistant/components/airly/strings.json | 8 ++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py index b255c5f078f..668cabdae63 100644 --- a/homeassistant/components/airly/coordinator.py +++ b/homeassistant/components/airly/coordinator.py @@ -105,7 +105,14 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i try: await measurements.update() except (AirlyError, ClientConnectorError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(error), + }, + ) from error _LOGGER.debug( "Requests remaining: %s/%s", @@ -126,7 +133,11 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i standards = measurements.current["standards"] if index["description"] == NO_AIRLY_SENSORS: - raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="no_station", + translation_placeholders={"entry": self.config_entry.title}, + ) for value in values: data[value["name"]] = value["value"] for standard in standards: diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 33ee8bbe4c9..fe4ccbb4745 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -36,5 +36,13 @@ "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" } } + }, + "exceptions": { + "update_error": { + "message": "An error occurred while retrieving data from the Airly API for {entry}: {error}" + }, + "no_station": { + "message": "An error occurred while retrieving data from the Airly API for {entry}: no measuring stations in this area" + } } } From c41d5f2577e873f46366fac7337739afcaccd56c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 18 Mar 2025 23:13:08 +0100 Subject: [PATCH 1787/1941] Fix cast.show_lovelace_view service description (#140859) --- homeassistant/components/cast/services.yaml | 2 +- homeassistant/components/cast/strings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml index e2e23ad40a2..45b36f6d983 100644 --- a/homeassistant/components/cast/services.yaml +++ b/homeassistant/components/cast/services.yaml @@ -7,11 +7,11 @@ show_lovelace_view: integration: cast domain: media_player dashboard_path: - required: true example: lovelace-cast selector: text: view_path: + required: true example: downstairs selector: text: diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 9c49813bd83..a8dccdff804 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -49,7 +49,7 @@ }, "dashboard_path": { "name": "Dashboard path", - "description": "The URL path of the dashboard to show." + "description": "The URL path of the dashboard to show, defaults to lovelace if not specified." }, "view_path": { "name": "View path", From 254622878af97ae9e3f8df06b2c2e6d252e367e5 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 18 Mar 2025 21:48:34 -0400 Subject: [PATCH 1788/1941] Add Roborock entity with the name of the current room (#140895) * Add current room entity * Update homeassistant/components/roborock/models.py Co-authored-by: Allen Porter * Update homeassistant/components/roborock/models.py Co-authored-by: Allen Porter * use current_room property * remove select changes --------- Co-authored-by: Allen Porter --- .../components/roborock/coordinator.py | 21 +++++--- homeassistant/components/roborock/models.py | 9 ++++ homeassistant/components/roborock/sensor.py | 50 +++++++++++++++++-- .../components/roborock/strings.json | 3 ++ tests/components/roborock/mock_data.py | 1 + tests/components/roborock/test_sensor.py | 6 ++- 6 files changed, 77 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index c333b143b10..698e2c268ed 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -29,6 +29,7 @@ from roborock.web_api import RoborockApiClient from vacuum_map_parser_base.config.color import ColorsPalette from vacuum_map_parser_base.config.image_config import ImageConfig from vacuum_map_parser_base.config.size import Sizes +from vacuum_map_parser_base.map_data import MapData from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser from homeassistant.config_entries import ConfigEntry @@ -168,18 +169,20 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): sw_version=self.roborock_device_info.device.fv, ) - def parse_image(self, map_bytes: bytes) -> bytes | None: - """Parse map_bytes and store it as image bytes.""" + def parse_map_data_v1( + self, map_bytes: bytes + ) -> tuple[bytes | None, MapData | None]: + """Parse map_bytes and return MapData and the image.""" try: parsed_map = self.map_parser.parse(map_bytes) except (IndexError, ValueError) as err: _LOGGER.debug("Exception when parsing map contents: %s", err) - return None + return None, None if parsed_map.image is None: - return None + return None, None img_byte_arr = io.BytesIO() parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT) - return img_byte_arr.getvalue() + return img_byte_arr.getvalue(), parsed_map async def _async_setup(self) -> None: """Set up the coordinator.""" @@ -206,6 +209,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): rooms={}, image=image, last_updated=dt_util.utcnow() - IMAGE_CACHE_INTERVAL, + map_data=None, ) for image, roborock_map in zip(stored_images, roborock_maps, strict=False) } @@ -230,20 +234,21 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): translation_domain=DOMAIN, translation_key="map_failure", ) - parsed_image = self.parse_image(response) - if parsed_image is None: + parsed_image, parsed_map = self.parse_map_data_v1(response) + if parsed_image is None or parsed_map is None: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="map_failure", ) + current_roborock_map_info = self.maps[self.current_map] if parsed_image != self.maps[self.current_map].image: await self.map_storage.async_save_map( self.current_map, parsed_image, ) - current_roborock_map_info = self.maps[self.current_map] current_roborock_map_info.image = parsed_image current_roborock_map_info.last_updated = dt_util.utcnow() + current_roborock_map_info.map_data = parsed_map async def _verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 113f99d9474..ab40f23d574 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -6,6 +6,7 @@ from typing import Any from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.roborock_typing import DeviceProp +from vacuum_map_parser_base.map_data import MapData @dataclass @@ -51,3 +52,11 @@ class RoborockMapInfo: rooms: dict[int, str] image: bytes | None last_updated: datetime + map_data: MapData | None + + @property + def current_room(self) -> str | None: + """Get the currently active room for this map if any.""" + if self.map_data is None or self.map_data.vacuum_room is None: + return None + return self.rooms.get(self.map_data.vacuum_room) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 556d8443669..33ecaf74d4f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -36,7 +36,11 @@ from .coordinator import ( RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, ) -from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 +from .entity import ( + RoborockCoordinatedEntityA01, + RoborockCoordinatedEntityV1, + RoborockEntity, +) PARALLEL_UPDATES = 0 @@ -306,7 +310,7 @@ async def async_setup_entry( ) -> None: """Set up the Roborock vacuum sensors.""" coordinators = config_entry.runtime_data - async_add_entities( + entities: list[RoborockEntity] = [ RoborockSensorEntity( coordinator, description, @@ -314,8 +318,9 @@ async def async_setup_entry( for coordinator in coordinators.v1 for description in SENSOR_DESCRIPTIONS if description.value_fn(coordinator.roborock_device_info.props) is not None - ) - async_add_entities( + ] + entities.extend(RoborockCurrentRoom(coordinator) for coordinator in coordinators.v1) + entities.extend( RoborockSensorEntityA01( coordinator, description, @@ -324,6 +329,7 @@ async def async_setup_entry( for description in A01_SENSOR_DESCRIPTIONS if description.data_protocol in coordinator.data ) + async_add_entities(entities) class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): @@ -353,6 +359,42 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity): ) +class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity): + """Representation of a Current Room Sensor.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_translation_key = "current_room" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: RoborockDataUpdateCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__( + f"current_room_{coordinator.duid_slug}", + coordinator, + None, + is_dock_entity=False, + ) + + @property + def options(self) -> list[str]: + """Return the currently valid rooms.""" + if self.coordinator.current_map is not None: + return list( + self.coordinator.maps[self.coordinator.current_map].rooms.values() + ) + return [] + + @property + def native_value(self) -> str | None: + """Return the value reported by the sensor.""" + if self.coordinator.current_map is not None: + return self.coordinator.maps[self.coordinator.current_map].current_room + return None + + class RoborockSensorEntityA01(RoborockCoordinatedEntityA01, SensorEntity): """Representation of a A01 Roborock sensor.""" diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index c115ec33851..a59dc80e65d 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -181,6 +181,9 @@ "countdown": { "name": "Countdown" }, + "current_room": { + "name": "Current room" + }, "dock_error": { "name": "Dock error", "state": { diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 59c54892687..87acc85b2aa 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1151,6 +1151,7 @@ MAP_DATA = MapData(0, 0) MAP_DATA.image = ImageData( 100, 10, 10, 10, 10, ImageConfig(), Image.new("RGB", (1, 1)), lambda p: p ) +MAP_DATA.vacuum_room = 17 SCENES = [ diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 4925c5da219..719b398de94 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -29,7 +29,7 @@ def platforms() -> list[Platform]: async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 40 + assert len(hass.states.async_all("sensor")) == 42 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -63,6 +63,10 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state == "2023-01-01T03:43:58+00:00" ) + assert ( + hass.states.get("sensor.roborock_s7_maxv_current_room").state + == "Example room 2" + ) assert hass.states.get("sensor.dyad_pro_status").state == "drying" assert hass.states.get("sensor.dyad_pro_battery").state == "100" assert hass.states.get("sensor.dyad_pro_filter_time_left").state == "111" From caf81eecd384a76bc38bc4e500eeafb1c3b5ad38 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 19 Mar 2025 07:25:41 +0100 Subject: [PATCH 1789/1941] Bump bring-api to v1.1.0 (#140906) --- homeassistant/components/bring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bring/snapshots/test_diagnostics.ambr | 6 ++++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index f292b10f7dc..b2d42835cce 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["bring_api"], "quality_scale": "platinum", - "requirements": ["bring-api==1.0.2"] + "requirements": ["bring-api==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bf8ac5df9ff..06ad6d0b816 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -656,7 +656,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.2 +bring-api==1.1.0 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffa587ad5cf..844e6b6b246 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -576,7 +576,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==1.0.2 +bring-api==1.1.0 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index 8570bc0410f..3f4c8f5f339 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -139,6 +139,7 @@ 'language': 'de', 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', 'pushEnabled': True, @@ -149,6 +150,7 @@ 'language': 'en', 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', 'pushEnabled': True, @@ -159,6 +161,7 @@ 'language': 'en', 'name': None, 'photoPath': None, + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', 'pushEnabled': True, @@ -303,6 +306,7 @@ 'language': 'de', 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '9a21fdfc-63a4-441a-afc1-ef3030605a9d', 'pushEnabled': True, @@ -313,6 +317,7 @@ 'language': 'en', 'name': '**REDACTED**', 'photoPath': '', + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '73af455f-c158-4004-a5e0-79f4f8a6d4bd', 'pushEnabled': True, @@ -323,6 +328,7 @@ 'language': 'en', 'name': None, 'photoPath': None, + 'plusExpiry': None, 'plusTryOut': False, 'publicUuid': '7d5e9d08-877a-4c36-8740-a9bf74ec690a', 'pushEnabled': True, From d37783fb219cdc4ca8865353be53a713fd3c8341 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:53:00 +0200 Subject: [PATCH 1790/1941] Bump actions/download-artifact from 4.1.9 to 4.2.0 (#140907) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.9 to 4.2.0. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4.1.9...v4.2.0) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- .github/workflows/ci.yaml | 8 ++++---- .github/workflows/wheels.yml | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ab64f1f3e7e..0aac66c2747 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.9 + uses: actions/download-artifact@v4.2.0 with: name: translations @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: translations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 49cb7ae019c..4d8849abfda 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -968,7 +968,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: pytest_buckets - name: Compile English translations @@ -1312,7 +1312,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.9 + uses: actions/download-artifact@v4.2.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1454,7 +1454,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.9 + uses: actions/download-artifact@v4.2.0 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1479,7 +1479,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: pattern: test-results-* - name: Upload test results to Codecov diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c651ccbe715..4baddd3a80f 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.9 + uses: actions/download-artifact@v4.2.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 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.9 + uses: actions/download-artifact@v4.2.0 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.1.9 + uses: actions/download-artifact@v4.2.0 with: name: requirements_all_wheels From f4fe2342790e8ba67662a78b1f952dbf8ea124e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Mar 2025 22:26:23 -1000 Subject: [PATCH 1791/1941] Bump annotatedyaml to 0.4.4 (#140861) * Bump annotatedyaml to 0.4.2 changelog: https://github.com/home-assistant-libs/annotatedyaml/compare/v0.2.0...v0.4.2 ~10-11% performance improvement * tweak imports * bump to .3 to make pylint happy * bump again for fixes --------- Co-authored-by: Shay Levy --- homeassistant/package_constraints.txt | 2 +- homeassistant/util/yaml/__init__.py | 3 +-- homeassistant/util/yaml/objects.py | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f63492a8b3f..c72c5c4c646 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ aiohttp==3.11.14 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.2.0 +annotatedyaml==0.4.4 astral==2.2 async-interrupt==1.2.2 async-upnp-client==0.43.0 diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index a3c0ab3d083..323383ef53f 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -1,8 +1,7 @@ """YAML utility functions.""" -from annotatedyaml import SECRET_YAML, YamlTypeError +from annotatedyaml import SECRET_YAML, Input, YamlTypeError from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute -from annotatedyaml.objects import Input from .dumper import dump, save_yaml from .loader import Secrets, load_yaml, load_yaml_dict, parse_yaml, secret_yaml diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index 4b21e8118b3..26714b0fdd4 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -2,6 +2,6 @@ from __future__ import annotations -from annotatedyaml.objects import Input, NodeDictClass, NodeListClass, NodeStrClass +from annotatedyaml import Input, NodeDictClass, NodeListClass, NodeStrClass __all__ = ["Input", "NodeDictClass", "NodeListClass", "NodeStrClass"] diff --git a/pyproject.toml b/pyproject.toml index 1879a2544c3..628ec457bf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", - "annotatedyaml==0.2.0", + "annotatedyaml==0.4.4", "astral==2.2", "async-interrupt==1.2.2", "attrs==25.1.0", diff --git a/requirements.txt b/requirements.txt index 176b1ae0c24..1aa96e89bb6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 -annotatedyaml==0.2.0 +annotatedyaml==0.4.4 astral==2.2 async-interrupt==1.2.2 attrs==25.1.0 From 7c6abe17a280879fae82884a46083fd05594622f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 09:55:49 +0100 Subject: [PATCH 1792/1941] Clarify description of `speed` field in `omnilogic.set_pump_speed` action (#140912) Replace "VSP" (for variable speed pump) with just "pump" so it can be properly translated. --- homeassistant/components/omnilogic/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/omnilogic/strings.json b/homeassistant/components/omnilogic/strings.json index 5b193b7f5ba..6f207337789 100644 --- a/homeassistant/components/omnilogic/strings.json +++ b/homeassistant/components/omnilogic/strings.json @@ -34,7 +34,7 @@ "fields": { "speed": { "name": "Speed", - "description": "Speed for the VSP between min and max speed." + "description": "Speed for the pump between min and max speed." } } } From 793e36635b760fd5fee1d9485c9c16c821a8f4ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 10:07:47 +0100 Subject: [PATCH 1793/1941] Improve google cast known hosts configuration (#140913) --- homeassistant/components/cast/config_flow.py | 97 ++++++++------------ homeassistant/components/cast/strings.json | 10 +- tests/components/cast/test_config_flow.py | 26 ++++-- 3 files changed, 63 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 034cf856023..6c33eac230f 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -16,12 +16,21 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) -KNOWN_HOSTS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) +KNOWN_HOSTS_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_KNOWN_HOSTS, + ): SelectSelector( + SelectSelectorConfig(custom_value=True, options=[], multiple=True), + ) + } +) WANTED_UUID_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string])) @@ -30,12 +39,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize flow.""" - self._ignore_cec = set[str]() - self._known_hosts = set[str]() - self._wanted_uuid = set[str]() - @staticmethod @callback def async_get_options_flow( @@ -62,48 +65,31 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm the setup.""" - errors = {} - data = {CONF_KNOWN_HOSTS: self._known_hosts} - if user_input is not None: - bad_hosts = False - known_hosts = user_input[CONF_KNOWN_HOSTS] - known_hosts = [x.strip() for x in known_hosts.split(",") if x.strip()] - try: - known_hosts = KNOWN_HOSTS_SCHEMA(known_hosts) - except vol.Invalid: - errors["base"] = "invalid_known_hosts" - bad_hosts = True - else: - self._known_hosts = known_hosts - data = self._get_data() - if not bad_hosts: - return self.async_create_entry(title="Google Cast", data=data) + known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, [])) + return self.async_create_entry( + title="Google Cast", + data=self._get_data(known_hosts=known_hosts), + ) - fields = {} - fields[vol.Optional(CONF_KNOWN_HOSTS, default="")] = str - - return self.async_show_form( - step_id="config", data_schema=vol.Schema(fields), errors=errors - ) + return self.async_show_form(step_id="config", data_schema=KNOWN_HOSTS_SCHEMA) async def async_step_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm the setup.""" - - data = self._get_data() - if user_input is not None or not onboarding.async_is_onboarded(self.hass): - return self.async_create_entry(title="Google Cast", data=data) + return self.async_create_entry(title="Google Cast", data=self._get_data()) return self.async_show_form(step_id="confirm") - def _get_data(self): + def _get_data( + self, *, known_hosts: list[str] | None = None + ) -> dict[str, list[str]]: return { - CONF_IGNORE_CEC: list(self._ignore_cec), - CONF_KNOWN_HOSTS: list(self._known_hosts), - CONF_UUID: list(self._wanted_uuid), + CONF_IGNORE_CEC: [], + CONF_KNOWN_HOSTS: known_hosts or [], + CONF_UUID: [], } @@ -123,31 +109,24 @@ class CastOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the Google Cast options.""" errors: dict[str, str] = {} - current_config = self.config_entry.data if user_input is not None: - bad_hosts, known_hosts = _string_to_list( - user_input.get(CONF_KNOWN_HOSTS, ""), KNOWN_HOSTS_SCHEMA + known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, [])) + self.updated_config = dict(self.config_entry.data) + self.updated_config[CONF_KNOWN_HOSTS] = known_hosts + + if self.show_advanced_options: + return await self.async_step_advanced_options() + + self.hass.config_entries.async_update_entry( + self.config_entry, data=self.updated_config ) - - if not bad_hosts: - self.updated_config = dict(current_config) - self.updated_config[CONF_KNOWN_HOSTS] = known_hosts - - if self.show_advanced_options: - return await self.async_step_advanced_options() - - self.hass.config_entries.async_update_entry( - self.config_entry, data=self.updated_config - ) - return self.async_create_entry(title="", data={}) - - fields: dict[vol.Marker, type[str]] = {} - suggested_value = _list_to_string(current_config.get(CONF_KNOWN_HOSTS)) - _add_with_suggestion(fields, CONF_KNOWN_HOSTS, suggested_value) + return self.async_create_entry(title="", data={}) return self.async_show_form( step_id="basic_options", - data_schema=vol.Schema(fields), + data_schema=self.add_suggested_values_to_schema( + KNOWN_HOSTS_SCHEMA, self.config_entry.data + ), errors=errors, last_step=not self.show_advanced_options, ) @@ -206,6 +185,10 @@ def _string_to_list(string, schema): return invalid, items +def _trim_items(items: list[str]) -> list[str]: + return [x.strip() for x in items if x.strip()] + + def _add_with_suggestion( fields: dict[vol.Marker, type[str]], key: str, suggested_value: str ) -> None: diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index a8dccdff804..8c7c7c0cff0 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -6,9 +6,11 @@ }, "config": { "title": "Google Cast configuration", - "description": "Known Hosts - A comma-separated list of hostnames or IP-addresses of cast devices, use if mDNS discovery is not working.", "data": { - "known_hosts": "Known hosts" + "known_hosts": "Add known host" + }, + "data_description": { + "known_hosts": "Hostnames or IP-addresses of cast devices, use if mDNS discovery is not working" } } }, @@ -20,9 +22,11 @@ "step": { "basic_options": { "title": "[%key:component::cast::config::step::config::title%]", - "description": "[%key:component::cast::config::step::config::description%]", "data": { "known_hosts": "[%key:component::cast::config::step::config::data::known_hosts%]" + }, + "data_description": { + "known_hosts": "[%key:component::cast::config::step::config::data_description::known_hosts%]" } }, "advanced_options": { diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 2dcf007c6d4..e02230892bf 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -87,7 +87,7 @@ async def test_user_setup_options(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"known_hosts": "192.168.0.1, , 192.168.0.2 "} + result["flow_id"], {"known_hosts": ["192.168.0.1", "", " ", "192.168.0.2 "]} ) users = await hass.auth.async_get_users() @@ -152,13 +152,13 @@ def get_suggested(schema, key): @pytest.mark.parametrize( - "parameter_data", + ("parameter", "initial", "suggested", "user_input", "updated"), [ ( "known_hosts", ["192.168.0.10", "192.168.0.11"], - "192.168.0.10,192.168.0.11", - "192.168.0.1, , 192.168.0.2 ", + ["192.168.0.10", "192.168.0.11"], + ["192.168.0.1", " ", " 192.168.0.2 "], ["192.168.0.1", "192.168.0.2"], ), ( @@ -177,11 +177,17 @@ def get_suggested(schema, key): ), ], ) -async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: +async def test_option_flow( + hass: HomeAssistant, + parameter: str, + initial: list[str], + suggested: str | list[str], + user_input: str | list[str], + updated: list[str], +) -> None: """Test config flow options.""" basic_parameters = ["known_hosts"] advanced_parameters = ["ignore_cec", "uuid"] - parameter, initial, suggested, user_input, updated = parameter_data data = { "ignore_cec": [], @@ -213,7 +219,7 @@ async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: for other_param in basic_parameters: if other_param == parameter: continue - assert get_suggested(data_schema, other_param) == "" + assert get_suggested(data_schema, other_param) == [] if parameter in basic_parameters: assert get_suggested(data_schema, parameter) == suggested @@ -261,7 +267,7 @@ async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"known_hosts": ""}, + user_input={}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == {} @@ -277,7 +283,7 @@ async def test_known_hosts(hass: HomeAssistant, castbrowser_mock) -> None: "cast", context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"known_hosts": "192.168.0.1, 192.168.0.2"} + result["flow_id"], {"known_hosts": ["192.168.0.1", "192.168.0.2"]} ) assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done(wait_background_tasks=True) @@ -290,7 +296,7 @@ async def test_known_hosts(hass: HomeAssistant, castbrowser_mock) -> None: result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"known_hosts": "192.168.0.11, 192.168.0.12"}, + user_input={"known_hosts": ["192.168.0.11", "192.168.0.12"]}, ) await hass.async_block_till_done(wait_background_tasks=True) From f28b9ba9618066d785334a10546362edbdd2fc64 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 10:36:49 +0100 Subject: [PATCH 1794/1941] Fix sentence-casing in `nibe_heatpump` strings (#140915) --- homeassistant/components/nibe_heatpump/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index 6fa421e0855..3ca70189964 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -10,13 +10,13 @@ }, "modbus": { "data": { - "model": "Model of Heat Pump", + "model": "Model of heat pump", "modbus_url": "Modbus URL", - "modbus_unit": "Modbus Unit Identifier" + "modbus_unit": "Modbus unit identifier" }, "data_description": { - "modbus_url": "Modbus URL that describes the connection to your Heat Pump or MODBUS40 unit. It should be on the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote telnet based Modbus RTU connection.", - "modbus_unit": "Unit identification for your Heat Pump. Can usually be left at 0." + "modbus_url": "Modbus URL that describes the connection to your heat pump or MODBUS40 unit. It should be on the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote telnet based Modbus RTU connection.", + "modbus_unit": "Unit identification for your heat pump. Can usually be left at 0." } }, "nibegw": { From f79aa2f73e1ed33542ba8ae8419cb207b991a706 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 11:02:37 +0100 Subject: [PATCH 1795/1941] Fix typos in `nibe_heatpump` strings (#140917) * Fix typo in `nibe_heatpump` strings * Also capitalize "Telnet" --- homeassistant/components/nibe_heatpump/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index 3ca70189964..c65a76d3364 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -15,7 +15,7 @@ "modbus_unit": "Modbus unit identifier" }, "data_description": { - "modbus_url": "Modbus URL that describes the connection to your heat pump or MODBUS40 unit. It should be on the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote telnet based Modbus RTU connection.", + "modbus_url": "Modbus URL that describes the connection to your heat pump or MODBUS40 unit. It should be in the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote Telnet-based Modbus RTU connection.", "modbus_unit": "Unit identification for your heat pump. Can usually be left at 0." } }, From 3fd17c802c6eb6077eb1c5baee13dad6877d71d0 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 19 Mar 2025 11:25:12 +0100 Subject: [PATCH 1796/1941] Bump pylamarzocco to 1.4.9 (#140916) --- .../components/lamarzocco/__init__.py | 52 ++++++++++++----- .../components/lamarzocco/manifest.json | 2 +- homeassistant/components/lamarzocco/number.py | 18 ++++-- homeassistant/components/lamarzocco/select.py | 1 + .../components/lamarzocco/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../lamarzocco/fixtures/config.json | 38 ++++++++++++- .../lamarzocco/fixtures/config_mini.json | 10 +++- .../snapshots/test_diagnostics.ambr | 56 +++++++++++++------ .../lamarzocco/snapshots/test_number.ambr | 46 +++++++-------- tests/components/lamarzocco/test_init.py | 25 ++++++++- 12 files changed, 183 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index d20616e1940..25c8fd1091e 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -61,6 +61,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - client=client, ) + # initialize the firmware update coordinator early to check the firmware version + firmware_device = LaMarzoccoMachine( + model=entry.data[CONF_MODEL], + serial_number=entry.unique_id, + name=entry.data[CONF_NAME], + cloud_client=cloud_client, + ) + + firmware_coordinator = LaMarzoccoFirmwareUpdateCoordinator( + hass, entry, firmware_device + ) + await firmware_coordinator.async_config_entry_first_refresh() + gateway_version = version.parse( + firmware_device.firmware[FirmwareType.GATEWAY].current_version + ) + + if gateway_version >= version.parse("v5.0.9"): + # remove host from config entry, it is not supported anymore + data = {k: v for k, v in entry.data.items() if k != CONF_HOST} + hass.config_entries.async_update_entry( + entry, + data=data, + ) + + elif gateway_version < version.parse("v3.4-rc5"): + # incompatible gateway firmware, create an issue + ir.async_create_issue( + hass, + DOMAIN, + "unsupported_gateway_firmware", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="unsupported_gateway_firmware", + translation_placeholders={"gateway_version": str(gateway_version)}, + ) + # initialize local API local_client: LaMarzoccoLocalClient | None = None if (host := entry.data.get(CONF_HOST)) is not None: @@ -117,30 +153,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - coordinators = LaMarzoccoRuntimeData( LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client), - LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device), + firmware_coordinator, LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), ) # API does not like concurrent requests, so no asyncio.gather here await coordinators.config_coordinator.async_config_entry_first_refresh() - await coordinators.firmware_coordinator.async_config_entry_first_refresh() await coordinators.statistics_coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinators - gateway_version = device.firmware[FirmwareType.GATEWAY].current_version - if version.parse(gateway_version) < version.parse("v3.4-rc5"): - # incompatible gateway firmware, create an issue - ir.async_create_issue( - hass, - DOMAIN, - "unsupported_gateway_firmware", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="unsupported_gateway_firmware", - translation_placeholders={"gateway_version": gateway_version}, - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def update_listener( diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index eceb2bbf53b..73f00b2bdd0 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.7"] + "requirements": ["pylamarzocco==1.4.9"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 666c57c1866..08e9ad7e590 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -144,9 +144,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( set_value_fn=lambda machine, value, key: machine.set_prebrew_time( prebrew_off_time=value, key=key ), - native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, + native_value_fn=lambda config, key: config.prebrew_configuration[key][ + 0 + ].off_time, available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREBREW, + and device.config.prebrew_mode + in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), supported_fn=lambda coordinator: coordinator.device.model != MachineModel.GS3_MP, ), @@ -162,9 +165,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( set_value_fn=lambda machine, value, key: machine.set_prebrew_time( prebrew_on_time=value, key=key ), - native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time, + native_value_fn=lambda config, key: config.prebrew_configuration[key][ + 0 + ].off_time, available_fn=lambda device: len(device.config.prebrew_configuration) > 0 - and device.config.prebrew_mode == PrebrewMode.PREBREW, + and device.config.prebrew_mode + in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), supported_fn=lambda coordinator: coordinator.device.model != MachineModel.GS3_MP, ), @@ -180,8 +186,8 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( set_value_fn=lambda machine, value, key: machine.set_preinfusion_time( preinfusion_time=value, key=key ), - native_value_fn=lambda config, key: config.prebrew_configuration[ - key + native_value_fn=lambda config, key: config.prebrew_configuration[key][ + 1 ].preinfusion_time, available_fn=lambda device: len(device.config.prebrew_configuration) > 0 and device.config.prebrew_mode == PrebrewMode.PREINFUSION, diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index d8217cefaff..5ebe2d7b9da 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -38,6 +38,7 @@ STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items( PREBREW_MODE_HA_TO_LM = { "disabled": PrebrewMode.DISABLED, "prebrew": PrebrewMode.PREBREW, + "prebrew_enabled": PrebrewMode.PREBREW_ENABLED, "preinfusion": PrebrewMode.PREINFUSION, } diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 62050685c27..04853b8d0ca 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -148,6 +148,7 @@ "state": { "disabled": "Disabled", "prebrew": "Prebrew", + "prebrew_enabled": "Prebrew", "preinfusion": "Preinfusion" } }, diff --git a/requirements_all.txt b/requirements_all.txt index 06ad6d0b816..d1081bd3341 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2081,7 +2081,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==1.4.7 +pylamarzocco==1.4.9 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 844e6b6b246..ab44df341d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1692,7 +1692,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.2 # homeassistant.components.lamarzocco -pylamarzocco==1.4.7 +pylamarzocco==1.4.9 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/tests/components/lamarzocco/fixtures/config.json b/tests/components/lamarzocco/fixtures/config.json index ea6e2ee76b8..5aac86dde97 100644 --- a/tests/components/lamarzocco/fixtures/config.json +++ b/tests/components/lamarzocco/fixtures/config.json @@ -101,28 +101,60 @@ "mode": "TypeB", "Group1": [ { + "mode": "TypeA", "groupNumber": "Group1", "doseType": "DoseA", "preWetTime": 0.5, "preWetHoldTime": 1 }, { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "DoseA", + "preWetTime": 0, + "preWetHoldTime": 4 + }, + { + "mode": "TypeA", "groupNumber": "Group1", "doseType": "DoseB", "preWetTime": 0.5, "preWetHoldTime": 1 }, { + "mode": "TypeB", "groupNumber": "Group1", - "doseType": "DoseC", - "preWetTime": 3.2999999523162842, - "preWetHoldTime": 3.2999999523162842 + "doseType": "DoseB", + "preWetTime": 0, + "preWetHoldTime": 4 }, { + "mode": "TypeA", + "groupNumber": "Group1", + "doseType": "DoseC", + "preWetTime": 3.3, + "preWetHoldTime": 3.3 + }, + { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "DoseC", + "preWetTime": 0, + "preWetHoldTime": 4 + }, + { + "mode": "TypeA", "groupNumber": "Group1", "doseType": "DoseD", "preWetTime": 2, "preWetHoldTime": 2 + }, + { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "DoseD", + "preWetTime": 0, + "preWetHoldTime": 4 } ] }, diff --git a/tests/components/lamarzocco/fixtures/config_mini.json b/tests/components/lamarzocco/fixtures/config_mini.json index 22533a94872..a726d715a6f 100644 --- a/tests/components/lamarzocco/fixtures/config_mini.json +++ b/tests/components/lamarzocco/fixtures/config_mini.json @@ -82,10 +82,18 @@ "mode": "TypeB", "Group1": [ { + "mode": "TypeA", "groupNumber": "Group1", - "doseType": "DoseA", + "doseType": "Continuous", "preWetTime": 2, "preWetHoldTime": 3 + }, + { + "mode": "TypeB", + "groupNumber": "Group1", + "doseType": "Continuous", + "preWetTime": 0, + "preWetHoldTime": 3 } ] }, diff --git a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr index b1d8140b2ce..018449f7c9a 100644 --- a/tests/components/lamarzocco/snapshots/test_diagnostics.ambr +++ b/tests/components/lamarzocco/snapshots/test_diagnostics.ambr @@ -27,22 +27,46 @@ }), 'plumbed_in': True, 'prebrew_configuration': dict({ - '1': dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - '2': dict({ - 'off_time': 1, - 'on_time': 0.5, - }), - '3': dict({ - 'off_time': 3.299999952316284, - 'on_time': 3.299999952316284, - }), - '4': dict({ - 'off_time': 2, - 'on_time': 2, - }), + '1': list([ + dict({ + 'off_time': 1, + 'on_time': 0.5, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), + '2': list([ + dict({ + 'off_time': 1, + 'on_time': 0.5, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), + '3': list([ + dict({ + 'off_time': 3.3, + 'on_time': 3.3, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), + '4': list([ + dict({ + 'off_time': 2, + 'on_time': 2, + }), + dict({ + 'off_time': 4, + 'on_time': 0, + }), + ]), }), 'prebrew_mode': 'TypeB', 'scale': None, diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index 0748c9384a9..de1f11b14eb 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -419,7 +419,7 @@ 'state': '121', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -438,7 +438,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -457,7 +457,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -473,10 +473,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.29999995231628', + 'state': '3.3', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-Enabled-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_off_time-6-TypeA-set_prebrew_time-kwargs0-GS3 AV][GS012345_prebrew_off_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -495,7 +495,7 @@ 'state': '2', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_1-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -514,7 +514,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_2-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -533,7 +533,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_3-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -549,10 +549,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.29999995231628', + 'state': '3.3', }) # --- -# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-Enabled-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_4-state] +# name: test_pre_brew_infusion_key_numbers[prebrew_on_time-6-TypeA-set_prebrew_time-kwargs1-GS3 AV][GS012345_prebrew_on_time_key_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -587,7 +587,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4', }) # --- # name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_2-state] @@ -606,7 +606,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4', }) # --- # name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_3-state] @@ -625,7 +625,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3.29999995231628', + 'state': '4', }) # --- # name: test_pre_brew_infusion_key_numbers[preinfusion_time-7-TypeB-set_preinfusion_time-kwargs2-GS3 AV][GS012345_preinfusion_time_key_4-state] @@ -644,10 +644,10 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '4', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini] +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -666,7 +666,7 @@ 'state': '3', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini].1 +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Linea Mini].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -705,7 +705,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra] +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -724,7 +724,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Micra].1 +# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-TypeA-6-kwargs0-Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -763,7 +763,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini] +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Linea Mini] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -782,7 +782,7 @@ 'state': '3', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini].1 +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Linea Mini].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -821,7 +821,7 @@ 'unit_of_measurement': , }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra] +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Micra] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -840,7 +840,7 @@ 'state': '1', }) # --- -# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Micra].1 +# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-TypeA-6-kwargs1-Micra].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -953,7 +953,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '4', }) # --- # name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Micra].1 diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 09ebc462952..a9a3b9f23e1 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -170,12 +170,18 @@ async def test_bluetooth_is_set_from_discovery( "homeassistant.components.lamarzocco.async_discovered_service_info", return_value=[service_info], ) as discovery, - patch("homeassistant.components.lamarzocco.LaMarzoccoMachine") as init_device, + patch( + "homeassistant.components.lamarzocco.LaMarzoccoMachine" + ) as mock_machine_class, ): + mock_machine = MagicMock() + mock_machine.get_firmware = AsyncMock() + mock_machine.firmware = mock_lamarzocco.firmware + mock_machine_class.return_value = mock_machine await async_init_integration(hass, mock_config_entry) discovery.assert_called_once() - init_device.assert_called_once() - _, kwargs = init_device.call_args + assert mock_machine_class.call_count == 2 + _, kwargs = mock_machine_class.call_args assert kwargs["bluetooth_client"] is not None assert mock_config_entry.data[CONF_NAME] == service_info.name assert mock_config_entry.data[CONF_MAC] == service_info.address @@ -223,6 +229,19 @@ async def test_gateway_version_issue( assert (issue is not None) == issue_exists +async def test_conf_host_removed_for_new_gateway( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Make sure we get the issue for certain gateway firmware versions.""" + mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = "v5.0.9" + + await async_init_integration(hass, mock_config_entry) + + assert CONF_HOST not in mock_config_entry.data + + async def test_device( hass: HomeAssistant, mock_lamarzocco: MagicMock, From adc3f542cfe5486c51832a87078f899b8d864be0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 19 Mar 2025 13:11:29 +0100 Subject: [PATCH 1797/1941] Update strings for Vodafone Station (#140919) --- .../components/vodafone_station/strings.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index dd847df4d6b..7d804d9ac3b 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -3,9 +3,11 @@ "flow_title": "{host}", "step": { "reauth_confirm": { - "description": "Please enter the correct password for host: {host}", "data": { "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "Please enter the correct password for host: {host}" } }, "user": { @@ -15,7 +17,9 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Vodafone Station." + "host": "The hostname or IP address of your Vodafone Station.", + "username": "The username for your Vodafone Station.", + "password": "The password for your Vodafone Station." } } }, @@ -41,6 +45,9 @@ "init": { "data": { "consider_home": "Seconds to consider a device at 'home'" + }, + "data_description": { + "consider_home": "The number of seconds to wait until marking a device as not home after it disconnects from the network." } } } From 245f0a19585576771b2868fc65adc8f8ed60a583 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 13:52:27 +0100 Subject: [PATCH 1798/1941] Minor typing tweak in cast (#140911) --- homeassistant/components/cast/const.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index 056ee054d1d..0a85a0007b3 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, NotRequired, TypedDict from homeassistant.util.signal_type import SignalType @@ -46,3 +46,4 @@ class HomeAssistantControllerData(TypedDict): hass_uuid: str client_id: str | None refresh_token: str + app_id: NotRequired[str] From 334359871d573e5fe7ac0155cd642e35008b780b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 19 Mar 2025 14:34:49 +0100 Subject: [PATCH 1799/1941] Add Reolink home hub scene select entity (#140823) --- homeassistant/components/reolink/icons.json | 3 + homeassistant/components/reolink/select.py | 63 ++++++++++++++++++- homeassistant/components/reolink/strings.json | 9 +++ tests/components/reolink/conftest.py | 2 + .../reolink/snapshots/test_diagnostics.ambr | 3 + tests/components/reolink/test_select.py | 52 +++++++++++++++ 6 files changed, 131 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index bcfea0bebd1..00045c4cda2 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -350,6 +350,9 @@ }, "sub_bit_rate": { "default": "mdi:play-speed" + }, + "scene_mode": { + "default": "mdi:view-list" } }, "sensor": { diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index c0b20da0238..e5d66ed3901 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -30,6 +30,8 @@ from .entity import ( ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, ) from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error @@ -49,6 +51,18 @@ class ReolinkSelectEntityDescription( value: Callable[[Host, int], str] | None = None +@dataclass(frozen=True, kw_only=True) +class ReolinkHostSelectEntityDescription( + SelectEntityDescription, + ReolinkHostEntityDescription, +): + """A class that describes host select entities.""" + + get_options: Callable[[Host], list[str]] + method: Callable[[Host, str], Any] + value: Callable[[Host], str] + + @dataclass(frozen=True, kw_only=True) class ReolinkChimeSelectEntityDescription( SelectEntityDescription, @@ -238,6 +252,19 @@ SELECT_ENTITIES = ( ), ) +HOST_SELECT_ENTITIES = ( + ReolinkHostSelectEntityDescription( + key="scene_mode", + cmd_key="GetScene", + translation_key="scene_mode", + entity_category=EntityCategory.CONFIG, + get_options=lambda api: api.baichuan.scene_names, + supported=lambda api: api.supported(None, "scenes"), + value=lambda api: api.baichuan.active_scene, + method=lambda api, name: api.baichuan.set_scene(scene_name=name), + ), +) + CHIME_SELECT_ENTITIES = ( ReolinkChimeSelectEntityDescription( key="motion_tone", @@ -300,12 +327,19 @@ async def async_setup_entry( """Set up a Reolink select entities.""" reolink_data: ReolinkData = config_entry.runtime_data - entities: list[ReolinkSelectEntity | ReolinkChimeSelectEntity] = [ + entities: list[ + ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity + ] = [ ReolinkSelectEntity(reolink_data, channel, entity_description) for entity_description in SELECT_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) ] + entities.extend( + ReolinkHostSelectEntity(reolink_data, entity_description) + for entity_description in HOST_SELECT_ENTITIES + if entity_description.supported(reolink_data.host.api) + ) entities.extend( ReolinkChimeSelectEntity(reolink_data, chime, entity_description) for entity_description in CHIME_SELECT_ENTITIES @@ -360,6 +394,33 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): self.async_write_ha_state() +class ReolinkHostSelectEntity(ReolinkHostCoordinatorEntity, SelectEntity): + """Base select entity class for Reolink Host.""" + + entity_description: ReolinkHostSelectEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkHostSelectEntityDescription, + ) -> None: + """Initialize Reolink select entity.""" + self.entity_description = entity_description + super().__init__(reolink_data) + self._attr_options = entity_description.get_options(self._host.api) + + @property + def current_option(self) -> str | None: + """Return the current option.""" + return self.entity_description.value(self._host.api) + + @raise_translated_error + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.method(self._host.api, option) + self.async_write_ha_state() + + class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): """Base select entity class for Reolink IP cameras.""" diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 80d9156e420..53df658239c 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -799,6 +799,15 @@ }, "sub_bit_rate": { "name": "Fluent bit rate" + }, + "scene_mode": { + "name": "Scene mode", + "state": { + "off": "[%key:common::state::off%]", + "disarm": "Disarmed", + "home": "Home", + "away": "Away" + } } }, "sensor": { diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 672919bc7a9..f2474d640d8 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -145,6 +145,8 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.day_night_state.return_value = "day" host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + host_mock.baichuan.active_scene = "off" + host_mock.baichuan.scene_names = ["off", "home"] host_mock.baichuan.abilities = { 0: {"chnID": 0, "aitype": 34615}, "Host": {"pushAlarm": 7}, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index b034122e1fc..5eb80d16356 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -170,6 +170,9 @@ '0': 1, 'null': 2, }), + 'GetScene': dict({ + 'null': 1, + }), 'GetStateLight': dict({ 'null': 1, }), diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 7910174380a..32bc5e4435e 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -104,6 +104,58 @@ async def test_play_quick_reply_message( reolink_connect.quick_reply_dict = MagicMock() +async def test_host_scene_select( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test host select entity with scene mode.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_scene_mode" + assert hass.states.get(entity_id).state == "off" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "home"}, + blocking=True, + ) + reolink_connect.baichuan.set_scene.assert_called_once() + + reolink_connect.baichuan.set_scene.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "home"}, + blocking=True, + ) + + reolink_connect.baichuan.set_scene.side_effect = InvalidParameterError("Test error") + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, "option": "home"}, + blocking=True, + ) + + reolink_connect.baichuan.active_scene = "Invalid value" + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + reolink_connect.baichuan.set_scene.reset_mock(side_effect=True) + reolink_connect.baichuan.active_scene = "off" + + async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From a2f0970dfcd8876106ba720b47d3e6d400d06848 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Wed, 19 Mar 2025 15:09:10 +0100 Subject: [PATCH 1800/1941] Bump fyta_cli to 0.7.2 (#140930) --- homeassistant/components/fyta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index 1c91807b711..615197203a8 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["fyta_cli"], "quality_scale": "platinum", - "requirements": ["fyta_cli==0.7.1"] + "requirements": ["fyta_cli==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d1081bd3341..37f84836635 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -968,7 +968,7 @@ freesms==0.2.0 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.1 +fyta_cli==0.7.2 # homeassistant.components.google_translate gTTS==2.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab44df341d7..9c084dfd70e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -821,7 +821,7 @@ freebox-api==1.2.2 fritzconnection[qr]==1.14.0 # homeassistant.components.fyta -fyta_cli==0.7.1 +fyta_cli==0.7.2 # homeassistant.components.google_translate gTTS==2.5.3 From 6434befdcdcd1a7442c8ed57b82f5b354692e7ca Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 15:12:43 +0100 Subject: [PATCH 1801/1941] Fix misleading airthings_ble test (#140933) --- tests/components/airthings_ble/test_config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index 314594c612f..2adc5498e7b 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -159,7 +159,6 @@ async def test_user_setup_replaces_ignored_device(hass: HomeAssistant) -> None: domain=DOMAIN, unique_id="cc:cc:cc:cc:cc:cc", source=SOURCE_IGNORE, - data={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"}, ) entry.add_to_hass(hass) with ( From 6af23d2348004a156b9488126667788d71bfebe1 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 19 Mar 2025 15:35:47 +0100 Subject: [PATCH 1802/1941] Add quality scale to Vodafone Station (#139444) * Add quality scale and strict typing to Vodafone Station * mypy and hassfest * tweek * parallel-updates * update * update manifest * apply review comment --- .../components/vodafone_station/manifest.json | 1 + .../vodafone_station/quality_scale.yaml | 88 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/vodafone_station/quality_scale.yaml diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 4acafc8df3a..e3a595d5af8 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,5 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], + "quality_scale": "bronze", "requirements": ["aiovodafone==0.6.1"] } diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml new file mode 100644 index 00000000000..d9240afc2e7 --- /dev/null +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no actions + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: no events + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: todo + comment: button presses not exception handled with HomeAssistantError + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: device not discoverable + discovery: + status: exempt + comment: device not discoverable + docs-data-update: done + docs-examples: + status: todo + comment: add some automation example + docs-known-limitations: + status: exempt + comment: no known limitations, yet + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: + status: todo + comment: add some info for troubleshooting + docs-use-cases: + status: todo + comment: add some use caes + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: no known use case + entity-translations: done + exception-translations: + status: todo + comment: some missing in coordinator + icon-translations: done + reconfiguration-flow: + status: todo + comment: handle host change + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index e1898afc79b..cdd062d2f4c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1087,7 +1087,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "vizio", "vlc", "vlc_telnet", - "vodafone_station", "voicerss", "voip", "volkszaehler", @@ -2171,7 +2170,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "vizio", "vlc", "vlc_telnet", - "vodafone_station", "voicerss", "voip", "volkszaehler", From 6211e378c3abd6d27ca922865814a5570fa114f2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 15:50:09 +0100 Subject: [PATCH 1803/1941] Fix flaky cast tests (#140928) --- tests/components/cast/test_media_player.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index b2ce60e9393..668ed985154 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -1909,6 +1909,7 @@ async def test_group_media_control( ) +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_on_idle( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1939,6 +1940,7 @@ async def test_failed_cast_on_idle( assert "Failed to cast media http://example.com:8123/tts.mp3." in caplog.text +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_other_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1963,6 +1965,7 @@ async def test_failed_cast_other_url( assert "Failed to cast media http://example.com:8123/tts.mp3." in caplog.text +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_internal_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1992,6 +1995,7 @@ async def test_failed_cast_internal_url( ) +@pytest.mark.usefixtures("mock_tts_cache_dir") async def test_failed_cast_external_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 4a5567806b897ea2ebe4f0579c8187c19ea159cc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 19 Mar 2025 16:14:02 +0100 Subject: [PATCH 1804/1941] Add exception translations for IMGW-PIB integration (#140936) Add exception translations --- homeassistant/components/imgw_pib/__init__.py | 9 ++++++++- homeassistant/components/imgw_pib/coordinator.py | 9 ++++++++- homeassistant/components/imgw_pib/strings.json | 8 ++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py index f9524316570..4bceee51f8e 100644 --- a/homeassistant/components/imgw_pib/__init__.py +++ b/homeassistant/components/imgw_pib/__init__.py @@ -38,7 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b hydrological_details=False, ) except (ClientError, TimeoutError, ApiError) as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "entry": entry.title, + "error": repr(err), + }, + ) from err coordinator = ImgwPibDataUpdateCoordinator(hass, entry, imgwpib, station_id) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/imgw_pib/coordinator.py b/homeassistant/components/imgw_pib/coordinator.py index fbe470ca953..f74878d672c 100644 --- a/homeassistant/components/imgw_pib/coordinator.py +++ b/homeassistant/components/imgw_pib/coordinator.py @@ -63,4 +63,11 @@ class ImgwPibDataUpdateCoordinator(DataUpdateCoordinator[HydrologicalData]): try: return await self.imgwpib.get_hydrological_data() except ApiError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(err), + }, + ) from err diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 9a17dcf7087..89be0661c6f 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -25,5 +25,13 @@ "name": "Water temperature" } } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while connecting to the IMGW-PIB API for {entry}: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the IMGW-PIB API for {entry}: {error}" + } } } From 6b9c1e17e05fb19488c611627dd5caf25794d37f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 16:37:07 +0100 Subject: [PATCH 1805/1941] Fix docstring in selector helper (#140929) --- homeassistant/helpers/selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index dd2fd8a677c..f2c76d1d019 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1136,7 +1136,7 @@ class SelectOptionDict(TypedDict): class SelectSelectorMode(StrEnum): - """Possible modes for a number selector.""" + """Possible modes for a select selector.""" LIST = "list" DROPDOWN = "dropdown" From 2c9eb288e3eb6c8737c9c5f7998ce12f761ed26e Mon Sep 17 00:00:00 2001 From: jukrebs <76174575+MaestroOnICe@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:51:39 +0100 Subject: [PATCH 1806/1941] Add capability to display updated firmware versions in Home Assistant (#140524) * add firmware version update * incoperate review feedback --- .../components/iometer/coordinator.py | 15 +++++++ homeassistant/components/iometer/entity.py | 2 +- tests/components/iometer/__init__.py | 12 ++++++ tests/components/iometer/test_init.py | 42 +++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/components/iometer/test_init.py diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py index 708983fb28e..4050341151b 100644 --- a/homeassistant/components/iometer/coordinator.py +++ b/homeassistant/components/iometer/coordinator.py @@ -8,6 +8,7 @@ from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -31,6 +32,7 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): config_entry: IOmeterConfigEntry client: IOmeterClient + current_fw_version: str = "" def __init__( self, @@ -58,4 +60,17 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]): except IOmeterConnectionError as error: raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error + fw_version = f"{status.device.core.version}/{status.device.bridge.version}" + if self.current_fw_version and fw_version != self.current_fw_version: + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, status.device.id)} + ) + assert device_entry + device_registry.async_update_device( + device_entry.id, + sw_version=fw_version, + ) + self.current_fw_version = fw_version + return IOmeterData(reading=reading, status=status) diff --git a/homeassistant/components/iometer/entity.py b/homeassistant/components/iometer/entity.py index 86494857e18..a52ef1c66ed 100644 --- a/homeassistant/components/iometer/entity.py +++ b/homeassistant/components/iometer/entity.py @@ -20,5 +20,5 @@ class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]): identifiers={(DOMAIN, status.device.id)}, manufacturer="IOmeter GmbH", model="IOmeter", - sw_version=f"{status.device.core.version}/{status.device.bridge.version}", + sw_version=coordinator.current_fw_version, ) diff --git a/tests/components/iometer/__init__.py b/tests/components/iometer/__init__.py index 5c08438925e..9e48fb982b3 100644 --- a/tests/components/iometer/__init__.py +++ b/tests/components/iometer/__init__.py @@ -1 +1,13 @@ """Tests for the IOmeter integration.""" + +from homeassistant.core import HomeAssistant + +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() diff --git a/tests/components/iometer/test_init.py b/tests/components/iometer/test_init.py new file mode 100644 index 00000000000..22a20b50c60 --- /dev/null +++ b/tests/components/iometer/test_init.py @@ -0,0 +1,42 @@ +"""Tests for the AirGradient integration.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.iometer.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, async_fire_time_changed + + +async def test_new_firmware_version( + hass: HomeAssistant, + mock_iometer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.sw_version == "build-58/build-65" + mock_iometer_client.get_current_status.return_value.device.core.version = "build-62" + mock_iometer_client.get_current_status.return_value.device.bridge.version = ( + "build-69" + ) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry.sw_version == "build-62/build-69" From 05c61b7ec35b444b0782cc260206b6e30c1d3828 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 19 Mar 2025 17:28:40 +0100 Subject: [PATCH 1807/1941] Rename BackupManager last_non_idle_event to last_action_event (#140291) * Rename BackupManager last_non_idle_event to last_action_event * Update snapshots --- homeassistant/components/backup/manager.py | 4 +- homeassistant/components/backup/websocket.py | 2 +- homeassistant/components/onboarding/views.py | 2 +- .../backup/snapshots/test_backup.ambr | 10 ++-- .../backup/snapshots/test_websocket.ambr | 46 +++++++++---------- tests/components/backup/test_manager.py | 44 +++++++++--------- tests/components/cloud/test_backup.py | 2 +- tests/components/hassio/test_backup.py | 10 ++-- .../onboarding/snapshots/test_views.ambr | 2 +- tests/components/synology_dsm/test_backup.py | 2 +- 10 files changed, 62 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 998e443a3b2..6dbe863185c 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -351,7 +351,7 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = BlockedEvent() - self.last_non_idle_event: ManagerStateEvent | None = None + self.last_action_event: ManagerStateEvent | None = None self._backup_event_subscriptions = hass.data[ DATA_BACKUP ].backup_event_subscriptions @@ -1337,7 +1337,7 @@ class BackupManager: LOGGER.debug("Backup state: %s -> %s", current_state, new_state) self.last_event = event if not isinstance(event, (BlockedEvent, IdleEvent)): - self.last_non_idle_event = event + self.last_action_event = event for subscription in self._backup_event_subscriptions: subscription(event) diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 8b5f35287dd..4c370a4224d 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -55,7 +55,7 @@ async def handle_info( "backups": list(backups.values()), "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, - "last_non_idle_event": manager.last_non_idle_event, + "last_action_event": manager.last_action_event, "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, "state": manager.state, diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a590588c009..5f1d908f7f8 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -367,7 +367,7 @@ class BackupInfoView(BackupOnboardingView): { "backups": list(backups.values()), "state": manager.state, - "last_non_idle_event": manager.last_non_idle_event, + "last_action_event": manager.last_action_event, } ) diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 28ee9b834c1..7cbbb9ddbce 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -114,9 +114,9 @@ 'with_automatic_settings': None, }), ]), + 'last_action_event': None, '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', @@ -148,9 +148,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, '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', @@ -182,9 +182,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, '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', @@ -216,9 +216,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, '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', @@ -250,9 +250,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, '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', diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 6ecb508d9e9..0bef632f0b4 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -3951,9 +3951,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, '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', @@ -3981,9 +3981,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, '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', @@ -4032,9 +4032,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4062,9 +4062,9 @@ }), 'backups': list([ ]), + 'last_action_event': None, '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', @@ -4113,9 +4113,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4175,9 +4175,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4221,9 +4221,9 @@ 'with_automatic_settings': None, }), ]), + 'last_action_event': None, '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', @@ -4278,9 +4278,9 @@ 'with_automatic_settings': None, }), ]), + 'last_action_event': None, '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', @@ -4333,9 +4333,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4395,9 +4395,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4458,9 +4458,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4522,9 +4522,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4584,9 +4584,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4646,9 +4646,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4709,9 +4709,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -4773,9 +4773,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -5350,9 +5350,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -5401,9 +5401,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -5456,9 +5456,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -5534,9 +5534,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -5586,9 +5586,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -5638,9 +5638,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', @@ -5690,9 +5690,9 @@ 'with_automatic_settings': True, }), ]), + 'last_action_event': None, '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', diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index 41f98d6fa53..fef4b84ac61 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -538,7 +538,7 @@ async def test_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -771,7 +771,7 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -863,7 +863,7 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": { + "last_action_event": { "manager_state": "create_backup", "reason": "upload_failed", "stage": None, @@ -1153,7 +1153,7 @@ async def test_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1250,7 +1250,7 @@ async def test_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1346,7 +1346,7 @@ async def test_initiate_backup_file_error_upload_to_agents( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1470,7 +1470,7 @@ async def test_initiate_backup_file_error_create_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -1967,7 +1967,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2050,7 +2050,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": { + "last_action_event": { "manager_state": "receive_backup", "reason": None, "stage": None, @@ -2103,7 +2103,7 @@ async def test_receive_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2215,7 +2215,7 @@ async def test_receive_backup_file_write_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2311,7 +2311,7 @@ async def test_receive_backup_read_tar_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -2476,7 +2476,7 @@ async def test_receive_backup_file_read_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3287,7 +3287,7 @@ async def test_initiate_backup_per_agent_encryption( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3390,7 +3390,7 @@ async def test_initiate_backup_per_agent_encryption( @pytest.mark.parametrize( - ("restore_result", "last_non_idle_event"), + ("restore_result", "last_action_event"), [ ( {"error": None, "error_type": None, "success": True}, @@ -3416,7 +3416,7 @@ async def test_restore_progress_after_restart( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, restore_result: dict[str, Any], - last_non_idle_event: dict[str, Any], + last_action_event: dict[str, Any], ) -> None: """Test restore backup progress after restart.""" @@ -3434,7 +3434,7 @@ async def test_restore_progress_after_restart( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": last_non_idle_event, + "last_action_event": last_action_event, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3460,7 +3460,7 @@ async def test_restore_progress_after_restart_fail_to_remove( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", @@ -3485,20 +3485,20 @@ async def test_manager_blocked_until_home_assistant_started( manager = hass.data[DATA_MANAGER] assert manager.state == BackupManagerState.BLOCKED - assert manager.last_non_idle_event is None + assert manager.last_action_event is None # Fired when Home Assistant changes to starting state hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() await hass.async_block_till_done() assert manager.state == BackupManagerState.BLOCKED - assert manager.last_non_idle_event is None + assert manager.last_action_event is None # Fired when Home Assistant changes to running state hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert manager.state == BackupManagerState.IDLE - assert manager.last_non_idle_event is None + assert manager.last_action_event is None async def test_manager_not_blocked_after_restore( @@ -3523,7 +3523,7 @@ async def test_manager_not_blocked_after_restore( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": { + "last_action_event": { "manager_state": "restore_backup", "reason": None, "stage": None, diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 5220d3eccd5..dd6252c4d62 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -208,7 +208,7 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index 07a68b158d3..e00994b355a 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -2394,7 +2394,7 @@ async def test_reader_writer_restore_wrong_parameters( @pytest.mark.parametrize( - ("get_job_result", "last_non_idle_event"), + ("get_job_result", "last_action_event"), [ ( TEST_JOB_DONE, @@ -2422,7 +2422,7 @@ async def test_restore_progress_after_restart( hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, get_job_result: supervisor_jobs.Job, - last_non_idle_event: dict[str, Any], + last_action_event: dict[str, Any], ) -> None: """Test restore backup progress after restart.""" @@ -2438,7 +2438,7 @@ async def test_restore_progress_after_restart( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] == last_non_idle_event + assert response["result"]["last_action_event"] == last_action_event assert response["result"]["state"] == "idle" @@ -2516,7 +2516,7 @@ async def test_restore_progress_after_restart_report_progress( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] == { + assert response["result"]["last_action_event"] == { "manager_state": "restore_backup", "reason": None, "stage": "addons", @@ -2545,7 +2545,7 @@ async def test_restore_progress_after_restart_unknown_job( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] is None + assert response["result"]["last_action_event"] is None assert response["result"]["state"] == "idle" diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr index 2d084bd9ade..48ddf30d1f2 100644 --- a/tests/components/onboarding/snapshots/test_views.ambr +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -62,7 +62,7 @@ 'with_automatic_settings': None, }), ]), - 'last_non_idle_event': None, + 'last_action_event': None, 'state': 'idle', }) # --- diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 24cfe29f52b..8475a253231 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -338,7 +338,7 @@ async def test_agents_list_backups_error( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "last_non_idle_event": None, + "last_action_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, "state": "idle", From d99df8701cbac0136d0de1fe55ca28009a1b6f26 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 18:50:19 +0100 Subject: [PATCH 1808/1941] Use official spelling "FFmpeg" in user-facing strings (#140937) * Use official spelling "FFmpeg" in user-facing strings * Replace "a" with "an" --- homeassistant/components/ffmpeg/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ffmpeg/strings.json b/homeassistant/components/ffmpeg/strings.json index 66c1f19de5b..cac7fcfc48c 100644 --- a/homeassistant/components/ffmpeg/strings.json +++ b/homeassistant/components/ffmpeg/strings.json @@ -2,7 +2,7 @@ "services": { "restart": { "name": "[%key:common::action::restart%]", - "description": "Sends a restart command to a ffmpeg based sensor.", + "description": "Sends a restart command to an FFmpeg-based sensor.", "fields": { "entity_id": { "name": "Entity", @@ -12,7 +12,7 @@ }, "start": { "name": "[%key:common::action::start%]", - "description": "Sends a start command to a ffmpeg based sensor.", + "description": "Sends a start command to an FFmpeg-based sensor.", "fields": { "entity_id": { "name": "Entity", @@ -22,7 +22,7 @@ }, "stop": { "name": "[%key:common::action::stop%]", - "description": "Sends a stop command to a ffmpeg based sensor.", + "description": "Sends a stop command to an FFmpeg-based sensor.", "fields": { "entity_id": { "name": "Entity", From 8afd9c0c448ca5ebfee90e2409ca694ed1d8f6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 19 Mar 2025 18:53:14 +0100 Subject: [PATCH 1809/1941] Handle API rate limit error on Home Connect entities fetch (#139384) * Handle API rate limit error on entities fetch * Apply suggestions Co-authored-by: Martin Hjelmare * Add decorator (does not work) * Fix decorator * Apply suggestions Co-authored-by: Martin Hjelmare * Add test --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/const.py | 1 + .../components/home_connect/entity.py | 44 +++++- .../components/home_connect/number.py | 23 +-- .../components/home_connect/select.py | 20 ++- .../components/home_connect/sensor.py | 21 ++- tests/components/home_connect/test_number.py | 97 ++++++++++++- tests/components/home_connect/test_select.py | 136 +++++++++++++++++- tests/components/home_connect/test_sensor.py | 124 +++++++++++++++- 8 files changed, 431 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 1c607ccec28..6255a513e39 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -10,6 +10,7 @@ from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" +API_DEFAULT_RETRY_AFTER = 60 APPLIANCES_WITH_PROGRAMS = ( "CleaningRobot", diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index b55ff374f34..8a0f9bd7640 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -1,21 +1,28 @@ """Home Connect entity base class.""" from abc import abstractmethod +from collections.abc import Callable, Coroutine import contextlib +from datetime import datetime import logging -from typing import cast +from typing import Any, Concatenate, cast from aiohomeconnect.model import EventKey, OptionKey -from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError +from aiohomeconnect.model.error import ( + ActiveProgramNotSetError, + HomeConnectError, + TooManyRequestsError, +) from homeassistant.const import STATE_UNAVAILABLE 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.event import async_call_later from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import API_DEFAULT_RETRY_AFTER, DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator from .utils import get_dict_from_home_connect_error @@ -127,3 +134,34 @@ class HomeConnectOptionEntity(HomeConnectEntity): def bsh_key(self) -> OptionKey: """Return the BSH key.""" return cast(OptionKey, self.entity_description.key) + + +def constraint_fetcher[_EntityT: HomeConnectEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Decorate the function to catch Home Connect too many requests error and retry later. + + If it needs to be called later, it will call async_write_ha_state function + """ + + async def handler_to_return( + self: _EntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + async def handler(_datetime: datetime | None = None) -> None: + try: + await func(self, *args, **kwargs) + except TooManyRequestsError as err: + if (retry_after := err.retry_after) is None: + retry_after = API_DEFAULT_RETRY_AFTER + async_call_later(self.hass, retry_after, handler) + except HomeConnectError as err: + _LOGGER.error( + "Error fetching constraints for %s: %s", self.entity_id, err + ) + else: + if _datetime is not None: + self.async_write_ha_state() + + await handler() + + return handler_to_return diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index cef35005b32..db0258f2739 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -25,7 +25,7 @@ from .const import ( UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity, HomeConnectOptionEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -189,19 +189,25 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): }, ) from err + @constraint_fetcher async def async_fetch_constraints(self) -> None: """Fetch the max and min values and step for the number entity.""" - try: + setting_key = cast(SettingKey, self.bsh_key) + data = self.appliance.settings.get(setting_key) + if not data or not data.unit or not data.constraints: data = await self.coordinator.client.get_setting( - self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key) + self.appliance.info.ha_id, setting_key=setting_key ) - except HomeConnectError as err: - _LOGGER.error("An error occurred: %s", err) - else: + if data.unit: + self._attr_native_unit_of_measurement = data.unit self.set_constraints(data) def set_constraints(self, setting: GetSetting) -> None: """Set constraints for the number entity.""" + if setting.unit: + self._attr_native_unit_of_measurement = UNIT_MAP.get( + setting.unit, setting.unit + ) if not (constraints := setting.constraints): return if constraints.max: @@ -222,10 +228,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): """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") + not hasattr(self, "_attr_native_unit_of_measurement") + or not hasattr(self, "_attr_native_min_value") or not hasattr(self, "_attr_native_max_value") or not hasattr(self, "_attr_native_step") ): @@ -253,7 +259,6 @@ class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity): 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 ( diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 527fd827399..001c2e9ec31 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -1,8 +1,8 @@ """Provides a select platform for Home Connect.""" from collections.abc import Callable, Coroutine -import contextlib from dataclasses import dataclass +import logging from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient @@ -47,9 +47,11 @@ from .coordinator import ( HomeConnectConfigEntry, HomeConnectCoordinator, ) -from .entity import HomeConnectEntity, HomeConnectOptionEntity +from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 1 FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = { @@ -460,17 +462,21 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() + await self.async_fetch_options() + + @constraint_fetcher + async def async_fetch_options(self) -> None: + """Fetch options from the API.""" 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), - ) + 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._original_option_keys = set(setting.constraints.allowed_values) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index c12e1b7b6e4..796af8260fc 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,12 +1,11 @@ """Provides a sensor for Home Connect.""" -import contextlib from dataclasses import dataclass from datetime import timedelta +import logging from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -28,7 +27,9 @@ from .const import ( UNIT_MAP, ) from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry -from .entity import HomeConnectEntity +from .entity import HomeConnectEntity, constraint_fetcher + +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -335,16 +336,14 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): else: await self.fetch_unit() + @constraint_fetcher 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 - ) + 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): diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index 214dcb6137c..bb87cf9f3dc 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable import random -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.model import ( ArrayOfEvents, @@ -22,6 +22,7 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, SelectedProgramNotSetError, + TooManyRequestsError, ) from aiohomeconnect.model.program import ( ProgramDefinitionConstraints, @@ -47,7 +48,7 @@ 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 +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -340,6 +341,98 @@ async def test_number_entity_functionality( assert hass.states.is_state(entity_id, str(float(value))) +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("retry_after", [0, None]) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "type", + "min_value", + "max_value", + "step_size", + "unit_of_measurement", + ), + [ + ( + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR, + "Double", + 7, + 15, + 5, + "°C", + ), + ], +) +@patch("homeassistant.components.home_connect.entity.API_DEFAULT_RETRY_AFTER", new=0) +async def test_fetch_constraints_after_rate_limit_error( + retry_after: int | None, + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + type: str, + min_value: int, + max_value: int, + step_size: int, + unit_of_measurement: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test that, if a API rate limit error is raised, the constraints are fetched later.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance_ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=random.randint(min_value, max_value), + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=retry_after), + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value=random.randint(min_value, max_value), + unit=unit_of_measurement, + type=type, + constraints=SettingConstraints( + min=min_value, + max=max_value, + step_size=step_size, + ), + ), + ] + ) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_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 + + @pytest.mark.parametrize( ("entity_id", "setting_key", "mock_attr"), [ diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index 8ce91ed681c..f20be33081c 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -21,6 +21,7 @@ from aiohomeconnect.model.error import ( ActiveProgramNotSetError, HomeConnectError, SelectedProgramNotSetError, + TooManyRequestsError, ) from aiohomeconnect.model.program import ( EnumerateProgram, @@ -50,7 +51,7 @@ 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 +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -575,6 +576,139 @@ async def test_fetch_allowed_values( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "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_after_rate_limit_error( + appliance_ha_id: str, + entity_id: str, + 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.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance_ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=0), + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + constraints=SettingConstraints( + allowed_values=allowed_values, + ), + ), + ] + ) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options + + +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize( + ( + "entity_id", + "setting_key", + "exception", + "expected_options", + ), + [ + ( + "select.hood_ambient_light_color", + SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR, + HomeConnectError(), + { + "b_s_h_common_enum_type_ambient_light_color_custom_color", + *{str(i) for i in range(1, 100)}, + }, + ), + ], +) +async def test_default_values_after_fetch_allowed_values_error( + appliance_ha_id: str, + entity_id: str, + setting_key: SettingKey, + exception: Exception, + 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.""" + + def get_settings_side_effect(ha_id: str): + if ha_id != appliance_ha_id: + return ArrayOfSettings([]) + return ArrayOfSettings( + [ + GetSetting( + key=setting_key, + raw_key=setting_key.value, + value="", # Not important + ) + ] + ) + + client.get_settings = AsyncMock(side_effect=get_settings_side_effect) + client.get_setting = AsyncMock(side_effect=exception) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert client.get_setting.call_count == 1 + + 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"), [ diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 04f5e056aa5..a7836223737 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -13,7 +13,7 @@ from aiohomeconnect.model import ( Status, StatusKey, ) -from aiohomeconnect.model.error import HomeConnectApiError +from aiohomeconnect.model.error import HomeConnectApiError, TooManyRequestsError from freezegun.api import FrozenDateTimeFactory import pytest @@ -26,12 +26,13 @@ from homeassistant.components.home_connect.const import ( BSH_EVENT_PRESENT_STATE_PRESENT, DOMAIN, ) +from homeassistant.components.home_connect.coordinator import HomeConnectError from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed TEST_HC_APP = "Dishwasher" @@ -724,3 +725,122 @@ async def test_sensor_unit_fetching( ) assert client.get_status_value.call_count == get_status_value_call_count + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching_error( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + 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, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock(side_effect=HomeConnectError()) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + assert hass.states.get(entity_id) + + +@pytest.mark.parametrize( + ( + "appliance_ha_id", + "entity_id", + "status_key", + "unit", + ), + [ + ( + "Oven", + "sensor.oven_current_oven_cavity_temperature", + StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, + "°C", + ), + ], + indirect=["appliance_ha_id"], +) +async def test_sensor_unit_fetching_after_rate_limit_error( + appliance_ha_id: str, + entity_id: str, + status_key: StatusKey, + unit: str, + 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, + ) + ] + ) + + client.get_status = AsyncMock(side_effect=get_status_mock) + client.get_status_value = AsyncMock( + side_effect=[ + TooManyRequestsError("error.key", retry_after=0), + Status( + key=status_key, + raw_key=status_key.value, + value=0, + unit=unit, + ), + ] + ) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + assert client.get_status_value.call_count == 2 + + entity_state = hass.states.get(entity_id) + assert entity_state + assert entity_state.attributes["unit_of_measurement"] == unit From 2ffec3415cc46908c539367e482bd8d2504d8490 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 19 Mar 2025 19:17:42 +0100 Subject: [PATCH 1810/1941] Use official spelling "FFmpeg" in `ezviz` / `canary` / `onvif` (#140938) * Use official spelling "FFmpeg" in `ezviz` * Use official spelling "FFmpeg" in `canary` Fix sentence-casing along the way. * Use official spelling "FFmpeg" in `onvif` Fix sentence-casing along the way --- homeassistant/components/canary/strings.json | 4 ++-- homeassistant/components/ezviz/strings.json | 2 +- homeassistant/components/onvif/strings.json | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/canary/strings.json b/homeassistant/components/canary/strings.json index 699e8b25e11..8be11a48b5e 100644 --- a/homeassistant/components/canary/strings.json +++ b/homeassistant/components/canary/strings.json @@ -21,8 +21,8 @@ "step": { "init": { "data": { - "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras", - "timeout": "Request Timeout (seconds)" + "ffmpeg_arguments": "Arguments passed to FFmpeg for cameras", + "timeout": "Request timeout (seconds)" } } } diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index f1653661cdd..cd8bbc9d199 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -54,7 +54,7 @@ "init": { "data": { "timeout": "Request timeout (seconds)", - "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras" + "ffmpeg_arguments": "Arguments passed to FFmpeg for cameras" } } } diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 0afb5e59e8e..7988c50b1ac 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -62,12 +62,12 @@ "step": { "onvif_devices": { "data": { - "extra_arguments": "Extra FFMPEG arguments", + "extra_arguments": "Extra FFmpeg arguments", "rtsp_transport": "RTSP transport mechanism", "use_wallclock_as_timestamps": "Use wall clock as timestamps", - "enable_webhooks": "Enable Webhooks" + "enable_webhooks": "Enable webhooks" }, - "title": "ONVIF Device Options" + "title": "ONVIF device options" } } }, From 4344e9d604a6cc7f930c2fcf52fd9b86d38e0bf9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 19 Mar 2025 19:23:15 +0100 Subject: [PATCH 1811/1941] Add remote control status to SmartThings (#140197) * Add remote control status to SmartThings * Add remote control status to SmartThings * Fix --- .../components/smartthings/binary_sensor.py | 7 + .../components/smartthings/icons.json | 12 + .../components/smartthings/strings.json | 3 + .../snapshots/test_binary_sensor.ambr | 376 ++++++++++++++++++ 4 files changed, 398 insertions(+) create mode 100644 homeassistant/components/smartthings/icons.json diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 25b9cbefb6f..0654846273e 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -75,6 +75,13 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="present", ) }, + Capability.REMOTE_CONTROL_STATUS: { + Attribute.REMOTE_CONTROL_ENABLED: SmartThingsBinarySensorEntityDescription( + key=Attribute.REMOTE_CONTROL_ENABLED, + translation_key="remote_control", + is_on_key="true", + ) + }, Capability.SOUND_SENSOR: { Attribute.SOUND: SmartThingsBinarySensorEntityDescription( key=Attribute.SOUND, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json new file mode 100644 index 00000000000..cbc4b6b80ce --- /dev/null +++ b/homeassistant/components/smartthings/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "remote_control": { + "default": "mdi:remote-off", + "state": { + "on": "mdi:remote" + } + } + } + } +} diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 99e1550caba..fdc905468f5 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -36,6 +36,9 @@ "filter_status": { "name": "Filter status" }, + "remote_control": { + "name": "Remote control" + }, "valve": { "name": "Valve" } diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 27a5e38a123..6223c6c526c 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -143,6 +143,147 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-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.microwave_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-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.oven_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-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.vulcan_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -191,6 +332,241 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_remote_control-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.dishwasher_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-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.dryer_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-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.seca_roupa_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-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.washer_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-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.washing_machine_remote_control', + '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': 'Remote control', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.remoteControlEnabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[ecobee_sensor][binary_sensor.child_bedroom_motion-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 100e4425e4f301856b20672e9505c692faaf276e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 19 Mar 2025 20:13:46 +0100 Subject: [PATCH 1812/1941] Log SmartThings subscription error on exception (#140939) --- homeassistant/components/smartthings/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 538a4a16171..58afbb6cb41 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -141,7 +141,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID], ) except SmartThingsSinkError as err: - _LOGGER.debug("Couldn't create a new subscription: %s", err) + _LOGGER.exception("Couldn't create a new subscription") raise ConfigEntryNotReady from err subscription_id = subscription.subscription_id _handle_new_subscription_identifier(subscription_id) From a600bc5e5788d757ad10bbcd8ce78fc6a92b3bc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Mar 2025 11:19:04 -1000 Subject: [PATCH 1813/1941] Add turn on/off support to HomeKit TVs (#140957) * Add turn on/off support to HomeKit TVs * 0 = off, 1 = on, not a bool * add coverage * update snapshot --- .../homekit_controller/media_player.py | 10 +++- .../snapshots/test_init.ambr | 4 +- .../specific_devices/test_lg_tv.py | 56 ++++++++++++++++++ .../homekit_controller/test_media_player.py | 59 +++++++++++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 tests/components/homekit_controller/specific_devices/test_lg_tv.py diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 5315c7c89f3..e3b4a760680 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -83,7 +83,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" - features = MediaPlayerEntityFeature(0) + features = MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_ON if self.service.has(CharacteristicsTypes.ACTIVE_IDENTIFIER): features |= MediaPlayerEntityFeature.SELECT_SOURCE @@ -177,6 +177,14 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): return MediaPlayerState.ON + async def async_turn_on(self) -> None: + """Turn the tv on.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: 1}) + + async def async_turn_off(self) -> None: + """Turn the tv off.""" + await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: 0}) + async def async_media_play(self) -> None: """Send play command.""" if self.state == MediaPlayerState.PLAYING: diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index a41964d98cc..62b53df33f2 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -14352,7 +14352,7 @@ 'original_name': 'LG webOS TV AF80', 'platform': 'homekit_controller', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48', 'unit_of_measurement': None, @@ -14371,7 +14371,7 @@ 'AV', 'HDMI 4', ]), - 'supported_features': , + 'supported_features': , }), 'entity_id': 'media_player.lg_webos_tv_af80', 'state': 'on', diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py new file mode 100644 index 00000000000..48d1fc3ebdc --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -0,0 +1,56 @@ +"""Test against characteristics captured from an LG TV.""" + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE_LIST, + MediaPlayerEntityFeature, +) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON +from homeassistant.core import HomeAssistant + +from ..common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_lg_tv_setup(hass: HomeAssistant) -> None: + """Test that a LG TV can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "lg_tv.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="LG webOS TV AF80", + model="OLED55B9PUA", + manufacturer="LG Electronics", + sw_version="04.71.04", + hw_version="1", + serial_number="A0000A000000000A", + devices=[], + entities=[], + ), + ) + + state = hass.states.get("media_player.lg_webos_tv_af80") + assert state is not None + assert state.state == STATE_ON + assert state.attributes[ATTR_INPUT_SOURCE_LIST] == [ + "AirPlay", + "Live TV", + "HDMI 1", + "Sony", + "Apple", + "AV", + "HDMI 4", + ] + features = state.attributes[ATTR_SUPPORTED_FEATURES] + assert features & MediaPlayerEntityFeature.TURN_ON + assert features & MediaPlayerEntityFeature.TURN_OFF + assert features & MediaPlayerEntityFeature.SELECT_SOURCE + assert features & MediaPlayerEntityFeature.PLAY + assert features & MediaPlayerEntityFeature.PAUSE diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index d1d280ef265..e00dde92a81 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -10,6 +10,11 @@ from aiohomekit.model.characteristics import ( from aiohomekit.model.services import Service, ServicesTypes import pytest +from homeassistant.components.media_player import ( + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -408,3 +413,57 @@ async def test_migrate_unique_id( entity_registry.async_get(media_player_entry.entity_id).unique_id == f"00:00:00:00:00:00_{aid}_8" ) + + +async def test_turn_on(hass: HomeAssistant, get_next_aid: Callable[[], int]) -> None: + """Test that we can turn on a media player.""" + helper = await setup_test_component( + hass, get_next_aid(), create_tv_service_with_target_media_state + ) + + await helper.async_update( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.CURRENT_MEDIA_STATE: 0, + }, + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.ACTIVE: 1, + }, + ) + + +async def test_turn_off(hass: HomeAssistant, get_next_aid: Callable[[], int]) -> None: + """Test that we can turn off a media player.""" + helper = await setup_test_component( + hass, get_next_aid(), create_tv_service_with_target_media_state + ) + + await helper.async_update( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.CURRENT_MEDIA_STATE: 0, + }, + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.TELEVISION, + { + CharacteristicsTypes.ACTIVE: 0, + }, + ) From d9cf2750d5b48113a7d063856f498698f4df43f7 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 19 Mar 2025 22:58:19 -0700 Subject: [PATCH 1814/1941] Ensure file is correctly uploaded by the GenAI SDK (#140969) Opened the file outside of the SDK --- .../google_generative_ai_conversation/__init__.py | 8 +++++++- .../google_generative_ai_conversation/test_init.py | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 6b10565e0b5..c32d7b5ddea 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import mimetypes from pathlib import Path from google import genai # type: ignore[attr-defined] @@ -83,7 +84,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if not Path(filename).exists(): raise HomeAssistantError(f"`{filename}` does not exist") - prompt_parts.append(client.files.upload(file=filename)) + mimetype = mimetypes.guess_type(filename)[0] + with open(filename, "rb") as file: + uploaded_file = client.files.upload( + file=file, config={"mime_type": mimetype} + ) + prompt_parts.append(uploaded_file) await hass.async_add_executor_job(append_files_to_prompt) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 25533ffd46e..a08acc0df3f 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,6 +1,6 @@ """Tests for the Google Generative AI Conversation integration.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, mock_open, patch import pytest from requests.exceptions import Timeout @@ -71,6 +71,8 @@ async def test_generate_content_service_with_image( ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), + patch("builtins.open", mock_open(read_data="this is an image")), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), ): response = await hass.services.async_call( "google_generative_ai_conversation", From 9f68ac575dbf4e46e003bea8b5128d095a81d2e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:20:33 +0100 Subject: [PATCH 1815/1941] Bump actions/upload-artifact from 4.6.1 to 4.6.2 (#140976) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.1 to 4.6.2. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.6.1...v4.6.2) --- updated-dependencies: - dependency-name: actions/upload-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 | 2 +- .github/workflows/ci.yaml | 30 +++++++++++++++--------------- .github/workflows/wheels.yml | 8 ++++---- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0aac66c2747..44dea4dc6ec 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.1 + uses: actions/upload-artifact@v4.6.2 with: name: translations path: translations.tar.gz diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4d8849abfda..584c9f10e42 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -552,7 +552,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -695,7 +695,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.1 + uses: actions/upload-artifact@v4.6.2 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -907,7 +907,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.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest_buckets path: pytest_buckets.txt @@ -1007,21 +1007,21 @@ 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.1 + uses: actions/upload-artifact@v4.6.2 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.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1138,7 +1138,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.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1146,7 +1146,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1154,7 +1154,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1273,7 +1273,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.1 + uses: actions/upload-artifact@v4.6.2 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1281,7 +1281,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1289,7 +1289,7 @@ jobs: overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: test-results-postgres-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1420,21 +1420,21 @@ 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.1 + uses: actions/upload-artifact@v4.6.2 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.1 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml overwrite: true - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4baddd3a80f..3c3af223c25 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.1 + uses: actions/upload-artifact@v4.6.2 with: name: env_file path: ./.env_file @@ -99,14 +99,14 @@ jobs: overwrite: true - name: Upload build_constraints - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 with: name: build_constraints path: ./build_constraints.txt overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.6.1 + uses: actions/upload-artifact@v4.6.2 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.1 + uses: actions/upload-artifact@v4.6.2 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt From 006dde435e5e270f2893e7897c0630159cd0cdd8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 09:26:39 +0100 Subject: [PATCH 1816/1941] Clarify descriptions of `lcn.address_to_device_id` action (#140979) Clarify descriptions of `lcn.address_to_device` action Changes the wording of the action and field descriptions so there is less ambiguity for translations. --- homeassistant/components/lcn/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 0bdd85a3678..0a8112d997a 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -396,19 +396,19 @@ }, "address_to_device_id": { "name": "Address to device ID", - "description": "Convert LCN address to device ID.", + "description": "Converts an LCN address into a device ID.", "fields": { "id": { "name": "Module or group ID", - "description": "Target module or group ID." + "description": "Module or group number of the target." }, "segment_id": { "name": "Segment ID", - "description": "Target segment ID." + "description": "Segment number of the target." }, "type": { "name": "Type", - "description": "Target type." + "description": "Module type of the target." }, "host": { "name": "Host name", From 03bd8cd251cbb15032c37c69eba8bf228f07b4b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:30:36 +0100 Subject: [PATCH 1817/1941] Bump github/codeql-action from 3.28.11 to 3.28.12 (#140975) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.11 to 3.28.12. - [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.11...v3.28.12) --- 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 c4f98f2d863..f4d4144243c 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.11 + uses: github/codeql-action/init@v3.28.12 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.11 + uses: github/codeql-action/analyze@v3.28.12 with: category: "/language:python" From adf3e4fccad1e935b4cbb652c364415e6ce85d88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Mar 2025 09:30:59 +0100 Subject: [PATCH 1818/1941] Bump actions/download-artifact from 4.2.0 to 4.2.1 (#140974) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.2.0 to 4.2.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4.2.0...v4.2.1) --- 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 | 8 ++++---- .github/workflows/wheels.yml | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 44dea4dc6ec..03c38c60a10 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.2.0 + uses: actions/download-artifact@v4.2.1 with: name: translations @@ -462,7 +462,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: translations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 584c9f10e42..d0b5923b1fc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -968,7 +968,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: pytest_buckets - name: Compile English translations @@ -1312,7 +1312,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1454,7 +1454,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: pattern: coverage-* - name: Upload coverage to Codecov @@ -1479,7 +1479,7 @@ jobs: timeout-minutes: 10 steps: - name: Download all coverage artifacts - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: pattern: test-results-* - name: Upload test results to Codecov diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3c3af223c25..cdf0c07cccf 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.2.0 + uses: actions/download-artifact@v4.2.1 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: requirements_diff @@ -187,22 +187,22 @@ jobs: uses: actions/checkout@v4.2.2 - name: Download env_file - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: env_file - name: Download build_constraints - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: build_constraints - name: Download requirements_diff - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: requirements_diff - name: Download requirements_all_wheels - uses: actions/download-artifact@v4.2.0 + uses: actions/download-artifact@v4.2.1 with: name: requirements_all_wheels From 2ec80fd1ca8e8909b2f31d218b740ab8381b1482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 20 Mar 2025 09:39:28 +0100 Subject: [PATCH 1819/1941] Fix initial fetch of Home Connect appliance data to handle API rate limit errors (#139379) * Fix initial fetch of appliance data to handle API rate limit errors * Apply comments * Delete stale function * Handle api rate limit error at options fetching * Update appliances after stream non-breaking error * Always initialize coordinator data * Improve device update * Update test description Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/__init__.py | 9 +- .../components/home_connect/common.py | 35 ------ .../components/home_connect/coordinator.py | 100 ++++++++++++++++-- .../home_connect/test_coordinator.py | 44 +++++++- tests/components/home_connect/test_init.py | 50 ++++++++- 5 files changed, 188 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 6814ab3eed2..70b357518da 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -629,14 +629,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry) home_connect_client = HomeConnectClient(config_entry_auth) coordinator = HomeConnectCoordinator(hass, entry, home_connect_client) - await coordinator.async_config_entry_first_refresh() - + await coordinator.async_setup() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.runtime_data.start_event_listener() + entry.async_create_background_task( + hass, + coordinator.async_refresh(), + f"home_connect-initial-full-refresh-{entry.entry_id}", + ) + return True diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py index f52b59bc213..cd3fefad80c 100644 --- a/homeassistant/components/home_connect/common.py +++ b/homeassistant/components/home_connect/common.py @@ -137,41 +137,6 @@ def setup_home_connect_entry( 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 - for entity in entities_to_add - } - ) - entities.extend(entities_to_add) - async_add_entities(entities) - entry.async_on_unload( entry.runtime_data.async_add_special_listener( partial( diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index e877dc7bfe4..495b4efab32 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -import asyncio +from asyncio import sleep as asyncio_sleep from collections import defaultdict from collections.abc import Callable from dataclasses import dataclass @@ -29,6 +29,7 @@ from aiohomeconnect.model.error import ( HomeConnectApiError, HomeConnectError, HomeConnectRequestError, + TooManyRequestsError, UnauthorizedError, ) from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption @@ -36,11 +37,11 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed +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 APPLIANCES_WITH_PROGRAMS, DOMAIN +from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -154,7 +155,7 @@ class HomeConnectCoordinator( f"home_connect-events_listener_task-{self.config_entry.entry_id}", ) - async def _event_listener(self) -> None: + async def _event_listener(self) -> None: # noqa: C901 """Match event with listener for event type.""" retry_time = 10 while True: @@ -269,7 +270,7 @@ class HomeConnectCoordinator( error, retry_time, ) - await asyncio.sleep(retry_time) + 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) @@ -278,6 +279,13 @@ class HomeConnectCoordinator( ) break + # Trigger to delete the possible depaired device entities + # from known_entities variable at common.py + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context: + listener() + @callback def _call_event_listener(self, event_message: EventMessage) -> None: """Call listener for event.""" @@ -295,6 +303,42 @@ class HomeConnectCoordinator( async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: """Fetch data from Home Connect.""" + await self._async_setup() + + for appliance_data in self.data.values(): + appliance = appliance_data.info + ha_id = appliance.ha_id + while True: + try: + self.data[ha_id] = await self._get_appliance_data( + appliance, self.data.get(ha_id) + ) + except TooManyRequestsError as err: + _LOGGER.debug( + "Rate limit exceeded on initial fetch: %s", + err, + ) + await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER) + else: + break + + for listener, context in self._special_listeners.values(): + assert isinstance(context, tuple) + if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context: + listener() + + return self.data + + async def async_setup(self) -> None: + """Set up the devices.""" + try: + await self._async_setup() + except UpdateFailed as err: + raise ConfigEntryNotReady from err + + async def _async_setup(self) -> None: + """Set up the devices.""" + old_appliances = set(self.data.keys()) try: appliances = await self.client.get_home_appliances() except UnauthorizedError as error: @@ -312,12 +356,38 @@ class HomeConnectCoordinator( translation_placeholders=get_dict_from_home_connect_error(error), ) from error - return { - appliance.ha_id: await self._get_appliance_data( - appliance, self.data.get(appliance.ha_id) + for appliance in appliances.homeappliances: + self.device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, appliance.ha_id)}, + manufacturer=appliance.brand, + name=appliance.name, + model=appliance.vib, ) - for appliance in appliances.homeappliances - } + if appliance.ha_id not in self.data: + self.data[appliance.ha_id] = HomeConnectApplianceData( + commands=set(), + events={}, + info=appliance, + options={}, + programs=[], + settings={}, + status={}, + ) + else: + self.data[appliance.ha_id].info.connected = appliance.connected + old_appliances.remove(appliance.ha_id) + + for ha_id in old_appliances: + self.data.pop(ha_id, None) + device = self.device_registry.async_get_device( + identifiers={(DOMAIN, ha_id)} + ) + if device: + self.device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) async def _get_appliance_data( self, @@ -339,6 +409,8 @@ class HomeConnectCoordinator( await self.client.get_settings(appliance.ha_id) ).settings } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching settings for %s: %s", @@ -351,6 +423,8 @@ class HomeConnectCoordinator( status.key: status for status in (await self.client.get_status(appliance.ha_id)).status } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching status for %s: %s", @@ -365,6 +439,8 @@ class HomeConnectCoordinator( if appliance.type in APPLIANCES_WITH_PROGRAMS: try: all_programs = await self.client.get_all_programs(appliance.ha_id) + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching programs for %s: %s", @@ -421,6 +497,8 @@ class HomeConnectCoordinator( await self.client.get_available_commands(appliance.ha_id) ).commands } + except TooManyRequestsError: + raise except HomeConnectError: commands = set() @@ -455,6 +533,8 @@ class HomeConnectCoordinator( ).options or [] } + except TooManyRequestsError: + raise except HomeConnectError as error: _LOGGER.debug( "Error fetching options for %s: %s", diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 1e584335fcd..84bef94d658 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -29,6 +29,7 @@ from homeassistant.components.home_connect.const import ( BSH_DOOR_STATE_OPEN, BSH_EVENT_PRESENT_STATE_PRESENT, BSH_POWER_OFF, + DOMAIN, ) from homeassistant.config_entries import ConfigEntries, ConfigEntryState from homeassistant.const import EVENT_STATE_REPORTED, Platform @@ -38,7 +39,7 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -499,3 +500,44 @@ async def test_event_listener_resilience( state = hass.states.get(entity_id) assert state assert state.state == after_event_expected_state + + +async def test_devices_updated_on_refresh( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test handling of devices added or deleted while event stream is down.""" + appliances: list[HomeAppliance] = ( + client.get_home_appliances.return_value.homeappliances + ) + assert len(appliances) >= 3 + client.get_home_appliances = AsyncMock( + return_value=ArrayOfHomeAppliances(appliances[:2]), + ) + + await async_setup_component(hass, "homeassistant", {}) + assert config_entry.state == ConfigEntryState.NOT_LOADED + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for appliance in appliances[:2]: + assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) + assert not device_registry.async_get_device({(DOMAIN, appliances[2].ha_id)}) + + client.get_home_appliances = AsyncMock( + return_value=ArrayOfHomeAppliances(appliances[1:3]), + ) + await hass.services.async_call( + "homeassistant", + "update_entity", + {"entity_id": "switch.dishwasher_power"}, + blocking=True, + ) + + assert not device_registry.async_get_device({(DOMAIN, appliances[0].ha_id)}) + for appliance in appliances[2:3]: + assert device_registry.async_get_device({(DOMAIN, appliance.ha_id)}) diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 4287ac9d227..291caeafd58 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -3,11 +3,15 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey -from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError +from aiohomeconnect.model.error import ( + HomeConnectError, + TooManyRequestsError, + UnauthorizedError, +) import aiohttp import pytest from syrupy.assertion import SnapshotAssertion @@ -355,6 +359,48 @@ async def test_client_error( assert client_with_exception.get_home_appliances.call_count == 1 +@pytest.mark.parametrize( + "raising_exception_method", + [ + "get_settings", + "get_status", + "get_all_programs", + "get_available_commands", + "get_available_program", + ], +) +async def test_client_rate_limit_error( + raising_exception_method: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, +) -> None: + """Test client errors during setup integration.""" + retry_after = 42 + + original_mock = getattr(client, raising_exception_method) + mock = AsyncMock() + + async def side_effect(*args, **kwargs): + if mock.call_count <= 1: + raise TooManyRequestsError("error.key", retry_after=retry_after) + return await original_mock(*args, **kwargs) + + mock.side_effect = side_effect + setattr(client, raising_exception_method, mock) + + assert config_entry.state == ConfigEntryState.NOT_LOADED + with patch( + "homeassistant.components.home_connect.coordinator.asyncio_sleep", + ) as asyncio_sleep_mock: + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + assert mock.call_count >= 2 + asyncio_sleep_mock.assert_called_once_with(retry_after) + + @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, From 32f9c07254c535ed6d658d02d32ddd3ee998eff7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 20 Mar 2025 09:47:02 +0100 Subject: [PATCH 1820/1941] Add missing exception translation in Vodafone Station (#140951) * Add missing exception translation in Vodafone Station * strings --- homeassistant/components/vodafone_station/coordinator.py | 6 +++++- .../components/vodafone_station/quality_scale.yaml | 4 +--- homeassistant/components/vodafone_station/strings.json | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 55643cd2778..cee66bd2e7c 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -122,7 +122,11 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): data_sensors = await self.api.get_sensor_data() await self.api.logout() except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err except ( exceptions.CannotConnect, exceptions.AlreadyLogged, diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml index d9240afc2e7..f9fa27b3032 100644 --- a/homeassistant/components/vodafone_station/quality_scale.yaml +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -70,9 +70,7 @@ rules: status: exempt comment: no known use case entity-translations: done - exception-translations: - status: todo - comment: some missing in coordinator + exception-translations: done icon-translations: done reconfiguration-flow: status: todo diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 7d804d9ac3b..de4bc364d4b 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -115,6 +115,9 @@ "exceptions": { "update_failed": { "message": "Error fetching data: {error}" + }, + "cannot_authenticate": { + "message": "Error authenticating: {error}" } } } From 2674b02bfa4296d0ed6a19d0780197d8f71a9743 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 10:16:48 +0100 Subject: [PATCH 1821/1941] Refactor zwave_js config entry setup (#107635) * Refactor zwave_js config entry setup * Fix blocking update test * Address timeout comment * Remove platform tasks * Replace deprecated async_add_job * Use ConfigEntry.async_on_state_change * Use modern config entry methods * Clarify exception message * Test listen error after config entry setup * Test listen failure during setup after forward entry * Test not reloading when hass is stopping * Test client disconnect is called on entry unload * Fix and test client not connected during driver setup * Fix and test driver ready timeout * Stringify listen task exception when logging * Use identity compare * Guard for closed connection * Consolidate listen task checking and tests --- homeassistant/components/zwave_js/__init__.py | 228 ++++++++++-------- .../components/zwave_js/config_flow.py | 3 +- tests/components/zwave_js/conftest.py | 25 +- tests/components/zwave_js/test_init.py | 224 ++++++++++++++++- tests/components/zwave_js/test_update.py | 11 +- 5 files changed, 362 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index c8503b1f4c6..a7b8f9ed665 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections import defaultdict -from contextlib import suppress +import contextlib import logging from typing import Any @@ -12,7 +12,11 @@ from awesomeversion import AwesomeVersion import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, RemoveNodeReason -from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion +from zwave_js_server.exceptions import ( + BaseZwaveJSServerError, + InvalidServerVersion, + NotConnected, +) from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.notification import ( @@ -25,7 +29,7 @@ from zwave_js_server.model.value import Value, ValueNotification from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.components.persistent_notification import async_create -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_DOMAIN, @@ -36,7 +40,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -130,9 +134,8 @@ from .migrate import async_migrate_discovered_value from .services import ZWaveServices CONNECT_TIMEOUT = 10 -DATA_CLIENT_LISTEN_TASK = "client_listen_task" DATA_DRIVER_EVENTS = "driver_events" -DATA_START_CLIENT_TASK = "start_client_task" +DRIVER_READY_TIMEOUT = 60 CONFIG_SCHEMA = vol.Schema( { @@ -145,6 +148,24 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.EVENT, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, + Platform.UPDATE, +] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Z-Wave JS component.""" @@ -196,53 +217,99 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Failed to connect: {err}") from err async_delete_issue(hass, DOMAIN, "invalid_server_version") - LOGGER.info("Connected to Zwave JS Server") + LOGGER.debug("Connected to Zwave JS Server") # Set up websocket API async_register_api(hass) - entry.runtime_data = {} - # Create a task to allow the config entry to be unloaded before the driver is ready. - # Unloading the config entry is needed if the client listen task errors. - start_client_task = hass.async_create_task(start_client(hass, entry, client)) - entry.runtime_data[DATA_START_CLIENT_TASK] = start_client_task + driver_ready = asyncio.Event() + listen_task = entry.async_create_background_task( + hass, + client_listen(hass, entry, client, driver_ready), + f"{DOMAIN}_{entry.title}_client_listen", + ) - return True - - -async def start_client( - hass: HomeAssistant, entry: ConfigEntry, client: ZwaveClient -) -> None: - """Start listening with the client.""" - entry.runtime_data[DATA_CLIENT] = client - driver_events = entry.runtime_data[DATA_DRIVER_EVENTS] = DriverEvents(hass, entry) + entry.async_on_unload(client.disconnect) async def handle_ha_shutdown(event: Event) -> None: """Handle HA shutdown.""" - await disconnect_client(hass, entry) + await client.disconnect() - listen_task = asyncio.create_task( - client_listen(hass, entry, client, driver_events.ready) - ) - entry.runtime_data[DATA_CLIENT_LISTEN_TASK] = listen_task entry.async_on_unload( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown) ) - try: - await driver_events.ready.wait() - except asyncio.CancelledError: - LOGGER.debug("Cancelling start client") - return - - LOGGER.info("Connection to Zwave JS Server initialized") - - assert client.driver - async_dispatcher_send( - hass, f"{DOMAIN}_{client.driver.controller.home_id}_connected_to_server" + driver_ready_task = entry.async_create_task( + hass, + driver_ready.wait(), + f"{DOMAIN}_{entry.title}_driver_ready", + ) + done, pending = await asyncio.wait( + (driver_ready_task, listen_task), + return_when=asyncio.FIRST_COMPLETED, + timeout=DRIVER_READY_TIMEOUT, ) - await driver_events.setup(client.driver) + if driver_ready_task in pending or listen_task in done: + error_message = "Driver ready timed out" + listen_error: BaseException | None = None + if listen_task.done(): + listen_error, error_message = _get_listen_task_error(listen_task) + else: + listen_task.cancel() + driver_ready_task.cancel() + raise ConfigEntryNotReady(error_message) from listen_error + + LOGGER.debug("Connection to Zwave JS Server initialized") + + entry_runtime_data = entry.runtime_data = { + DATA_CLIENT: client, + } + entry_runtime_data[DATA_DRIVER_EVENTS] = driver_events = DriverEvents(hass, entry) + + driver = client.driver + # When the driver is ready we know it's set on the client. + assert driver is not None + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + with contextlib.suppress(NotConnected): + # If the client isn't connected the listen task may have an exception + # and we'll handle the clean up below. + await driver_events.setup(driver) + + # If the listen task is already failed, we need to raise ConfigEntryNotReady + if listen_task.done(): + listen_error, error_message = _get_listen_task_error(listen_task) + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + raise ConfigEntryNotReady(error_message) from listen_error + + # Re-attach trigger listeners. + # Schedule this call to make sure the config entry is loaded first. + + @callback + def on_config_entry_loaded() -> None: + """Signal that server connection and driver are ready.""" + if entry.state is ConfigEntryState.LOADED: + async_dispatcher_send( + hass, + f"{DOMAIN}_{driver.controller.home_id}_connected_to_server", + ) + + entry.async_on_unload(entry.async_on_state_change(on_config_entry_loaded)) + + return True + + +def _get_listen_task_error( + listen_task: asyncio.Task, +) -> tuple[BaseException | None, str]: + """Check the listen task for errors.""" + if listen_error := listen_task.exception(): + error_message = f"Client listen failed: {listen_error}" + else: + error_message = "Client connection was closed" + return listen_error, error_message class DriverEvents: @@ -255,8 +322,6 @@ class DriverEvents: self.config_entry = entry self.dev_reg = dr.async_get(hass) self.hass = hass - self.platform_setup_tasks: dict[str, asyncio.Task] = {} - self.ready = asyncio.Event() # Make sure to not pass self to ControllerEvents until all attributes are set. self.controller_events = ControllerEvents(hass, self) @@ -339,16 +404,6 @@ class DriverEvents: controller.on("identify", self.controller_events.async_on_identify) ) - async def async_setup_platform(self, platform: Platform) -> None: - """Set up platform if needed.""" - if platform not in self.platform_setup_tasks: - self.platform_setup_tasks[platform] = self.hass.async_create_task( - self.hass.config_entries.async_forward_entry_setups( - self.config_entry, [platform] - ) - ) - await self.platform_setup_tasks[platform] - class ControllerEvents: """Represent controller events. @@ -380,9 +435,6 @@ class ControllerEvents: async def async_on_node_added(self, node: ZwaveNode) -> None: """Handle node added event.""" - # Every node including the controller will have at least one sensor - await self.driver_events.async_setup_platform(Platform.SENSOR) - # Remove stale entities that may exist from a previous interview when an # interview is started. base_unique_id = get_valueless_base_unique_id(self.driver_events.driver, node) @@ -411,7 +463,6 @@ class ControllerEvents: ) # Create a ping button for each device - await self.driver_events.async_setup_platform(Platform.BUTTON) async_dispatcher_send( self.hass, f"{DOMAIN}_{self.config_entry.entry_id}_add_ping_button_entity", @@ -668,9 +719,6 @@ class NodeEvents: cc.id == CommandClass.FIRMWARE_UPDATE_MD.value for cc in node.command_classes ): - await self.controller_events.driver_events.async_setup_platform( - Platform.UPDATE - ) async_dispatcher_send( self.hass, f"{DOMAIN}_{self.config_entry.entry_id}_add_firmware_update_entity", @@ -701,21 +749,19 @@ class NodeEvents: value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], ) -> None: """Handle discovery info and all dependent tasks.""" + platform = disc_info.platform # This migration logic was added in 2021.3 to handle a breaking change to # the value_id format. Some time in the future, this call (as well as the # helper functions) can be removed. async_migrate_discovered_value( self.hass, self.ent_reg, - self.controller_events.registered_unique_ids[device.id][disc_info.platform], + self.controller_events.registered_unique_ids[device.id][platform], device, self.controller_events.driver_events.driver, disc_info, ) - platform = disc_info.platform - await self.controller_events.driver_events.async_setup_platform(platform) - LOGGER.debug("Discovered entity: %s", disc_info) async_dispatcher_send( self.hass, @@ -930,63 +976,37 @@ async def client_listen( driver_ready: asyncio.Event, ) -> None: """Listen with the client.""" - should_reload = True try: await client.listen(driver_ready) - except asyncio.CancelledError: - should_reload = False except BaseZwaveJSServerError as err: - LOGGER.error("Failed to listen: %s", err) - except Exception as err: # noqa: BLE001 + if entry.state is not ConfigEntryState.LOADED: + raise + LOGGER.error("Client listen failed: %s", err) + except Exception as err: # We need to guard against unknown exceptions to not crash this task. LOGGER.exception("Unexpected exception: %s", err) + if entry.state is not ConfigEntryState.LOADED: + raise # The entry needs to be reloaded since a new driver state # will be acquired on reconnect. # All model instances will be replaced when the new state is acquired. - if should_reload: - LOGGER.info("Disconnected from server. Reloading integration") - hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) - - -async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Disconnect client.""" - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] - listen_task: asyncio.Task = entry.runtime_data[DATA_CLIENT_LISTEN_TASK] - start_client_task: asyncio.Task = entry.runtime_data[DATA_START_CLIENT_TASK] - driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS] - listen_task.cancel() - start_client_task.cancel() - platform_setup_tasks = driver_events.platform_setup_tasks.values() - for task in platform_setup_tasks: - task.cancel() - - tasks = (listen_task, start_client_task, *platform_setup_tasks) - await asyncio.gather(*tasks, return_exceptions=True) - for task in tasks: - with suppress(asyncio.CancelledError): - await task - - if client.connected: - await client.disconnect() - LOGGER.info("Disconnected from Zwave JS Server") + if not hass.is_stopping: + if entry.state is not ConfigEntryState.LOADED: + raise HomeAssistantError("Listen task ended unexpectedly") + LOGGER.debug("Disconnected from server. Reloading integration") + hass.config_entries.async_schedule_reload(entry.entry_id) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] - driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS] - platforms = [ - platform - for platform, task in driver_events.platform_setup_tasks.items() - if not task.cancel() - ] - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if client.connected and client.driver: - await async_disable_server_logging_if_needed(hass, entry, client.driver) - if DATA_CLIENT_LISTEN_TASK in entry.runtime_data: - await disconnect_client(hass, entry) + entry_runtime_data = entry.runtime_data + client: ZwaveClient = entry_runtime_data[DATA_CLIENT] + + if client.connected and (driver := client.driver): + await async_disable_server_logging_if_needed(hass, entry, driver) if entry.data.get(CONF_USE_ADDON) and entry.disabled_by: addon_manager: AddonManager = get_addon_manager(hass) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 44adf6a12ab..aed0dd839be 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -42,7 +42,6 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from homeassistant.helpers.typing import VolDictType -from . import disconnect_client from .addon import get_addon_manager from .const import ( ADDON_SLUG, @@ -861,7 +860,7 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): and self.config_entry.state == ConfigEntryState.LOADED ): # Disconnect integration before restarting add-on. - await disconnect_client(self.hass, self.config_entry) + await self.hass.config_entries.async_unload(self.config_entry.entry_id) return await self.async_step_start_addon() diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index bcdc0c3ce16..1917ebedd34 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -511,18 +511,25 @@ def aeotec_smart_switch_7_state_fixture() -> NodeDataType: @pytest.fixture(name="listen_block") -def mock_listen_block_fixture(): +def mock_listen_block_fixture() -> asyncio.Event: """Mock a listen block.""" return asyncio.Event() +@pytest.fixture(name="listen_result") +def listen_result_fixture() -> asyncio.Future[None]: + """Mock a listen result.""" + return asyncio.Future() + + @pytest.fixture(name="client") def mock_client_fixture( - controller_state, - controller_node_state, - version_state, - log_config_state, - listen_block, + controller_state: dict[str, Any], + controller_node_state: dict[str, Any], + version_state: dict[str, Any], + log_config_state: dict[str, Any], + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], ): """Mock a client.""" with patch( @@ -537,6 +544,7 @@ def mock_client_fixture( async def listen(driver_ready: asyncio.Event) -> None: driver_ready.set() await listen_block.wait() + await listen_result async def disconnect(): client.connected = False @@ -817,7 +825,10 @@ def nortek_thermostat_removed_event_fixture(client) -> Node: @pytest.fixture(name="integration") -async def integration_fixture(hass: HomeAssistant, client) -> MockConfigEntry: +async def integration_fixture( + hass: HomeAssistant, + client: MagicMock, +) -> MockConfigEntry: """Set up the zwave_js integration.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index c575066b57c..91e333f7c7d 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -3,14 +3,19 @@ import asyncio from copy import deepcopy import logging -from unittest.mock import AsyncMock, call, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, call, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import AddonsOptions import pytest from zwave_js_server.client import Client from zwave_js_server.event import Event -from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion +from zwave_js_server.exceptions import ( + BaseZwaveJSServerError, + InvalidServerVersion, + NotConnected, +) from zwave_js_server.model.node import Node from zwave_js_server.model.version import VersionInfo @@ -21,7 +26,7 @@ from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -32,7 +37,11 @@ from homeassistant.setup import async_setup_component from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY -from tests.common import MockConfigEntry, async_get_persistent_notifications +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_get_persistent_notifications, +) from tests.typing import WebSocketGenerator @@ -127,24 +136,215 @@ async def test_noop_statistics(hass: HomeAssistant, client) -> None: assert not mock_cmd2.called -@pytest.mark.parametrize("error", [BaseZwaveJSServerError("Boom"), Exception("Boom")]) -async def test_listen_failure(hass: HomeAssistant, client, error) -> None: - """Test we handle errors during client listen.""" +async def test_driver_ready_timeout_during_setup( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, +) -> None: + """Test we handle driver ready timeout during setup.""" - async def listen(driver_ready): - """Mock the client listen method.""" - # Set the connect side effect to stop an endless loop on reload. - client.connect.side_effect = BaseZwaveJSServerError("Boom") - raise error + async def listen(driver_ready: asyncio.Event) -> None: + """Mock listen.""" + await listen_block.wait() client.listen.side_effect = listen + + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 + + with patch("homeassistant.components.zwave_js.DRIVER_READY_TIMEOUT", new=0): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize("core_state", [CoreState.running, CoreState.stopping]) +@pytest.mark.parametrize( + ("listen_future_result_method", "listen_future_result"), + [ + ("set_exception", BaseZwaveJSServerError("Boom")), + ("set_exception", Exception("Boom")), + ("set_result", None), + ], +) +async def test_listen_done_during_setup_before_forward_entry( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], + core_state: CoreState, + listen_future_result_method: str, + listen_future_result: Exception | None, +) -> None: + """Test listen task finishing during setup before forward entry.""" + assert hass.state is CoreState.running + + async def listen(driver_ready: asyncio.Event) -> None: + await listen_block.wait() + await listen_result + async_fire_time_changed(hass, fire_all=True) + + client.listen.side_effect = listen + hass.set_state(core_state) + listen_block.set() + getattr(listen_result, listen_future_result_method)(listen_future_result) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +async def test_not_connected_during_setup_after_forward_entry( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], +) -> None: + """Test we handle not connected client during setup after forward entry.""" + + async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: + """Mock send command.""" + listen_block.set() + listen_result.set_result(None) + # Yield to allow the listen task to run + await asyncio.sleep(0) + raise NotConnected("Boom") + + async def listen(driver_ready: asyncio.Event) -> None: + """Mock listen.""" + driver_ready.set() + client.async_send_command.side_effect = send_command_side_effect + await listen_block.wait() + await listen_result + + client.listen.side_effect = listen + + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize("core_state", [CoreState.running, CoreState.stopping]) +@pytest.mark.parametrize( + ("listen_future_result_method", "listen_future_result"), + [ + ("set_exception", BaseZwaveJSServerError("Boom")), + ("set_exception", Exception("Boom")), + ("set_result", None), + ], +) +async def test_listen_done_during_setup_after_forward_entry( + hass: HomeAssistant, + client: MagicMock, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], + core_state: CoreState, + listen_future_result_method: str, + listen_future_result: Exception | None, +) -> None: + """Test listen task finishing during setup after forward entry.""" + assert hass.state is CoreState.running + + async def send_command_side_effect(*args: Any, **kwargs: Any) -> None: + """Mock send command.""" + listen_block.set() + getattr(listen_result, listen_future_result_method)(listen_future_result) + # Yield to allow the listen task to run + await asyncio.sleep(0) + + async def listen(driver_ready: asyncio.Event) -> None: + """Mock listen.""" + driver_ready.set() + client.async_send_command.side_effect = send_command_side_effect + await listen_block.wait() + await listen_result + + client.listen.side_effect = listen + hass.set_state(core_state) + + entry = MockConfigEntry( + domain="zwave_js", + data={"url": "ws://test.org", "data_collection_opted_in": True}, + ) + entry.add_to_hass(hass) + assert client.disconnect.call_count == 0 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + assert client.disconnect.call_count == 1 + + +@pytest.mark.parametrize( + ("core_state", "final_config_entry_state", "disconnect_call_count"), + [ + ( + CoreState.running, + ConfigEntryState.SETUP_RETRY, + 2, + ), # the reload will cause a disconnect call too + ( + CoreState.stopping, + ConfigEntryState.LOADED, + 0, + ), # the home assistant stop event will handle the disconnect + ], +) +@pytest.mark.parametrize( + ("listen_future_result_method", "listen_future_result"), + [ + ("set_exception", BaseZwaveJSServerError("Boom")), + ("set_exception", Exception("Boom")), + ("set_result", None), + ], +) +async def test_listen_done_after_setup( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, + listen_block: asyncio.Event, + listen_result: asyncio.Future[None], + core_state: CoreState, + listen_future_result_method: str, + listen_future_result: Exception | None, + final_config_entry_state: ConfigEntryState, + disconnect_call_count: int, +) -> None: + """Test listen task finishing after setup.""" + config_entry = integration + assert config_entry.state is ConfigEntryState.LOADED + assert hass.state is CoreState.running + assert client.disconnect.call_count == 0 + + hass.set_state(core_state) + listen_block.set() + getattr(listen_result, listen_future_result_method)(listen_future_result) + await hass.async_block_till_done() + + assert config_entry.state is final_config_entry_state + assert client.disconnect.call_count == disconnect_call_count async def test_new_entity_on_value_added( diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index d6683fa24cb..6a4f48a0dc5 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -658,8 +658,10 @@ async def test_update_entity_delay( assert len(client.async_send_command.call_args_list) == 2 - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done(wait_background_tasks=True) + update_interval = timedelta(minutes=5) + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() nodes: set[int] = set() @@ -668,8 +670,9 @@ async def test_update_entity_delay( assert args["command"] == "controller.get_available_firmware_updates" nodes.add(args["nodeId"]) - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done(wait_background_tasks=True) + freezer.tick(update_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 4 args = client.async_send_command.call_args_list[3][0][0] From 3fb0290fbacfb6ff379c88dfc893c53db043d1bf Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 20 Mar 2025 11:19:26 +0200 Subject: [PATCH 1822/1941] Remove unused params in "zwave_js/provision_smart_start_node" API (#140982) --- homeassistant/components/zwave_js/api.py | 30 +-------- tests/components/zwave_js/test_api.py | 79 +++++------------------- 2 files changed, 18 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index ec164e2b505..dd698d9ed66 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -976,13 +976,7 @@ async def websocket_validate_dsk_and_enter_pin( { vol.Required(TYPE): "zwave_js/provision_smart_start_node", vol.Required(ENTRY_ID): str, - vol.Exclusive( - PLANNED_PROVISIONING_ENTRY, "options" - ): PLANNED_PROVISIONING_ENTRY_SCHEMA, - vol.Exclusive( - QR_PROVISIONING_INFORMATION, "options" - ): QR_PROVISIONING_INFORMATION_SCHEMA, - vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA, + vol.Required(QR_PROVISIONING_INFORMATION): QR_PROVISIONING_INFORMATION_SCHEMA, } ) @websocket_api.async_response @@ -997,28 +991,10 @@ async def websocket_provision_smart_start_node( driver: Driver, ) -> None: """Pre-provision a smart start node.""" - try: - cv.has_at_least_one_key( - PLANNED_PROVISIONING_ENTRY, QR_PROVISIONING_INFORMATION, QR_CODE_STRING - )(msg) - except vol.Invalid as err: - connection.send_error( - msg[ID], - ERR_INVALID_FORMAT, - err.args[0], - ) - return - provisioning_info = ( - msg.get(PLANNED_PROVISIONING_ENTRY) - or msg.get(QR_PROVISIONING_INFORMATION) - or msg[QR_CODE_STRING] - ) + provisioning_info = msg[QR_PROVISIONING_INFORMATION] - if ( - QR_PROVISIONING_INFORMATION in msg - and provisioning_info.version == QRCodeVersion.S2 - ): + if provisioning_info.version == QRCodeVersion.S2: connection.send_error( msg[ID], ERR_INVALID_FORMAT, diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index b2741a53a92..62e7f25bc08 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1095,52 +1095,27 @@ async def test_provision_smart_start_node( client.async_send_command.return_value = {"success": True} - # Test provisioning entry - await ws_client.send_json( - { - ID: 2, - TYPE: "zwave_js/provision_smart_start_node", - ENTRY_ID: entry.entry_id, - PLANNED_PROVISIONING_ENTRY: { - DSK: "test", - SECURITY_CLASSES: [0], - }, - } - ) - - msg = await ws_client.receive_json() - assert msg["success"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.provision_smart_start_node", - "entry": ProvisioningEntry( - "test", [SecurityClass.S2_UNAUTHENTICATED] - ).to_dict(), + valid_qr_info = { + VERSION: 1, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + "name": "test", } - client.async_send_command.reset_mock() - client.async_send_command.return_value = {"success": True} - # Test QR provisioning information await ws_client.send_json( { ID: 3, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_PROVISIONING_INFORMATION: { - VERSION: 1, - SECURITY_CLASSES: [0], - DSK: "test", - GENERIC_DEVICE_CLASS: 1, - SPECIFIC_DEVICE_CLASS: 1, - INSTALLER_ICON_TYPE: 1, - MANUFACTURER_ID: 1, - PRODUCT_TYPE: 1, - PRODUCT_ID: 1, - APPLICATION_VERSION: "test", - "name": "test", - }, + QR_PROVISIONING_INFORMATION: valid_qr_info, } ) @@ -1171,28 +1146,6 @@ async def test_provision_smart_start_node( client.async_send_command.reset_mock() client.async_send_command.return_value = {"success": True} - # Test QR code string - await ws_client.send_json( - { - ID: 4, - TYPE: "zwave_js/provision_smart_start_node", - ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", - } - ) - - msg = await ws_client.receive_json() - assert msg["success"] - - assert len(client.async_send_command.call_args_list) == 1 - assert client.async_send_command.call_args[0][0] == { - "command": "controller.provision_smart_start_node", - "entry": "90testtesttesttesttesttesttesttesttesttesttesttesttest", - } - - client.async_send_command.reset_mock() - client.async_send_command.return_value = {"success": True} - # Test QR provisioning information with S2 version throws error await ws_client.send_json( { @@ -1243,9 +1196,7 @@ async def test_provision_smart_start_node( ID: 7, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: ( - "90testtesttesttesttesttesttesttesttesttesttesttesttest" - ), + QR_PROVISIONING_INFORMATION: valid_qr_info, } ) msg = await ws_client.receive_json() @@ -1263,7 +1214,7 @@ async def test_provision_smart_start_node( ID: 8, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_PROVISIONING_INFORMATION: valid_qr_info, } ) msg = await ws_client.receive_json() From c6d3928ed1130a542d23b26d7f51acff07b1aa62 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Mar 2025 10:29:40 +0100 Subject: [PATCH 1823/1941] Add template function: combine (#140948) * Add template function: combine * Add test to take away concern raised --- homeassistant/helpers/template.py | 28 ++++++++++++++++++++++++ tests/helpers/test_template.py | 36 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 20531596fdd..69a9232431f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2785,6 +2785,32 @@ def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]: return flattened +def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: + """Combine multiple dictionaries into one.""" + if not args: + raise TypeError("combine expected at least 1 argument, got 0") + + result: dict[Any, Any] = {} + for arg in args: + if not isinstance(arg, dict): + raise TypeError(f"combine expected a dict, got {type(arg).__name__}") + + if recursive: + for key, value in arg.items(): + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = combine(result[key], value, recursive=True) + else: + result[key] = value + else: + result |= arg + + return result + + def md5(value: str) -> str: """Generate md5 hash from a string.""" return hashlib.md5(value.encode()).hexdigest() @@ -3012,6 +3038,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["sha1"] = sha1 self.filters["sha256"] = sha256 self.filters["sha512"] = sha512 + self.filters["combine"] = combine self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -3056,6 +3083,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["sha1"] = sha1 self.globals["sha256"] = sha256 self.globals["sha512"] = sha512 + self.globals["combine"] = combine self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index bdf400ce357..e4e73fc52d9 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6840,3 +6840,39 @@ def test_sha512(hass: HomeAssistant) -> None: template.Template("{{ 'Home Assistant' | sha512 }}", hass).async_render() == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" ) + + +def test_combine(hass: HomeAssistant) -> None: + """Test combine filter and function.""" + assert template.Template( + "{{ {'a': 1, 'b': 2} | combine({'b': 3, 'c': 4}) }}", hass + ).async_render() == {"a": 1, "b": 3, "c": 4} + + assert template.Template( + "{{ combine({'a': 1, 'b': 2}, {'b': 3, 'c': 4}) }}", hass + ).async_render() == {"a": 1, "b": 3, "c": 4} + + assert template.Template( + "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", + hass, + ).async_render() == {"a": 1, "b": {"x": 1, "y": 2}, "c": 4} + + # Test that recursive=False does not merge nested dictionaries + assert template.Template( + "{{ combine({'a': 1, 'b': {'x': 1}}, {'b': {'y': 2}, 'c': 4}, recursive=False) }}", + hass, + ).async_render() == {"a": 1, "b": {"y": 2}, "c": 4} + + # Test that None values are handled correctly in recursive merge + assert template.Template( + "{{ combine({'a': 1, 'b': none}, {'b': {'y': 2}, 'c': 4}, recursive=True) }}", + hass, + ).async_render() == {"a": 1, "b": {"y": 2}, "c": 4} + + with pytest.raises( + TemplateError, match="combine expected at least 1 argument, got 0" + ): + template.Template("{{ combine() }}", hass).async_render() + + with pytest.raises(TemplateError, match="combine expected a dict, got str"): + template.Template("{{ {'a': 1} | combine('not a dict') }}", hass).async_render() From 827d5256c60070ad8439a6356e2d2b191ab53c74 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 20 Mar 2025 11:02:51 +0100 Subject: [PATCH 1824/1941] Bump pySmartThings to 2.7.4 (#140720) * Bump pySmartThings to 2.7.3 * Bump pySmartThings to 2.7.3 * Fix * Fix * Fix --- .../components/smartthings/diagnostics.py | 2 +- .../components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 588 +++++++++--------- .../smartthings/test_diagnostics.py | 6 +- 6 files changed, 303 insertions(+), 299 deletions(-) diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py index dbc5d4e8224..04517112802 100644 --- a/homeassistant/components/smartthings/diagnostics.py +++ b/homeassistant/components/smartthings/diagnostics.py @@ -23,7 +23,7 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" client = entry.runtime_data.client - return await client.get_raw_devices() + return {"devices": await client.get_raw_devices()} async def async_get_device_diagnostics( diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 74f0e4bae83..a456a6bef2f 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.7.2"] + "requirements": ["pysmartthings==2.7.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 37f84836635..f4e20f563a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2314,7 +2314,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.2 +pysmartthings==2.7.4 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c084dfd70e..b4435e22827 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1883,7 +1883,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smartthings -pysmartthings==2.7.2 +pysmartthings==2.7.4 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/tests/components/smartthings/snapshots/test_diagnostics.ambr b/tests/components/smartthings/snapshots/test_diagnostics.ambr index 7610c8839ba..b9847bf9746 100644 --- a/tests/components/smartthings/snapshots/test_diagnostics.ambr +++ b/tests/components/smartthings/snapshots/test_diagnostics.ambr @@ -1,307 +1,311 @@ # serializer version: 1 # name: test_config_entry_diagnostics[da_ac_rac_000001] dict({ - '_links': dict({ - }), - 'items': list([ + 'devices': list([ dict({ - 'allowed': list([ - ]), - 'components': list([ + '_links': dict({ + }), + 'items': list([ dict({ - 'capabilities': list([ + 'allowed': list([ + ]), + 'components': list([ dict({ - 'id': 'ocf', - 'version': 1, + 'capabilities': list([ + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'refresh', + 'version': 1, + }), + dict({ + 'id': 'execute', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledComponents', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'samsungce.deviceIdentification', + 'version': 1, + }), + dict({ + 'id': 'samsungce.dongleSoftwareInstallation', + 'version': 1, + }), + dict({ + 'id': 'samsungce.softwareUpdate', + 'version': 1, + }), + dict({ + 'id': 'samsungce.selfCheck', + 'version': 1, + }), + dict({ + 'id': 'samsungce.driverVersion', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'AirConditioner', + }), + ]), + 'id': 'main', + 'label': 'main', }), dict({ - 'id': 'switch', - 'version': 1, - }), - dict({ - 'id': 'airConditionerMode', - 'version': 1, - }), - dict({ - 'id': 'airConditionerFanMode', - 'version': 1, - }), - dict({ - 'id': 'fanOscillationMode', - 'version': 1, - }), - dict({ - 'id': 'airQualitySensor', - 'version': 1, - }), - dict({ - 'id': 'temperatureMeasurement', - 'version': 1, - }), - dict({ - 'id': 'thermostatCoolingSetpoint', - 'version': 1, - }), - dict({ - 'id': 'relativeHumidityMeasurement', - 'version': 1, - }), - dict({ - 'id': 'dustSensor', - 'version': 1, - }), - dict({ - 'id': 'veryFineDustSensor', - 'version': 1, - }), - dict({ - 'id': 'audioVolume', - 'version': 1, - }), - dict({ - 'id': 'remoteControlStatus', - 'version': 1, - }), - dict({ - 'id': 'powerConsumptionReport', - 'version': 1, - }), - dict({ - 'id': 'demandResponseLoadControl', - 'version': 1, - }), - dict({ - 'id': 'refresh', - 'version': 1, - }), - dict({ - 'id': 'execute', - 'version': 1, - }), - dict({ - 'id': 'custom.spiMode', - 'version': 1, - }), - dict({ - 'id': 'custom.thermostatSetpointControl', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOptionalMode', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerTropicalNightMode', - 'version': 1, - }), - dict({ - 'id': 'custom.autoCleaningMode', - 'version': 1, - }), - dict({ - 'id': 'custom.deviceReportStateConfiguration', - 'version': 1, - }), - dict({ - 'id': 'custom.energyType', - 'version': 1, - }), - dict({ - 'id': 'custom.dustFilter', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOdorController', - 'version': 1, - }), - dict({ - 'id': 'custom.deodorFilter', - 'version': 1, - }), - dict({ - 'id': 'custom.disabledComponents', - 'version': 1, - }), - dict({ - 'id': 'custom.disabledCapabilities', - 'version': 1, - }), - dict({ - 'id': 'samsungce.deviceIdentification', - 'version': 1, - }), - dict({ - 'id': 'samsungce.dongleSoftwareInstallation', - 'version': 1, - }), - dict({ - 'id': 'samsungce.softwareUpdate', - 'version': 1, - }), - dict({ - 'id': 'samsungce.selfCheck', - 'version': 1, - }), - dict({ - 'id': 'samsungce.driverVersion', - 'version': 1, + 'capabilities': list([ + dict({ + 'id': 'switch', + 'version': 1, + }), + dict({ + 'id': 'airConditionerMode', + 'version': 1, + }), + dict({ + 'id': 'airConditionerFanMode', + 'version': 1, + }), + dict({ + 'id': 'fanOscillationMode', + 'version': 1, + }), + dict({ + 'id': 'temperatureMeasurement', + 'version': 1, + }), + dict({ + 'id': 'thermostatCoolingSetpoint', + 'version': 1, + }), + dict({ + 'id': 'relativeHumidityMeasurement', + 'version': 1, + }), + dict({ + 'id': 'airQualitySensor', + 'version': 1, + }), + dict({ + 'id': 'dustSensor', + 'version': 1, + }), + dict({ + 'id': 'veryFineDustSensor', + 'version': 1, + }), + dict({ + 'id': 'odorSensor', + 'version': 1, + }), + dict({ + 'id': 'remoteControlStatus', + 'version': 1, + }), + dict({ + 'id': 'audioVolume', + 'version': 1, + }), + dict({ + 'id': 'custom.thermostatSetpointControl', + 'version': 1, + }), + dict({ + 'id': 'custom.autoCleaningMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerTropicalNightMode', + 'version': 1, + }), + dict({ + 'id': 'custom.disabledCapabilities', + 'version': 1, + }), + dict({ + 'id': 'ocf', + 'version': 1, + }), + dict({ + 'id': 'powerConsumptionReport', + 'version': 1, + }), + dict({ + 'id': 'demandResponseLoadControl', + 'version': 1, + }), + dict({ + 'id': 'custom.spiMode', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOptionalMode', + 'version': 1, + }), + dict({ + 'id': 'custom.deviceReportStateConfiguration', + 'version': 1, + }), + dict({ + 'id': 'custom.energyType', + 'version': 1, + }), + dict({ + 'id': 'custom.dustFilter', + 'version': 1, + }), + dict({ + 'id': 'custom.airConditionerOdorController', + 'version': 1, + }), + dict({ + 'id': 'custom.deodorFilter', + 'version': 1, + }), + ]), + 'categories': list([ + dict({ + 'categoryType': 'manufacturer', + 'name': 'Other', + }), + ]), + 'id': '1', + 'label': '1', }), ]), - 'categories': list([ - dict({ - 'categoryType': 'manufacturer', - 'name': 'AirConditioner', - }), - ]), - 'id': 'main', - 'label': 'main', - }), - dict({ - 'capabilities': list([ - dict({ - 'id': 'switch', - 'version': 1, - }), - dict({ - 'id': 'airConditionerMode', - 'version': 1, - }), - dict({ - 'id': 'airConditionerFanMode', - 'version': 1, - }), - dict({ - 'id': 'fanOscillationMode', - 'version': 1, - }), - dict({ - 'id': 'temperatureMeasurement', - 'version': 1, - }), - dict({ - 'id': 'thermostatCoolingSetpoint', - 'version': 1, - }), - dict({ - 'id': 'relativeHumidityMeasurement', - 'version': 1, - }), - dict({ - 'id': 'airQualitySensor', - 'version': 1, - }), - dict({ - 'id': 'dustSensor', - 'version': 1, - }), - dict({ - 'id': 'veryFineDustSensor', - 'version': 1, - }), - dict({ - 'id': 'odorSensor', - 'version': 1, - }), - dict({ - 'id': 'remoteControlStatus', - 'version': 1, - }), - dict({ - 'id': 'audioVolume', - 'version': 1, - }), - dict({ - 'id': 'custom.thermostatSetpointControl', - 'version': 1, - }), - dict({ - 'id': 'custom.autoCleaningMode', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerTropicalNightMode', - 'version': 1, - }), - dict({ - 'id': 'custom.disabledCapabilities', - 'version': 1, - }), - dict({ - 'id': 'ocf', - 'version': 1, - }), - dict({ - 'id': 'powerConsumptionReport', - 'version': 1, - }), - dict({ - 'id': 'demandResponseLoadControl', - 'version': 1, - }), - dict({ - 'id': 'custom.spiMode', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOptionalMode', - 'version': 1, - }), - dict({ - 'id': 'custom.deviceReportStateConfiguration', - 'version': 1, - }), - dict({ - 'id': 'custom.energyType', - 'version': 1, - }), - dict({ - 'id': 'custom.dustFilter', - 'version': 1, - }), - dict({ - 'id': 'custom.airConditionerOdorController', - 'version': 1, - }), - dict({ - 'id': 'custom.deodorFilter', - 'version': 1, - }), - ]), - 'categories': list([ - dict({ - 'categoryType': 'manufacturer', - 'name': 'Other', - }), - ]), - 'id': '1', - 'label': '1', + 'createTime': '2021-04-06T16:43:34.753Z', + 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', + 'deviceManufacturerCode': 'Samsung Electronics', + 'deviceTypeName': 'Samsung OCF Air Conditioner', + 'executionContext': 'CLOUD', + 'label': 'AC Office Granit', + 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', + 'manufacturerName': 'Samsung Electronics', + 'name': '[room a/c] Samsung', + 'ocf': dict({ + 'additionalAuthCodeRequired': False, + 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', + 'manufacturerName': 'Samsung Electronics', + 'ocfDeviceType': 'x.com.st.d.sensor.light', + 'transferCandidate': False, + 'vendorId': 'VD-Sensor.Light-2023', + }), + 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', + 'presentationId': 'DA-AC-RAC-000001', + 'profile': dict({ + 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', + }), + 'restrictionTier': 0, + 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', + 'type': 'OCF', }), ]), - 'createTime': '2021-04-06T16:43:34.753Z', - 'deviceId': '96a5ef74-5832-a84b-f1f7-ca799957065d', - 'deviceManufacturerCode': 'Samsung Electronics', - 'deviceTypeName': 'Samsung OCF Air Conditioner', - 'executionContext': 'CLOUD', - 'label': 'AC Office Granit', - 'locationId': '58d3fd7c-c512-4da3-b500-ef269382756c', - 'manufacturerName': 'Samsung Electronics', - 'name': '[room a/c] Samsung', - 'ocf': dict({ - 'additionalAuthCodeRequired': False, - 'lastSignupTime': '2025-01-08T02:32:04.631093137Z', - 'manufacturerName': 'Samsung Electronics', - 'ocfDeviceType': 'x.com.st.d.sensor.light', - 'transferCandidate': False, - 'vendorId': 'VD-Sensor.Light-2023', - }), - 'ownerId': 'f9a28d7c-1ed5-d9e9-a81c-18971ec081db', - 'presentationId': 'DA-AC-RAC-000001', - 'profile': dict({ - 'id': '60fbc713-8da5-315d-b31a-6d6dcde4be7b', - }), - 'restrictionTier': 0, - 'roomId': '7715151d-0314-457a-a82c-5ce48900e065', - 'type': 'OCF', }), ]), }) diff --git a/tests/components/smartthings/test_diagnostics.py b/tests/components/smartthings/test_diagnostics.py index f486c19de14..b28a3a1aff5 100644 --- a/tests/components/smartthings/test_diagnostics.py +++ b/tests/components/smartthings/test_diagnostics.py @@ -30,9 +30,9 @@ async def test_config_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a device entry.""" - mock_smartthings.get_raw_devices.return_value = load_json_object_fixture( - "devices/da_ac_rac_000001.json", DOMAIN - ) + mock_smartthings.get_raw_devices.return_value = [ + load_json_object_fixture("devices/da_ac_rac_000001.json", DOMAIN) + ] await setup_integration(hass, mock_config_entry) assert ( await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) From 56e966a980c437ce249fdf158e8192522a487292 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 20 Mar 2025 11:04:49 +0100 Subject: [PATCH 1825/1941] Update project metadata for PEP 639 (#140960) --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 628ec457bf0..74122927660 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,12 @@ [build-system] -requires = ["setuptools==75.1.0"] +requires = ["setuptools==77.0.1"] build-backend = "setuptools.build_meta" [project] name = "homeassistant" version = "2025.4.0.dev0" -license = {text = "Apache-2.0"} +license = "Apache-2.0" +license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." readme = "README.rst" authors = [ @@ -16,7 +17,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: End Users/Desktop", "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.13", "Topic :: Home Automation", From d3c40939f6b5bab748fe62d1363e7dda80d60550 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Mar 2025 11:34:02 +0100 Subject: [PATCH 1826/1941] Reorder template extensions (#140985) --- homeassistant/helpers/template.py | 342 ++++++++++++++++-------------- 1 file changed, 183 insertions(+), 159 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 69a9232431f..0d017dda64f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2983,116 +2983,119 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): str | jinja2.nodes.Template, CodeType | None ] = weakref.WeakValueDictionary() self.add_extension("jinja2.ext.loopcontrols") - self.filters["round"] = forgiving_round - self.filters["multiply"] = multiply - self.filters["add"] = add - self.filters["log"] = logarithm - self.filters["sin"] = sine - self.filters["cos"] = cosine - self.filters["tan"] = tangent - self.filters["asin"] = arc_sine - self.filters["acos"] = arc_cosine - self.filters["atan"] = arc_tangent - self.filters["atan2"] = arc_tangent2 - self.filters["sqrt"] = square_root - self.filters["as_datetime"] = as_datetime - self.filters["as_timedelta"] = as_timedelta - self.filters["as_timestamp"] = forgiving_as_timestamp - self.filters["as_local"] = dt_util.as_local - self.filters["timestamp_custom"] = timestamp_custom - self.filters["timestamp_local"] = timestamp_local - self.filters["timestamp_utc"] = timestamp_utc - self.filters["to_json"] = to_json - self.filters["from_json"] = from_json - self.filters["is_defined"] = fail_when_undefined - self.filters["average"] = average - self.filters["median"] = median - self.filters["statistical_mode"] = statistical_mode - self.filters["random"] = random_every_time - self.filters["base64_encode"] = base64_encode - self.filters["base64_decode"] = base64_decode - self.filters["ordinal"] = ordinal - self.filters["regex_match"] = regex_match - self.filters["regex_replace"] = regex_replace - self.filters["regex_search"] = regex_search - self.filters["regex_findall"] = regex_findall - self.filters["regex_findall_index"] = regex_findall_index - self.filters["bitwise_and"] = bitwise_and - self.filters["bitwise_or"] = bitwise_or - self.filters["bitwise_xor"] = bitwise_xor - self.filters["pack"] = struct_pack - self.filters["unpack"] = struct_unpack - self.filters["ord"] = ord - self.filters["is_number"] = is_number - self.filters["float"] = forgiving_float_filter - self.filters["int"] = forgiving_int_filter - self.filters["slugify"] = slugify - self.filters["iif"] = iif - self.filters["bool"] = forgiving_boolean - self.filters["version"] = version - self.filters["contains"] = contains - self.filters["shuffle"] = shuffle - self.filters["typeof"] = typeof - self.filters["flatten"] = flatten - self.filters["md5"] = md5 - self.filters["sha1"] = sha1 - self.filters["sha256"] = sha256 - self.filters["sha512"] = sha512 - self.filters["combine"] = combine - self.globals["log"] = logarithm - self.globals["sin"] = sine - self.globals["cos"] = cosine - self.globals["tan"] = tangent - self.globals["sqrt"] = square_root - self.globals["pi"] = math.pi - self.globals["tau"] = math.pi * 2 - self.globals["e"] = math.e - self.globals["asin"] = arc_sine + self.globals["acos"] = arc_cosine - self.globals["atan"] = arc_tangent - self.globals["atan2"] = arc_tangent2 - self.globals["float"] = forgiving_float self.globals["as_datetime"] = as_datetime self.globals["as_local"] = dt_util.as_local self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp - self.globals["timedelta"] = timedelta - self.globals["merge_response"] = merge_response - self.globals["strptime"] = strptime - self.globals["urlencode"] = urlencode + self.globals["asin"] = arc_sine + self.globals["atan"] = arc_tangent + self.globals["atan2"] = arc_tangent2 self.globals["average"] = average - self.globals["median"] = median - self.globals["statistical_mode"] = statistical_mode - self.globals["max"] = min_max_from_filter(self.filters["max"], "max") - self.globals["min"] = min_max_from_filter(self.filters["min"], "min") - self.globals["is_number"] = is_number - self.globals["set"] = _to_set - self.globals["tuple"] = _to_tuple - self.globals["int"] = forgiving_int - self.globals["pack"] = struct_pack - self.globals["unpack"] = struct_unpack - self.globals["slugify"] = slugify - self.globals["iif"] = iif self.globals["bool"] = forgiving_boolean - self.globals["version"] = version - self.globals["zip"] = zip - self.globals["shuffle"] = shuffle - self.globals["typeof"] = typeof + self.globals["combine"] = combine + self.globals["cos"] = cosine + self.globals["e"] = math.e self.globals["flatten"] = flatten + self.globals["float"] = forgiving_float + self.globals["iif"] = iif + self.globals["int"] = forgiving_int + self.globals["is_number"] = is_number + self.globals["log"] = logarithm + self.globals["max"] = min_max_from_filter(self.filters["max"], "max") self.globals["md5"] = md5 + self.globals["median"] = median + self.globals["merge_response"] = merge_response + self.globals["min"] = min_max_from_filter(self.filters["min"], "min") + self.globals["pack"] = struct_pack + self.globals["pi"] = math.pi + self.globals["set"] = _to_set self.globals["sha1"] = sha1 self.globals["sha256"] = sha256 self.globals["sha512"] = sha512 - self.globals["combine"] = combine + self.globals["shuffle"] = shuffle + self.globals["sin"] = sine + self.globals["slugify"] = slugify + self.globals["sqrt"] = square_root + self.globals["statistical_mode"] = statistical_mode + self.globals["strptime"] = strptime + self.globals["tan"] = tangent + self.globals["tau"] = math.pi * 2 + self.globals["timedelta"] = timedelta + self.globals["tuple"] = _to_tuple + self.globals["typeof"] = typeof + self.globals["unpack"] = struct_unpack + self.globals["urlencode"] = urlencode + self.globals["version"] = version + self.globals["zip"] = zip + + self.filters["acos"] = arc_cosine + self.filters["add"] = add + self.filters["as_datetime"] = as_datetime + self.filters["as_local"] = dt_util.as_local + self.filters["as_timedelta"] = as_timedelta + self.filters["as_timestamp"] = forgiving_as_timestamp + self.filters["asin"] = arc_sine + self.filters["atan"] = arc_tangent + self.filters["atan2"] = arc_tangent2 + self.filters["average"] = average + self.filters["base64_decode"] = base64_decode + self.filters["base64_encode"] = base64_encode + self.filters["bitwise_and"] = bitwise_and + self.filters["bitwise_or"] = bitwise_or + self.filters["bitwise_xor"] = bitwise_xor + self.filters["bool"] = forgiving_boolean + self.filters["combine"] = combine + self.filters["contains"] = contains + self.filters["cos"] = cosine + self.filters["flatten"] = flatten + self.filters["float"] = forgiving_float_filter + self.filters["from_json"] = from_json + self.filters["iif"] = iif + self.filters["int"] = forgiving_int_filter + self.filters["is_defined"] = fail_when_undefined + self.filters["is_number"] = is_number + self.filters["log"] = logarithm + self.filters["md5"] = md5 + self.filters["median"] = median + self.filters["multiply"] = multiply + self.filters["ord"] = ord + self.filters["ordinal"] = ordinal + self.filters["pack"] = struct_pack + self.filters["random"] = random_every_time + self.filters["regex_findall_index"] = regex_findall_index + self.filters["regex_findall"] = regex_findall + self.filters["regex_match"] = regex_match + self.filters["regex_replace"] = regex_replace + self.filters["regex_search"] = regex_search + self.filters["round"] = forgiving_round + self.filters["sha1"] = sha1 + self.filters["sha256"] = sha256 + self.filters["sha512"] = sha512 + self.filters["shuffle"] = shuffle + self.filters["sin"] = sine + self.filters["slugify"] = slugify + self.filters["sqrt"] = square_root + self.filters["statistical_mode"] = statistical_mode + self.filters["tan"] = tangent + self.filters["timestamp_custom"] = timestamp_custom + self.filters["timestamp_local"] = timestamp_local + self.filters["timestamp_utc"] = timestamp_utc + self.filters["to_json"] = to_json + self.filters["typeof"] = typeof + self.filters["unpack"] = struct_unpack + self.filters["version"] = version + + self.tests["contains"] = contains + self.tests["datetime"] = _is_datetime self.tests["is_number"] = is_number self.tests["list"] = _is_list - self.tests["set"] = _is_set - self.tests["tuple"] = _is_tuple - self.tests["datetime"] = _is_datetime - self.tests["string_like"] = _is_string_like self.tests["match"] = regex_match self.tests["search"] = regex_search - self.tests["contains"] = contains + self.tests["set"] = _is_set + self.tests["string_like"] = _is_string_like + self.tests["tuple"] = _is_tuple if hass is None: return @@ -3119,28 +3122,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return jinja_context(wrapper) - self.globals["device_entities"] = hassfunction(device_entities) - self.filters["device_entities"] = self.globals["device_entities"] - - self.globals["device_attr"] = hassfunction(device_attr) - self.filters["device_attr"] = self.globals["device_attr"] - - self.globals["config_entry_attr"] = hassfunction(config_entry_attr) - self.filters["config_entry_attr"] = self.globals["config_entry_attr"] - - self.globals["is_device_attr"] = hassfunction(is_device_attr) - self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) - - self.globals["config_entry_id"] = hassfunction(config_entry_id) - self.filters["config_entry_id"] = self.globals["config_entry_id"] - - self.globals["device_id"] = hassfunction(device_id) - self.filters["device_id"] = self.globals["device_id"] - - self.globals["issues"] = hassfunction(issues) - - self.globals["issue"] = hassfunction(issue) - self.filters["issue"] = self.globals["issue"] + # Area extensions self.globals["areas"] = hassfunction(areas) @@ -3156,6 +3138,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = self.globals["area_devices"] + # Floor extensions + self.globals["floors"] = hassfunction(floors) self.filters["floors"] = self.globals["floors"] @@ -3171,9 +3155,35 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["floor_entities"] = hassfunction(floor_entities) self.filters["floor_entities"] = self.globals["floor_entities"] + # Integration extensions + self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = self.globals["integration_entities"] + # Config entry extensions + + self.globals["config_entry_attr"] = hassfunction(config_entry_attr) + self.filters["config_entry_attr"] = self.globals["config_entry_attr"] + + self.globals["config_entry_id"] = hassfunction(config_entry_id) + self.filters["config_entry_id"] = self.globals["config_entry_id"] + + # Device extensions + + self.globals["device_attr"] = hassfunction(device_attr) + self.filters["device_attr"] = self.globals["device_attr"] + + self.globals["device_entities"] = hassfunction(device_entities) + self.filters["device_entities"] = self.globals["device_entities"] + + self.globals["is_device_attr"] = hassfunction(is_device_attr) + self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) + + self.globals["device_id"] = hassfunction(device_id) + self.filters["device_id"] = self.globals["device_id"] + + # Label extensions + self.globals["labels"] = hassfunction(labels) self.filters["labels"] = self.globals["labels"] @@ -3192,6 +3202,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["label_entities"] = hassfunction(label_entities) self.filters["label_entities"] = self.globals["label_entities"] + # Issue extensions + + self.globals["issues"] = hassfunction(issues) + self.globals["issue"] = hassfunction(issue) + self.filters["issue"] = self.globals["issue"] + if limited: # Only device_entities is available to limited templates, mark other # functions and filters as unsupported. @@ -3204,38 +3220,38 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): return warn_unsupported hass_globals = [ - "closest", - "distance", - "expand", - "is_hidden_entity", - "is_state", - "is_state_attr", - "state_attr", - "states", - "state_translated", - "has_value", - "utcnow", - "now", - "device_attr", - "is_device_attr", - "device_id", "area_id", "area_name", + "closest", + "device_attr", + "device_id", + "distance", + "expand", "floor_id", "floor_name", + "has_value", + "is_device_attr", + "is_hidden_entity", + "is_state_attr", + "is_state", + "label_id", + "label_name", + "now", "relative_time", + "state_attr", + "state_translated", + "states", "time_since", "time_until", "today_at", - "label_id", - "label_name", + "utcnow", ] hass_filters = [ - "closest", - "expand", - "device_id", "area_id", "area_name", + "closest", + "device_id", + "expand", "floor_id", "floor_name", "has_value", @@ -3245,8 +3261,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): hass_tests = [ "has_value", "is_hidden_entity", - "is_state", "is_state_attr", + "is_state", ] for glob in hass_globals: self.globals[glob] = unsupported(glob) @@ -3256,38 +3272,46 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters[test] = unsupported(test) return - self.globals["expand"] = hassfunction(expand) - self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = hassfunction(closest_filter) self.globals["distance"] = hassfunction(distance) + self.globals["expand"] = hassfunction(expand) + self.globals["has_value"] = hassfunction(has_value) + self.globals["now"] = hassfunction(now) + self.globals["relative_time"] = hassfunction(relative_time) + self.globals["time_since"] = hassfunction(time_since) + self.globals["time_until"] = hassfunction(time_until) + self.globals["today_at"] = hassfunction(today_at) + self.globals["utcnow"] = hassfunction(utcnow) + + self.filters["closest"] = hassfunction(closest_filter) + self.filters["expand"] = self.globals["expand"] + self.filters["has_value"] = self.globals["has_value"] + self.filters["relative_time"] = self.globals["relative_time"] + self.filters["time_since"] = self.globals["time_since"] + self.filters["time_until"] = self.globals["time_until"] + self.filters["today_at"] = self.globals["today_at"] + + self.tests["has_value"] = hassfunction(has_value, pass_eval_context) + + # Entity extensions + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) self.tests["is_hidden_entity"] = hassfunction( is_hidden_entity, pass_eval_context ) - self.globals["is_state"] = hassfunction(is_state) - self.tests["is_state"] = hassfunction(is_state, pass_eval_context) + + # State extensions + self.globals["is_state_attr"] = hassfunction(is_state_attr) - self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) + self.globals["is_state"] = hassfunction(is_state) self.globals["state_attr"] = hassfunction(state_attr) - self.filters["state_attr"] = self.globals["state_attr"] - self.globals["states"] = AllStates(hass) - self.filters["states"] = self.globals["states"] self.globals["state_translated"] = StateTranslated(hass) + self.globals["states"] = AllStates(hass) + self.filters["state_attr"] = self.globals["state_attr"] self.filters["state_translated"] = self.globals["state_translated"] - self.globals["has_value"] = hassfunction(has_value) - self.filters["has_value"] = self.globals["has_value"] - self.tests["has_value"] = hassfunction(has_value, pass_eval_context) - self.globals["utcnow"] = hassfunction(utcnow) - self.globals["now"] = hassfunction(now) - self.globals["relative_time"] = hassfunction(relative_time) - self.filters["relative_time"] = self.globals["relative_time"] - self.globals["time_since"] = hassfunction(time_since) - self.filters["time_since"] = self.globals["time_since"] - self.globals["time_until"] = hassfunction(time_until) - self.filters["time_until"] = self.globals["time_until"] - self.globals["today_at"] = hassfunction(today_at) - self.filters["today_at"] = self.globals["today_at"] + self.filters["states"] = self.globals["states"] + self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) + self.tests["is_state"] = hassfunction(is_state, pass_eval_context) def is_safe_callable(self, obj): """Test if callback is safe.""" From a20601a1f07146358ff55fa3fef3e4f11e132241 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 20 Mar 2025 11:39:57 +0100 Subject: [PATCH 1827/1941] Bump reolink-aio to 0.12.3 (#140789) * Add password length restriction * Bump reolink-aio to 0.12.3 * Add repair issue for too long password * finish password too long repair issue * add test --- homeassistant/components/reolink/__init__.py | 4 +- homeassistant/components/reolink/host.py | 34 ++++++++++---- .../components/reolink/manifest.json | 2 +- homeassistant/components/reolink/strings.json | 6 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/reolink/test_init.py | 47 +++++++++++++++++++ 7 files changed, 82 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 2489133841a..99ca91c5bdf 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -67,9 +67,7 @@ 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, config_entry.entry_id - ) + host = ReolinkHost(hass, config_entry.data, config_entry.options, config_entry) try: await host.async_init() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 53061500e32..a027177f1fc 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -41,7 +41,7 @@ from .exceptions import ( ReolinkWebhookException, UserNotAdmin, ) -from .util import get_store +from .util import ReolinkConfigEntry, get_store DEFAULT_TIMEOUT = 30 FIRST_TCP_PUSH_TIMEOUT = 10 @@ -67,11 +67,11 @@ class ReolinkHost: hass: HomeAssistant, config: Mapping[str, Any], options: Mapping[str, Any], - config_entry_id: str | None = None, + config_entry: ReolinkConfigEntry | 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_entry = config_entry self._config = config self._unique_id: str = "" @@ -151,15 +151,33 @@ class ReolinkHost: async def async_init(self) -> None: """Connect to Reolink host.""" if not self._api.valid_password(): + if ( + len(self._config[CONF_PASSWORD]) >= 32 + and self._config_entry is not None + ): + ir.async_create_issue( + self._hass, + DOMAIN, + f"password_too_long_{self._config_entry.entry_id}", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="password_too_long", + translation_placeholders={"name": self._config_entry.title}, + ) + raise PasswordIncompatible( - "Reolink password contains incompatible special character, " - "please change the password to only contain characters: " - f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}" + "Reolink password contains incompatible special character or " + "is too long, please change the password to only contain characters: " + f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS} " + "and not be longer than 31 characters" ) 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_entry is not None: + ir.async_delete_issue( + self._hass, DOMAIN, f"password_too_long_{self._config_entry.entry_id}" + ) + store = get_store(self._hass, self._config_entry.entry_id) if self._config.get(CONF_SUPPORTS_PRIVACY_MODE) and ( data := await store.async_load() ): diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 0cb5eb3e13c..41cfe1f9ae3 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.3b1"] + "requirements": ["reolink-aio==0.12.3"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 53df658239c..74823c4bd32 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -31,7 +31,7 @@ "cannot_connect": "Failed to connect, check the IP address of the camera", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "not_admin": "User needs to be admin, user \"{username}\" has authorisation level \"{userlevel}\"", - "password_incompatible": "Password contains incompatible special character, only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}", + "password_incompatible": "Password contains incompatible special character or is too long, maximum 31 characters and only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}", "unknown": "[%key:common::config_flow::error::unknown%]", "update_needed": "Failed to login because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", "webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}" @@ -129,6 +129,10 @@ "hub_switch_deprecated": { "title": "Reolink Home Hub switches deprecated", "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are deprecated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned." + }, + "password_too_long": { + "title": "Reolink password too long", + "description": "The password for \"{name}\" is more than 31 characters long, this is no longer compatible with the Reolink API. Please change the password using the Reolink app/client to a password with is shorter than 32 characters. After changing the password, fill in the new password in the Reolink Re-authentication flow to continue using this integration. The latest version of the Reolink app/client also has a password limit of 31 characters." } }, "services": { diff --git a/requirements_all.txt b/requirements_all.txt index f4e20f563a1..9848158a10e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2622,7 +2622,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.3b1 +reolink-aio==0.12.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4435e22827..cc2b8acc214 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2122,7 +2122,7 @@ renault-api==0.2.9 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.12.3b1 +reolink-aio==0.12.3 # homeassistant.components.rflink rflink==0.0.66 diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index ad7f5540b04..4c4908dca6f 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -22,7 +22,11 @@ from homeassistant.components.reolink import ( from homeassistant.components.reolink.const import CONF_BC_PORT, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -35,17 +39,25 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) +from homeassistant.helpers.device_registry import format_mac from homeassistant.setup import async_setup_component from .conftest import ( + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DEFAULT_PROTOCOL, TEST_BC_PORT, TEST_CAM_MODEL, + TEST_HOST, TEST_HOST_MODEL, TEST_MAC, TEST_NVR_NAME, TEST_PORT, + TEST_PRIVACY, TEST_UID, TEST_UID_CAM, + TEST_USE_HTTPS, + TEST_USERNAME, ) from tests.common import MockConfigEntry, async_fire_time_changed @@ -723,6 +735,41 @@ async def test_firmware_repair_issue( await hass.async_block_till_done() assert (DOMAIN, "firmware_update_host") in issue_registry.issues + reolink_connect.camera_sw_version_update_required.return_value = False + + +async def test_password_too_long_repair_issue( + hass: HomeAssistant, + reolink_connect: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test password too long issue is raised.""" + reolink_connect.valid_password.return_value = False + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: "too_longgggggggggggggggggggggggggggggggggggggggggggggggggg", + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + DOMAIN, + f"password_too_long_{config_entry.entry_id}", + ) in issue_registry.issues + reolink_connect.valid_password.return_value = True async def test_new_device_discovered( From d8a4a97ee01de330b4c1ac33e1d150c54b89922c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 12:19:14 +0100 Subject: [PATCH 1828/1941] Allow patching Z-Wave platforms specifically in tests (#140987) --- tests/components/zwave_js/conftest.py | 14 ++++++++++++-- tests/components/zwave_js/test_siren.py | 9 ++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 1917ebedd34..ce7b0e0109e 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -13,7 +13,9 @@ from zwave_js_server.model.node import Node from zwave_js_server.model.node.data_model import NodeDataType from zwave_js_server.version import VersionInfo +from homeassistant.components.zwave_js import PLATFORMS from homeassistant.components.zwave_js.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonArrayType @@ -828,18 +830,26 @@ def nortek_thermostat_removed_event_fixture(client) -> Node: async def integration_fixture( hass: HomeAssistant, client: MagicMock, + platforms: list[Platform], ) -> MockConfigEntry: """Set up the zwave_js integration.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.zwave_js.PLATFORMS", platforms): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() client.async_send_command.reset_mock() return entry +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return PLATFORMS + + @pytest.fixture(name="chain_actuator_zws12") def window_cover_fixture(client, chain_actuator_zws12_state) -> Node: """Mock a window cover node.""" diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 4eb872954d1..d932338f9dc 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS siren platform.""" +import pytest from zwave_js_server.event import Event from homeassistant.components.siren import ( @@ -7,7 +8,7 @@ from homeassistant.components.siren import ( ATTR_TONE, ATTR_VOLUME_LEVEL, ) -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant SIREN_ENTITY = "siren.indoor_siren_6_play_tone_2" @@ -64,6 +65,12 @@ TONE_ID_VALUE_ID = { } +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SIREN] + + async def test_siren( hass: HomeAssistant, client, aeotec_zw164_siren, integration ) -> None: From df0125abdd6672087dcdb41164b74e8c0decd96f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 12:54:40 +0100 Subject: [PATCH 1829/1941] Patch Z-Wave platforms in api tests (#140988) --- tests/components/zwave_js/test_api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 62e7f25bc08..f0134c7c43c 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -103,6 +103,12 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [] + + def get_device(hass: HomeAssistant, node): """Get device ID for a node.""" dev_reg = dr.async_get(hass) From c9b27cf26e3662600abda73aaaadfc67291f7900 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 20 Mar 2025 12:56:45 +0100 Subject: [PATCH 1830/1941] Detect early base platforms in bootstrap (#140359) * Detect early base platforms in bootstrap * Address feedback * Address feedback --- tests/test_bootstrap.py | 79 +++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 050963316dc..1fb87ac5ef6 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1546,41 +1546,68 @@ def test_should_rollover_is_always_false() -> None: async def test_no_base_platforms_loaded_before_recorder(hass: HomeAssistant) -> None: """Verify stage 0 not load base platforms before recorder. - If a stage 0 integration has a base platform in its dependencies and - it loads before the recorder, it may load integrations that expect - the recorder to be loaded. We need to ensure that no stage 0 integration - has a base platform in its dependencies that loads before the recorder. + If a stage 0 integration implements base platforms or has a base + platform in its dependencies and it loads before the recorder, + because of platform-based YAML schema, it may inadvertently + load integrations that expect the recorder to already be loaded. + We need to ensure that doesn't happen. """ + IGNORE_BASE_PLATFORM_FILES = { + # config/scene.py is not a platform + "config": {"scene.py"}, + # websocket_api/sensor.py is using the platform YAML schema + # we must not migrate it to an integration key until + # we remove the platform YAML schema support for sensors + "websocket_api": {"sensor.py"}, + } + integrations_before_recorder: set[str] = set() for _, integrations, _ in bootstrap.STAGE_0_INTEGRATIONS: integrations_before_recorder |= integrations if "recorder" in integrations: break + else: + pytest.fail("recorder not in stage 0") - integrations_or_execs = await loader.async_get_integrations( + integrations_or_excs = await loader.async_get_integrations( hass, integrations_before_recorder ) - integrations: list[Integration] = [] - resolve_deps_tasks: list[asyncio.Task[bool]] = [] - for integration in integrations_or_execs.values(): - assert not isinstance(integrations_or_execs, Exception) - integrations.append(integration) - resolve_deps_tasks.append(integration.resolve_dependencies()) + integrations: dict[str, Integration] = {} + for domain, integration in integrations_or_excs.items(): + assert not isinstance(integrations_or_excs, Exception) + integrations[domain] = integration + + integrations_all_dependencies = await loader.resolve_integrations_dependencies( + hass, integrations.values() + ) + all_integrations = integrations.copy() + all_integrations.update( + (domain, loader.async_get_loaded_integration(hass, domain)) + for domains in integrations_all_dependencies.values() + for domain in domains + ) + + problems: dict[str, set[str]] = {} + for domain in integrations: + domain_with_base_platforms_deps = ( + integrations_all_dependencies[domain] & BASE_PLATFORMS + ) + if domain_with_base_platforms_deps: + problems[domain] = domain_with_base_platforms_deps + assert not problems, ( + f"Integrations that are setup before recorder have base platforms in their dependencies: {problems}" + ) - await asyncio.gather(*resolve_deps_tasks) base_platform_py_files = {f"{base_platform}.py" for base_platform in BASE_PLATFORMS} - for integration in integrations: - domain_with_base_platforms_deps = BASE_PLATFORMS.intersection( - integration.all_dependencies - ) - assert not domain_with_base_platforms_deps, ( - f"{integration.domain} has base platforms in dependencies: " - f"{domain_with_base_platforms_deps}" - ) - integration_top_level_files = base_platform_py_files.intersection( - integration._top_level_files - ) - assert not integration_top_level_files, ( - f"{integration.domain} has base platform files in top level files: " - f"{integration_top_level_files}" + + for domain, integration in all_integrations.items(): + integration_base_platforms_files = ( + integration._top_level_files & base_platform_py_files ) + if ignore := IGNORE_BASE_PLATFORM_FILES.get(domain): + integration_base_platforms_files -= ignore + if integration_base_platforms_files: + problems[domain] = integration_base_platforms_files + assert not problems, ( + f"Integrations that are setup before recorder implement base platforms: {problems}" + ) From 5f84fc3ee593fdd5ce2eebe37d23956cbbedbf3a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 15:06:21 +0100 Subject: [PATCH 1831/1941] Patch Z-Wave platforms in binary sensor tests (#140992) --- tests/components/zwave_js/test_binary_sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 0054439ef1d..657dd337bf9 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Z-Wave JS binary sensor platform.""" +import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node @@ -10,6 +11,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNKNOWN, EntityCategory, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -26,6 +28,12 @@ from .common import ( from tests.common import MockConfigEntry +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BINARY_SENSOR] + + async def test_low_battery_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration ) -> None: From 212d39ba19c3c374514b1d44f910546b5ac444d6 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 20 Mar 2025 10:12:43 -0400 Subject: [PATCH 1832/1941] Migrate template switch to new style (#140324) * Migrate template switch to new style * update tests * Update tests * Add config flow migration * comment fixes * revert entity config migration --- homeassistant/components/template/config.py | 9 +- homeassistant/components/template/switch.py | 124 +- .../template/snapshots/test_switch.ambr | 15 +- tests/components/template/test_config_flow.py | 30 + tests/components/template/test_switch.py | 1090 ++++++++++------- 5 files changed, 798 insertions(+), 470 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 07c3c1b437f..4e07d67f6e9 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -17,6 +17,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import ( @@ -41,6 +42,7 @@ from . import ( number as number_platform, select as select_platform, sensor as sensor_platform, + switch as switch_platform, weather as weather_platform, ) from .const import ( @@ -112,8 +114,13 @@ CONFIG_SECTION_SCHEMA = vol.Schema( vol.Optional(WEATHER_DOMAIN): vol.All( cv.ensure_list, [weather_platform.WEATHER_SCHEMA] ), + vol.Optional(SWITCH_DOMAIN): vol.All( + cv.ensure_list, [switch_platform.SWITCH_SCHEMA] + ), }, - ensure_domains_do_not_have_trigger_or_action(BUTTON_DOMAIN, LIGHT_DOMAIN), + ensure_domains_do_not_have_trigger_or_action( + BUTTON_DOMAIN, LIGHT_DOMAIN, SWITCH_DOMAIN + ), ) ) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index feaabc3b17c..b76fc28b83c 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, CONF_DEVICE_ID, CONF_NAME, + CONF_STATE, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -25,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv, selector, template from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( @@ -35,16 +36,41 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .template_entity import ( + LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, + TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, + TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, rewrite_common_legacy_to_modern_conf, ) _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] -SWITCH_SCHEMA = vol.All( +LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { + CONF_VALUE_TEMPLATE: CONF_STATE, +} + +DEFAULT_NAME = "Template Switch" + + +SWITCH_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_STATE): cv.template, + vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_PICTURE): cv.template, + } + ) + .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) +) + +LEGACY_SWITCH_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -59,13 +85,13 @@ SWITCH_SCHEMA = vol.All( ) PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA)} + {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(LEGACY_SWITCH_SCHEMA)} ) SWITCH_CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), @@ -73,24 +99,62 @@ SWITCH_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities(hass: HomeAssistant, config: ConfigType): - """Create the Template switches.""" +def rewrite_legacy_to_modern_conf( + hass: HomeAssistant, config: dict[str, dict] +) -> list[dict]: + """Rewrite legacy switch configuration definitions to modern ones.""" switches = [] - for object_id, entity_config in config[CONF_SWITCHES].items(): - entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config) - unique_id = entity_config.get(CONF_UNIQUE_ID) + for object_id, entity_conf in config.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_common_legacy_to_modern_conf( + hass, entity_conf, LEGACY_FIELDS + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + switches.append(entity_conf) + + return switches + + +def rewrite_options_to_moder_conf(option_config: dict[str, dict]) -> dict[str, dict]: + """Rewrite option configuration to modern configuration.""" + option_config = {**option_config} + + if CONF_VALUE_TEMPLATE in option_config: + option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE) + + return option_config + + +@callback +def _async_create_template_tracking_entities( + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template switches.""" + switches = [] + + for entity_conf in definitions: + unique_id = entity_conf.get(CONF_UNIQUE_ID) + + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" switches.append( SwitchTemplate( hass, - object_id, - entity_config, + entity_conf, unique_id, ) ) - return switches + async_add_entities(switches) async def async_setup_platform( @@ -100,7 +164,21 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template switches.""" - async_add_entities(await _async_create_entities(hass, config)) + if discovery_info is None: + _async_create_template_tracking_entities( + async_add_entities, + hass, + rewrite_legacy_to_modern_conf(hass, config[CONF_SWITCHES]), + None, + ) + return + + _async_create_template_tracking_entities( + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) async def async_setup_entry( @@ -111,10 +189,9 @@ async def async_setup_entry( """Initialize config entry.""" _options = dict(config_entry.options) _options.pop("template_type") + _options = rewrite_options_to_moder_conf(_options) validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities( - [SwitchTemplate(hass, None, validated_config, config_entry.entry_id)] - ) + async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)]) @callback @@ -123,7 +200,7 @@ def async_create_preview_switch( ) -> SwitchTemplate: """Create a preview switch.""" validated_config = SWITCH_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return SwitchTemplate(hass, None, validated_config, None) + return SwitchTemplate(hass, validated_config, None) class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): @@ -134,22 +211,19 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, - object_id, config: ConfigType, - unique_id, + unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__( - hass, config=config, fallback_name=object_id, unique_id=unique_id - ) - if object_id is not None: + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) name = self._attr_name if TYPE_CHECKING: assert name is not None - self._template = config.get(CONF_VALUE_TEMPLATE) + self._template = config.get(CONF_STATE) if on_action := config.get(CONF_TURN_ON): self.add_script(CONF_TURN_ON, on_action, name, DOMAIN) diff --git a/tests/components/template/snapshots/test_switch.ambr b/tests/components/template/snapshots/test_switch.ambr index c240a9436a0..909110fdbc8 100644 --- a/tests/components/template/snapshots/test_switch.ambr +++ b/tests/components/template/snapshots/test_switch.ambr @@ -1,5 +1,18 @@ # serializer version: 1 -# name: test_setup_config_entry +# name: test_setup_config_entry[state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My template', + }), + 'context': , + 'entity_id': 'switch.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_config_entry[value_template] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'My template', diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 2c9b81e7c91..21d740b165b 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -16,6 +16,36 @@ from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +SWITCH_BEFORE_OPTIONS = { + "name": "test_template_switch", + "template_type": "switch", + "turn_off": [{"event": "test_template_switch", "event_data": {"event": "off"}}], + "turn_on": [{"event": "test_template_switch", "event_data": {"event": "on"}}], + "value_template": "{{ now().minute % 2 == 0 }}", +} + + +SWITCH_AFTER_OPTIONS = { + "name": "test_template_switch", + "template_type": "switch", + "turn_off": [{"event": "test_template_switch", "event_data": {"event": "off"}}], + "turn_on": [{"event": "test_template_switch", "event_data": {"event": "on"}}], + "state": "{{ now().minute % 2 == 0 }}", + "value_template": "{{ now().minute % 2 == 0 }}", +} + +SENSOR_OPTIONS = { + "name": "test_template_sensor", + "template_type": "sensor", + "state": "{{ 'a' if now().minute % 2 == 0 else 'b' }}", +} + +BINARY_SENSOR_OPTIONS = { + "name": "test_template_sensor", + "template_type": "binary_sensor", + "state": "{{ now().minute % 2 == 0 else }}", +} + @pytest.mark.parametrize( ( diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index 2fc0f29acaf..f0dbe43b51e 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -1,11 +1,13 @@ """The tests for the Template switch platform.""" +from typing import Any + import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant import setup -from homeassistant.components import template +from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -16,8 +18,11 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component +from .conftest import ConfigurationStyle + from tests.common import ( MockConfigEntry, assert_setup_component, @@ -25,26 +30,225 @@ from tests.common import ( mock_restore_cache, ) -OPTIMISTIC_SWITCH_CONFIG = { - "turn_on": { - "service": "test.automation", - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, +TEST_OBJECT_ID = "test_template_switch" +TEST_ENTITY_ID = f"switch.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "switch.test_state" + +SWITCH_TURN_ON = { + "service": "test.automation", + "data_template": { + "action": "turn_on", + "caller": "{{ this.entity_id }}", }, - "turn_off": { - "service": "test.automation", - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, +} +SWITCH_TURN_OFF = { + "service": "test.automation", + "data_template": { + "action": "turn_off", + "caller": "{{ this.entity_id }}", }, } +SWITCH_ACTIONS = { + "turn_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, +} +NAMED_SWITCH_ACTIONS = { + **SWITCH_ACTIONS, + "name": TEST_OBJECT_ID, +} +UNIQUE_ID_CONFIG = { + **SWITCH_ACTIONS, + "unique_id": "not-so-unique-anymore", +} +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, switch_config: dict[str, Any] +) -> None: + """Do setup of switch integration via legacy format.""" + config = {"switch": {"platform": "template", "switches": switch_config}} + + with assert_setup_component(count, switch.DOMAIN): + assert await async_setup_component( + hass, + switch.DOMAIN, + config, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, switch_config: dict[str, Any] +) -> None: + """Do setup of switch integration via modern format.""" + config = {"template": {"switch": switch_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + switch_config: dict[str, Any], +) -> None: + """Do setup of switch integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, switch_config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, switch_config) + + +@pytest.fixture +async def setup_state_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, +): + """Do setup of switch integration using a state template.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + "value_template": state_template, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": state_template, + }, + ) + + +@pytest.fixture +async def setup_single_attribute_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_template: str, +) -> None: + """Do setup of switch integration testing a single attribute.""" + extra = {attribute: attribute_template} if attribute and attribute_template else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + "value_template": "{{ 1 == 1 }}", + **extra, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + "state": "{{ 1 == 1 }}", + **extra, + }, + ) + + +@pytest.fixture +async def setup_optimistic_switch( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, +) -> None: + """Do setup of an optimistic switch.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + **NAMED_SWITCH_ACTIONS, + }, + ) + + +async def test_legacy_to_modern_config(hass: HomeAssistant) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "value_template": "{{ 1 == 1 }}", + "unique_id": "foo-bar-switch", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + **SWITCH_ACTIONS, + } + } + altered_configs = rewrite_legacy_to_modern_conf(hass, config) + + assert len(altered_configs) == 1 + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "turn_off": SWITCH_TURN_OFF, + "turn_on": SWITCH_TURN_ON, + "unique_id": "foo-bar-switch", + "state": Template("{{ 1 == 1 }}", hass), + } + ] == altered_configs + + +@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ True }}")]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_setup(hass: HomeAssistant, setup_state_switch) -> None: + """Test template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.name == TEST_OBJECT_ID + assert state.state == STATE_ON + + +@pytest.mark.parametrize("state_key", ["value_template", "state"]) async def test_setup_config_entry( hass: HomeAssistant, + state_key: str, snapshot: SnapshotAssertion, ) -> None: """Test the config flow.""" @@ -60,7 +264,7 @@ async def test_setup_config_entry( domain=template.DOMAIN, options={ "name": "My template", - "value_template": "{{ states('switch.one') }}", + state_key: "{{ states('switch.one') }}", "template_type": SWITCH_DOMAIN, }, title="My template", @@ -75,200 +279,108 @@ async def test_setup_config_entry( assert state == snapshot -async def test_template_state_text(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_template_state_text(hass: HomeAssistant, setup_state_switch) -> None: """Test the state text of a template.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("switch.test_state", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON - hass.states.async_set("switch.test_state", STATE_OFF) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF -async def test_template_state_boolean_on(hass: HomeAssistant) -> None: - """Test the setting of the state with boolean on.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ 1 == 1 }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") - assert state.state == STATE_ON +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("expected", "state_template"), + [ + (STATE_ON, "{{ 1 == 1 }}"), + (STATE_OFF, "{{ 1 == 2 }}"), + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_template_state_boolean( + hass: HomeAssistant, expected: str, setup_state_switch +) -> None: + """Test the setting of the state with boolean template.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == expected -async def test_template_state_boolean_off(hass: HomeAssistant) -> None: - """Test the setting of the state with off.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ 1 == 2 }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") - assert state.state == STATE_OFF - - -async def test_icon_template(hass: HomeAssistant) -> None: - """Test icon template.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "icon_template": ( - "{% if states.switch.test_state.state %}" - "mdi:check" - "{% endif %}" - ), - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") +@pytest.mark.parametrize( + ("count", "attribute_template"), + [(1, "{% if states.switch.test_state.state %}mdi:check{% endif %}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "icon_template"), + (ConfigurationStyle.MODERN, "icon"), + ], +) +async def test_icon_template( + hass: HomeAssistant, setup_single_attribute_switch +) -> None: + """Test the state text of a template.""" + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("icon") == "" - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:check" -async def test_entity_picture_template(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("count", "attribute_template"), + [(1, "{% if states.switch.test_state.state %}/local/switch.png{% endif %}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "entity_picture_template"), + (ConfigurationStyle.MODERN, "picture"), + ], +) +async def test_entity_picture_template( + hass: HomeAssistant, setup_single_attribute_switch +) -> None: """Test entity_picture template.""" - with assert_setup_component(1, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - "entity_picture_template": ( - "{% if states.switch.test_state.state %}" - "/local/switch.png" - "{% endif %}" - ), - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("entity_picture") == "" - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/switch.png" -async def test_template_syntax_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("count", "state_template"), [(0, "{% if rubbish %}")]) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], +) +async def test_template_syntax_error(hass: HomeAssistant, setup_state_switch) -> None: """Test templating syntax error.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{% if rubbish %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - assert hass.states.async_all("switch") == [] -async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: - """Test invalid name.""" +async def test_invalid_legacy_slug_does_not_create(hass: HomeAssistant) -> None: + """Test invalid legacy slug.""" with assert_setup_component(0, "switch"): assert await async_setup_component( hass, @@ -278,7 +390,7 @@ async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: "platform": "template", "switches": { "test INVALID switch": { - **OPTIMISTIC_SWITCH_CONFIG, + **SWITCH_ACTIONS, "value_template": "{{ rubbish }", } }, @@ -293,19 +405,32 @@ async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_invalid_switch_does_not_create(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "template": {"switch": "Invalid"}, + }, + template.DOMAIN, + ), + ( + { + "switch": { + "platform": "template", + "switches": {TEST_OBJECT_ID: "Invalid"}, + } + }, + switch.DOMAIN, + ), + ], +) +async def test_invalid_switch_does_not_create( + hass: HomeAssistant, config: dict, domain: str +) -> None: """Test invalid switch.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": {"test_template_switch": "Invalid"}, - } - }, - ) + with assert_setup_component(0, domain): + assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() @@ -314,12 +439,33 @@ async def test_invalid_switch_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_no_switches_does_not_create(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("config", "domain", "count"), + [ + ( + { + "template": {"switch": []}, + }, + template.DOMAIN, + 1, + ), + ( + { + "switch": { + "platform": "template", + } + }, + switch.DOMAIN, + 0, + ), + ], +) +async def test_no_switches_does_not_create( + hass: HomeAssistant, config: dict, domain: str, count: int +) -> None: """Test if there are no switches no creation.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, "switch", {"switch": {"platform": "template"}} - ) + with assert_setup_component(count, domain): + assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() @@ -328,239 +474,254 @@ async def test_no_switches_does_not_create(hass: HomeAssistant) -> None: assert hass.states.async_all("switch") == [] -async def test_missing_on_does_not_create(hass: HomeAssistant) -> None: - """Test missing on.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - "value_template": "{{ states.switch.test_state.state }}", - "not_on": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "turn_off": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all("switch") == [] - - -async def test_missing_off_does_not_create(hass: HomeAssistant) -> None: - """Test missing off.""" - with assert_setup_component(0, "switch"): - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - "value_template": "{{ states.switch.test_state.state }}", - "turn_on": { - "service": "switch.turn_on", - "entity_id": "switch.test_state", - }, - "not_off": { - "service": "switch.turn_off", - "entity_id": "switch.test_state", - }, - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all("switch") == [] - - -async def test_on_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: - """Test on action.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", + "template": { + "switch": { + "not_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, + "state": "{{ states.switch.test_state.state }}", } }, - } - }, - ) + }, + template.DOMAIN, + ), + ( + { + "switch": { + "platform": "template", + "switches": { + TEST_OBJECT_ID: { + "not_on": SWITCH_TURN_ON, + "turn_off": SWITCH_TURN_OFF, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + } + }, + switch.DOMAIN, + ), + ], +) +async def test_missing_on_does_not_create( + hass: HomeAssistant, config: dict, domain: str +) -> None: + """Test missing on.""" + with assert_setup_component(0, domain): + assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("switch.test_state", STATE_OFF) + assert hass.states.async_all("switch") == [] + + +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "template": { + "switch": { + "turn_on": SWITCH_TURN_ON, + "not_off": SWITCH_TURN_OFF, + "state": "{{ states.switch.test_state.state }}", + } + }, + }, + template.DOMAIN, + ), + ( + { + "switch": { + "platform": "template", + "switches": { + TEST_OBJECT_ID: { + "turn_on": SWITCH_TURN_ON, + "not_off": SWITCH_TURN_OFF, + "value_template": "{{ states.switch.test_state.state }}", + } + }, + } + }, + switch.DOMAIN, + ), + ], +) +async def test_missing_off_does_not_create( + hass: HomeAssistant, config: dict, domain: str +) -> None: + """Test missing off.""" + with assert_setup_component(0, domain): + assert await async_setup_component(hass, domain, config) + + await hass.async_block_till_done() + await hass.async_start() await hass.async_block_till_done() - state = hass.states.get("switch.test_template_switch") + assert hass.states.async_all("switch") == [] + + +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states('switch.test_state') }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_on_action( + hass: HomeAssistant, setup_state_switch, calls: list[ServiceCall] +) -> None: + """Test on action.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) async def test_on_action_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] ) -> None: """Test on action in optimistic mode.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - } - }, - } - }, - ) - - await hass.async_start() + hass.states.async_set(TEST_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - hass.states.async_set("switch.test_template_switch", STATE_OFF) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON assert len(calls) == 1 assert calls[-1].data["action"] == "turn_on" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID -async def test_off_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: +@pytest.mark.parametrize( + ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) +async def test_off_action( + hass: HomeAssistant, setup_state_switch, calls: list[ServiceCall] +) -> None: """Test off action.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ states.switch.test_state.state }}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("switch.test_state", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) assert len(calls) == 1 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] +) async def test_off_action_optimistic( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, setup_optimistic_switch, calls: list[ServiceCall] ) -> None: """Test off action in optimistic mode.""" - assert await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - } - }, - } - }, - ) - - await hass.async_start() + hass.states.async_set(TEST_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("switch.test_template_switch", STATE_ON) - await hass.async_block_till_done() - - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_template_switch"}, + {ATTR_ENTITY_ID: TEST_ENTITY_ID}, blocking=True, ) - state = hass.states.get("switch.test_template_switch") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF assert len(calls) == 1 assert calls[-1].data["action"] == "turn_off" - assert calls[-1].data["caller"] == "switch.test_template_switch" + assert calls[-1].data["caller"] == TEST_ENTITY_ID -async def test_restore_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "switch": { + "platform": "template", + "switches": { + "s1": { + **SWITCH_ACTIONS, + }, + "s2": { + **SWITCH_ACTIONS, + }, + }, + } + }, + switch.DOMAIN, + ), + ( + { + "template": { + "switch": [ + { + "name": "s1", + **SWITCH_ACTIONS, + }, + { + "name": "s2", + **SWITCH_ACTIONS, + }, + ], + } + }, + template.DOMAIN, + ), + ], +) +async def test_restore_state( + hass: HomeAssistant, count: int, domain: str, config: dict[str, Any] +) -> None: """Test state restoration.""" mock_restore_cache( hass, @@ -573,23 +734,9 @@ async def test_restore_state(hass: HomeAssistant) -> None: hass.set_state(CoreState.starting) mock_component(hass, "recorder") - await async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "s1": { - **OPTIMISTIC_SWITCH_CONFIG, - }, - "s2": { - **OPTIMISTIC_SWITCH_CONFIG, - }, - }, - } - }, - ) + with assert_setup_component(count, domain): + await async_setup_component(hass, domain, config) + await hass.async_block_till_done() state = hass.states.get("switch.s1") @@ -601,100 +748,157 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.state == STATE_OFF -async def test_available_template_with_entities(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("count", "attribute_template"), + [(1, "{{ is_state('switch.test_state', 'on') }}")], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + ], +) +async def test_available_template_with_entities( + hass: HomeAssistant, setup_single_attribute_switch +) -> None: """Test availability templates with values from other entities.""" - await setup.async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ 1 == 1 }}", - "availability_template": ( - "{{ is_state('availability_state.state', 'on') }}" - ), - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - hass.states.async_set("availability_state.state", STATE_ON) + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("switch.test_template_switch").state != STATE_UNAVAILABLE - - hass.states.async_set("availability_state.state", STATE_OFF) - await hass.async_block_till_done() - - assert hass.states.get("switch.test_template_switch").state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("config", "domain"), + [ + ( + { + "switch": { + "platform": "template", + "switches": { + TEST_OBJECT_ID: { + **SWITCH_ACTIONS, + "value_template": "{{ true }}", + "availability_template": "{{ x - 12 }}", + } + }, + } + }, + switch.DOMAIN, + ), + ( + { + "template": { + "switch": { + **NAMED_SWITCH_ACTIONS, + "state": "{{ true }}", + "availability": "{{ x - 12 }}", + }, + } + }, + template.DOMAIN, + ), + ], +) async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + count: int, + config: dict[str, Any], + domain: str, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that an invalid availability keeps the device available.""" - await setup.async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch": { - **OPTIMISTIC_SWITCH_CONFIG, - "value_template": "{{ true }}", - "availability_template": "{{ x - 12 }}", - } - }, - } - }, - ) + with assert_setup_component(count, domain): + await async_setup_component(hass, domain, config) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - assert hass.states.get("switch.test_template_switch").state != STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE assert "UndefinedError: 'x' is undefined" in caplog.text -async def test_unique_id(hass: HomeAssistant) -> None: - """Test unique_id option only creates one switch per id.""" - await setup.async_setup_component( - hass, - "switch", - { - "switch": { - "platform": "template", - "switches": { - "test_template_switch_01": { - **OPTIMISTIC_SWITCH_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_switch_02": { - **OPTIMISTIC_SWITCH_CONFIG, - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("switch_config", "style"), + [ + ( + { + "test_template_switch_01": UNIQUE_ID_CONFIG, + "test_template_switch_02": UNIQUE_ID_CONFIG, + }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_switch_01", + **UNIQUE_ID_CONFIG, }, - } - }, - ) + { + "name": "test_template_switch_02", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ], +) +async def test_unique_id(hass: HomeAssistant, setup_switch) -> None: + """Test unique_id option only creates one switch per id.""" + assert len(hass.states.async_all("switch")) == 1 + + +async def test_nested_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test a template unique_id propagates to switch unique_ids.""" + with assert_setup_component(1, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + { + "template": { + "unique_id": "x", + "switch": [ + { + **SWITCH_ACTIONS, + "name": "test_a", + "unique_id": "a", + "state": "{{ true }}", + }, + { + **SWITCH_ACTIONS, + "name": "test_b", + "unique_id": "b", + "state": "{{ true }}", + }, + ], + }, + }, + ) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() - assert len(hass.states.async_all("switch")) == 1 + assert len(hass.states.async_all("switch")) == 2 + + entry = entity_registry.async_get("switch.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("switch.test_b") + assert entry + assert entry.unique_id == "x-b" async def test_device_id( @@ -720,7 +924,7 @@ async def test_device_id( domain=template.DOMAIN, options={ "name": "My template", - "value_template": "{{ true }}", + "state": "{{ true }}", "template_type": "switch", "device_id": device_entry.id, }, From 2a4ed9ace7853290c3918b389f7e349003ab0703 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 20 Mar 2025 10:14:45 -0400 Subject: [PATCH 1833/1941] Add translations for Roborock Exceptions (#140964) * Add translations to a few exceptions * match existing wording * fix regex * consolidate errors * fix test --- homeassistant/components/roborock/coordinator.py | 12 ++++++++++-- homeassistant/components/roborock/quality_scale.yaml | 2 +- homeassistant/components/roborock/strings.json | 6 ++++++ homeassistant/components/roborock/vacuum.py | 9 +++++++-- tests/components/roborock/test_vacuum.py | 4 +++- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 698e2c268ed..6d0c9737a29 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -193,7 +193,12 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): try: maps = await self.api.get_multi_maps_list() except RoborockException as err: - raise UpdateFailed("Failed to get map data: {err}") from err + _LOGGER.debug("Failed to get maps: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="map_failure", + translation_placeholders={"error": str(err)}, + ) from err # Rooms names populated later with calls to `set_current_map_rooms` for each map roborock_maps = maps.map_info if (maps and maps.map_info) else () stored_images = await asyncio.gather( @@ -310,7 +315,10 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): await self.set_current_map_rooms() except RoborockException as ex: _LOGGER.debug("Failed to update data: %s", ex) - raise UpdateFailed(ex) from ex + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_data_fail", + ) from ex if self.roborock_device_info.props.status.in_cleaning: if self._is_cloud_api: self.update_interval = V1_CLOUD_IN_CLEANING_INTERVAL diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index c7675ef96d1..feee5cb434c 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -64,7 +64,7 @@ rules: status: exempt comment: There are no noisy entities. entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index a59dc80e65d..caad67e4ce6 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -457,6 +457,12 @@ "map_failure": { "message": "Something went wrong creating the map" }, + "position_not_found": { + "message": "Robot position not found" + }, + "update_data_fail": { + "message": "Failed to update data" + }, "no_coordinators": { "message": "No devices were able to successfully setup" }, diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index c5357597527..058fffbdb1c 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -221,13 +221,18 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity): map_data = await self.coordinator.cloud_api.get_map_v1() if not isinstance(map_data, bytes): - raise HomeAssistantError("Failed to retrieve map data.") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), []) parsed_map = parser.parse(map_data) robot_position = parsed_map.vacuum_position if robot_position is None: - raise HomeAssistantError("Robot position not found") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="position_not_found" + ) return { "x": robot_position.x, diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 2a2d9f210ed..5d6e7a599bd 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -291,7 +291,9 @@ async def test_get_current_position_no_map_data( "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", return_value=None, ), - pytest.raises(HomeAssistantError, match="Failed to retrieve map data."), + pytest.raises( + HomeAssistantError, match="Something went wrong creating the map" + ), ): await hass.services.async_call( DOMAIN, From a835c85f591e413884acdd4f07dd3cdd9afe1ad0 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 20 Mar 2025 15:37:02 +0100 Subject: [PATCH 1834/1941] Patch Z-Wave platforms in button tests (#141001) --- tests/components/zwave_js/test_button.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index b0c06668926..0282a268b54 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -5,11 +5,17 @@ import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.BUTTON] + + async def test_ping_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 70ed120c6e2f93ecc2f6d4fdad157436409a2bb3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 20 Mar 2025 16:58:49 +0100 Subject: [PATCH 1835/1941] Add exception translations for GIOS integration (#141006) Add exception translations --- homeassistant/components/gios/__init__.py | 9 ++++++++- homeassistant/components/gios/coordinator.py | 9 ++++++++- homeassistant/components/gios/strings.json | 8 ++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index f756980f5d0..31f704fcacc 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -44,7 +44,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool try: gios = await Gios.create(websession, station_id) except (GiosError, ConnectionError, ClientConnectorError) as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "entry": entry.title, + "error": repr(err), + }, + ) from err coordinator = GiosDataUpdateCoordinator(hass, entry, gios) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py index 95f3b8af797..eb0dd82eb67 100644 --- a/homeassistant/components/gios/coordinator.py +++ b/homeassistant/components/gios/coordinator.py @@ -57,4 +57,11 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): async with asyncio.timeout(API_TIMEOUT): return await self.gios.async_update() except (GiosError, ClientConnectorError) as error: - raise UpdateFailed(error) from error + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(error), + }, + ) from error diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index ff4c2a80b16..eca23159a13 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -170,5 +170,13 @@ } } } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while connecting to the GIOS API for {entry}: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the GIOS API for {entry}: {error}" + } } } From e48a25e9526093b3348f0c91a6f6b29d3c4e6f6a Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:18:08 +0000 Subject: [PATCH 1836/1941] Add button platform for Squeezebox integration (#140697) * initial * trans key correction * base class updates * model tidy up * Update homeassistant/components/squeezebox/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/squeezebox/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/squeezebox/media_player.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/squeezebox/media_player.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/squeezebox/button.py Co-authored-by: Joost Lekkerkerker * review updates * update * move manufacturer to library * updates * list concat * review updates * Update tests/components/squeezebox/test_button.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/squeezebox/__init__.py | 1 + homeassistant/components/squeezebox/button.py | 155 ++++++++++++++++++ homeassistant/components/squeezebox/entity.py | 30 +++- .../components/squeezebox/media_player.py | 34 +--- .../components/squeezebox/strings.json | 23 +++ tests/components/squeezebox/conftest.py | 32 +++- .../snapshots/test_media_player.ambr | 2 +- tests/components/squeezebox/test_button.py | 23 +++ 8 files changed, 266 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/squeezebox/button.py create mode 100644 tests/components/squeezebox/test_button.py diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index fd641d3389d..78a97e38833 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -53,6 +53,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR, ] diff --git a/homeassistant/components/squeezebox/button.py b/homeassistant/components/squeezebox/button.py new file mode 100644 index 00000000000..098df3a1b5c --- /dev/null +++ b/homeassistant/components/squeezebox/button.py @@ -0,0 +1,155 @@ +"""Platform for button integration for squeezebox.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SqueezeboxConfigEntry +from .const import SIGNAL_PLAYER_DISCOVERED +from .coordinator import SqueezeBoxPlayerUpdateCoordinator +from .entity import SqueezeboxEntity + +_LOGGER = logging.getLogger(__name__) + +HARDWARE_MODELS_WITH_SCREEN = [ + "Squeezebox Boom", + "Squeezebox Radio", + "Transporter", + "Squeezebox Touch", + "Squeezebox", + "SliMP3", + "Squeezebox 1", + "Squeezebox 2", + "Squeezebox 3", +] + +HARDWARE_MODELS_WITH_TONE = [ + *HARDWARE_MODELS_WITH_SCREEN, + "Squeezebox Receiver", +] + + +@dataclass(frozen=True, kw_only=True) +class SqueezeboxButtonEntityDescription(ButtonEntityDescription): + """Squeezebox Button description.""" + + press_action: str + + +BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = tuple( + SqueezeboxButtonEntityDescription( + key=f"preset_{i}", + translation_key="preset", + translation_placeholders={"index": str(i)}, + press_action=f"preset_{i}.single", + ) + for i in range(1, 7) +) + +SCREEN_BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = ( + SqueezeboxButtonEntityDescription( + key="brightness_up", + translation_key="brightness_up", + press_action="brightness_up", + ), + SqueezeboxButtonEntityDescription( + key="brightness_down", + translation_key="brightness_down", + press_action="brightness_down", + ), +) + +TONE_BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = ( + SqueezeboxButtonEntityDescription( + key="bass_up", + translation_key="bass_up", + press_action="bass_up", + ), + SqueezeboxButtonEntityDescription( + key="bass_down", + translation_key="bass_down", + press_action="bass_down", + ), + SqueezeboxButtonEntityDescription( + key="treble_up", + translation_key="treble_up", + press_action="treble_up", + ), + SqueezeboxButtonEntityDescription( + key="treble_down", + translation_key="treble_down", + press_action="treble_down", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SqueezeboxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Squeezebox button platform from a server config entry.""" + + # Add button entities when player discovered + async def _player_discovered( + player_coordinator: SqueezeBoxPlayerUpdateCoordinator, + ) -> None: + _LOGGER.debug( + "Setting up button entity for player %s, model %s", + player_coordinator.player.name, + player_coordinator.player.model, + ) + + entities: list[SqueezeboxButtonEntity] = [] + + entities.extend( + SqueezeboxButtonEntity(player_coordinator, description) + for description in BUTTON_ENTITIES + ) + + entities.extend( + SqueezeboxButtonEntity(player_coordinator, description) + for description in TONE_BUTTON_ENTITIES + if player_coordinator.player.model in HARDWARE_MODELS_WITH_TONE + ) + + entities.extend( + SqueezeboxButtonEntity(player_coordinator, description) + for description in SCREEN_BUTTON_ENTITIES + if player_coordinator.player.model in HARDWARE_MODELS_WITH_SCREEN + ) + + async_add_entities(entities) + + entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered) + ) + + +class SqueezeboxButtonEntity(SqueezeboxEntity, ButtonEntity): + """Representation of Buttons for Squeezebox entities.""" + + entity_description: SqueezeboxButtonEntityDescription + + def __init__( + self, + coordinator: SqueezeBoxPlayerUpdateCoordinator, + entity_description: SqueezeboxButtonEntityDescription, + ) -> None: + """Initialize the SqueezeBox Button.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{format_mac(self._player.player_id)}_{entity_description.key}" + ) + + async def async_press(self) -> None: + """Execute the button action.""" + await self._player.async_query("button", self.entity_description.press_action) diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py index 027ca68edc6..2c443c24ffd 100644 --- a/homeassistant/components/squeezebox/entity.py +++ b/homeassistant/components/squeezebox/entity.py @@ -1,11 +1,37 @@ """Base class for Squeezebox Sensor entities.""" -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, STATUS_QUERY_UUID -from .coordinator import LMSStatusDataUpdateCoordinator +from .coordinator import ( + LMSStatusDataUpdateCoordinator, + SqueezeBoxPlayerUpdateCoordinator, +) + + +class SqueezeboxEntity(CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator]): + """Base entity class for Squeezebox entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: + """Initialize the SqueezeBox entity.""" + super().__init__(coordinator) + self._player = coordinator.player + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, format_mac(self._player.player_id))}, + name=self._player.name, + connections={(CONNECTION_NETWORK_MAC, format_mac(self._player.player_id))}, + via_device=(DOMAIN, coordinator.server_uuid), + model=self._player.model, + manufacturer=self._player.creator, + ) class LMSStatusEntity(CoordinatorEntity[LMSStatusDataUpdateCoordinator]): diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 1767d92730a..40662477745 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -35,15 +35,10 @@ from homeassistant.helpers import ( entity_platform, entity_registry as er, ) -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceInfo, - format_mac, -) +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.start import async_at_start -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from .browse_media import ( @@ -68,6 +63,7 @@ from .const import ( SQUEEZEBOX_SOURCE_STRINGS, ) from .coordinator import SqueezeBoxPlayerUpdateCoordinator +from .entity import SqueezeboxEntity if TYPE_CHECKING: from . import SqueezeboxConfigEntry @@ -181,9 +177,7 @@ def get_announce_timeout(extra: dict) -> int | None: return announce_timeout -class SqueezeBoxMediaPlayerEntity( - CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator], MediaPlayerEntity -): +class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): """Representation of the media player features of a SqueezeBox device. Wraps a pysqueezebox.Player() object. @@ -217,30 +211,10 @@ class SqueezeBoxMediaPlayerEntity( def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None: """Initialize the SqueezeBox device.""" super().__init__(coordinator) - player = coordinator.player - self._player = player self._query_result: bool | dict = {} self._remove_dispatcher: Callable | None = None self._previous_media_position = 0 - self._attr_unique_id = format_mac(player.player_id) - _manufacturer = None - if player.model.startswith("SqueezeLite") or "SqueezePlay" in player.model: - _manufacturer = "Ralph Irving" - elif ( - "Squeezebox" in player.model - or "Transporter" in player.model - or "Slim" in player.model - ): - _manufacturer = "Logitech" - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - name=player.name, - connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)}, - via_device=(DOMAIN, coordinator.server_uuid), - model=player.model, - manufacturer=_manufacturer, - ) + self._attr_unique_id = format_mac(self._player.player_id) self._browse_data = BrowseData() @callback diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index ed569989b56..83c5d7dd5d0 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -63,6 +63,29 @@ } }, "entity": { + "button": { + "preset": { + "name": "Preset {index}" + }, + "brightness_up": { + "name": "Brightness up" + }, + "brightness_down": { + "name": "Brightness down" + }, + "bass_up": { + "name": "Bass up" + }, + "bass_down": { + "name": "Bass down" + }, + "treble_up": { + "name": "Treble up" + }, + "treble_down": { + "name": "Treble down" + } + }, "binary_sensor": { "rescan": { "name": "Library rescan" diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 429c3b62087..769e611bf28 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -269,6 +269,7 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.title = None mock_player.image_url = None mock_player.model = "SqueezeLite" + mock_player.creator = "Ralph Irving & Adrian Smith" return mock_player @@ -309,7 +310,27 @@ async def configure_squeezebox_media_player_platform( ) -> None: """Configure a squeezebox config entry with appropriate mocks for media_player.""" with ( - patch("homeassistant.components.squeezebox.PLATFORMS", [Platform.MEDIA_PLAYER]), + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.MEDIA_PLAYER], + ), + patch("homeassistant.components.squeezebox.Server", return_value=lms), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + +async def configure_squeezebox_media_player_button_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + lms: MagicMock, +) -> None: + """Configure a squeezebox config entry with appropriate mocks for media_player.""" + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.BUTTON], + ), patch("homeassistant.components.squeezebox.Server", return_value=lms), ): await hass.config_entries.async_setup(config_entry.entry_id) @@ -325,6 +346,15 @@ async def configured_player( return (await lms.async_get_players())[0] +@pytest.fixture +async def configured_player_with_button( + hass: HomeAssistant, config_entry: MockConfigEntry, lms: MagicMock +) -> MagicMock: + """Fixture mocking calls to pysqueezebox Player from a configured squeezebox.""" + await configure_squeezebox_media_player_button_platform(hass, config_entry, lms) + return (await lms.async_get_players())[0] + + @pytest.fixture async def configured_players( hass: HomeAssistant, config_entry: MockConfigEntry, lms_factory: MagicMock diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index 34d6ae16af8..c0633035a84 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -24,7 +24,7 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': 'Ralph Irving', + 'manufacturer': 'Ralph Irving & Adrian Smith', 'model': 'SqueezeLite', 'model_id': None, 'name': 'Test Player', diff --git a/tests/components/squeezebox/test_button.py b/tests/components/squeezebox/test_button.py new file mode 100644 index 00000000000..16ced65be61 --- /dev/null +++ b/tests/components/squeezebox/test_button.py @@ -0,0 +1,23 @@ +"""Tests for the squeezebox button component.""" + +from unittest.mock import MagicMock + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + + +async def test_squeezebox_press( + hass: HomeAssistant, configured_player_with_button: MagicMock +) -> None: + """Test press service call.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_player_preset_1"}, + blocking=True, + ) + + configured_player_with_button.async_query.assert_called_with( + "button", "preset_1.single" + ) From 4bbd49af53febe29a03f4786483337ab86f097bf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 18:20:08 +0100 Subject: [PATCH 1837/1941] Capitalize "PIN to Drive" feature name in `teslemetry` (#141011) * Capitalize "PIN to Drive" as feature name in `teslemetry` Fixes the spelling of "PIN" for consistency and turns "PIN to Drive" into the feature name that Tesla uses (in English). * Update test_binary_sensor.ambr --- homeassistant/components/teslemetry/strings.json | 2 +- .../components/teslemetry/snapshots/test_binary_sensor.ambr | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 9dc17fd2ef7..c1df7d5aa57 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -132,7 +132,7 @@ "name": "Tire pressure warning rear right" }, "pin_to_drive_enabled": { - "name": "Pin to drive enabled" + "name": "PIN to Drive enabled" }, "drive_rail": { "name": "Drive rail" diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 84c50c3ebe9..a295dc16344 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -1631,7 +1631,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Pin to drive enabled', + 'original_name': 'PIN to Drive enabled', 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, @@ -1643,7 +1643,7 @@ # name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Pin to drive enabled', + 'friendly_name': 'Test PIN to Drive enabled', }), 'context': , 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', @@ -3010,7 +3010,7 @@ # name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Pin to drive enabled', + 'friendly_name': 'Test PIN to Drive enabled', }), 'context': , 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', From 32c6fb862939d2cdd5227df71b10d1fc17c1dc46 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 20 Mar 2025 18:20:40 +0100 Subject: [PATCH 1838/1941] Bump uv to 0.6.8 (#141007) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 251c92539a1..2efb9d59a44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN \ && go2rtc --version # Install uv -RUN pip3 install uv==0.6.1 +RUN pip3 install uv==0.6.8 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c72c5c4c646..1399c1884ea 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -68,7 +68,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.1 +uv==0.6.8 voluptuous-openapi==0.0.6 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 74122927660..1bd74791a18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.6.1", + "uv==0.6.8", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.6", diff --git a/requirements.txt b/requirements.txt index 1aa96e89bb6..0735e38c89c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.12.2,<5.0 ulid-transform==1.4.0 urllib3>=1.26.5,<2 -uv==0.6.1 +uv==0.6.8 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 758a4355176..79716b6fec3 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.6.8,source=/uv,target=/bin/uv \ # Uv creates a lock file in /tmp --mount=type=tmpfs,target=/tmp \ # Required for PyTurboJPEG From eca10ea5913f230366dfade32eed0c86dbb30f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 20 Mar 2025 17:45:52 +0000 Subject: [PATCH 1839/1941] Improve Withings sleep and weight default units (#140665) --- homeassistant/components/withings/sensor.py | 8 ++- .../withings/snapshots/test_sensor.ambr | 56 ++++++++++++------- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 28a0fbd1492..f20145f8bf9 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -122,7 +122,7 @@ MEASUREMENT_SENSORS: dict[ measurement_type=MeasurementType.HEIGHT, translation_key="height", native_unit_of_measurement=UnitOfLength.METERS, - suggested_display_precision=1, + suggested_display_precision=2, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -326,6 +326,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.deep_sleep_duration, translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), @@ -334,6 +335,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.sleep_latency, translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -343,6 +345,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.wake_up_latency, translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -376,6 +379,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.light_sleep_duration, translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -385,6 +389,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.rem_sleep_duration, translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -451,6 +456,7 @@ SLEEP_SENSORS = [ value_fn=lambda sleep_summary: sleep_summary.total_time_awake, translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index ec9fc1ed3fc..f735c506f65 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -503,6 +503,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -512,7 +515,7 @@ 'supported_features': 0, 'translation_key': 'deep_sleep', 'unique_id': 'withings_12345_sleep_deep_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_deep_sleep-state] @@ -521,14 +524,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Deep sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_deep_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5820', + 'state': '1.617', }) # --- # name: test_all_entities[sensor.henk_diastolic_blood_pressure-entry] @@ -1778,7 +1781,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 1, + 'suggested_display_precision': 2, }), }), 'original_device_class': , @@ -2242,6 +2245,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2251,7 +2257,7 @@ 'supported_features': 0, 'translation_key': 'light_sleep', 'unique_id': 'withings_12345_sleep_light_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_light_sleep-state] @@ -2260,14 +2266,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Light sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_light_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '10440', + 'state': '2.900', }) # --- # name: test_all_entities[sensor.henk_maximum_heart_rate-entry] @@ -2988,6 +2994,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -2997,7 +3006,7 @@ 'supported_features': 0, 'translation_key': 'rem_sleep', 'unique_id': 'withings_12345_sleep_rem_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_rem_sleep-state] @@ -3006,14 +3015,14 @@ 'device_class': 'duration', 'friendly_name': 'henk REM sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_rem_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2400', + 'state': '0.667', }) # --- # name: test_all_entities[sensor.henk_skin_temperature-entry] @@ -3616,6 +3625,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -3625,7 +3637,7 @@ 'supported_features': 0, 'translation_key': 'time_to_sleep', 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_time_to_sleep-state] @@ -3634,14 +3646,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Time to sleep', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_time_to_sleep', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '540', + 'state': '0.150', }) # --- # name: test_all_entities[sensor.henk_time_to_wakeup-entry] @@ -3668,6 +3680,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -3677,7 +3692,7 @@ 'supported_features': 0, 'translation_key': 'time_to_wakeup', 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_time_to_wakeup-state] @@ -3686,14 +3701,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Time to wakeup', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_time_to_wakeup', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1140', + 'state': '0.317', }) # --- # name: test_all_entities[sensor.henk_total_calories_burnt_today-entry] @@ -3971,6 +3986,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -3980,7 +3998,7 @@ 'supported_features': 0, 'translation_key': 'wakeup_time', 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_wakeup_time-state] @@ -3989,14 +4007,14 @@ 'device_class': 'duration', 'friendly_name': 'henk Wakeup time', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_wakeup_time', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '3060', + 'state': '0.850', }) # --- # name: test_all_entities[sensor.henk_weight-entry] From f9bb25062129cd6c4ef4c32d020363073d487c4b Mon Sep 17 00:00:00 2001 From: EnjoyingM <6302356+mtielen@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:46:04 +0100 Subject: [PATCH 1840/1941] Wolf Smartset: Adding Heatpump Parameters: Frequency, RPM and Flow rate (#140844) * Add missing Heatpump parameters and units * Fix merge issue * Fix snapshot * Removing bundle_id as extra state attribute till functionality is needed and updating api translation with missing phrase * Fix translations for listparameters * Fix translations for listparameters --- homeassistant/components/wolflink/sensor.py | 25 +++ tests/components/wolflink/conftest.py | 13 ++ .../wolflink/snapshots/test_sensor.ambr | 158 ++++++++++++++++++ 3 files changed, 196 insertions(+) diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 0f58817a38d..9380c28de89 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -7,12 +7,15 @@ from dataclasses import dataclass from wolf_comm.models import ( EnergyParameter, + FlowParameter, + FrequencyParameter, HoursParameter, ListItemParameter, Parameter, PercentageParameter, PowerParameter, Pressure, + RPMParameter, SimpleParameter, Temperature, ) @@ -21,15 +24,19 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + REVOLUTIONS_PER_MINUTE, UnitOfEnergy, + UnitOfFrequency, UnitOfPower, UnitOfPressure, UnitOfTemperature, UnitOfTime, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -98,6 +105,24 @@ SENSOR_DESCRIPTIONS = [ native_unit_of_measurement=UnitOfTime.HOURS, supported_fn=lambda param: isinstance(param, HoursParameter), ), + WolflinkSensorEntityDescription( + key="flow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + supported_fn=lambda param: isinstance(param, FlowParameter), + ), + WolflinkSensorEntityDescription( + key="frequency", + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + supported_fn=lambda param: isinstance(param, FrequencyParameter), + ), + WolflinkSensorEntityDescription( + key="rpm", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + supported_fn=lambda param: isinstance(param, RPMParameter), + ), WolflinkSensorEntityDescription( key="default", supported_fn=lambda param: isinstance(param, SimpleParameter), diff --git a/tests/components/wolflink/conftest.py b/tests/components/wolflink/conftest.py index bfa41c4a4af..5142762b5e4 100644 --- a/tests/components/wolflink/conftest.py +++ b/tests/components/wolflink/conftest.py @@ -8,12 +8,15 @@ from unittest.mock import MagicMock, patch import pytest from wolf_comm import ( EnergyParameter, + FlowParameter, + FrequencyParameter, HoursParameter, ListItem, ListItemParameter, PercentageParameter, PowerParameter, Pressure, + RPMParameter, SimpleParameter, Temperature, Value, @@ -86,6 +89,13 @@ def mock_wolflink() -> Generator[MagicMock]: ), HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000), SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000), + FrequencyParameter( + 9002800000, "Frequency Parameter", "Heating", 9005200000, 1000 + ), + RPMParameter(1000280001, "RPM Parameter", "Heating", 10005200000, 7000), + FlowParameter(1100280001, "Flow Parameter", "Heating", 11005200000, 8000), + HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000), + SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000), ] wolflink.fetch_value.return_value = [ @@ -97,6 +107,9 @@ def mock_wolflink() -> Generator[MagicMock]: Value(2002800000, "20", 1), Value(7002800000, "10", 1), Value(1002800000, "12", 1), + Value(9002800000, "50", 1), + Value(1000280001, "1500", 1), + Value(1100280001, "5", 1), ] yield wolflink diff --git a/tests/components/wolflink/snapshots/test_sensor.ambr b/tests/components/wolflink/snapshots/test_sensor.ambr index 6fdccfb303c..c1ff80c9630 100644 --- a/tests/components/wolflink/snapshots/test_sensor.ambr +++ b/tests/components/wolflink/snapshots/test_sensor.ambr @@ -84,6 +84,110 @@ 'state': '183', }) # --- +# name: test_sensors[sensor.flow_parameter-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.flow_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:11005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.flow_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Flow Parameter', + 'parameter_id': 11005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 1100280001, + }), + 'context': , + 'entity_id': 'sensor.flow_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensors[sensor.frequency_parameter-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.frequency_parameter', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:9005200000', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.frequency_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Frequency Parameter', + 'parameter_id': 9005200000, + 'parent': 'Heating', + 'unit_of_measurement': , + 'value_id': 9002800000, + }), + 'context': , + 'entity_id': 'sensor.frequency_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- # name: test_sensors[sensor.hours_parameter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -341,6 +445,60 @@ 'state': '3', }) # --- +# name: test_sensors[sensor.rpm_parameter-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.rpm_parameter', + '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': 'RPM Parameter', + 'platform': 'wolflink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234:10005200000', + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensors[sensor.rpm_parameter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'RPM Parameter', + 'parameter_id': 10005200000, + 'parent': 'Heating', + 'state_class': , + 'unit_of_measurement': 'rpm', + 'value_id': 1000280001, + }), + 'context': , + 'entity_id': 'sensor.rpm_parameter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500', + }) +# --- # name: test_sensors[sensor.simple_parameter-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From a030502489b0411f59da65b80e44baf13eff8eb0 Mon Sep 17 00:00:00 2001 From: poucz Date: Thu, 20 Mar 2025 19:20:12 +0100 Subject: [PATCH 1841/1941] Add MQTT cover stop tilt (#139912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop tilt move. Stop tilt use same payload as cover - payload_stop * Add test for STOP_TILT * Tilt action * Revert "Tilt action" This reverts commit 7ce4fbb086616a900fc53277d379cbf03e9e0339. * Update tests/components/mqtt/test_cover.py Co-authored-by: Abílio Costa * Update homeassistant/components/mqtt/cover.py Co-authored-by: Abílio Costa * Append CONF_PAYLOAD_STOP_TILT * Update homeassistant/components/mqtt/cover.py Co-authored-by: Jan Bouwhuis * Test for new payload * Update tests/components/mqtt/test_cover.py Co-authored-by: Jan Bouwhuis * Update tests/components/mqtt/test_cover.py Co-authored-by: Jan Bouwhuis * Ruff format * abbreviation --------- Co-authored-by: Abílio Costa Co-authored-by: Jan Bouwhuis --- .../components/mqtt/abbreviations.py | 1 + homeassistant/components/mqtt/cover.py | 10 ++++ tests/components/mqtt/test_cover.py | 58 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 2d73cc5865c..a9037a5f247 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -150,6 +150,7 @@ ABBREVIATIONS = { "pl_rst_pct": "payload_reset_percentage", "pl_rst_pr_mode": "payload_reset_preset_mode", "pl_stop": "payload_stop", + "pl_stop_tilt": "payload_stop_tilt", "pl_strt": "payload_start", "pl_ret": "payload_return_to_base", "pl_toff": "payload_turn_off", diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c93fdd9c760..428c4d0e205 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -81,6 +81,7 @@ CONF_TILT_STATUS_TOPIC = "tilt_status_topic" CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" CONF_STATE_STOPPED = "state_stopped" +CONF_PAYLOAD_STOP_TILT = "payload_stop_tilt" CONF_TILT_CLOSED_POSITION = "tilt_closed_value" CONF_TILT_MAX = "tilt_max" CONF_TILT_MIN = "tilt_min" @@ -203,6 +204,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_GET_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_PAYLOAD_STOP_TILT, default=DEFAULT_PAYLOAD_STOP): vol.Any( + cv.string, None + ), } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) @@ -592,6 +596,12 @@ class MqttCover(MqttEntity, CoverEntity): self._attr_current_cover_tilt_position = tilt_percentage self.async_write_ha_state() + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop moving the cover tilt.""" + await self.async_publish_with_config( + self._config[CONF_TILT_COMMAND_TOPIC], self._config[CONF_PAYLOAD_STOP_TILT] + ) + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position_percentage = kwargs[ATTR_POSITION] diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 1e45853026a..81530758de7 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -37,6 +37,7 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, SERVICE_TOGGLE, SERVICE_TOGGLE_COVER_TILT, STATE_CLOSED, @@ -936,6 +937,63 @@ async def test_send_stop_cover_command( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("hass_config", "payload_stop"), + [ + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "tilt_command_topic": "tilt-command-topic", + "payload_stop_tilt": "TILT_STOP", + "qos": 2, + } + } + }, + "TILT_STOP", + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "tilt_command_topic": "tilt-command-topic", + "qos": 2, + } + } + }, + "STOP", + ), + ], +) +async def test_send_stop_tilt_command( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + payload_stop: str, +) -> None: + """Test the sending of stop_cover_tilt.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "tilt-command-topic", payload_stop, 2, False + ) + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + @pytest.mark.parametrize( "hass_config", [ From a338205b73197231b98f6b9f54f5044dee1d3839 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 19:30:28 +0100 Subject: [PATCH 1842/1941] Fix sentence-casing of "round-trip time" sensors in `ping` (#141012) * Fix sentence-casing of "round-trip time" sensors in `ping` Also add a hyphen for better English grammar. * Update test_sensor.ambr --- homeassistant/components/ping/strings.json | 8 ++++---- tests/components/ping/snapshots/test_sensor.ambr | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index ef9f74b4207..c301a1b277d 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -2,16 +2,16 @@ "entity": { "sensor": { "round_trip_time_avg": { - "name": "Round Trip Time Average" + "name": "Round-trip time average" }, "round_trip_time_max": { - "name": "Round Trip Time Maximum" + "name": "Round-trip time maximum" }, "round_trip_time_mdev": { - "name": "Round Trip Time Mean Deviation" + "name": "Round-trip time mean deviation" }, "round_trip_time_min": { - "name": "Round Trip Time Minimum" + "name": "Round-trip time minimum" } } }, diff --git a/tests/components/ping/snapshots/test_sensor.ambr b/tests/components/ping/snapshots/test_sensor.ambr index bb811af6a34..6b86c327863 100644 --- a/tests/components/ping/snapshots/test_sensor.ambr +++ b/tests/components/ping/snapshots/test_sensor.ambr @@ -26,7 +26,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Round Trip Time Average', + 'original_name': 'Round-trip time average', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -38,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': '10.10.10.10 Round Trip Time Average', + 'friendly_name': '10.10.10.10 Round-trip time average', 'state_class': , 'unit_of_measurement': , }), @@ -77,7 +77,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Round Trip Time Maximum', + 'original_name': 'Round-trip time maximum', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -89,7 +89,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': '10.10.10.10 Round Trip Time Maximum', + 'friendly_name': '10.10.10.10 Round-trip time maximum', 'state_class': , 'unit_of_measurement': , }), @@ -134,7 +134,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Round Trip Time Minimum', + 'original_name': 'Round-trip time minimum', 'platform': 'ping', 'previous_unique_id': None, 'supported_features': 0, @@ -146,7 +146,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': '10.10.10.10 Round Trip Time Minimum', + 'friendly_name': '10.10.10.10 Round-trip time minimum', 'state_class': , 'unit_of_measurement': , }), From 53f1dd8adf096cb60e6e42f1d2d35d52fd19f0e8 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 20 Mar 2025 19:33:45 +0100 Subject: [PATCH 1843/1941] Improve error handling and add exception translations for NextDNS integration (#141005) * Add exception translations * Coverage * Add missing auth_error * Coverage * Use async_start_reauth * Fix test * Remove method placeholder --- homeassistant/components/nextdns/__init__.py | 16 ++++- homeassistant/components/nextdns/button.py | 25 ++++++- .../components/nextdns/coordinator.py | 15 +++- homeassistant/components/nextdns/strings.json | 14 ++++ homeassistant/components/nextdns/switch.py | 14 +++- tests/components/nextdns/test_button.py | 70 ++++++++++++++++++- tests/components/nextdns/test_switch.py | 33 ++++++++- 7 files changed, 174 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 478ff215c30..eb8bd26cb9b 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -36,6 +36,7 @@ from .const import ( ATTR_SETTINGS, ATTR_STATUS, CONF_PROFILE_ID, + DOMAIN, UPDATE_INTERVAL_ANALYTICS, UPDATE_INTERVAL_CONNECTION, UPDATE_INTERVAL_SETTINGS, @@ -88,9 +89,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b try: nextdns = await NextDns.create(websession, api_key) except (ApiError, ClientConnectorError, RetryError, TimeoutError) as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "entry": entry.title, + "error": repr(err), + }, + ) from err except InvalidApiKeyError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"entry": entry.title}, + ) from err tasks = [] coordinators = {} diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index b36c243a463..2adccaa304f 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -2,15 +2,19 @@ from __future__ import annotations -from nextdns import AnalyticsStatus +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectorError +from nextdns import AnalyticsStatus, ApiError, InvalidApiKeyError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry +from .const import DOMAIN from .coordinator import NextDnsUpdateCoordinator PARALLEL_UPDATES = 1 @@ -53,4 +57,21 @@ class NextDnsButton( async def async_press(self) -> None: """Trigger cleaning logs.""" - await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id) + try: + await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id) + except ( + ApiError, + ClientConnectorError, + TimeoutError, + ClientError, + ) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="method_error", + translation_placeholders={ + "entity": self.entity_id, + "error": repr(err), + }, + ) from err + except InvalidApiKeyError: + self.coordinator.config_entry.async_start_reauth(self.hass) diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 850702e4488..41f6ff43a2a 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -79,9 +79,20 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): ClientConnectorError, RetryError, ) as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={ + "entry": self.config_entry.title, + "error": repr(err), + }, + ) from err except InvalidApiKeyError as err: - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + translation_placeholders={"entry": self.config_entry.title}, + ) from err async def _async_update_data_internal(self) -> CoordinatorDataT: """Update data via library.""" diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index f2a5fa2816d..38944a0711e 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -359,5 +359,19 @@ "name": "Force YouTube restricted mode" } } + }, + "exceptions": { + "auth_error": { + "message": "Authentication failed for {entry}, please update your API key" + }, + "cannot_connect": { + "message": "An error occurred while connecting to the NextDNS API for {entry}: {error}" + }, + "method_error": { + "message": "An error occurred while calling the NextDNS API method for {entity}: {error}" + }, + "update_error": { + "message": "An error occurred while retrieving data from the NextDNS API for {entry}: {error}" + } } } diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index b7c77bd9dbd..8bdca76b955 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -8,7 +8,7 @@ from typing import Any from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from nextdns import ApiError, Settings +from nextdns import ApiError, InvalidApiKeyError, Settings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry +from .const import DOMAIN from .coordinator import NextDnsUpdateCoordinator PARALLEL_UPDATES = 1 @@ -582,9 +583,16 @@ class NextDnsSwitch( ClientError, ) as err: raise HomeAssistantError( - "NextDNS API returned an error calling set_setting for" - f" {self.entity_id}: {err}" + translation_domain=DOMAIN, + translation_key="method_error", + translation_placeholders={ + "entity": self.entity_id, + "error": repr(err), + }, ) from err + except InvalidApiKeyError: + self.coordinator.config_entry.async_start_reauth(self.hass) + return if result: self._attr_is_on = new_state diff --git a/tests/components/nextdns/test_button.py b/tests/components/nextdns/test_button.py index 51970b9bb48..3d2422c34a7 100644 --- a/tests/components/nextdns/test_button.py +++ b/tests/components/nextdns/test_button.py @@ -1,12 +1,19 @@ """Test button of NextDNS integration.""" -from unittest.mock import patch +from unittest.mock import Mock, patch +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectorError +from nextdns import ApiError, InvalidApiKeyError +import pytest from syrupy import SnapshotAssertion -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.nextdns.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -36,7 +43,7 @@ async def test_button_press(hass: HomeAssistant) -> None: ): await hass.services.async_call( BUTTON_DOMAIN, - "press", + SERVICE_PRESS, {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, blocking=True, ) @@ -47,3 +54,60 @@ async def test_button_press(hass: HomeAssistant) -> None: state = hass.states.get("button.fake_profile_clear_logs") assert state assert state.state == now.isoformat() + + +@pytest.mark.parametrize( + "exc", + [ + ApiError(Mock()), + TimeoutError, + ClientConnectorError(Mock(), Mock()), + ClientError, + ], +) +async def test_button_failure(hass: HomeAssistant, exc: Exception) -> None: + """Tests that the press action throws HomeAssistantError.""" + await init_integration(hass) + + with ( + patch("homeassistant.components.nextdns.NextDns.clear_logs", side_effect=exc), + pytest.raises( + HomeAssistantError, + match="An error occurred while calling the NextDNS API method for button.fake_profile_clear_logs", + ), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, + blocking=True, + ) + + +async def test_button_auth_error(hass: HomeAssistant) -> None: + """Tests that the press action starts re-auth flow.""" + entry = await init_integration(hass) + + with patch( + "homeassistant.components.nextdns.NextDns.clear_logs", + side_effect=InvalidApiKeyError, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.fake_profile_clear_logs"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 6e344e34336..c85525ac457 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -5,12 +5,14 @@ from unittest.mock import Mock, patch from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from nextdns import ApiError +from nextdns import ApiError, InvalidApiKeyError import pytest from syrupy import SnapshotAssertion from tenacity import RetryError +from homeassistant.components.nextdns.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -158,3 +160,32 @@ async def test_switch_failure(hass: HomeAssistant, exc: Exception) -> None: {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, blocking=True, ) + + +async def test_switch_auth_error(hass: HomeAssistant) -> None: + """Tests that the turn on/off action starts re-auth flow.""" + entry = await init_integration(hass) + + with patch( + "homeassistant.components.nextdns.NextDns.set_setting", + side_effect=InvalidApiKeyError, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.fake_profile_block_page"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id From 95014dfdd8258da010459db27f4cb45dec8949ed Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 20:43:13 +0100 Subject: [PATCH 1844/1941] Fix name of `energenie_power_sockets` integration (#141014) * Fix name of `energenie_power_sockets` integration Remove "integration." from the integration name. * Fix --------- Co-authored-by: Joostlek --- homeassistant/components/energenie_power_sockets/strings.json | 2 +- script/hassfest/translations.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json index 4e4e49c68fb..bd536568d2c 100644 --- a/homeassistant/components/energenie_power_sockets/strings.json +++ b/homeassistant/components/energenie_power_sockets/strings.json @@ -1,5 +1,5 @@ { - "title": "Energenie Power Sockets Integration.", + "title": "Energenie Power Sockets", "config": { "step": { "user": { diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 8e59bd8582e..f4c05f504ca 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -29,6 +29,7 @@ ALLOW_NAME_TRANSLATION = { "cert_expiry", "cpuspeed", "emulated_roku", + "energenie_power_sockets", "faa_delays", "garages_amsterdam", "generic", From 5d1c8ea5375164e4825e5890c22743b5331ac4f1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 20 Mar 2025 20:45:07 +0100 Subject: [PATCH 1845/1941] Reolink fix playback headers (#141015) --- homeassistant/components/reolink/views.py | 36 +++++++++++++++++------ tests/components/reolink/test_views.py | 8 ++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 1a4585bc997..44265244b18 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -83,7 +83,16 @@ class PlaybackProxyView(HomeAssistantView): _LOGGER.warning("Reolink playback proxy error: %s", str(err)) return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST) + headers = dict(request.headers) + headers.pop("Host", None) + headers.pop("Referer", None) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requested Playback Proxy Method %s, Headers: %s", + request.method, + headers, + ) _LOGGER.debug( "Opening VOD stream from %s: %s", host.api.camera_name(ch), @@ -93,6 +102,7 @@ class PlaybackProxyView(HomeAssistantView): try: reolink_response = await self.session.get( reolink_url, + headers=headers, timeout=ClientTimeout( connect=15, sock_connect=15, sock_read=5, total=None ), @@ -118,18 +128,25 @@ class PlaybackProxyView(HomeAssistantView): ]: err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}" _LOGGER.error(err_str) + if reolink_response.content_type == "text/html": + text = await reolink_response.text() + _LOGGER.debug(text) return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) - response = web.StreamResponse( - status=200, - reason="OK", - headers={ - "Content-Type": "video/mp4", - }, + response_headers = dict(reolink_response.headers) + _LOGGER.debug( + "Response Playback Proxy Status %s:%s, Headers: %s", + reolink_response.status, + reolink_response.reason, + response_headers, ) + response_headers["Content-Type"] = "video/mp4" - if reolink_response.content_length is not None: - response.content_length = reolink_response.content_length + response = web.StreamResponse( + status=reolink_response.status, + reason=reolink_response.reason, + headers=response_headers, + ) await response.prepare(request) @@ -141,7 +158,8 @@ class PlaybackProxyView(HomeAssistantView): "Timeout while reading Reolink playback from %s, writing EOF", host.api.nvr_name, ) + finally: + reolink_response.release() - reolink_response.release() await response.write_eof() return response diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index c994cc59c5d..3521de072b6 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -46,8 +46,12 @@ def get_mock_session( mock_response = Mock() mock_response.content_length = content_length + mock_response.headers = {} + mock_response.status = 200 + mock_response.reason = "OK" mock_response.content_type = content_type mock_response.content.iter_chunked = Mock(return_value=content) + mock_response.text = AsyncMock(return_value="test") mock_session = Mock() mock_session.get = AsyncMock(return_value=mock_response) @@ -178,16 +182,18 @@ async def test_playback_proxy_timeout( assert response.status == 200 +@pytest.mark.parametrize(("content_type"), [("video/x-flv"), ("text/html")]) async def test_playback_wrong_content( hass: HomeAssistant, reolink_connect: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, + content_type: str, ) -> None: """Test playback proxy URL with a wrong content type in the response.""" reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) - mock_session = get_mock_session(content_type="video/x-flv") + mock_session = get_mock_session(content_type=content_type) with patch( "homeassistant.components.reolink.views.async_get_clientsession", From 98f71939865523ef5e490e8e5e6d11e19a12ce9f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 20 Mar 2025 21:23:35 +0100 Subject: [PATCH 1846/1941] Apply sentence-casing to all status codes in `litterrobot` (#141020) --- .../components/litterrobot/strings.json | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 19b007de068..052427f3032 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -77,31 +77,31 @@ "status_code": { "name": "Status code", "state": { - "br": "Bonnet Removed", - "ccc": "Clean Cycle Complete", - "ccp": "Clean Cycle In Progress", - "cd": "Cat Detected", - "csf": "Cat Sensor Fault", - "csi": "Cat Sensor Interrupted", - "cst": "Cat Sensor Timing", - "df1": "Drawer Almost Full - 2 Cycles Left", - "df2": "Drawer Almost Full - 1 Cycle Left", - "dfs": "Drawer Full", - "dhf": "Dump + Home Position Fault", - "dpf": "Dump Position Fault", - "ec": "Empty Cycle", - "hpf": "Home Position Fault", + "br": "Bonnet removed", + "ccc": "Clean cycle complete", + "ccp": "Clean cycle in progress", + "cd": "Cat detected", + "csf": "Cat sensor fault", + "csi": "Cat sensor interrupted", + "cst": "Cat sensor timing", + "df1": "Drawer almost full - 2 cycles left", + "df2": "Drawer almost full - 1 cycle left", + "dfs": "Drawer full", + "dhf": "Dump + home position fault", + "dpf": "Dump position fault", + "ec": "Empty cycle", + "hpf": "Home position fault", "off": "[%key:common::state::off%]", "offline": "Offline", - "otf": "Over Torque Fault", + "otf": "Over torque fault", "p": "[%key:common::state::paused%]", - "pd": "Pinch Detect", - "pwrd": "Powering Down", - "pwru": "Powering Up", + "pd": "Pinch detect", + "pwrd": "Powering down", + "pwru": "Powering up", "rdy": "Ready", - "scf": "Cat Sensor Fault At Startup", - "sdf": "Drawer Full At Startup", - "spf": "Pinch Detect At Startup" + "scf": "Cat sensor fault at startup", + "sdf": "Drawer full at startup", + "spf": "Pinch detect at startup" } }, "waste_drawer": { From a45c8d282037506804c75148ae9cefcce9816893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 20 Mar 2025 22:52:46 +0100 Subject: [PATCH 1847/1941] Fix some Home Connect options keys (#141023) Fix some options keys --- .../components/home_connect/services.yaml | 46 +++++----- .../components/home_connect/strings.json | 88 +++++++++---------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 613b3f5af3a..2b53090fd34 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -468,11 +468,11 @@ set_program_and_options: translation_key: venting_level options: - cooking_hood_enum_type_stage_fan_off - - cooking_hood_enum_type_stage_fan_stage01 - - cooking_hood_enum_type_stage_fan_stage02 - - cooking_hood_enum_type_stage_fan_stage03 - - cooking_hood_enum_type_stage_fan_stage04 - - cooking_hood_enum_type_stage_fan_stage05 + - cooking_hood_enum_type_stage_fan_stage_01 + - cooking_hood_enum_type_stage_fan_stage_02 + - cooking_hood_enum_type_stage_fan_stage_03 + - cooking_hood_enum_type_stage_fan_stage_04 + - cooking_hood_enum_type_stage_fan_stage_05 cooking_hood_option_intensive_level: example: cooking_hood_enum_type_intensive_stage_intensive_stage1 required: false @@ -528,7 +528,7 @@ set_program_and_options: collapsed: true fields: laundry_care_washer_option_temperature: - example: laundry_care_washer_enum_type_temperature_g_c40 + example: laundry_care_washer_enum_type_temperature_g_c_40 required: false selector: select: @@ -536,14 +536,14 @@ set_program_and_options: translation_key: washer_temperature options: - laundry_care_washer_enum_type_temperature_cold - - laundry_care_washer_enum_type_temperature_g_c20 - - laundry_care_washer_enum_type_temperature_g_c30 - - laundry_care_washer_enum_type_temperature_g_c40 - - laundry_care_washer_enum_type_temperature_g_c50 - - laundry_care_washer_enum_type_temperature_g_c60 - - laundry_care_washer_enum_type_temperature_g_c70 - - laundry_care_washer_enum_type_temperature_g_c80 - - laundry_care_washer_enum_type_temperature_g_c90 + - 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 @@ -557,15 +557,15 @@ set_program_and_options: translation_key: spin_speed options: - laundry_care_washer_enum_type_spin_speed_off - - laundry_care_washer_enum_type_spin_speed_r_p_m400 - - laundry_care_washer_enum_type_spin_speed_r_p_m600 - - laundry_care_washer_enum_type_spin_speed_r_p_m700 - - laundry_care_washer_enum_type_spin_speed_r_p_m800 - - laundry_care_washer_enum_type_spin_speed_r_p_m900 - - laundry_care_washer_enum_type_spin_speed_r_p_m1000 - - laundry_care_washer_enum_type_spin_speed_r_p_m1200 - - laundry_care_washer_enum_type_spin_speed_r_p_m1400 - - laundry_care_washer_enum_type_spin_speed_r_p_m1600 + - laundry_care_washer_enum_type_spin_speed_r_p_m_400 + - laundry_care_washer_enum_type_spin_speed_r_p_m_600 + - laundry_care_washer_enum_type_spin_speed_r_p_m_700 + - laundry_care_washer_enum_type_spin_speed_r_p_m_800 + - laundry_care_washer_enum_type_spin_speed_r_p_m_900 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1000 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1200 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1400 + - laundry_care_washer_enum_type_spin_speed_r_p_m_1600 - laundry_care_washer_enum_type_spin_speed_ul_off - laundry_care_washer_enum_type_spin_speed_ul_low - laundry_care_washer_enum_type_spin_speed_ul_medium diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8d377ac9e04..1b4c79f6092 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -417,11 +417,11 @@ "venting_level": { "options": { "cooking_hood_enum_type_stage_fan_off": "Fan off", - "cooking_hood_enum_type_stage_fan_stage01": "Fan stage 1", - "cooking_hood_enum_type_stage_fan_stage02": "Fan stage 2", - "cooking_hood_enum_type_stage_fan_stage03": "Fan stage 3", - "cooking_hood_enum_type_stage_fan_stage04": "Fan stage 4", - "cooking_hood_enum_type_stage_fan_stage05": "Fan stage 5" + "cooking_hood_enum_type_stage_fan_stage_01": "Fan stage 1", + "cooking_hood_enum_type_stage_fan_stage_02": "Fan stage 2", + "cooking_hood_enum_type_stage_fan_stage_03": "Fan stage 3", + "cooking_hood_enum_type_stage_fan_stage_04": "Fan stage 4", + "cooking_hood_enum_type_stage_fan_stage_05": "Fan stage 5" } }, "intensive_level": { @@ -441,14 +441,14 @@ "washer_temperature": { "options": { "laundry_care_washer_enum_type_temperature_cold": "Cold", - "laundry_care_washer_enum_type_temperature_g_c20": "20ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c30": "30ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c40": "40ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c50": "50ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c60": "60ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c70": "70ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c80": "80ºC clothes", - "laundry_care_washer_enum_type_temperature_g_c90": "90ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_20": "20ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_30": "30ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_40": "40ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_50": "50ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_60": "60ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_70": "70ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_80": "80ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c_90": "90ºC clothes", "laundry_care_washer_enum_type_temperature_ul_cold": "Cold", "laundry_care_washer_enum_type_temperature_ul_warm": "Warm", "laundry_care_washer_enum_type_temperature_ul_hot": "Hot", @@ -458,15 +458,15 @@ "spin_speed": { "options": { "laundry_care_washer_enum_type_spin_speed_off": "Off", - "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m700": "700 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m900": "900 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", - "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "1600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "900 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "1000 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm", "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", @@ -1384,11 +1384,11 @@ "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%]" + "cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]", + "cooking_hood_enum_type_stage_fan_stage_02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_02%]", + "cooking_hood_enum_type_stage_fan_stage_03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_03%]", + "cooking_hood_enum_type_stage_fan_stage_04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_04%]", + "cooking_hood_enum_type_stage_fan_stage_05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_05%]" } }, "intensive_level": { @@ -1411,14 +1411,14 @@ "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_g_c_20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_20%]", + "laundry_care_washer_enum_type_temperature_g_c_30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_30%]", + "laundry_care_washer_enum_type_temperature_g_c_40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_40%]", + "laundry_care_washer_enum_type_temperature_g_c_50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_50%]", + "laundry_care_washer_enum_type_temperature_g_c_60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_60%]", + "laundry_care_washer_enum_type_temperature_g_c_70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_70%]", + "laundry_care_washer_enum_type_temperature_g_c_80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_80%]", + "laundry_care_washer_enum_type_temperature_g_c_90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_90%]", "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%]", @@ -1429,15 +1429,15 @@ "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_m700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m700%]", - "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_m900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m900%]", - "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_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_800%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_900%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1000%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]", + "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]", "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%]", From b9367399172f1482e400cd49bd454d450e460518 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 21 Mar 2025 00:33:16 +0100 Subject: [PATCH 1848/1941] Update pylint to 3.3.6 (#141028) --- homeassistant/components/mqtt/client.py | 2 -- homeassistant/components/template/template_entity.py | 2 +- homeassistant/components/tts/__init__.py | 2 +- requirements_test.txt | 4 ++-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index e985dc9b87f..f6f53599363 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -1022,8 +1022,6 @@ class MQTT: Resubscribe to all topics we were subscribed to and publish birth message. """ - # pylint: disable-next=import-outside-toplevel - if reason_code.is_failure: # 24: Continue authentication # 25: Re-authenticate diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 93ba1fa7471..88708278758 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -248,7 +248,7 @@ class _TemplateAttribute: return -class TemplateEntity(AbstractTemplateEntity): # pylint: disable=hass-enforce-class-module +class TemplateEntity(AbstractTemplateEntity): """Entity that uses templates to calculate attributes.""" _attr_available = True diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 350b03a2e80..cb207643471 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -163,7 +163,7 @@ class TTSCache: self._partial_data.append(chunk) for queue in self._consumers: queue.put_nowait(chunk) - except Exception as err: # pylint: disable=broad-except + except Exception as err: self._loading_error = err raise finally: diff --git a/requirements_test.txt b/requirements_test.txt index 6a95b6dadb1..baf72265c40 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.8 +astroid==3.3.9 coverage==7.6.12 freezegun==1.5.1 license-expression==30.4.1 @@ -15,7 +15,7 @@ mock-open==1.4.0 mypy-dev==1.16.0a5 pre-commit==4.0.0 pydantic==2.10.6 -pylint==3.3.4 +pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 pytest-asyncio==0.25.3 From 72645dff8b9f8199d1c07a1784f9ac1582163705 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Mar 2025 00:34:48 +0100 Subject: [PATCH 1849/1941] Bump actions/cache from 4.2.2 to 4.2.3 (#140977) --- .github/workflows/ci.yaml | 44 +++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0b5923b1fc..2b1606568b5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -255,7 +255,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -271,7 +271,7 @@ jobs: uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -301,7 +301,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -310,7 +310,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -341,7 +341,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -350,7 +350,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -381,7 +381,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -390,7 +390,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -497,7 +497,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: venv key: >- @@ -505,7 +505,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -593,7 +593,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -626,7 +626,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -683,7 +683,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -726,7 +726,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -773,7 +773,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -825,7 +825,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -833,7 +833,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v4.2.2 + uses: actions/cache@v4.2.3 with: path: .mypy_cache key: >- @@ -895,7 +895,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -955,7 +955,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -1080,7 +1080,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -1214,7 +1214,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true @@ -1365,7 +1365,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v4.2.2 + uses: actions/cache/restore@v4.2.3 with: path: venv fail-on-cache-miss: true From 87c8234cdc0e8b5b26887055b3fa82bc66a8a1d3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 20 Mar 2025 20:43:29 -0400 Subject: [PATCH 1850/1941] Allow USB polling monitor on macOS for development (#141029) * Allow USB polling on macOS * Remove `_async_supports_monitoring` --- homeassistant/components/usb/__init__.py | 27 ++++++++---------------- 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index d68742522a0..994f4f71c35 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -265,8 +265,15 @@ class USBDiscovery: async def async_setup(self) -> None: """Set up USB Discovery.""" - if self._async_supports_monitoring(): - await self._async_start_monitor() + try: + await self._async_start_aiousbwatcher() + except InotifyNotAvailableError as ex: + _LOGGER.info( + "Falling back to periodic filesystem polling for development, " + "aiousbwatcher is not available on this system: %s", + ex, + ) + self._async_start_monitor_polling() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) @@ -281,22 +288,6 @@ class USBDiscovery: if self._request_debouncer: self._request_debouncer.async_shutdown() - @hass_callback - def _async_supports_monitoring(self) -> bool: - return sys.platform == "linux" - - async def _async_start_monitor(self) -> None: - """Start monitoring hardware.""" - try: - await self._async_start_aiousbwatcher() - except InotifyNotAvailableError as ex: - _LOGGER.info( - "Falling back to periodic filesystem polling for development, aiousbwatcher " - "is not available on this system: %s", - ex, - ) - self._async_start_monitor_polling() - @hass_callback def _async_start_monitor_polling(self) -> None: """Start monitoring hardware with polling (for development only!).""" From d12b4a14605200fd0f17f1033d21b23764a2fa2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 21 Mar 2025 00:53:53 +0000 Subject: [PATCH 1851/1941] Log a warning for modules that log too often (#139708) * Log a warning for modules that log too often * Improve var naming * Increase time window; improve log info * Fix zha type * Fix typo * Ignore debug logs * Use timer to avoid now() calls * Switch to async_track_time_interval * Allow using base QueueLister * Add test for counters reset * Make var names consistent; reduce message/time ratio * Use log times instead of timer * Simplify reset test * Warn only once per module * Remove uneeded counter reset --- homeassistant/util/logging.py | 63 ++++++++++++++++-- tests/util/test_logging.py | 120 ++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 4 deletions(-) diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 2c4eb744614..1e516742bfe 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -2,14 +2,16 @@ from __future__ import annotations +from collections import defaultdict from collections.abc import Callable, Coroutine from functools import partial, wraps import inspect import logging import logging.handlers -import queue +from queue import SimpleQueue +import time import traceback -from typing import Any, cast, overload +from typing import Any, cast, overload, override from homeassistant.core import ( HassJobType, @@ -18,6 +20,59 @@ from homeassistant.core import ( get_hassjob_callable_job_type, ) +_LOGGER = logging.getLogger(__name__) + + +class HomeAssistantQueueListener(logging.handlers.QueueListener): + """Custom QueueListener to watch for noisy loggers.""" + + LOG_COUNTS_RESET_INTERVAL = 300 + MAX_LOGS_COUNT = 200 + + _last_reset: float + _log_counts: dict[str, int] + _warned_modules: set[str] + + def __init__( + self, queue: SimpleQueue[logging.Handler], *handlers: logging.Handler + ) -> None: + """Initialize the handler.""" + super().__init__(queue, *handlers) + self._warned_modules = set() + self._reset_counters(time.time()) + + @override + def handle(self, record: logging.LogRecord) -> None: + """Handle the record.""" + super().handle(record) + + if record.levelno < logging.INFO: + return + + if (record.created - self._last_reset) > self.LOG_COUNTS_RESET_INTERVAL: + self._reset_counters(record.created) + + module_name = record.name + if module_name == __name__ or module_name in self._warned_modules: + return + + self._log_counts[module_name] += 1 + module_count = self._log_counts[module_name] + if module_count < self.MAX_LOGS_COUNT: + return + + _LOGGER.warning( + "Module %s is logging too frequently. %d messages since last count", + module_name, + module_count, + ) + self._warned_modules.add(module_name) + + def _reset_counters(self, time_sec: float) -> None: + _LOGGER.debug("Resetting log counters") + self._last_reset = time_sec + self._log_counts = defaultdict(int) + class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" @@ -60,7 +115,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: This allows us to avoid blocking I/O and formatting messages in the event loop as log messages are written in another thread. """ - simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue() + simple_queue: SimpleQueue[logging.Handler] = SimpleQueue() queue_handler = HomeAssistantQueueHandler(simple_queue) logging.root.addHandler(queue_handler) @@ -71,7 +126,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: logging.root.removeHandler(handler) migrated_handlers.append(handler) - listener = logging.handlers.QueueListener(simple_queue, *migrated_handlers) + listener = HomeAssistantQueueListener(simple_queue, *migrated_handlers) queue_handler.listener = listener listener.start() diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index e5b85f35693..d213a68d7f2 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -6,6 +6,7 @@ import logging import queue from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.core import ( @@ -17,6 +18,13 @@ from homeassistant.core import ( from homeassistant.util import logging as logging_util +async def empty_log_queue() -> None: + """Empty the log queue.""" + log_queue: queue.SimpleQueue = logging.root.handlers[0].queue + while not log_queue.empty(): + await asyncio.sleep(0) + + async def test_logging_with_queue_handler() -> None: """Test logging with HomeAssistantQueueHandler.""" @@ -149,3 +157,115 @@ async def test_catch_log_exception_catches_and_logs() -> None: func("failure sync passed") assert saved_args == [("failure sync passed",)] + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 5) +@pytest.mark.parametrize( + ( + "logger1_count", + "logger1_expected_notices", + "logger2_count", + "logger2_expected_notices", + ), + [(4, 0, 0, 0), (5, 1, 1, 0), (11, 1, 5, 1), (20, 1, 20, 1)], +) +async def test_noisy_loggers( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + logger1_count: int, + logger1_expected_notices: int, + logger2_count: int, + logger2_expected_notices: int, +) -> None: + """Test that noisy loggers all logged as warnings.""" + + logging_util.async_activate_log_queue_handler(hass) + logger1 = logging.getLogger("noisy1") + logger2 = logging.getLogger("noisy2.module") + + for _ in range(logger1_count): + logger1.info("This is a log") + + for _ in range(logger2_count): + logger2.info("This is another log") + + await empty_log_queue() + + assert ( + caplog.text.count( + "Module noisy1 is logging too frequently. 5 messages since last count" + ) + == logger1_expected_notices + ) + assert ( + caplog.text.count( + "Module noisy2.module is logging too frequently. 5 messages since last count" + ) + == logger2_expected_notices + ) + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 5) +async def test_noisy_loggers_ignores_lower_than_info( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that noisy loggers all logged as warnings, except for levels lower than INFO.""" + + logging_util.async_activate_log_queue_handler(hass) + logger = logging.getLogger("noisy_module") + + for _ in range(5): + logger.debug("This is a log") + + await empty_log_queue() + expected_warning = "Module noisy_module is logging too frequently" + assert caplog.text.count(expected_warning) == 0 + + logger.info("This is a log") + logger.info("This is a log") + logger.warning("This is a log") + logger.error("This is a log") + logger.critical("This is a log") + + await empty_log_queue() + assert caplog.text.count(expected_warning) == 1 + + # close the handler so the queue thread stops + logging.root.handlers[0].close() + + +@patch("homeassistant.util.logging.HomeAssistantQueueListener.MAX_LOGS_COUNT", 3) +async def test_noisy_loggers_counters_reset( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that noisy logger counters reset periodically.""" + + logging_util.async_activate_log_queue_handler(hass) + logger = logging.getLogger("noisy_module") + + expected_warning = "Module noisy_module is logging too frequently" + + # Do multiple iterations to ensure the reset is periodic + for _ in range(logging_util.HomeAssistantQueueListener.MAX_LOGS_COUNT * 2): + logger.info("This is log 0") + await empty_log_queue() + + freezer.tick( + logging_util.HomeAssistantQueueListener.LOG_COUNTS_RESET_INTERVAL + 1 + ) + + logger.info("This is log 1") + await empty_log_queue() + assert caplog.text.count(expected_warning) == 0 + + logger.info("This is log 2") + logger.info("This is log 3") + await empty_log_queue() + assert caplog.text.count(expected_warning) == 1 + # close the handler so the queue thread stops + logging.root.handlers[0].close() From a388863e6291338f0a13a9e6f87239eef10a0f67 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 20 Mar 2025 21:28:37 -0400 Subject: [PATCH 1852/1941] Remove stale devices automatically for Roborock (#140991) * Remove stale devices * Add test * extra test + fix networking patch bug --- homeassistant/components/roborock/__init__.py | 22 +++++++ .../components/roborock/quality_scale.yaml | 6 +- tests/components/roborock/conftest.py | 11 +++- tests/components/roborock/mock_data.py | 3 + .../roborock/snapshots/test_diagnostics.ambr | 2 +- tests/components/roborock/test_init.py | 60 ++++++++++++++++++- 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1b90adaf6ec..a3ccf0c6eed 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -23,6 +23,7 @@ from roborock.web_api import RoborockApiClient from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import ( @@ -134,6 +135,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> 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, config_entry_id=entry.entry_id + ) + for device in device_entries: + # Remove any devices that are no longer in the account. + # The API returns all devices, even if they are offline + device_duids = { + identifier[1].replace("_dock", "") for identifier in device.identifiers + } + if any(device_duid in device_map for device_duid in device_duids): + continue + _LOGGER.info( + "Removing device: %s because it is no longer exists in your account", + device.name, + ) + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=entry.entry_id, + ) + return True diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index feee5cb434c..06a7638c222 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -70,11 +70,7 @@ rules: repair-issues: status: todo comment: The Cloud vs Local API warning should probably be a repair issue. - stale-devices: - status: todo - comment: | - The integration does not yet handle stale devices. The roborock app does - support deleting devices and this is a gap #132590 + stale-devices: done # Platinum async-dependency: todo inject-websession: diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 332a9143c51..fcd469ca10a 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -11,6 +11,7 @@ import uuid import pytest from roborock import RoborockCategory, RoomMapping from roborock.code_mappings import DyadError, RoborockDyadStateCode, ZeoError, ZeoState +from roborock.containers import NetworkInfo from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.version_a01_apis import RoborockMqttClientA01 @@ -29,6 +30,7 @@ from .mock_data import ( MAP_DATA, MULTI_MAP_LIST, NETWORK_INFO, + NETWORK_INFO_2, PROP, SCENES, USER_DATA, @@ -87,6 +89,13 @@ def bypass_api_client_fixture() -> None: yield +def cycle_network_info() -> Generator[NetworkInfo]: + """Return the appropriate network info for the corresponding device.""" + while True: + yield NETWORK_INFO + yield NETWORK_INFO_2 + + @pytest.fixture(name="bypass_api_fixture") def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: """Skip calls to the API.""" @@ -98,7 +107,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - return_value=NETWORK_INFO, + side_effect=cycle_network_info(), ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 87acc85b2aa..507e8060653 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1122,6 +1122,9 @@ PROP = DeviceProp( NETWORK_INFO = NetworkInfo( ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90 ) +NETWORK_INFO_2 = NetworkInfo( + ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd", bssid="bssid", rssi=90 +) MULTI_MAP_LIST = MultiMapsList.from_dict( { diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 26ecb729312..313824e70ec 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -357,7 +357,7 @@ }), 'network_info': dict({ 'bssid': '**REDACTED**', - 'ip': '123.232.12.1', + 'ip': '123.232.12.2', 'mac': '**REDACTED**', 'rssi': 90, 'ssid': 'wifi', diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 9a749a71e30..226eea816b9 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -17,9 +17,10 @@ from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from .mock_data import HOME_DATA +from .mock_data import HOME_DATA, NETWORK_INFO from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -295,3 +296,60 @@ async def test_no_user_agreement( await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement" + + +async def test_stale_device( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test that we remove a device if it no longer is given by home_data.""" + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.LOADED + existing_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert len(existing_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + hd = deepcopy(HOME_DATA) + hd.devices = [hd.devices[0]] + + with patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=hd, + ): + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + new_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert ( + len(new_devices) == 4 + ) # 2 for the one remaining robot. 1 for both the A01s which are shared and + # therefore not deleted. + + +async def test_no_stale_device( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, + device_registry: DeviceRegistry, +) -> None: + """Test that we don't remove a device if fails to setup.""" + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert mock_roborock_entry.state is ConfigEntryState.LOADED + existing_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert len(existing_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo + + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, RoborockException], + ): + await hass.config_entries.async_reload(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + new_devices = device_registry.devices.get_devices_for_config_entry_id( + mock_roborock_entry.entry_id + ) + assert len(new_devices) == 6 # 2 for each robot, 1 for A01, 1 for Zeo From a83bf4f51496b25cfe35ac6efdf5540cab0d2c35 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 20 Mar 2025 19:37:54 -0700 Subject: [PATCH 1853/1941] Add a GetHomeState tool to return the current state of the home (#140971) * Add a GetHomeState tool to return the current state of the home * Fix check for exposing entities * Add "all" to get home state description --- homeassistant/helpers/llm.py | 49 +++++++++++++++++++++++++++++++++--- tests/helpers/test_llm.py | 31 ++++++++++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 4ad2bdd6563..5995543914f 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -66,6 +66,11 @@ Answer questions about the world truthfully. Answer in plain text. Keep it simple and to the point. """ +NO_ENTITIES_PROMPT = ( + "Only if the user wants to control a device, tell them to expose entities " + "to their voice assistant in Home Assistant." +) + @callback def async_render_no_api_prompt(hass: HomeAssistant) -> str: @@ -329,10 +334,7 @@ class AssistAPI(API): self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: if not exposed_entities or not exposed_entities["entities"]: - return ( - "Only if the user wants to control a device, tell them to expose entities " - "to their voice assistant in Home Assistant." - ) + return NO_ENTITIES_PROMPT return "\n".join( [ *self._async_get_preable(llm_context), @@ -454,6 +456,9 @@ class AssistAPI(API): for script_entity_id in exposed_entities[SCRIPT_DOMAIN] ) + if exposed_domains: + tools.append(GetHomeStateTool()) + return tools @@ -885,3 +890,39 @@ class CalendarGetEventsTool(Tool): ] return {"success": True, "result": events} + + +class GetHomeStateTool(Tool): + """Tool for getting the current state of exposed entities. + + This returns state for all entities that have been exposed to + the assistant. This is different than the GetState intent, which + returns state for entities based on intent parameters. + """ + + name = "get_home_state" + description = "Get the current state of all devices in the home. " + + async def async_call( + self, + hass: HomeAssistant, + tool_input: ToolInput, + llm_context: LLMContext, + ) -> JsonObjectType: + """Get the current state of exposed entities.""" + if llm_context.assistant is None: + # Note this doesn't happen in practice since this tool won't be + # exposed if no assistant is configured. + return {"success": False, "error": "No assistant configured"} + + exposed_entities = _get_exposed_entities(hass, llm_context.assistant) + if not exposed_entities["entities"]: + return {"success": False, "error": NO_ENTITIES_PROMPT} + prompt = [ + "An overview of the areas and the devices in this smart home:", + yaml_util.dump(list(exposed_entities["entities"].values())), + ] + return { + "success": True, + "result": "\n".join(prompt), + } diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 630ed3f4fa1..45ed009fcf1 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -181,19 +181,19 @@ async def test_assist_api( assert len(llm.async_get_apis(hass)) == 1 api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 0 + assert [tool.name for tool in api.tools] == ["get_home_state"] # Match all intent_handler.platforms = None api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 1 + assert [tool.name for tool in api.tools] == ["test_intent", "get_home_state"] # Match specific domain intent_handler.platforms = {"light"} api = await llm.async_get_api(hass, "assist", llm_context) - assert len(api.tools) == 1 + assert len(api.tools) == 2 tool = api.tools[0] assert tool.name == "test_intent" assert tool.description == "Execute Home Assistant test_intent intent" @@ -643,6 +643,15 @@ async def test_assist_api_prompt( {exposed_entities_prompt}""" ) + # Verify that the get_home_state tool returns the same results as the exposed_entities_prompt + result = await api.async_call_tool( + llm.ToolInput(tool_name="get_home_state", tool_args={}) + ) + assert result == { + "success": True, + "result": exposed_entities_prompt, + } + # Fake that request is made from a specific device ID with an area llm_context.device_id = device.id area_prompt = ( @@ -1267,3 +1276,19 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: "start_date_time": now, "end_date_time": dt_util.start_of_local_day() + timedelta(days=7), } + + +async def test_no_tools_exposed(hass: HomeAssistant) -> None: + """Test that tools are not exposed when no entities are exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) + context = Context() + llm_context = llm.LLMContext( + platform="test_platform", + context=context, + user_prompt="test_text", + language="*", + assistant="conversation", + device_id=None, + ) + api = await llm.async_get_api(hass, "assist", llm_context) + assert api.tools == [] From e388d0c3449171dd17198ed5b1db114da3a292ec Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 21 Mar 2025 02:42:02 -0400 Subject: [PATCH 1854/1941] Bump python-snoo to 0.6.4 (#141030) --- homeassistant/components/snoo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index 0de1e6cf760..4084a7e3e79 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.6.3"] + "requirements": ["python-snoo==0.6.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9848158a10e..d31204ea3fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2471,7 +2471,7 @@ python-roborock==2.14.0 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.3 +python-snoo==0.6.4 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc2b8acc214..fa95c6431ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2001,7 +2001,7 @@ python-roborock==2.14.0 python-smarttub==0.0.39 # homeassistant.components.snoo -python-snoo==0.6.3 +python-snoo==0.6.4 # homeassistant.components.songpal python-songpal==0.16.2 From 110500b860e0096087bae81daabb63d1d0ea6477 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 21 Mar 2025 02:44:57 -0400 Subject: [PATCH 1855/1941] Bump ZHA to 0.0.53 (#141025) * Bump ZHA to 0.0.53 * Regenerate snapshot --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/snapshots/test_diagnostics.ambr | 11 ++++++++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d16ce5a64bf..6ed8b253e75 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.52"], + "requirements": ["zha==0.0.53"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index d31204ea3fa..aa0e19c4768 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3153,7 +3153,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.52 +zha==0.0.53 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa95c6431ce..1c4f23a343f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2539,7 +2539,7 @@ zeroconf==0.146.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.52 +zha==0.0.53 # homeassistant.components.zwave_js zwave-js-server-python==0.62.0 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index ba8aa9ea245..7a599b00a21 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -179,7 +179,16 @@ }), '0x0010': dict({ 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", - 'value': None, + 'value': list([ + 50, + 79, + 50, + 2, + 0, + 141, + 21, + 0, + ]), }), '0x0011': dict({ 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", From 021e4fab8c043df1ae2b720ab09818f04e2dc441 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Mar 2025 21:12:55 -1000 Subject: [PATCH 1856/1941] Bump habluetooth to 3.36.0 (#141037) * Bump habluetooth to 3.35.0 changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.32.0...v3.35.0 * adjust --- 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 a0679f8e842..7dfb21a6e0b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", "dbus-fast==2.39.6", - "habluetooth==3.32.0" + "habluetooth==3.36.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1399c1884ea..a797b1b5146 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.39.6 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.32.0 +habluetooth==3.36.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index aa0e19c4768..e45155eb492 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.32.0 +habluetooth==3.36.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c4f23a343f..ac047685724 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.32.0 +habluetooth==3.36.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 From bce7fcc3c60dc3c18464c46ed7e923e7bde4fdb4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 21 Mar 2025 09:44:02 +0100 Subject: [PATCH 1857/1941] Capitalize "DIP" abbreviation in `apcupsd` (#141048) As "DIP" stands for "dual in-line package" it becomes capitalized as an abbreviation. --- homeassistant/components/apcupsd/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index 93102ac1393..fb5df9ec390 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -57,7 +57,7 @@ "name": "Status date" }, "dip_switch_settings": { - "name": "Dip switch settings" + "name": "DIP switch settings" }, "low_battery_signal": { "name": "Low battery signal" From 2785688f573165462bb360f3caa467ba77d931f4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 21 Mar 2025 10:14:20 +0100 Subject: [PATCH 1858/1941] Add `calibrate` button for Shelly BLU TRV (#140578) * Initial commit * Refactor * Call async_add_entities() once * Type * Cleaning * `supported` is not needed here * Add error handling * Add test * Fix name * Change class name * Change method name * Move BLU_TRV_TIMEOUT * Fix BLU_TRV_TIMEOUT import * Coverage * Use test snapshots * Support error translations * Fix tests * Introduce ShellyBaseButton class * Rename press_method to _press_method * Improve exception strings --- homeassistant/components/shelly/button.py | 147 ++++++++++++-- homeassistant/components/shelly/climate.py | 8 +- homeassistant/components/shelly/const.py | 3 - homeassistant/components/shelly/number.py | 4 +- homeassistant/components/shelly/strings.json | 8 + .../shelly/snapshots/test_button.ambr | 96 +++++++++ tests/components/shelly/test_button.py | 182 +++++++++++++++++- tests/components/shelly/test_climate.py | 3 +- tests/components/shelly/test_number.py | 4 +- 9 files changed, 426 insertions(+), 29 deletions(-) create mode 100644 tests/components/shelly/snapshots/test_button.ambr diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 1f3c555a64b..15bde4fbdff 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -2,12 +2,13 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Final -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY, RPC_GENERATIONS +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( ButtonDeviceClass, @@ -16,15 +17,20 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + CONNECTION_NETWORK_MAC, + DeviceInfo, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from .const import LOGGER, SHELLY_GAS_MODELS +from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .utils import get_device_entry_gen +from .utils import get_device_entry_gen, get_rpc_key_ids @dataclass(frozen=True, kw_only=True) @@ -33,7 +39,7 @@ class ShellyButtonDescription[ ](ButtonEntityDescription): """Class to describe a Button entity.""" - press_action: Callable[[_ShellyCoordinatorT], Coroutine[Any, Any, None]] + press_action: str supported: Callable[[_ShellyCoordinatorT], bool] = lambda _: True @@ -44,14 +50,14 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_reboot(), + press_action="trigger_reboot", ), ShellyButtonDescription[ShellyBlockCoordinator]( key="self_test", name="Self test", translation_key="self_test", entity_category=EntityCategory.DIAGNOSTIC, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_self_test(), + press_action="trigger_shelly_gas_self_test", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( @@ -59,7 +65,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Mute", translation_key="mute", entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_mute(), + press_action="trigger_shelly_gas_mute", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( @@ -67,11 +73,22 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ name="Unmute", translation_key="unmute", entity_category=EntityCategory.CONFIG, - press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_unmute(), + press_action="trigger_shelly_gas_unmute", supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, ), ] +BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [ + ShellyButtonDescription[ShellyRpcCoordinator]( + key="calibrate", + name="Calibrate", + translation_key="calibrate", + entity_category=EntityCategory.CONFIG, + press_action="trigger_blu_trv_calibration", + supported=lambda coordinator: coordinator.device.model == MODEL_BLU_GATEWAY, + ), +] + @callback def async_migrate_unique_ids( @@ -123,14 +140,28 @@ async def async_setup_entry( hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) ) - async_add_entities( + entities: list[ShellyButton | ShellyBluTrvButton] = [] + + entities.extend( ShellyButton(coordinator, button) for button in BUTTONS if button.supported(coordinator) ) + if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): + if TYPE_CHECKING: + assert isinstance(coordinator, ShellyRpcCoordinator) -class ShellyButton( + entities.extend( + ShellyBluTrvButton(coordinator, button, id_) + for id_ in blutrv_key_ids + for button in BLU_TRV_BUTTONS + ) + + async_add_entities(entities) + + +class ShellyBaseButton( CoordinatorEntity[ShellyRpcCoordinator | ShellyBlockCoordinator], ButtonEntity ): """Defines a Shelly base button.""" @@ -148,14 +179,100 @@ class ShellyButton( ) -> None: """Initialize Shelly button.""" super().__init__(coordinator) + self.entity_description = description + async def async_press(self) -> None: + """Triggers the Shelly button press service.""" + try: + await self._press_method() + except DeviceConnectionError as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="device_communication_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.device.name, + "error": repr(err), + }, + ) from err + except RpcCallError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="rpc_call_action_error", + translation_placeholders={ + "entity": self.entity_id, + "device": self.coordinator.device.name, + "error": repr(err), + }, + ) from err + except InvalidAuthError: + await self.coordinator.async_shutdown_device_and_start_reauth() + + async def _press_method(self) -> None: + """Press method.""" + raise NotImplementedError + + +class ShellyButton(ShellyBaseButton): + """Defines a Shelly button.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator, + description: ShellyButtonDescription[ + ShellyRpcCoordinator | ShellyBlockCoordinator + ], + ) -> None: + """Initialize Shelly button.""" + super().__init__(coordinator, description) + self._attr_name = f"{coordinator.device.name} {description.name}" self._attr_unique_id = f"{coordinator.mac}_{description.key}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) - async def async_press(self) -> None: - """Triggers the Shelly button press service.""" - await self.entity_description.press_action(self.coordinator) + async def _press_method(self) -> None: + """Press method.""" + method = getattr(self.coordinator.device, self.entity_description.press_action) + + if TYPE_CHECKING: + assert method is not None + + await method() + + +class ShellyBluTrvButton(ShellyBaseButton): + """Represent a Shelly BLU TRV button.""" + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + description: ShellyButtonDescription, + id_: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator, description) + + ble_addr: str = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["addr"] + device_name = ( + coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["name"] + or f"shellyblutrv-{ble_addr.replace(':', '')}" + ) + self._attr_name = f"{device_name} {description.name}" + self._attr_unique_id = f"{ble_addr}_{description.key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, ble_addr)} + ) + self._id = id_ + + async def _press_method(self) -> None: + """Press method.""" + method = getattr(self.coordinator.device, self.entity_description.press_action) + + if TYPE_CHECKING: + assert method is not None + + await method(self._id) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index a3ec9be7cb0..c3612ed3f4f 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -7,7 +7,12 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS +from aioshelly.const import ( + BLU_TRV_IDENTIFIER, + BLU_TRV_MODEL_NAME, + BLU_TRV_TIMEOUT, + RPC_GENERATIONS, +) from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -36,7 +41,6 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( BLU_TRV_TEMPERATURE_SETTINGS, - BLU_TRV_TIMEOUT, DOMAIN, LOGGER, NOT_CALIBRATED_ISSUE_ID, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index d47f2b0ae80..c94c827b7db 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -271,9 +271,6 @@ API_WS_URL = "/api/shelly/ws" COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+") -# value confirmed by Shelly team -BLU_TRV_TIMEOUT = 60 - ROLE_TO_DEVICE_CLASS_MAP = { "current_humidity": SensorDeviceClass.HUMIDITY, "current_temperature": SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 59716f39c7f..a8e6de1ca73 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, cast from aioshelly.block_device import Block -from aioshelly.const import RPC_GENERATIONS +from aioshelly.const import BLU_TRV_TIMEOUT, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.number import ( @@ -25,7 +25,7 @@ from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceIn from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .const import CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index ba9a8492194..22d88928387 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -203,6 +203,14 @@ } } }, + "exceptions": { + "device_communication_action_error": { + "message": "Device communication error occurred while calling the entity {entity} action for {device} device: {error}" + }, + "rpc_call_action_error": { + "message": "RPC call error occurred while calling the entity {entity} action for {device} device: {error}" + } + }, "issues": { "device_not_calibrated": { "title": "Shelly device {device_name} is not calibrated", diff --git a/tests/components/shelly/snapshots/test_button.ambr b/tests/components/shelly/snapshots/test_button.ambr new file mode 100644 index 00000000000..f5a38f1b847 --- /dev/null +++ b/tests/components/shelly/snapshots/test_button.ambr @@ -0,0 +1,96 @@ +# serializer version: 1 +# name: test_rpc_blu_trv_button[button.trv_name_calibrate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.trv_name_calibrate', + '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': 'TRV-Name Calibrate', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'calibrate', + 'unique_id': 'f8:44:77:25:f0:dd_calibrate', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_blu_trv_button[button.trv_name_calibrate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TRV-Name Calibrate', + }), + 'context': , + 'entity_id': 'button.trv_name_calibrate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_rpc_button[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test name Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_button[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index 14349411670..2a9720ca7ae 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -2,12 +2,17 @@ from unittest.mock import Mock +from aioshelly.const import MODEL_BLU_GATEWAY_G3 +from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.shelly.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import EntityRegistry from . import init_integration @@ -38,7 +43,10 @@ async def test_block_button( async def test_rpc_button( - hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test rpc device OTA button.""" await init_integration(hass, 2) @@ -46,11 +54,11 @@ async def test_rpc_button( entity_id = "button.test_name_reboot" # reboot button - assert hass.states.get(entity_id).state == STATE_UNKNOWN + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") entry = entity_registry.async_get(entity_id) - assert entry - assert entry.unique_id == "123456789ABC_reboot" + assert entry == snapshot(name=f"{entity_id}-entry") await hass.services.async_call( BUTTON_DOMAIN, @@ -61,6 +69,68 @@ async def test_rpc_button( assert mock_rpc_device.trigger_reboot.call_count == 1 +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling the entity button.test_name_reboot action for Test name device", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling the entity button.test_name_reboot action for Test name device", + ), + ], +) +async def test_rpc_button_exc( + hass: HomeAssistant, + mock_rpc_device: Mock, + exception: Exception, + error: str, +) -> None: + """Test RPC button with exception.""" + await init_integration(hass, 2) + + mock_rpc_device.trigger_reboot.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + + +async def test_rpc_button_reauth_error( + hass: HomeAssistant, mock_rpc_device: Mock +) -> None: + """Test rpc device OTA button with authentication error.""" + entry = await init_integration(hass, 2) + + mock_rpc_device.trigger_reboot.side_effect = InvalidAuthError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.test_name_reboot"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id + + @pytest.mark.parametrize( ("gen", "old_unique_id", "new_unique_id", "migration"), [ @@ -104,3 +174,107 @@ async def test_migrate_unique_id( bool("Migrating unique_id for button.test_name_reboot" in caplog.text) == migration ) + + +async def test_rpc_blu_trv_button( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, +) -> None: + """Test RPC BLU TRV button.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + entity_id = "button.trv_name_calibrate" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_blu_trv.trigger_blu_trv_calibration.call_count == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + DeviceConnectionError, + "Device communication error occurred while calling the entity button.trv_name_calibrate action for Test name device", + ), + ( + RpcCallError(999), + "RPC call error occurred while calling the entity button.trv_name_calibrate action for Test name device", + ), + ], +) +async def test_rpc_blu_trv_button_exc( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, + error: str, +) -> None: + """Test RPC BLU TRV button with exception.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.trigger_blu_trv_calibration.side_effect = exception + + with pytest.raises(HomeAssistantError, match=error): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.trv_name_calibrate"}, + blocking=True, + ) + + +async def test_rpc_blu_trv_button_auth_error( + hass: HomeAssistant, + mock_blu_trv: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC BLU TRV button with authentication error.""" + monkeypatch.delitem(mock_blu_trv.status, "script:1") + monkeypatch.delitem(mock_blu_trv.status, "script:2") + monkeypatch.delitem(mock_blu_trv.status, "script:3") + + entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + mock_blu_trv.trigger_blu_trv_calibration.side_effect = InvalidAuthError + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.trv_name_calibrate"}, + blocking=True, + ) + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index fcfed090a66..ac9c7967540 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, Mock, PropertyMock from aioshelly.const import ( BLU_TRV_IDENTIFIER, + BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3, MODEL_VALVE, MODEL_WALL_DISPLAY, @@ -27,7 +28,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN +from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 6bddd1eeb23..c032a137bfc 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_G3 +from aioshelly.const import BLU_TRV_TIMEOUT, MODEL_BLU_GATEWAY_G3 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError import pytest from syrupy import SnapshotAssertion @@ -18,7 +18,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, NumberMode, ) -from homeassistant.components.shelly.const import BLU_TRV_TIMEOUT, DOMAIN +from homeassistant.components.shelly.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State From 3101d9099bda96c10e5231b9e336b0c67fdde8c8 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 21 Mar 2025 11:19:07 +0100 Subject: [PATCH 1859/1941] Fix spelling of "mDNS" in `esphome` (#141052) Change "MDNS" to the correct "mDNS". --- homeassistant/components/esphome/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 1534a49e365..c6916a3636d 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "mdns_missing_mac": "Missing MAC address in MDNS properties.", + "mdns_missing_mac": "Missing MAC address in mDNS properties.", "service_received": "Action received", "mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.", From 1fafe81d20dbbd84eb8da5a6e3d12fc3159fe528 Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Fri, 21 Mar 2025 11:20:15 +0100 Subject: [PATCH 1860/1941] Update Stookwijzer diagnostics and description (#141041) Update diagnostics and description --- .../components/stookwijzer/diagnostics.py | 1 + .../components/stookwijzer/strings.json | 2 +- tests/components/stookwijzer/conftest.py | 31 +++++++++++++++++++ .../snapshots/test_diagnostics.ambr | 22 +++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index 2849e0e976a..1f3ef4ee4ba 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -18,4 +18,5 @@ async def async_get_config_entry_diagnostics( "advice": client.advice, "air_quality_index": client.lki, "windspeed_ms": client.windspeed_ms, + "forecast": await client.async_get_forecast(), } diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index d7304fa1238..a028f1f19c5 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -29,7 +29,7 @@ }, "issues": { "location_migration_failed": { - "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", + "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", "title": "Migration of your location failed" } }, diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index 40582dc4be3..dd7f2a7bbc3 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -1,6 +1,7 @@ """Fixtures for Stookwijzer integration tests.""" from collections.abc import Generator +from typing import Required, TypedDict from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -12,6 +13,14 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +class Forecast(TypedDict): + """Typed Stookwijzer forecast dict.""" + + datetime: Required[str] + advice: str | None + final: bool | None + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" @@ -80,6 +89,28 @@ def mock_stookwijzer() -> Generator[MagicMock]: client.windspeed_ms = 2.5 client.windspeed_bft = 2 client.advice = "code_yellow" + client.async_get_forecast.return_value = ( + Forecast( + datetime="2025-02-12T17:00:00+01:00", + advice="code_yellow", + final=True, + ), + Forecast( + datetime="2025-02-12T23:00:00+01:00", + advice="code_yellow", + final=True, + ), + Forecast( + datetime="2025-02-13T05:00:00+01:00", + advice="code_orange", + final=False, + ), + Forecast( + datetime="2025-02-13T11:00:00+01:00", + advice="code_orange", + final=False, + ), + ) yield stookwijzer_mock diff --git a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr index e2535d54466..452b5bd0a30 100644 --- a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr +++ b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr @@ -3,6 +3,28 @@ dict({ 'advice': 'code_yellow', 'air_quality_index': 2, + 'forecast': list([ + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T17:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T23:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T05:00:00+01:00', + 'final': False, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T11:00:00+01:00', + 'final': False, + }), + ]), 'windspeed_ms': 2.5, }) # --- From 4ed2689678211246407e2b5ae3855cbd2d9210ce Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 21 Mar 2025 11:25:26 +0100 Subject: [PATCH 1861/1941] Handle wrong WebDAV URL more gracefully in config flow (#141040) --- homeassistant/components/webdav/config_flow.py | 4 +++- homeassistant/components/webdav/strings.json | 1 + tests/components/webdav/test_config_flow.py | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index f75544d25ad..fa1a4fe3ca9 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from aiowebdav2.exceptions import UnauthorizedError +from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError import voluptuous as vol import yarl @@ -65,6 +65,8 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): result = await client.check() except UnauthorizedError: errors["base"] = "invalid_auth" + except MethodNotSupportedError: + errors["base"] = "invalid_method" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error") errors["base"] = "unknown" diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index b03ffaf2a3d..ac6418f1239 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -21,6 +21,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py index eb887edb1a1..9204e6eadab 100644 --- a/tests/components/webdav/test_config_flow.py +++ b/tests/components/webdav/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import UnauthorizedError +from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError import pytest from homeassistant import config_entries @@ -86,6 +86,7 @@ async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: ("exception", "expected_error"), [ (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (MethodNotSupportedError("check", "https://webdav.demo"), "invalid_method"), (Exception("Unexpected error"), "unknown"), ], ) From 858f0e66573419e2fe87924dd310fd4aa24b3fe6 Mon Sep 17 00:00:00 2001 From: Wouter <33957974+wjtje@users.noreply.github.com> Date: Fri, 21 Mar 2025 13:13:56 +0100 Subject: [PATCH 1862/1941] Fixed issue where the device was already disconnected when setting up the event platform (#140722) * Changed where the script events are collected to remove any device communication from async_setup_entry * Implemented improvements and added a test to test whats happends when script_getcode fails * Renamed script_events to rpc_script_event to make clear this is only for RPC devices Co-authored-by: Shay Levy --------- Co-authored-by: Shay Levy --- homeassistant/components/shelly/__init__.py | 8 +++++++- homeassistant/components/shelly/coordinator.py | 1 + homeassistant/components/shelly/event.py | 12 ++++-------- homeassistant/components/shelly/utils.py | 17 +++++++++++++++++ tests/components/shelly/conftest.py | 3 +++ tests/components/shelly/test_init.py | 15 +++++++++++++++ 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 7440013940c..a7ee1c029df 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Final +from aioshelly.ble.const import BLE_SCRIPT_NAME from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions from aioshelly.const import DEFAULT_COAP_PORT, RPC_GENERATIONS @@ -11,6 +12,7 @@ from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, + RpcCallError, ) from aioshelly.rpc_device import RpcDevice, bluetooth_mac_from_primary_mac import voluptuous as vol @@ -59,6 +61,7 @@ from .utils import ( get_coap_context, get_device_entry_gen, get_http_port, + get_rpc_scripts_event_types, get_ws_context, ) @@ -270,7 +273,10 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) async_create_issue_unsupported_firmware(hass, entry) await device.shutdown() raise ConfigEntryNotReady - except (DeviceConnectionError, MacAddressMismatchError) as err: + runtime_data.rpc_script_events = await get_rpc_scripts_event_types( + device, ignore_scripts=[BLE_SCRIPT_NAME] + ) + except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err: await device.shutdown() raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 95812c12e10..85cf430bc5d 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -88,6 +88,7 @@ class ShellyEntryData: rest: ShellyRestCoordinator | None = None rpc: ShellyRpcCoordinator | None = None rpc_poll: ShellyRpcPollingCoordinator | None = None + rpc_script_events: dict[int, list[str]] | None = None type ShellyConfigEntry = ConfigEntry[ShellyEntryData] diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index bfd705f447a..ec5810581b1 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -34,7 +34,6 @@ from .utils import ( get_device_entry_gen, get_rpc_entity_name, get_rpc_key_instances, - get_rpc_script_event_types, is_block_momentary_input, is_rpc_momentary_input, ) @@ -109,18 +108,15 @@ async def async_setup_entry( script_instances = get_rpc_key_instances( coordinator.device.status, SCRIPT_EVENT.key ) + script_events = config_entry.runtime_data.rpc_script_events for script in script_instances: script_name = get_rpc_entity_name(coordinator.device, script) if script_name == BLE_SCRIPT_NAME: continue - event_types = await get_rpc_script_event_types( - coordinator.device, int(script.split(":")[-1]) - ) - if not event_types: - continue - - entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) + script_id = int(script.split(":")[-1]) + if script_events and (event_types := script_events[script_id]): + entities.append(ShellyRpcScriptEvent(coordinator, script, event_types)) # If a script is removed, from the device configuration, we need to remove orphaned entities async_remove_orphaned_entities( diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 19897dbb185..474e2bb9410 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -664,3 +664,20 @@ def get_shelly_air_lamp_life(lamp_seconds: int) -> float: if lamp_hours >= SHAIR_MAX_WORK_HOURS: return 0.0 return 100 * (1 - lamp_hours / SHAIR_MAX_WORK_HOURS) + + +async def get_rpc_scripts_event_types( + device: RpcDevice, ignore_scripts: list[str] +) -> dict[int, list[str]]: + """Return a dict of all scripts and their event types.""" + script_instances = get_rpc_key_instances(device.status, "script") + script_events = {} + for script in script_instances: + script_name = get_rpc_entity_name(device, script) + if script_name in ignore_scripts: + continue + + script_id = int(script.split(":")[-1]) + script_events[script_id] = await get_rpc_script_event_types(device, script_id) + + return script_events diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8030df6e473..8f8255235be 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -513,6 +513,9 @@ def _mock_blu_rtv_device(version: str | None = None): firmware_version="some fw string", initialized=True, connected=True, + script_getcode=AsyncMock( + side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + ), xmod_info={}, ) type(device).name = PropertyMock(return_value="Test name") diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index ef9b8f72616..0cec6383461 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -10,6 +10,7 @@ from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, MacAddressMismatchError, + RpcCallError, ) from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac import pytest @@ -555,3 +556,17 @@ async def test_bluetooth_cleanup_on_remove_entry( remove_mock.assert_called_once_with( hass, format_mac(bluetooth_mac_from_primary_mac(entry.unique_id)).upper() ) + + +async def test_device_script_getcode_error( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test device script get code error.""" + monkeypatch.setattr( + mock_rpc_device, "script_getcode", AsyncMock(side_effect=RpcCallError(0)) + ) + + entry = await init_integration(hass, 2) + assert entry.state is ConfigEntryState.SETUP_RETRY From 466ec0b596e8d9141d94c00e9946bfcc2799796a Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 21 Mar 2025 08:31:17 -0400 Subject: [PATCH 1863/1941] Fix failing Roborock test (#141059) Fix the falky test --- tests/components/roborock/conftest.py | 11 +------- .../roborock/snapshots/test_diagnostics.ambr | 2 +- tests/components/roborock/test_init.py | 26 ++++++++++++++----- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index fcd469ca10a..332a9143c51 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -11,7 +11,6 @@ import uuid import pytest from roborock import RoborockCategory, RoomMapping from roborock.code_mappings import DyadError, RoborockDyadStateCode, ZeoError, ZeoState -from roborock.containers import NetworkInfo from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from roborock.version_a01_apis import RoborockMqttClientA01 @@ -30,7 +29,6 @@ from .mock_data import ( MAP_DATA, MULTI_MAP_LIST, NETWORK_INFO, - NETWORK_INFO_2, PROP, SCENES, USER_DATA, @@ -89,13 +87,6 @@ def bypass_api_client_fixture() -> None: yield -def cycle_network_info() -> Generator[NetworkInfo]: - """Return the appropriate network info for the corresponding device.""" - while True: - yield NETWORK_INFO - yield NETWORK_INFO_2 - - @pytest.fixture(name="bypass_api_fixture") def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: """Skip calls to the API.""" @@ -107,7 +98,7 @@ def bypass_api_fixture(bypass_api_client_fixture: Any) -> None: ), patch( "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", - side_effect=cycle_network_info(), + return_value=NETWORK_INFO, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 313824e70ec..26ecb729312 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -357,7 +357,7 @@ }), 'network_info': dict({ 'bssid': '**REDACTED**', - 'ip': '123.232.12.2', + 'ip': '123.232.12.1', 'mac': '**REDACTED**', 'rssi': 90, 'ssid': 'wifi', diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 226eea816b9..3d288b6479b 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component -from .mock_data import HOME_DATA, NETWORK_INFO +from .mock_data import HOME_DATA, NETWORK_INFO, NETWORK_INFO_2 from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -305,7 +305,11 @@ async def test_stale_device( device_registry: DeviceRegistry, ) -> None: """Test that we remove a device if it no longer is given by home_data.""" - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.LOADED existing_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id @@ -314,9 +318,15 @@ async def test_stale_device( hd = deepcopy(HOME_DATA) hd.devices = [hd.devices[0]] - with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", - return_value=hd, + with ( + patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data_v2", + return_value=hd, + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ), ): await hass.config_entries.async_reload(mock_roborock_entry.entry_id) await hass.async_block_till_done() @@ -336,7 +346,11 @@ async def test_no_stale_device( device_registry: DeviceRegistry, ) -> None: """Test that we don't remove a device if fails to setup.""" - await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + with patch( + "homeassistant.components.roborock.RoborockMqttClientV1.get_networking", + side_effect=[NETWORK_INFO, NETWORK_INFO_2], + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert mock_roborock_entry.state is ConfigEntryState.LOADED existing_devices = device_registry.devices.get_devices_for_config_entry_id( mock_roborock_entry.entry_id From a9cbc72ce5493352c6fe988d064b7b30fcffe23c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 14:03:44 +0100 Subject: [PATCH 1864/1941] Add child lock to SmartThings (#140200) * Add kids lock to SmartThings * Add kids lock to SmartThings * Fix * Fix --- .../components/smartthings/binary_sensor.py | 7 + .../components/smartthings/icons.json | 6 + .../components/smartthings/strings.json | 3 + .../snapshots/test_binary_sensor.ambr | 376 ++++++++++++++++++ 4 files changed, 392 insertions(+) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 0654846273e..ace23ba4ec2 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -61,6 +61,13 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="replace", ) }, + Capability.SAMSUNG_CE_KIDS_LOCK: { + Attribute.LOCK_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.LOCK_STATE, + translation_key="child_lock", + is_on_key="locked", + ) + }, Capability.MOTION_SENSOR: { Attribute.MOTION: SmartThingsBinarySensorEntityDescription( key=Attribute.MOTION, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index cbc4b6b80ce..971550b8f69 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -6,6 +6,12 @@ "state": { "on": "mdi:remote" } + }, + "child_lock": { + "default": "mdi:lock-open", + "state": { + "on": "mdi:lock" + } } } } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fdc905468f5..48314341da9 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -39,6 +39,9 @@ "remote_control": { "name": "Remote control" }, + "child_lock": { + "name": "Child lock" + }, "valve": { "name": "Valve" } diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 6223c6c526c..4edb3160cf8 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -143,6 +143,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_child_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.microwave_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -190,6 +237,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_child_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.oven_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -237,6 +331,53 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_child_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.vulcan_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -332,6 +473,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dishwasher_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'f36dc7ce-cac0-0667-dc14-a3704eb5e676.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dishwasher Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -379,6 +567,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_child_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.dryer_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -426,6 +661,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_child_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.seca_roupa_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -473,6 +755,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_child_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washer_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -520,6 +849,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_child_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': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.washing_machine_child_lock', + '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': 'Child lock', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.lockState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Child lock', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From f84a46680df0393c209c8bf323be6e90007ceb11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 16:20:42 +0100 Subject: [PATCH 1865/1941] Add event platform to SmartThings (#141066) * Add event platform to SmartThings * Add event platform to SmartThings * Fix --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/entity.py | 13 +- homeassistant/components/smartthings/event.py | 63 +++ .../components/smartthings/strings.json | 32 ++ tests/components/smartthings/__init__.py | 3 +- tests/components/smartthings/conftest.py | 1 + .../device_status/heatit_zpushwall.json | 116 ++++++ .../fixtures/devices/heatit_zpushwall.json | 155 ++++++++ .../smartthings/snapshots/test_event.ambr | 361 ++++++++++++++++++ .../smartthings/snapshots/test_init.ambr | 33 ++ .../smartthings/snapshots/test_sensor.ambr | 49 +++ tests/components/smartthings/test_event.py | 61 +++ 12 files changed, 882 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/smartthings/event.py create mode 100644 tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json create mode 100644 tests/components/smartthings/fixtures/devices/heatit_zpushwall.json create mode 100644 tests/components/smartthings/snapshots/test_event.ambr create mode 100644 tests/components/smartthings/test_event.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 58afbb6cb41..1fa6a1e259b 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -73,6 +73,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.COVER, + Platform.EVENT, Platform.FAN, Platform.LIGHT, Platform.LOCK, diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index c2637174a5c..660ab499d19 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -32,14 +32,17 @@ class SmartThingsEntity(Entity): device: FullDevice, rooms: dict[str, str], capabilities: set[Capability], + *, + component: str = MAIN, ) -> None: """Initialize the instance.""" self.client = client self.capabilities = capabilities + self.component = component self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = { - capability: device.status[MAIN][capability] + capability: device.status[component][capability] for capability in capabilities - if capability in device.status[MAIN] + if capability in device.status[component] } self.device = device self._attr_unique_id = device.device.device_id @@ -84,7 +87,7 @@ class SmartThingsEntity(Entity): self.async_on_remove( self.client.add_device_capability_event_listener( self.device.device.device_id, - MAIN, + self.component, capability, self._update_handler, ) @@ -98,7 +101,7 @@ class SmartThingsEntity(Entity): def supports_capability(self, capability: Capability) -> bool: """Test if device supports a capability.""" - return capability in self.device.status[MAIN] + return capability in self.device.status[self.component] def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any: """Get the value of a device attribute.""" @@ -123,5 +126,5 @@ class SmartThingsEntity(Entity): if argument is not None: kwargs["argument"] = argument await self.client.execute_device_command( - self.device.device.device_id, capability, command, MAIN, **kwargs + self.device.device.device_id, capability, command, self.component, **kwargs ) diff --git a/homeassistant/components/smartthings/event.py b/homeassistant/components/smartthings/event.py new file mode 100644 index 00000000000..b629bd92b35 --- /dev/null +++ b/homeassistant/components/smartthings/event.py @@ -0,0 +1,63 @@ +"""Support for events through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import cast + +from pysmartthings import Attribute, Capability, Component, DeviceEvent, SmartThings + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add events for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsButtonEvent(entry_data.client, entry_data.rooms, device, component) + for device in entry_data.devices.values() + for component in device.device.components + if Capability.BUTTON in component.capabilities + ) + + +class SmartThingsButtonEvent(SmartThingsEntity, EventEntity): + """Define a SmartThings event.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_translation_key = "button" + + def __init__( + self, + client: SmartThings, + rooms: dict[str, str], + device: FullDevice, + component: Component, + ) -> None: + """Init the class.""" + super().__init__( + client, device, rooms, {Capability.BUTTON}, component=component.id + ) + self._attr_name = component.label + self._attr_unique_id = ( + f"{device.device.device_id}_{component.id}_{Capability.BUTTON}" + ) + + @property + def event_types(self) -> list[str]: + """Return the event types.""" + return self.get_attribute_value( + Capability.BUTTON, Attribute.SUPPORTED_BUTTON_VALUES + ) + + def _update_handler(self, event: DeviceEvent) -> None: + self._trigger_event(cast(str, event.value)) + self.async_write_ha_state() diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 48314341da9..39973ef5380 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -46,6 +46,38 @@ "name": "Valve" } }, + "event": { + "button": { + "state": { + "pushed": "Pushed", + "held": "Held", + "double": "Double", + "pushed_2x": "Pushed 2x", + "pushed_3x": "Pushed 3x", + "pushed_4x": "Pushed 4x", + "pushed_5x": "Pushed 5x", + "pushed_6x": "Pushed 6x", + "down": "Down", + "down_2x": "Down 2x", + "down_3x": "Down 3x", + "down_4x": "Down 4x", + "down_5x": "Down 5x", + "down_6x": "Down 6x", + "down_hold": "Down hold", + "up": "Up", + "up_2x": "Up 2x", + "up_3x": "Up 3x", + "up_4x": "Up 4x", + "up_5x": "Up 5x", + "up_6x": "Up 6x", + "up_hold": "Up hold", + "swipe_up": "Swipe up", + "swipe_down": "Swipe down", + "swipe_left": "Swipe left", + "swipe_right": "Swipe right" + } + } + }, "sensor": { "lighting_mode": { "name": "Activity lighting mode" diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py index e87d1a8bcdf..ad09f1a7acf 100644 --- a/tests/components/smartthings/__init__.py +++ b/tests/components/smartthings/__init__.py @@ -55,6 +55,7 @@ async def trigger_update( attribute: Attribute, value: str | float | dict[str, Any] | list[Any] | None, data: dict[str, Any] | None = None, + component: str = MAIN, ) -> None: """Trigger an update.""" event = DeviceEvent( @@ -62,7 +63,7 @@ async def trigger_update( "abc", "abc", device_id, - MAIN, + component, capability, attribute, value, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index d26805eb04b..9e70c1b2b34 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -131,6 +131,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "fake_fan", "generic_fan_3_speed", "heatit_ztrm3_thermostat", + "heatit_zpushwall", "generic_ef00_v1", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", diff --git a/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json b/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json new file mode 100644 index 00000000000..591d1128ea0 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/heatit_zpushwall.json @@ -0,0 +1,116 @@ +{ + "components": { + "button4": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-02-10T08:01:11.326Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.695Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.717Z" + } + } + }, + "button5": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-03-09T16:37:40.792Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.762Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.813Z" + } + } + }, + "button2": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-02-10T08:00:57.171Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.861Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.906Z" + } + } + }, + "button3": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-01-30T05:53:00.663Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.852Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.848Z" + } + } + }, + "button6": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2024-10-02T13:11:07.346Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.816Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.848Z" + } + } + }, + "main": { + "refresh": {}, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-03-10T10:32:19.528Z" + }, + "type": { + "value": null + } + } + }, + "button1": { + "button": { + "button": { + "value": "pushed", + "timestamp": "2025-01-30T05:52:46.718Z" + }, + "numberOfButtons": { + "value": 1, + "timestamp": "2023-12-04T16:51:16.717Z" + }, + "supportedButtonValues": { + "value": ["pushed", "held", "down_hold"], + "timestamp": "2023-12-04T16:51:16.767Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json b/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json new file mode 100644 index 00000000000..0cd42e0e2ce --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/heatit_zpushwall.json @@ -0,0 +1,155 @@ +{ + "items": [ + { + "deviceId": "5e5b97f3-3094-44e6-abc0-f61283412d6a", + "name": "heatit-zpushwall", + "label": "Livingroom smart switch", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "52933933-7123-3315-a441-92d65df5f031", + "deviceManufacturerCode": "019B-0004-2403", + "locationId": "c85a9f8a-5d2e-4cdd-8bdb-bc49ba4a3544", + "ownerId": "7b68139b-d068-45d8-bf27-961320350024", + "roomId": "56e43461-2f7d-4c43-ba7c-29465f991289", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "battery", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button1", + "label": "button1", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button2", + "label": "button2", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button3", + "label": "button3", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button4", + "label": "button4", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button5", + "label": "button5", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "button6", + "label": "button6", + "capabilities": [ + { + "id": "button", + "version": 1 + } + ], + "categories": [ + { + "name": "RemoteController", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-04T16:51:15.774Z", + "parentDeviceId": "4869d882-e898-40c3-a198-7611b72187a5", + "profile": { + "id": "2d6e59af-63df-3102-8515-66f3d75c9323" + }, + "zwave": { + "networkId": "12", + "driverId": "1d39c140-ce10-490d-bf52-4de7b72caab6", + "executingLocally": true, + "hubId": "4869d882-e898-40c3-a198-7611b72187a5", + "networkSecurityLevel": "ZWAVE_S2_AUTHENTICATED", + "provisioningState": "NONFUNCTIONAL", + "manufacturerId": 411, + "productType": 4, + "productId": 9219 + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_event.ambr b/tests/components/smartthings/snapshots/test_event.ambr new file mode 100644 index 00000000000..79c57df5fd7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_event.ambr @@ -0,0 +1,361 @@ +# serializer version: 1 +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button1', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button1_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button1', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button2', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button2_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button2', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button3', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button3_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button3', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button4', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button4_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button4', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button5', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button5_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button5', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.livingroom_smart_switch_button6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'button6', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'button', + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a_button6_button', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[heatit_zpushwall][event.livingroom_smart_switch_button6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'pushed', + 'held', + 'down_hold', + ]), + 'friendly_name': 'Livingroom smart switch button6', + }), + 'context': , + 'entity_id': 'event.livingroom_smart_switch_button6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index e62c34cd11c..48a1138e344 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -992,6 +992,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[heatit_zpushwall] + 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', + '5e5b97f3-3094-44e6-abc0-f61283412d6a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Livingroom smart switch', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[heatit_ztrm3_thermostat] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 954bcc5c281..8b1a3c9f7d6 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -6578,6 +6578,55 @@ 'state': '21.0', }) # --- +# name: test_all_entities[heatit_zpushwall][sensor.livingroom_smart_switch_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.livingroom_smart_switch_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5e5b97f3-3094-44e6-abc0-f61283412d6a.battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[heatit_zpushwall][sensor.livingroom_smart_switch_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Livingroom smart switch Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.livingroom_smart_switch_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[heatit_ztrm3_thermostat][sensor.hall_thermostat_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_event.py b/tests/components/smartthings/test_event.py new file mode 100644 index 00000000000..bdca7674981 --- /dev/null +++ b/tests/components/smartthings/test_event.py @@ -0,0 +1,61 @@ +"""Test for the SmartThings event platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pysmartthings import Attribute, Capability +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.EVENT) + + +@pytest.mark.parametrize("device_fixture", ["heatit_zpushwall"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + freezer.move_to("2023-10-21") + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state == STATE_UNKNOWN + ) + + await trigger_update( + hass, + devices, + "5e5b97f3-3094-44e6-abc0-f61283412d6a", + Capability.BUTTON, + Attribute.BUTTON, + "pushed", + component="button1", + ) + + assert ( + hass.states.get("event.livingroom_smart_switch_button1").state + == "2023-10-21T00:00:00.000+00:00" + ) From c1753631b174be54cb3dc17b8c2c0e68e51d48a0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 21 Mar 2025 16:26:51 +0100 Subject: [PATCH 1866/1941] Handle button presses exceptions for Vodafone Station (#140953) * Handle button presses execeptions for Vodafone Station * apply review comment --- .../components/vodafone_station/button.py | 34 +++++++++++++++++-- .../vodafone_station/quality_scale.yaml | 4 +-- .../components/vodafone_station/strings.json | 3 ++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 5c98c3241e9..8dda4d49c7b 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -4,8 +4,16 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from json.decoder import JSONDecodeError from typing import Any, Final +from aiovodafone.exceptions import ( + AlreadyLogged, + CannotAuthenticate, + CannotConnect, + GenericLoginError, +) + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -13,10 +21,11 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import _LOGGER +from .const import _LOGGER, DOMAIN from .coordinator import VodafoneConfigEntry, VodafoneStationRouter # Coordinator is used to centralize the data updates @@ -108,4 +117,25 @@ class VodafoneStationSensorEntity( async def async_press(self) -> None: """Triggers the Shelly button press service.""" - await self.entity_description.press_action(self.coordinator) + + try: + await self.entity_description.press_action(self.coordinator) + except CannotAuthenticate as err: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err + except ( + CannotConnect, + AlreadyLogged, + GenericLoginError, + JSONDecodeError, + ) as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_execute_action", + translation_placeholders={"error": repr(err)}, + ) from err diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml index f9fa27b3032..fe114b4b324 100644 --- a/homeassistant/components/vodafone_station/quality_scale.yaml +++ b/homeassistant/components/vodafone_station/quality_scale.yaml @@ -26,9 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: button presses not exception handled with HomeAssistantError + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index de4bc364d4b..e05e1877798 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -116,6 +116,9 @@ "update_failed": { "message": "Error fetching data: {error}" }, + "cannot_execute_action": { + "message": "Cannot execute requested action: {error}" + }, "cannot_authenticate": { "message": "Error authenticating: {error}" } From 74ed0e801118450975cc9f2778aeb3b7fa1635f4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 16:29:00 +0100 Subject: [PATCH 1867/1941] Add support for PM1.0 in SmartThings (#141061) * Add support for PM1.0 in SmartThings * Add test fixtures * Add test fixtures --- .../components/smartthings/sensor.py | 13 +- tests/components/smartthings/conftest.py | 1 + .../device_status/da_ac_airsensor_01001.json | 362 ++++++++++++++++ .../devices/da_ac_airsensor_01001.json | 145 +++++++ .../smartthings/snapshots/test_init.ambr | 33 ++ .../smartthings/snapshots/test_sensor.ambr | 410 ++++++++++++++++++ 6 files changed, 961 insertions(+), 3 deletions(-) create mode 100644 tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json create mode 100644 tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 1437cbe6000..21d256968ae 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -225,7 +225,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # Haven't seen at devices yet Capability.CARBON_DIOXIDE_MEASUREMENT: { Attribute.CARBON_DIOXIDE: [ SmartThingsSensorEntityDescription( @@ -467,7 +466,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_REPEAT: { Attribute.PLAYBACK_REPEAT_MODE: [ SmartThingsSensorEntityDescription( @@ -476,7 +474,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # part of the proposed spec, Haven't seen at devices yet Capability.MEDIA_PLAYBACK_SHUFFLE: { Attribute.PLAYBACK_SHUFFLE: [ SmartThingsSensorEntityDescription( @@ -903,6 +900,16 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.VERY_FINE_DUST_SENSOR: { + Attribute.VERY_FINE_DUST_LEVEL: [ + SmartThingsSensorEntityDescription( + key=Attribute.VERY_FINE_DUST_LEVEL, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.VOLTAGE_MEASUREMENT: { Attribute.VOLTAGE: [ SmartThingsSensorEntityDescription( diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 9e70c1b2b34..761b65adc8a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -91,6 +91,7 @@ def mock_smartthings() -> Generator[AsyncMock]: @pytest.fixture( params=[ + "da_ac_airsensor_01001", "da_ac_rac_000001", "da_ac_rac_100001", "da_ac_rac_01001", diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json b/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json new file mode 100644 index 00000000000..903b5163335 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ac_airsensor_01001.json @@ -0,0 +1,362 @@ +{ + "components": { + "main": { + "samsungce.rechargeableBattery": { + "chargingStatus": { + "value": "charging", + "timestamp": "2025-02-18T05:20:27.966Z" + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-02-22T04:50:19.633Z" + }, + "resolution": { + "value": 1, + "timestamp": "2024-12-20T14:38:31.662Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 54, + "unit": "%", + "timestamp": "2025-03-21T07:26:16.872Z" + } + }, + "refresh": {}, + "carbonDioxideHealthConcern": { + "carbonDioxideHealthConcern": { + "value": "moderate", + "timestamp": "2025-03-21T13:40:56.560Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.sensors"], + "if": ["oic.if.baseline", "oic.if.s"], + "x.com.samsung.da.cleanLevel": "2", + "x.com.samsung.da.refresh": "Off", + "x.com.samsung.da.lastSensingTime": "1740829045", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": ["2"] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": ["2"] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": ["29", "1"] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": ["7", "1"] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": ["6", "1"] + }, + { + "x.com.samsung.da.id": "5", + "x.com.samsung.da.description": "Sensor for CO2", + "x.com.samsung.da.type": "CO2", + "x.com.samsung.da.value": ["2527", "3"] + } + ] + } + }, + "data": { + "href": "/sensors/vs/0" + }, + "timestamp": "2025-03-01T11:37:26.334Z" + } + }, + "carbonDioxideMeasurement": { + "carbonDioxide": { + "value": 1045, + "unit": "ppm", + "timestamp": "2025-03-21T15:05:44.312Z" + } + }, + "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": "ASM-KR-TP1-22-ACMB1M", + "timestamp": "2025-03-20T23:08:07.388Z" + } + }, + "airQualitySensor": { + "airQuality": { + "value": 2, + "unit": "CAQI", + "timestamp": "2025-03-21T15:06:39.609Z" + } + }, + "fineDustHealthConcern": { + "fineDustHealthConcern": { + "value": "good", + "timestamp": "2025-03-21T10:25:04.548Z" + } + }, + "ocf": { + "st": { + "value": null + }, + "mndt": { + "value": null + }, + "mnfv": { + "value": "ASM-KR-TP1-22-ACMB1M_16240426", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnhw": { + "value": "Realtek", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "di": { + "value": "a3a970ea-e09c-9c04-161b-94c934e21666", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnsl": { + "value": "http://www.samsung.com", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "dmv": { + "value": "1.2.1", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "n": { + "value": "Samsung AirMonitor", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnmo": { + "value": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "vid": { + "value": "DA-AC-AIRSENSOR-01001", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnml": { + "value": "http://www.samsung.com", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnpv": { + "value": "DAWIT 2.0", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "mnos": { + "value": "TizenRT 4.0", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "pi": { + "value": "a3a970ea-e09c-9c04-161b-94c934e21666", + "timestamp": "2024-08-19T07:28:01.277Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2024-08-19T07:28:01.277Z" + } + }, + "odorSensor": { + "odorLevel": { + "value": 1, + "timestamp": "2025-03-21T13:29:15.650Z" + } + }, + "veryFineDustHealthConcern": { + "veryFineDustHealthConcern": { + "value": "good", + "timestamp": "2025-03-21T02:56:21.007Z" + } + }, + "samsungce.doNotDisturb": { + "settable": { + "value": true, + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "dayOfWeek": { + "value": null + }, + "repeatMode": { + "value": null + }, + "startTime": { + "value": "14:00:00Z", + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "endTime": { + "value": "22:00:00Z", + "timestamp": "2024-12-20T14:38:31.895Z" + }, + "activated": { + "value": false, + "timestamp": "2024-12-20T14:38:31.895Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2025-03-01T11:37:26.334Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 22100101, + "timestamp": "2023-12-09T04:05:59.505Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": null + }, + "otnDUID": { + "value": "EXCHUODPSCTZY", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "AM0", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2024-12-20T14:38:31.716Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2024-12-20T14:38:31.716Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 23.0, + "unit": "C", + "timestamp": "2025-03-21T04:40:33.951Z" + } + }, + "dustSensor": { + "dustLevel": { + "value": 31, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:39.609Z" + }, + "fineDustLevel": { + "value": 7, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:28.515Z" + } + }, + "veryFineDustSensor": { + "veryFineDustLevel": { + "value": 6, + "unit": "\u03bcg/m^3", + "timestamp": "2025-03-21T15:06:28.515Z" + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": "enabled", + "timestamp": "2024-12-20T14:38:31.769Z" + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-03-20T22:02:48.215Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2024-12-20T14:38:31.769Z" + } + }, + "dustHealthConcern": { + "dustHealthConcern": { + "value": "moderate", + "timestamp": "2025-03-21T15:06:39.609Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json b/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json new file mode 100644 index 00000000000..c8304e9c6d8 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ac_airsensor_01001.json @@ -0,0 +1,145 @@ +{ + "items": [ + { + "deviceId": "a3a970ea-e09c-9c04-161b-94c934e21666", + "name": "Samsung AirMonitor", + "label": "\uc5d0\uc5b4\ubaa8\ub2c8\ud130 \ud50c\ub7ec\uc2a4", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-AC-AIRSENSOR-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "33db9e71-abe9-43a0-acd3-3f0927bbe5b7", + "ownerId": "9a1ee192-04ba-46ca-9c3d-196d8dbcf807", + "roomId": "445c006d-1796-4dd6-8308-1c3cd045e8ff", + "deviceTypeName": "x.com.st.d.airqualitysensor", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "dustSensor", + "version": 1 + }, + { + "id": "dustHealthConcern", + "version": 1 + }, + { + "id": "fineDustHealthConcern", + "version": 1 + }, + { + "id": "veryFineDustSensor", + "version": 1 + }, + { + "id": "veryFineDustHealthConcern", + "version": 1 + }, + { + "id": "airQualitySensor", + "version": 1 + }, + { + "id": "odorSensor", + "version": 1 + }, + { + "id": "carbonDioxideMeasurement", + "version": 1 + }, + { + "id": "carbonDioxideHealthConcern", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.rechargeableBattery", + "version": 1 + }, + { + "id": "samsungce.doNotDisturb", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + } + ], + "categories": [ + { + "name": "AirQualityDetector", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2023-12-09T04:05:59.040Z", + "profile": { + "id": "1d34dd9d-6840-3df6-a6d0-5d9f4a4af2e1" + }, + "ocf": { + "ocfDeviceType": "x.com.st.d.airqualitysensor", + "name": "Samsung AirMonitor", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "1.2.1", + "manufacturerName": "Samsung Electronics", + "modelNumber": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000", + "platformVersion": "DAWIT 2.0", + "platformOS": "TizenRT 4.0", + "hwVersion": "Realtek", + "firmwareVersion": "ASM-KR-TP1-22-ACMB1M_16240426", + "vendorId": "DA-AC-AIRSENSOR-01001", + "vendorResourceClientServerVersion": "MediaTek Release 240426", + "lastSignupTime": "2023-12-09T04:05:54.816486Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 48a1138e344..930b3851806 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -299,6 +299,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ac_airsensor_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', + 'a3a970ea-e09c-9c04-161b-94c934e21666', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'ASM-KR-TP1-22-ACMB1M', + 'model_id': None, + 'name': '에어모니터 플러스', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'ASM-KR-TP1-22-ACMB1M_16240426', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ac_rac_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 8b1a3c9f7d6..8656d12c955 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -674,6 +674,416 @@ 'state': '15.0', }) # --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_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.eeomoniteo_peulreoseu_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': 'a3a970ea-e09c-9c04-161b-94c934e21666.airQuality', + 'unit_of_measurement': 'CAQI', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '에어모니터 플러스 Air quality', + 'state_class': , + 'unit_of_measurement': 'CAQI', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_carbon_dioxide-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.eeomoniteo_peulreoseu_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.carbonDioxide', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': '에어모니터 플러스 Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1045', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_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.eeomoniteo_peulreoseu_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': 'a3a970ea-e09c-9c04-161b-94c934e21666.humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': '에어모니터 플러스 Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_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.eeomoniteo_peulreoseu_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': 'a3a970ea-e09c-9c04-161b-94c934e21666.odorLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_odor_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '에어모니터 플러스 Odor sensor', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_odor_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm1-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.eeomoniteo_peulreoseu_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a3a970ea-e09c-9c04-161b-94c934e21666.veryFineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': '에어모니터 플러스 PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_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.eeomoniteo_peulreoseu_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': 'a3a970ea-e09c-9c04-161b-94c934e21666.dustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': '에어모니터 플러스 PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_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.eeomoniteo_peulreoseu_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': 'a3a970ea-e09c-9c04-161b-94c934e21666.fineDustLevel', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': '에어모니터 플러스 PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_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.eeomoniteo_peulreoseu_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': 'a3a970ea-e09c-9c04-161b-94c934e21666.temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ac_airsensor_01001][sensor.eeomoniteo_peulreoseu_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '에어모니터 플러스 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eeomoniteo_peulreoseu_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.0', + }) +# --- # name: test_all_entities[da_ac_rac_000001][sensor.ac_office_granit_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 16d335efc0261d883f947f200747b954ebf9b6e3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 21 Mar 2025 16:59:03 +0100 Subject: [PATCH 1868/1941] Update quality scale for Sensibo (#135924) * Update quality scale for Sensibo * platinum --- .../components/sensibo/manifest.json | 1 + .../components/sensibo/quality_scale.yaml | 22 +++++++++---------- script/hassfest/quality_scale.py | 1 - 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index e6398c5076e..610695aaf7b 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -14,5 +14,6 @@ }, "iot_class": "cloud_polling", "loggers": ["pysensibo"], + "quality_scale": "platinum", "requirements": ["pysensibo==1.1.0"] } diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml index c21cf100e9d..3d71d0ad3ba 100644 --- a/homeassistant/components/sensibo/quality_scale.yaml +++ b/homeassistant/components/sensibo/quality_scale.yaml @@ -19,9 +19,9 @@ rules: comment: | No integrations services. common-modules: done - docs-high-level-description: todo + docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done docs-actions: done brands: done # Silver @@ -39,9 +39,7 @@ rules: comment: | Tests are very complex and needs a rewrite for future additions integration-owner: done - docs-installation-parameters: - status: todo - comment: configuration_basic + docs-installation-parameters: done docs-configuration-parameters: status: exempt comment: | @@ -71,13 +69,13 @@ rules: status: exempt comment: | This integration doesn't have any cases where raising an issue is needed. - docs-use-cases: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-data-update: todo - docs-known-limitations: todo - docs-troubleshooting: todo - docs-examples: todo + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-data-update: done + docs-known-limitations: done + docs-troubleshooting: done + docs-examples: done # Platinum async-dependency: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index cdd062d2f4c..3fedebe89f4 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1958,7 +1958,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "season", "sendgrid", "sense", - "sensibo", "sensirion_ble", "sensorpro", "sensorpush", From e78e87389204e34ac27e851adfd3cbca7b8c5082 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 17:01:13 +0100 Subject: [PATCH 1869/1941] Add update platform to SmartThings (#141070) * 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 update * 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 * Finalize * Add test fixtures * Add test fixtures --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/update.py | 89 ++++ .../device_status/contact_sensor.json | 2 +- .../smartthings/snapshots/test_update.ambr | 421 ++++++++++++++++++ tests/components/smartthings/test_update.py | 142 ++++++ 5 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smartthings/update.py create mode 100644 tests/components/smartthings/snapshots/test_update.ambr create mode 100644 tests/components/smartthings/test_update.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 1fa6a1e259b..8b5860bc3af 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -80,6 +80,7 @@ PLATFORMS = [ Platform.SCENE, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, Platform.VALVE, ] diff --git a/homeassistant/components/smartthings/update.py b/homeassistant/components/smartthings/update.py new file mode 100644 index 00000000000..bd856bd38ba --- /dev/null +++ b/homeassistant/components/smartthings/update.py @@ -0,0 +1,89 @@ +"""Support for update entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from awesomeversion import AwesomeVersion +from pysmartthings import Attribute, Capability, Command + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add update entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsUpdateEntity( + entry_data.client, device, entry_data.rooms, {Capability.FIRMWARE_UPDATE} + ) + for device in entry_data.devices.values() + if Capability.FIRMWARE_UPDATE in device.status[MAIN] + ) + + +def is_hex_version(version: str) -> bool: + """Check if the version is a hex version.""" + return len(version) == 8 and all(c in "0123456789abcdefABCDEF" for c in version) + + +class SmartThingsUpdateEntity(SmartThingsEntity, UpdateEntity): + """Define a SmartThings update entity.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + @property + def installed_version(self) -> str | None: + """Return the installed version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.CURRENT_VERSION + ) + + @property + def latest_version(self) -> str | None: + """Return the available version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.AVAILABLE_VERSION + ) + + @property + def in_progress(self) -> bool: + """Return if the entity is in progress.""" + return ( + self.get_attribute_value(Capability.FIRMWARE_UPDATE, Attribute.STATE) + == "updateInProgress" + ) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the firmware update.""" + await self.execute_device_command( + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + ) + + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return if the latest version is newer.""" + if is_hex_version(latest_version): + latest_version = f"0x{latest_version}" + if is_hex_version(installed_version): + installed_version = f"0x{installed_version}" + return AwesomeVersion(latest_version) > AwesomeVersion(installed_version) diff --git a/tests/components/smartthings/fixtures/device_status/contact_sensor.json b/tests/components/smartthings/fixtures/device_status/contact_sensor.json index fa158d41b39..ca8c2628c99 100644 --- a/tests/components/smartthings/fixtures/device_status/contact_sensor.json +++ b/tests/components/smartthings/fixtures/device_status/contact_sensor.json @@ -36,7 +36,7 @@ "value": null }, "availableVersion": { - "value": "00000103", + "value": "00000104", "timestamp": "2025-02-09T13:59:19.101Z" }, "lastUpdateStatus": { diff --git a/tests/components/smartthings/snapshots/test_update.ambr b/tests/components/smartthings/snapshots/test_update.ambr new file mode 100644 index 00000000000..e74d2d8518c --- /dev/null +++ b/tests/components/smartthings/snapshots/test_update.ambr @@ -0,0 +1,421 @@ +# serializer version: 1 +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Firmware', + 'in_progress': False, + 'installed_version': '2.00.09 (20009)', + 'latest_version': '2.00.09 (20009)', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[centralite][update.dimmer_debian_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.dimmer_debian_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + '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][update.dimmer_debian_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Dimmer Debian Firmware', + 'in_progress': False, + 'installed_version': '16015010', + 'latest_version': '16015010', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.dimmer_debian_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.front_door_open_closed_sensor_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': '.Front Door Open/Closed Sensor Firmware', + 'in_progress': False, + 'installed_version': '00000103', + 'latest_version': '00000104', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.front_door_open_closed_sensor_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind Firmware', + 'in_progress': False, + 'installed_version': '22007631', + 'latest_version': '22007631', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.deck_door_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Deck Door Firmware', + 'in_progress': False, + 'installed_version': '0000001B', + 'latest_version': '0000001B', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.deck_door_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.arlo_beta_basestation_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Arlo Beta Basestation Firmware', + 'in_progress': False, + 'installed_version': '00102101', + 'latest_version': '00102101', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.arlo_beta_basestation_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.basement_door_lock_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Basement Door Lock Firmware', + 'in_progress': False, + 'installed_version': '00840847', + 'latest_version': '00840847', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.basement_door_lock_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/test_update.py b/tests/components/smartthings/test_update.py new file mode 100644 index 00000000000..8c3d9e1a968 --- /dev/null +++ b/tests/components/smartthings/test_update.py @@ -0,0 +1,142 @@ +"""Test for the SmartThings update platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings.const import MAIN +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.UPDATE) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_installing_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test installing an update.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.front_door_open_closed_sensor_firmware"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +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("update.front_door_open_closed_sensor_firmware").state + == STATE_ON + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.CURRENT_VERSION, + "00000104", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").state + == STATE_OFF + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_state_progress_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state progress update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is False + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.STATE, + "updateInProgress", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is True + ) + + +@pytest.mark.parametrize("device_fixture", ["centralite"]) +async def test_state_update_available( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update available.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_OFF + + await trigger_update( + hass, + devices, + "d0268a69-abfb-4c92-a646-61cec2e510ad", + Capability.FIRMWARE_UPDATE, + Attribute.AVAILABLE_VERSION, + "16015011", + ) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_ON From 5f6762321457a6db69f964a16dd0b0b8aaeeebfa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 18:26:17 +0100 Subject: [PATCH 1870/1941] Deprecate SmartThings events (#141073) --- homeassistant/components/smartthings/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 8b5860bc3af..c90dccfe937 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -207,6 +207,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) rooms=rooms, ) + # Events are deprecated and will be removed in 2025.10 def handle_button_press(event: DeviceEvent) -> None: """Handle a button press.""" if ( From 276e2e8f59f01a088f0b75f6f8147225c75b5c0b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 18:32:05 +0100 Subject: [PATCH 1871/1941] Move device creation in SmartThings (#141074) Move device creation --- .../components/smartthings/__init__.py | 81 ++++++++++++++----- .../components/smartthings/binary_sensor.py | 4 +- .../components/smartthings/climate.py | 14 +--- homeassistant/components/smartthings/cover.py | 6 +- .../components/smartthings/entity.py | 31 ------- homeassistant/components/smartthings/event.py | 7 +- homeassistant/components/smartthings/fan.py | 7 +- homeassistant/components/smartthings/light.py | 7 +- homeassistant/components/smartthings/lock.py | 2 +- .../components/smartthings/sensor.py | 4 +- .../components/smartthings/switch.py | 4 +- .../components/smartthings/update.py | 4 +- homeassistant/components/smartthings/valve.py | 8 +- .../smartthings/snapshots/test_init.ambr | 2 +- 14 files changed, 81 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index c90dccfe937..5cc7b3e2c36 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast from aiohttp import ClientError from pysmartthings import ( @@ -22,6 +22,12 @@ from pysmartthings import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_HW_VERSION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, + ATTR_VIA_DEVICE, CONF_ACCESS_TOKEN, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, @@ -172,25 +178,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) raise ConfigEntryAuthFailed from err device_registry = dr.async_get(hass) - for dev in device_status.values(): - for component in dev.device.components: - if component.id == MAIN and Capability.BRIDGE in component.capabilities: - assert dev.device.hub - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, dev.device.device_id)}, - connections=( - {(dr.CONNECTION_NETWORK_MAC, dev.device.hub.mac_address)} - if dev.device.hub.mac_address - else set() - ), - name=dev.device.label, - sw_version=dev.device.hub.firmware_version, - model=dev.device.hub.hardware_type, - suggested_area=( - rooms.get(dev.device.room_id) if dev.device.room_id else None - ), - ) + create_devices(device_registry, device_status, entry, rooms) + scenes = { scene.scene_id: scene for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) @@ -278,6 +267,58 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +def create_devices( + device_registry: dr.DeviceRegistry, + devices: dict[str, FullDevice], + entry: SmartThingsConfigEntry, + rooms: dict[str, str], +) -> None: + """Create devices in the device registry.""" + for device in devices.values(): + kwargs: dict[str, Any] = {} + if device.device.hub is not None: + kwargs = { + ATTR_SW_VERSION: device.device.hub.firmware_version, + ATTR_MODEL: device.device.hub.hardware_type, + } + if device.device.hub.mac_address: + kwargs[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, device.device.hub.mac_address) + } + if device.device.parent_device_id: + kwargs[ATTR_VIA_DEVICE] = (DOMAIN, device.device.parent_device_id) + if (ocf := device.device.ocf) is not None: + kwargs.update( + { + ATTR_MANUFACTURER: ocf.manufacturer_name, + ATTR_MODEL: ( + (ocf.model_number.split("|")[0]) if ocf.model_number else None + ), + ATTR_HW_VERSION: ocf.hardware_version, + ATTR_SW_VERSION: ocf.firmware_version, + } + ) + if (viper := device.device.viper) is not None: + kwargs.update( + { + ATTR_MANUFACTURER: viper.manufacturer_name, + ATTR_MODEL: viper.model_name, + ATTR_HW_VERSION: viper.hardware_version, + ATTR_SW_VERSION: viper.software_version, + } + ) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, device.device.device_id)}, + configuration_url="https://account.smartthings.com", + name=device.device.label, + suggested_area=( + rooms.get(device.device.room_id) if device.device.room_id else None + ), + **kwargs, + ) + + KEEP_CAPABILITY_QUIRK: dict[ Capability | str, Callable[[dict[Attribute | str, Status]], bool] ] = { diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index ace23ba4ec2..b67b15dfdbc 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -134,7 +134,6 @@ async def async_setup_entry( entry_data.client, device, description, - entry_data.rooms, capability, attribute, ) @@ -155,12 +154,11 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsBinarySensorEntityDescription, - rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: """Init the class.""" - super().__init__(client, device, rooms, {capability}) + super().__init__(client, device, {capability}) self._attribute = attribute self.capability = capability self.entity_description = entity_description diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index c6dee3e2be4..e20f191352f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -118,12 +118,12 @@ async def async_setup_entry( """Add climate entities for a config entry.""" entry_data = entry.runtime_data entities: list[ClimateEntity] = [ - SmartThingsAirConditioner(entry_data.client, entry_data.rooms, device) + SmartThingsAirConditioner(entry_data.client, device) for device in entry_data.devices.values() if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] entities.extend( - SmartThingsThermostat(entry_data.client, entry_data.rooms, device) + SmartThingsThermostat(entry_data.client, device) for device in entry_data.devices.values() if all( capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES @@ -137,14 +137,11 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): _attr_name = None - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, - rooms, { Capability.THERMOSTAT_FAN_MODE, Capability.THERMOSTAT_MODE, @@ -338,14 +335,11 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): _attr_name = None _attr_preset_mode = None - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, - rooms, { Capability.AIR_CONDITIONER_MODE, Capability.SWITCH, diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 84bf0412ab4..0b68409443d 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -41,9 +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, entry_data.rooms, Capability(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 @@ -60,14 +58,12 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): self, client: SmartThings, device: FullDevice, - rooms: dict[str, str], capability: Capability, ) -> None: """Initialize the cover class.""" super().__init__( client, device, - rooms, { capability, Capability.BATTERY, diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py index 660ab499d19..12c07bea983 100644 --- a/homeassistant/components/smartthings/entity.py +++ b/homeassistant/components/smartthings/entity.py @@ -30,7 +30,6 @@ class SmartThingsEntity(Entity): self, client: SmartThings, device: FullDevice, - rooms: dict[str, str], capabilities: set[Capability], *, component: str = MAIN, @@ -47,38 +46,8 @@ class SmartThingsEntity(Entity): self.device = device self._attr_unique_id = device.device.device_id self._attr_device_info = DeviceInfo( - configuration_url="https://account.smartthings.com", identifiers={(DOMAIN, device.device.device_id)}, - name=device.device.label, - suggested_area=( - rooms.get(device.device.room_id) if device.device.room_id else None - ), ) - if device.device.parent_device_id: - self._attr_device_info["via_device"] = ( - DOMAIN, - device.device.parent_device_id, - ) - if (ocf := device.device.ocf) is not None: - self._attr_device_info.update( - { - "manufacturer": ocf.manufacturer_name, - "model": ( - (ocf.model_number.split("|")[0]) if ocf.model_number else None - ), - "hw_version": ocf.hardware_version, - "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/homeassistant/components/smartthings/event.py b/homeassistant/components/smartthings/event.py index b629bd92b35..e22a32c7726 100644 --- a/homeassistant/components/smartthings/event.py +++ b/homeassistant/components/smartthings/event.py @@ -22,7 +22,7 @@ async def async_setup_entry( """Add events for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsButtonEvent(entry_data.client, entry_data.rooms, device, component) + SmartThingsButtonEvent(entry_data.client, device, component) for device in entry_data.devices.values() for component in device.device.components if Capability.BUTTON in component.capabilities @@ -38,14 +38,11 @@ class SmartThingsButtonEvent(SmartThingsEntity, EventEntity): def __init__( self, client: SmartThings, - rooms: dict[str, str], device: FullDevice, component: Component, ) -> None: """Init the class.""" - super().__init__( - client, device, rooms, {Capability.BUTTON}, component=component.id - ) + super().__init__(client, device, {Capability.BUTTON}, component=component.id) self._attr_name = component.label self._attr_unique_id = ( f"{device.device.device_id}_{component.id}_{Capability.BUTTON}" diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index ef3d9702ce2..1c4cb4edc4a 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -31,7 +31,7 @@ async def async_setup_entry( """Add fans for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsFan(entry_data.client, entry_data.rooms, device) + SmartThingsFan(entry_data.client, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] and any( @@ -51,14 +51,11 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): _attr_name = None _attr_speed_count = int_states_in_range(SPEED_RANGE) - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, - rooms, { Capability.SWITCH, Capability.FAN_SPEED, diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 12c7f7ebbcb..1ad315bcd97 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -41,7 +41,7 @@ async def async_setup_entry( """Add lights for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLight(entry_data.client, entry_data.rooms, device) + 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) @@ -71,14 +71,11 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity): # highest kelvin found supported across 20+ handlers. _attr_max_color_temp_kelvin = 9000 # 111 mireds - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Initialize a SmartThingsLight.""" super().__init__( client, device, - rooms, { Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE, diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 76a643e417e..f56ecd5d565 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -33,7 +33,7 @@ async def async_setup_entry( """Add locks for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsLock(entry_data.client, device, entry_data.rooms, {Capability.LOCK}) + SmartThingsLock(entry_data.client, device, {Capability.LOCK}) for device in entry_data.devices.values() if Capability.LOCK in device.status[MAIN] ) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 21d256968ae..ee8550e4f06 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -997,7 +997,6 @@ async def async_setup_entry( entry_data.client, device, description, - entry_data.rooms, capability, attribute, ) @@ -1030,7 +1029,6 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): client: SmartThings, device: FullDevice, entity_description: SmartThingsSensorEntityDescription, - rooms: dict[str, str], capability: Capability, attribute: Attribute, ) -> None: @@ -1038,7 +1036,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): capabilities_to_subscribe = {capability} if entity_description.use_temperature_unit: capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT) - super().__init__(client, device, rooms, capabilities_to_subscribe) + super().__init__(client, device, capabilities_to_subscribe) 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/switch.py b/homeassistant/components/smartthings/switch.py index f470a90bb39..380005f1b93 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -37,9 +37,7 @@ async def async_setup_entry( """Add switches for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsSwitch( - entry_data.client, device, entry_data.rooms, {Capability.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) diff --git a/homeassistant/components/smartthings/update.py b/homeassistant/components/smartthings/update.py index bd856bd38ba..bb226918596 100644 --- a/homeassistant/components/smartthings/update.py +++ b/homeassistant/components/smartthings/update.py @@ -28,9 +28,7 @@ async def async_setup_entry( """Add update entities for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsUpdateEntity( - entry_data.client, device, entry_data.rooms, {Capability.FIRMWARE_UPDATE} - ) + SmartThingsUpdateEntity(entry_data.client, device, {Capability.FIRMWARE_UPDATE}) for device in entry_data.devices.values() if Capability.FIRMWARE_UPDATE in device.status[MAIN] ) diff --git a/homeassistant/components/smartthings/valve.py b/homeassistant/components/smartthings/valve.py index a38eb9e65c4..3c401c087ec 100644 --- a/homeassistant/components/smartthings/valve.py +++ b/homeassistant/components/smartthings/valve.py @@ -30,7 +30,7 @@ async def async_setup_entry( """Add valves for a config entry.""" entry_data = entry.runtime_data async_add_entities( - SmartThingsValve(entry_data.client, entry_data.rooms, device) + SmartThingsValve(entry_data.client, device) for device in entry_data.devices.values() if Capability.VALVE in device.status[MAIN] ) @@ -43,11 +43,9 @@ class SmartThingsValve(SmartThingsEntity, ValveEntity): _attr_reports_position = False _attr_name = None - def __init__( - self, client: SmartThings, rooms: dict[str, str], device: FullDevice - ) -> None: + def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" - super().__init__(client, device, rooms, {Capability.VALVE}) + super().__init__(client, device, {Capability.VALVE}) self._attr_device_class = DEVICE_CLASS_MAP.get( device.device.components[0].user_category or device.device.components[0].manufacturer_category diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 930b3851806..d6e98553015 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -1624,7 +1624,7 @@ 'area_id': 'theater', 'config_entries': , 'config_entries_subentries': , - 'configuration_url': None, + 'configuration_url': 'https://account.smartthings.com', 'connections': set({ tuple( 'mac', From 1385bcdb90e7abde0edd7e745661cbecc9feceed Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 21 Mar 2025 20:19:45 +0100 Subject: [PATCH 1872/1941] Grade SmartThings on the integration quality scale (#141078) --- .../components/smartthings/manifest.json | 1 + .../components/smartthings/quality_scale.yaml | 80 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/smartthings/quality_scale.yaml diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index a456a6bef2f..d7133ce7c6d 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -29,5 +29,6 @@ "documentation": "https://www.home-assistant.io/integrations/smartthings", "iot_class": "cloud_push", "loggers": ["pysmartthings"], + "quality_scale": "bronze", "requirements": ["pysmartthings==2.7.4"] } diff --git a/homeassistant/components/smartthings/quality_scale.yaml b/homeassistant/components/smartthings/quality_scale.yaml new file mode 100644 index 00000000000..8a902094687 --- /dev/null +++ b/homeassistant/components/smartthings/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration works via push. + 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 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: + status: exempt + comment: No options to configure + docs-installation-parameters: + status: exempt + comment: No parameters needed during installation + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: | + This integration connects via the cloud. + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: | + This integration does not have any entities that are disabled by default. + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + This integration doesn't have any cases where raising an issue is needed. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 3fedebe89f4..d74011801d5 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -920,7 +920,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "sma", "smappee", "smart_meter_texas", - "smartthings", "smarttub", "smarty", "smhi", @@ -1996,7 +1995,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "sma", "smappee", "smart_meter_texas", - "smartthings", "smarttub", "smarty", "smhi", From 84c6fa256cb35e2afecee5b2d43034962f91283a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Mar 2025 21:44:33 +0100 Subject: [PATCH 1873/1941] Bump home-assistant/builder from 2025.02.0 to 2025.03.0 (#141039) Bumps [home-assistant/builder](https://github.com/home-assistant/builder) from 2025.02.0 to 2025.03.0. - [Release notes](https://github.com/home-assistant/builder/releases) - [Commits](https://github.com/home-assistant/builder/compare/2025.02.0...2025.03.0) --- updated-dependencies: - dependency-name: home-assistant/builder dependency-type: direct:production ... Signed-off-by: dependabot[bot] 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 03c38c60a10..fcf707fef3d 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@2025.02.0 + uses: home-assistant/builder@2025.03.0 with: args: | $BUILD_ARGS \ @@ -263,7 +263,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2025.02.0 + uses: home-assistant/builder@2025.03.0 with: args: | $BUILD_ARGS \ From 2571725eb93be9c58203ccccc7cbbf4e0476bcea Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Fri, 21 Mar 2025 14:41:43 -0700 Subject: [PATCH 1874/1941] Add metered PDU dynamic outlet sensors to NUT (#140179) * Add metered PDU dynamic outlet sensors * Make deep copy and improve efficiency of loops * Improve performance by creating new dict Co-authored-by: J. Nick Koston * Remove unused import copy * Use outlet name (if available) in friendly name and remove as separate sensor --------- Co-authored-by: J. Nick Koston --- homeassistant/components/nut/icons.json | 15 +++++ homeassistant/components/nut/sensor.py | 72 +++++++++++++++++++--- homeassistant/components/nut/strings.json | 7 +++ tests/components/nut/test_sensor.py | 73 ++++++++++++++++++++--- tests/components/nut/util.py | 5 +- 5 files changed, 153 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index 261d28d712f..bfd9407bb6c 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -67,6 +67,21 @@ "input_voltage_status": { "default": "mdi:information-outline" }, + "outlet_number_current": { + "default": "mdi:gauge" + }, + "outlet_number_current_status": { + "default": "mdi:information-outline" + }, + "outlet_number_desc": { + "default": "mdi:information-outline" + }, + "outlet_number_power": { + "default": "mdi:gauge" + }, + "outlet_number_realpower": { + "default": "mdi:gauge" + }, "outlet_voltage": { "default": "mdi:gauge" }, diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 1484f11dac7..ceea426c06d 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1029,6 +1029,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NUT sensors.""" + valid_sensor_types: dict[str, SensorEntityDescription] pynut_data = config_entry.runtime_data coordinator = pynut_data.coordinator @@ -1036,20 +1037,75 @@ async def async_setup_entry( unique_id = pynut_data.unique_id status = coordinator.data - resources = [sensor_id for sensor_id in SENSOR_TYPES if sensor_id in status] - # Display status is a special case that falls back to the status value - # of the UPS instead. - if KEY_STATUS in resources: - resources.append(KEY_STATUS_DISPLAY) + # Dynamically add outlet sensors to valid sensors dictionary + if (num_outlets := status.get("outlet.count")) is not None: + additional_sensor_types: dict[str, SensorEntityDescription] = {} + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str: str = str(outlet_num) + outlet_name: str = ( + status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str + ) + additional_sensor_types |= { + f"outlet.{outlet_num_str}.current": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.current", + translation_key="outlet_number_current", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + f"outlet.{outlet_num_str}.current_status": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.current_status", + translation_key="outlet_number_current_status", + translation_placeholders={"outlet_name": outlet_name}, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + f"outlet.{outlet_num_str}.desc": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.desc", + translation_key="outlet_number_desc", + translation_placeholders={"outlet_name": outlet_name}, + ), + f"outlet.{outlet_num_str}.power": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.power", + translation_key="outlet_number_power", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + f"outlet.{outlet_num_str}.realpower": SensorEntityDescription( + key=f"outlet.{outlet_num_str}.realpower", + translation_key="outlet_number_realpower", + translation_placeholders={"outlet_name": outlet_name}, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + } + + valid_sensor_types = {**SENSOR_TYPES, **additional_sensor_types} + else: + valid_sensor_types = SENSOR_TYPES # If device reports ambient sensors are not present, then remove - if status.get(AMBIENT_PRESENT) == "no": - resources = [item for item in resources if item not in AMBIENT_SENSORS] + has_ambient_sensors: bool = status.get(AMBIENT_PRESENT) != "no" + resources = [ + sensor_id + for sensor_id in valid_sensor_types + if sensor_id in status + and (has_ambient_sensors or sensor_id not in AMBIENT_SENSORS) + ] + + # Display status is a special case that falls back to the status value + # of the UPS instead. + if KEY_STATUS in status: + resources.append(KEY_STATUS_DISPLAY) async_add_entities( NUTSensor( coordinator, - SENSOR_TYPES[sensor_type], + valid_sensor_types[sensor_type], data, unique_id, ) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 08971732bc6..76d6f6df0b7 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -157,6 +157,13 @@ "input_l1_n_voltage": { "name": "Input L1 voltage" }, "input_l2_n_voltage": { "name": "Input L2 voltage" }, "input_l3_n_voltage": { "name": "Input L3 voltage" }, + "outlet_number_current": { "name": "Outlet {outlet_name} current" }, + "outlet_number_current_status": { + "name": "Outlet {outlet_name} current status" + }, + "outlet_number_desc": { "name": "Outlet {outlet_name} description" }, + "outlet_number_power": { "name": "Outlet {outlet_name} power" }, + "outlet_number_realpower": { "name": "Outlet {outlet_name} real power" }, "outlet_voltage": { "name": "Outlet voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py index 6483d581070..cdec6c5083b 100644 --- a/tests/components/nut/test_sensor.py +++ b/tests/components/nut/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_RESOURCES, PERCENTAGE, STATE_UNKNOWN, + UnitOfElectricCurrent, UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant @@ -103,7 +104,7 @@ async def test_ups_devices_with_unique_ids( [ ( "EATON-EPDU-G3", - "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", ), ], ) @@ -115,11 +116,13 @@ async def test_pdu_devices_with_unique_ids( ) -> None: """Test creation of device sensors with unique ids.""" - await _test_sensor_and_attributes( + await async_init_integration(hass, model) + + _test_sensor_and_attributes( hass, entity_registry, model, - unique_id=f"{unique_id_base}input.voltage", + unique_id=f"{unique_id_base}_input.voltage", device_id="sensor.ups1_input_voltage", state_value="122.91", expected_attributes={ @@ -130,11 +133,11 @@ async def test_pdu_devices_with_unique_ids( }, ) - await _test_sensor_and_attributes( + _test_sensor_and_attributes( hass, entity_registry, model, - unique_id=f"{unique_id_base}ambient.humidity.status", + unique_id=f"{unique_id_base}_ambient.humidity.status", device_id="sensor.ups1_ambient_humidity_status", state_value="good", expected_attributes={ @@ -143,11 +146,11 @@ async def test_pdu_devices_with_unique_ids( }, ) - await _test_sensor_and_attributes( + _test_sensor_and_attributes( hass, entity_registry, model, - unique_id=f"{unique_id_base}ambient.temperature.status", + unique_id=f"{unique_id_base}_ambient.temperature.status", device_id="sensor.ups1_ambient_temperature_status", state_value="good", expected_attributes={ @@ -248,7 +251,7 @@ async def test_stale_options( [ ( "EATON-EPDU-G3-AMBIENT-NOT-PRESENT", - "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_", + "EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000", ), ], ) @@ -273,3 +276,57 @@ async def test_pdu_devices_ambient_not_present( entry = entity_registry.async_get("sensor.ups1_ambient_temperature_status") assert not entry + + +@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_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Test for dynamically created outlet sensors.""" + + await async_init_integration(hass, model) + + _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}_outlet.1.current", + device_id="sensor.ups1_outlet_a1_current", + state_value="0", + expected_attributes={ + "device_class": SensorDeviceClass.CURRENT, + "friendly_name": "Ups1 Outlet A1 current", + "unit_of_measurement": UnitOfElectricCurrent.AMPERE, + }, + ) + + _test_sensor_and_attributes( + hass, + entity_registry, + model, + unique_id=f"{unique_id_base}_outlet.24.current", + device_id="sensor.ups1_outlet_a24_current", + state_value="0.19", + expected_attributes={ + "device_class": SensorDeviceClass.CURRENT, + "friendly_name": "Ups1 Outlet A24 current", + "unit_of_measurement": UnitOfElectricCurrent.AMPERE, + }, + ) + + entry = entity_registry.async_get("sensor.ups1_outlet_25_current") + assert not entry + + entry = entity_registry.async_get("sensor.ups1_outlet_a25_current") + assert not entry diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py index bd82ffdd6b4..07c073f0286 100644 --- a/tests/components/nut/util.py +++ b/tests/components/nut/util.py @@ -82,7 +82,7 @@ async def async_init_integration( return entry -async def _test_sensor_and_attributes( +def _test_sensor_and_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str, @@ -91,9 +91,8 @@ async def _test_sensor_and_attributes( state_value: str, expected_attributes: dict, ) -> None: - """Test creation of device sensors with unique ids.""" + """Test all of the sensor entry attributes.""" - await async_init_integration(hass, model) entry = entity_registry.async_get(device_id) assert entry assert entry.unique_id == unique_id From 6027a26761986983af7486d7f3957f2f8e3ae2e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Mar 2025 11:50:18 -1000 Subject: [PATCH 1875/1941] Add SSLContext.set_default_verify_paths to asyncio blocking detection (#140648) This one loads a significant number of files from /etc/ssl --- homeassistant/block_async_io.py | 9 +++++++++ tests/test_block_async_io.py | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index d224b0b151d..eb81268434b 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -178,6 +178,15 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = ( strict_core=False, skip_for_tests=True, ), + BlockingCall( + original_func=SSLContext.set_default_verify_paths, + object=SSLContext, + function="set_default_verify_paths", + check_allowed=None, + strict=False, + strict_core=False, + skip_for_tests=True, + ), BlockingCall( original_func=Path.open, object=Path, diff --git a/tests/test_block_async_io.py b/tests/test_block_async_io.py index f42fbb9f4ef..337e5500718 100644 --- a/tests/test_block_async_io.py +++ b/tests/test_block_async_io.py @@ -459,3 +459,14 @@ async def test_open_calls_ignored_in_tests(caplog: pytest.LogCaptureFixture) -> pass assert "Detected blocking call to open with args" not in caplog.text + + +async def test_protect_loop_set_default_verify_paths( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test SSLContext.set_default_verify_paths calls in the loop are logged.""" + with patch.object(block_async_io, "_IN_TESTS", False): + block_async_io.enable() + context = ssl.create_default_context() + context.set_default_verify_paths() + assert "Detected blocking call to set_default_verify_paths" in caplog.text From 34318ab655b02a8a34ae5d5037fa9f9d85085d64 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 21 Mar 2025 17:19:05 -0500 Subject: [PATCH 1876/1941] Bump pyheos to 1.0.4 (#141091) --- homeassistant/components/heos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 19feffd8ef1..cbac9f20574 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "platinum", - "requirements": ["pyheos==1.0.3"], + "requirements": ["pyheos==1.0.4"], "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/requirements_all.txt b/requirements_all.txt index e45155eb492..ab25d9571a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2000,7 +2000,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.3 +pyheos==1.0.4 # homeassistant.components.hive pyhive-integration==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac047685724..f5b42042d81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1626,7 +1626,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.3 +pyheos==1.0.4 # homeassistant.components.hive pyhive-integration==1.0.2 From ffd5c003cb9d2bfd279987435fda871adae6c027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Mar 2025 10:48:35 +0100 Subject: [PATCH 1877/1941] Remove Home Connect service error string constants (#141102) --- .../components/home_connect/__init__.py | 19 ++++-------- .../components/home_connect/const.py | 7 ----- .../components/home_connect/light.py | 18 +++++------ .../components/home_connect/number.py | 17 +++------- .../components/home_connect/select.py | 15 +++------ .../components/home_connect/switch.py | 31 ++++++------------- homeassistant/components/home_connect/time.py | 16 +++------- 7 files changed, 38 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 70b357518da..83de76431f9 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -60,9 +60,6 @@ from .const import ( SERVICE_SET_PROGRAM_AND_OPTIONS, SERVICE_SETTING, SERVICE_START_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_PROGRAM, - SVE_TRANSLATION_PLACEHOLDER_VALUE, TRANSLATION_KEYS_PROGRAMS_MAP, ) from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator @@ -336,7 +333,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: translation_key="start_program" if start else "select_program", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program, + "program": program, }, ) from err @@ -410,8 +407,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: 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), + "key": option_key, + "value": str(value), }, ) from err @@ -466,8 +463,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: translation_key="set_setting", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_KEY: key, - SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value), + "key": key, + "value": str(value), }, ) from err @@ -545,11 +542,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: translation_key=exception_translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - **( - {SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program} - if program - else {} - ), + **({"program": program} if program else {}), }, ) from err diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 6255a513e39..64bf4af29a4 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -79,13 +79,6 @@ ATTR_VALUE = "value" AFFECTS_TO_ACTIVE_PROGRAM = "active_program" AFFECTS_TO_SELECTED_PROGRAM = "selected_program" -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" - TRANSLATION_KEYS_PROGRAMS_MAP = { bsh_key_to_translation_key(program.value): cast(ProgramKey, program) diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 72c6b9aaa2b..707620f099a 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -21,11 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util from .common import setup_home_connect_entry -from .const import ( - BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, -) +from .const import BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, @@ -164,7 +160,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="turn_on_light", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err if self._color_key and self._custom_color_key: @@ -183,7 +179,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="select_light_custom_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err @@ -201,7 +197,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err return @@ -231,7 +227,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_color", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err return @@ -254,7 +250,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="set_light_brightness", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err @@ -272,7 +268,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): translation_key="turn_off_light", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, + "entity_id": self.entity_id, }, ) from err diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py index db0258f2739..99fe6c17296 100644 --- a/homeassistant/components/home_connect/number.py +++ b/homeassistant/components/home_connect/number.py @@ -16,14 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry -from .const import ( - DOMAIN, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, - UNIT_MAP, -) +from .const import DOMAIN, UNIT_MAP from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher from .utils import get_dict_from_home_connect_error @@ -180,12 +173,12 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", 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: str(value), + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": str(value), }, ) from err diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 001c2e9ec31..c82e0686cb5 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -31,11 +31,6 @@ from .const import ( INTENSIVE_LEVEL_OPTIONS, PROGRAMS_TRANSLATION_KEYS_MAP, 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, @@ -406,7 +401,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): translation_key=self.entity_description.error_translation_key, translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, + "program": program_key.value, }, ) from err @@ -443,12 +438,12 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", 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, + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": value, }, ) from err diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 6f9aa0e679f..33e30f184b7 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -22,16 +22,7 @@ from homeassistant.helpers.issue_registry import ( from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .common import setup_home_connect_entry -from .const import ( - BSH_POWER_OFF, - BSH_POWER_ON, - BSH_POWER_STANDBY, - DOMAIN, - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, -) +from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, @@ -226,8 +217,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_key="turn_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + "entity_id": self.entity_id, + "key": self.bsh_key, }, ) from err @@ -246,8 +237,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): translation_key="turn_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id, - SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key, + "entity_id": self.entity_id, + "key": self.bsh_key, }, ) from err @@ -385,7 +376,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_on", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, + "appliance_name": self.appliance.info.name, }, ) from err @@ -398,7 +389,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_domain=DOMAIN, translation_key="unable_to_retrieve_turn_off", translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name + "appliance_name": self.appliance.info.name }, ) @@ -406,9 +397,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="turn_off_not_supported", - translation_placeholders={ - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name - }, + translation_placeholders={"appliance_name": self.appliance.info.name}, ) try: await self.coordinator.client.set_setting( @@ -423,8 +412,8 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): translation_key="power_off", translation_placeholders={ **get_dict_from_home_connect_error(err), - SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name, - SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state, + "appliance_name": self.appliance.info.name, + "value": self.power_off_state, }, ) from err diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py index a1761219d30..7cfa0a7d3e4 100644 --- a/homeassistant/components/home_connect/time.py +++ b/homeassistant/components/home_connect/time.py @@ -12,13 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry -from .const import ( - DOMAIN, - SVE_TRANSLATION_KEY_SET_SETTING, - SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID, - SVE_TRANSLATION_PLACEHOLDER_KEY, - SVE_TRANSLATION_PLACEHOLDER_VALUE, -) +from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity from .utils import get_dict_from_home_connect_error @@ -84,12 +78,12 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity): except HomeConnectError as err: raise HomeAssistantError( translation_domain=DOMAIN, - translation_key=SVE_TRANSLATION_KEY_SET_SETTING, + translation_key="set_setting_entity", 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: str(value), + "entity_id": self.entity_id, + "key": self.bsh_key, + "value": str(value), }, ) from err From c08cbf3763fe806b20af81f8374d8f4da647c10f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 22 Mar 2025 10:57:59 +0100 Subject: [PATCH 1878/1941] Use ShellyConfigEntry type in Shelly config flow (#141103) Use ShellyConfigEntry type in async_get_options_flow --- homeassistant/components/shelly/config_flow.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 8e47235c981..c7c1cd70a53 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -22,12 +22,7 @@ from aioshelly.exceptions import ( from aioshelly.rpc_device import RpcDevice import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -49,7 +44,7 @@ from .const import ( LOGGER, BLEScannerMode, ) -from .coordinator import async_reconnect_soon +from .coordinator import ShellyConfigEntry, async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, @@ -458,13 +453,13 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow(config_entry: ShellyConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @classmethod @callback - def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: + def async_supports_options_flow(cls, config_entry: ShellyConfigEntry) -> bool: """Return options flow support for this handler.""" return ( get_device_entry_gen(config_entry) in RPC_GENERATIONS From 9d9b352631b9ed30a179af72c3f272acff4f216a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Mar 2025 12:35:46 +0100 Subject: [PATCH 1879/1941] Move Home Connect service actions to a services.py (#141100) * Move Home Connect service actions to a actions.py * Rename actions.py to services.py * Move more fuctions to module level --- .../components/home_connect/__init__.py | 576 +----------------- .../components/home_connect/services.py | 572 +++++++++++++++++ .../{test_init.ambr => test_services.ambr} | 0 tests/components/home_connect/test_init.py | 456 +------------- .../components/home_connect/test_services.py | 468 ++++++++++++++ 5 files changed, 1052 insertions(+), 1020 deletions(-) create mode 100644 homeassistant/components/home_connect/services.py rename tests/components/home_connect/snapshots/{test_init.ambr => test_services.ambr} (100%) create mode 100644 tests/components/home_connect/test_services.py diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 83de76431f9..fe01a3e9564 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,192 +2,29 @@ from __future__ import annotations -from collections.abc import Awaitable import logging -from typing import Any, cast +from typing import Any from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import ( - ArrayOfOptions, - CommandKey, - Option, - OptionKey, - ProgramKey, - SettingKey, -) -from aiohomeconnect.model.error import HomeConnectError import aiohttp -import voluptuous as vol -from homeassistant.const import ATTR_DEVICE_ID, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, - ServiceValidationError, -) -from homeassistant.helpers import ( - config_entry_oauth2_flow, - config_validation as cv, - device_registry as dr, -) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth -from .const import ( - AFFECTS_TO_ACTIVE_PROGRAM, - AFFECTS_TO_SELECTED_PROGRAM, - ATTR_AFFECTS_TO, - ATTR_KEY, - ATTR_PROGRAM, - ATTR_UNIT, - ATTR_VALUE, - DOMAIN, - OLD_NEW_UNIQUE_ID_SUFFIX_MAP, - PROGRAM_ENUM_OPTIONS, - SERVICE_OPTION_ACTIVE, - SERVICE_OPTION_SELECTED, - SERVICE_PAUSE_PROGRAM, - SERVICE_RESUME_PROGRAM, - SERVICE_SELECT_PROGRAM, - SERVICE_SET_PROGRAM_AND_OPTIONS, - SERVICE_SETTING, - SERVICE_START_PROGRAM, - TRANSLATION_KEYS_PROGRAMS_MAP, -) +from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error +from .services import register_actions _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - -PROGRAM_OPTIONS = { - bsh_key_to_translation_key(key): ( - key, - value, - ) - for key, value in { - OptionKey.BSH_COMMON_DURATION: int, - OptionKey.BSH_COMMON_START_IN_RELATIVE: int, - OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, - OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, - OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, - OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, - OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, - OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, - OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, - OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, - OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, - OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, - OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, - }.items() -} - - -SERVICE_SETTING_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(SettingKey), - vol.NotIn([SettingKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_OPTION_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(str, int, bool), - vol.Optional(ATTR_UNIT): str, - } -) - -# DEPRECATED: Remove in 2025.9.0 -SERVICE_PROGRAM_SCHEMA = vol.Any( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - vol.Required(ATTR_KEY): vol.All( - vol.Coerce(OptionKey), - vol.NotIn([OptionKey.UNKNOWN]), - ), - vol.Required(ATTR_VALUE): vol.Any(int, str), - vol.Optional(ATTR_UNIT): str, - }, - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_PROGRAM): vol.All( - vol.Coerce(ProgramKey), - vol.NotIn([ProgramKey.UNKNOWN]), - ), - }, -) - - -def _require_program_or_at_least_one_option(data: dict) -> dict: - if ATTR_PROGRAM not in data and not any( - option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="required_program_or_one_option_at_least", - ) - return data - - -SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): str, - vol.Required(ATTR_AFFECTS_TO): vol.In( - [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] - ), - vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), - } - ) - .extend( - { - vol.Optional(translation_key): vol.In(allowed_values.keys()) - for translation_key, ( - key, - allowed_values, - ) in PROGRAM_ENUM_OPTIONS.items() - } - ) - .extend( - { - vol.Optional(translation_key): schema - for translation_key, (key, schema) in PROGRAM_OPTIONS.items() - } - ), - _require_program_or_at_least_one_option, -) - -SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) - PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, @@ -200,402 +37,9 @@ PLATFORMS = [ ] -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( - 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) - assert _entry - if _entry.domain == DOMAIN: - entry = cast(HomeConnectConfigEntry, _entry) - break - if entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="config_entry_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - - ha_id = next( - ( - identifier[1] - for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - ), - None, - ) - if ha_id is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="appliance_not_found", - translation_placeholders={ - "device_id": device_id, - }, - ) - return entry.runtime_data.client, ha_id - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - - async def _async_service_program(call: ServiceCall, start: bool) -> None: - """Execute calls to services taking a program.""" - program = call.data[ATTR_PROGRAM] - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - option_key = call.data.get(ATTR_KEY) - options = ( - [ - Option( - option_key, - call.data[ATTR_VALUE], - unit=call.data.get(ATTR_UNIT), - ) - ] - if option_key is not None - else None - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_PROGRAM}: {program}", - *([f" {ATTR_KEY}: {options[0].key}"] if options else []), - *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), - *( - [f" {ATTR_UNIT}: {options[0].unit}"] - if options and options[0].unit - else [] - ), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", - f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", - *( - [ - f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" - ] - if options - else [] - ), - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - - 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), - "program": program, - }, - ) from err - - async def _async_service_set_program_options( - call: ServiceCall, active: bool - ) -> None: - """Execute calls to services taking a program.""" - option_key = call.data[ATTR_KEY] - value = call.data[ATTR_VALUE] - unit = call.data.get(ATTR_UNIT) - client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) - - async_create_issue( - hass, - DOMAIN, - "deprecated_set_program_and_option_actions", - breaks_in_ha_version="2025.9.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_set_program_and_option_actions", - translation_placeholders={ - "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, - "remove_release": "2025.9.0", - "deprecated_action_yaml": "\n".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_KEY}: {option_key}", - f" {ATTR_VALUE}: {value}", - *([f" {ATTR_UNIT}: {unit}"] if unit else []), - "```", - ] - ), - "new_action_yaml": "\n ".join( - [ - "```yaml", - f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", - "data:", - f" {ATTR_DEVICE_ID}: DEVICE_ID", - f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", - f" {bsh_key_to_translation_key(option_key)}: {value}", - "```", - ] - ), - "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", - }, - ) - try: - if active: - await client.set_active_program_option( - ha_id, - option_key=option_key, - value=value, - unit=unit, - ) - else: - await client.set_selected_program_option( - ha_id, - option_key=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), - "key": option_key, - "value": str(value), - }, - ) from err - - async def _async_service_command( - call: ServiceCall, command_key: CommandKey - ) -> None: - """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: - 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) -> None: - """Service for setting an option for an active program.""" - await _async_service_set_program_options(call, True) - - async def async_service_option_selected(call: ServiceCall) -> None: - """Service for setting an option for a selected program.""" - await _async_service_set_program_options(call, False) - - async def async_service_setting(call: ServiceCall) -> None: - """Service for changing a 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]) - - 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), - "key": key, - "value": str(value), - }, - ) from err - - async def async_service_pause_program(call: ServiceCall) -> None: - """Service for pausing a program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) - - async def async_service_resume_program(call: ServiceCall) -> None: - """Service for resuming a paused program.""" - await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) - - async def async_service_select_program(call: ServiceCall) -> None: - """Service for selecting a program.""" - await _async_service_program(call, False) - - async def async_service_set_program_and_options(call: ServiceCall) -> None: - """Service for setting a program and options.""" - data = dict(call.data) - program = data.pop(ATTR_PROGRAM, None) - affects_to = data.pop(ATTR_AFFECTS_TO) - client, ha_id = await _get_client_and_ha_id(hass, data.pop(ATTR_DEVICE_ID)) - - options: list[Option] = [] - - for option, value in data.items(): - if option in PROGRAM_ENUM_OPTIONS: - options.append( - Option( - PROGRAM_ENUM_OPTIONS[option][0], - PROGRAM_ENUM_OPTIONS[option][1][value], - ) - ) - elif option in PROGRAM_OPTIONS: - option_key = PROGRAM_OPTIONS[option][0] - options.append(Option(option_key, value)) - - method_call: Awaitable[Any] - exception_translation_key: str - if program: - program = ( - program - if isinstance(program, ProgramKey) - else TRANSLATION_KEYS_PROGRAMS_MAP[program] - ) - - if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: - method_call = client.start_program( - ha_id, program_key=program, options=options - ) - exception_translation_key = "start_program" - elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: - method_call = client.set_selected_program( - ha_id, program_key=program, options=options - ) - exception_translation_key = "select_program" - else: - array_of_options = ArrayOfOptions(options) - if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: - method_call = client.set_active_program_options( - ha_id, array_of_options=array_of_options - ) - exception_translation_key = "set_options_active_program" - else: - # affects_to is AFFECTS_TO_SELECTED_PROGRAM - method_call = client.set_selected_program_options( - ha_id, array_of_options=array_of_options - ) - exception_translation_key = "set_options_selected_program" - - try: - await method_call - except HomeConnectError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=exception_translation_key, - translation_placeholders={ - **get_dict_from_home_connect_error(err), - **({"program": program} if program else {}), - }, - ) from err - - async def async_service_start_program(call: ServiceCall) -> None: - """Service for starting a program.""" - await _async_service_program(call, True) - - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_ACTIVE, - async_service_option_active, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_OPTION_SELECTED, - async_service_option_selected, - schema=SERVICE_OPTION_SCHEMA, - ) - hass.services.async_register( - DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA - ) - hass.services.async_register( - DOMAIN, - SERVICE_PAUSE_PROGRAM, - async_service_pause_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_RESUME_PROGRAM, - async_service_resume_program, - schema=SERVICE_COMMAND_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SELECT_PROGRAM, - async_service_select_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_START_PROGRAM, - async_service_start_program, - schema=SERVICE_PROGRAM_SCHEMA, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SET_PROGRAM_AND_OPTIONS, - async_service_set_program_and_options, - schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, - ) - + register_actions(hass) return True diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py new file mode 100644 index 00000000000..fac1c5fe1a9 --- /dev/null +++ b/homeassistant/components/home_connect/services.py @@ -0,0 +1,572 @@ +"""Custom actions (previously known as services) for the Home Connect integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable +from typing import Any, cast + +from aiohomeconnect.client import Client as HomeConnectClient +from aiohomeconnect.model import ( + ArrayOfOptions, + CommandKey, + Option, + OptionKey, + ProgramKey, + SettingKey, +) +from aiohomeconnect.model.error import HomeConnectError +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + AFFECTS_TO_ACTIVE_PROGRAM, + AFFECTS_TO_SELECTED_PROGRAM, + ATTR_AFFECTS_TO, + ATTR_KEY, + ATTR_PROGRAM, + ATTR_UNIT, + ATTR_VALUE, + DOMAIN, + PROGRAM_ENUM_OPTIONS, + SERVICE_OPTION_ACTIVE, + SERVICE_OPTION_SELECTED, + SERVICE_PAUSE_PROGRAM, + SERVICE_RESUME_PROGRAM, + SERVICE_SELECT_PROGRAM, + SERVICE_SET_PROGRAM_AND_OPTIONS, + SERVICE_SETTING, + SERVICE_START_PROGRAM, + TRANSLATION_KEYS_PROGRAMS_MAP, +) +from .coordinator import HomeConnectConfigEntry +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.BSH_COMMON_DURATION: int, + OptionKey.BSH_COMMON_START_IN_RELATIVE: int, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, + OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, + OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, + OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, + OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + }.items() +} + + +SERVICE_SETTING_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(SettingKey), + vol.NotIn([SettingKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_OPTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + vol.Optional(ATTR_UNIT): str, + } +) + +# DEPRECATED: Remove in 2025.9.0 +SERVICE_PROGRAM_SCHEMA = vol.Any( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + vol.Required(ATTR_KEY): vol.All( + vol.Coerce(OptionKey), + vol.NotIn([OptionKey.UNKNOWN]), + ), + vol.Required(ATTR_VALUE): vol.Any(int, str), + vol.Optional(ATTR_UNIT): str, + }, + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): vol.All( + vol.Coerce(ProgramKey), + vol.NotIn([ProgramKey.UNKNOWN]), + ), + }, +) + + +def _require_program_or_at_least_one_option(data: dict) -> dict: + if ATTR_PROGRAM not in data and not any( + option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="required_program_or_one_option_at_least", + ) + return data + + +SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_AFFECTS_TO): vol.In( + [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] + ), + vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), + } + ) + .extend( + { + vol.Optional(translation_key): vol.In(allowed_values.keys()) + for translation_key, ( + key, + allowed_values, + ) in PROGRAM_ENUM_OPTIONS.items() + } + ) + .extend( + { + vol.Optional(translation_key): schema + for translation_key, (key, schema) in PROGRAM_OPTIONS.items() + } + ), + _require_program_or_at_least_one_option, +) + +SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) + + +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( + 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) + assert _entry + if _entry.domain == DOMAIN: + entry = cast(HomeConnectConfigEntry, _entry) + break + if entry is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + + ha_id = next( + ( + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ), + None, + ) + if ha_id is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="appliance_not_found", + translation_placeholders={ + "device_id": device_id, + }, + ) + return entry.runtime_data.client, ha_id + + +async def _async_service_program(call: ServiceCall, start: bool) -> None: + """Execute calls to services taking a program.""" + program = call.data[ATTR_PROGRAM] + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + option_key = call.data.get(ATTR_KEY) + options = ( + [ + Option( + option_key, + call.data[ATTR_VALUE], + unit=call.data.get(ATTR_UNIT), + ) + ] + if option_key is not None + else None + ) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_PROGRAM}: {program}", + *([f" {ATTR_KEY}: {options[0].key}"] if options else []), + *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), + *( + [f" {ATTR_UNIT}: {options[0].unit}"] + if options and options[0].unit + else [] + ), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", + f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", + *( + [ + f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" + ] + if options + else [] + ), + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + + 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), + "program": program, + }, + ) from err + + +async def _async_service_set_program_options(call: ServiceCall, active: bool) -> None: + """Execute calls to services taking a program.""" + option_key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + unit = call.data.get(ATTR_UNIT) + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_KEY}: {option_key}", + f" {ATTR_VALUE}: {value}", + *([f" {ATTR_UNIT}: {unit}"] if unit else []), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", + f" {bsh_key_to_translation_key(option_key)}: {value}", + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + try: + if active: + await client.set_active_program_option( + ha_id, + option_key=option_key, + value=value, + unit=unit, + ) + else: + await client.set_selected_program_option( + ha_id, + option_key=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), + "key": option_key, + "value": str(value), + }, + ) from err + + +async def _async_service_command(call: ServiceCall, command_key: CommandKey) -> None: + """Execute calls to services executing a command.""" + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + async_create_issue( + call.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: + 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) -> None: + """Service for setting an option for an active program.""" + await _async_service_set_program_options(call, True) + + +async def async_service_option_selected(call: ServiceCall) -> None: + """Service for setting an option for a selected program.""" + await _async_service_set_program_options(call, False) + + +async def async_service_setting(call: ServiceCall) -> None: + """Service for changing a setting.""" + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID]) + + 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), + "key": key, + "value": str(value), + }, + ) from err + + +async def async_service_pause_program(call: ServiceCall) -> None: + """Service for pausing a program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM) + + +async def async_service_resume_program(call: ServiceCall) -> None: + """Service for resuming a paused program.""" + await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM) + + +async def async_service_select_program(call: ServiceCall) -> None: + """Service for selecting a program.""" + await _async_service_program(call, False) + + +async def async_service_set_program_and_options(call: ServiceCall) -> None: + """Service for setting a program and options.""" + data = dict(call.data) + program = data.pop(ATTR_PROGRAM, None) + affects_to = data.pop(ATTR_AFFECTS_TO) + client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID)) + + options: list[Option] = [] + + for option, value in data.items(): + if option in PROGRAM_ENUM_OPTIONS: + options.append( + Option( + PROGRAM_ENUM_OPTIONS[option][0], + PROGRAM_ENUM_OPTIONS[option][1][value], + ) + ) + elif option in PROGRAM_OPTIONS: + option_key = PROGRAM_OPTIONS[option][0] + options.append(Option(option_key, value)) + + method_call: Awaitable[Any] + exception_translation_key: str + if program: + program = ( + program + if isinstance(program, ProgramKey) + else TRANSLATION_KEYS_PROGRAMS_MAP[program] + ) + + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.start_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "start_program" + elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: + method_call = client.set_selected_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "select_program" + else: + array_of_options = ArrayOfOptions(options) + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.set_active_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_active_program" + else: + # affects_to is AFFECTS_TO_SELECTED_PROGRAM + method_call = client.set_selected_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_selected_program" + + try: + await method_call + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=exception_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **({"program": program} if program else {}), + }, + ) from err + + +async def async_service_start_program(call: ServiceCall) -> None: + """Service for starting a program.""" + await _async_service_program(call, True) + + +def register_actions(hass: HomeAssistant) -> None: + """Register custom actions.""" + + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_ACTIVE, + async_service_option_active, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_SELECTED, + async_service_option_selected, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_PAUSE_PROGRAM, + async_service_pause_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + async_service_resume_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SELECT_PROGRAM, + async_service_select_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_PROGRAM, + async_service_start_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_AND_OPTIONS, + async_service_set_program_and_options, + schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, + ) diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_services.ambr similarity index 100% rename from tests/components/home_connect/snapshots/test_init.ambr rename to tests/components/home_connect/snapshots/test_services.ambr diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 291caeafd58..e0e586929a9 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -1,12 +1,11 @@ """Test the integration init functionality.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN -from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey, StatusKey +from aiohomeconnect.model import SettingKey, StatusKey from aiohomeconnect.model.error import ( HomeConnectError, TooManyRequestsError, @@ -14,7 +13,6 @@ from aiohomeconnect.model.error import ( ) import aiohttp import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.home_connect.const import DOMAIN @@ -25,9 +23,8 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( @@ -40,157 +37,6 @@ from .conftest import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator - -DEPRECATED_SERVICE_KV_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "set_option_active", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_option_selected", - "service_data": { - "device_id": "DEVICE_ID", - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, -] - -SERVICE_KV_CALL_PARAMS = [ - *DEPRECATED_SERVICE_KV_CALL_PARAMS, - { - "domain": DOMAIN, - "service": "change_setting", - "service_data": { - "device_id": "DEVICE_ID", - "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, - "value": True, - }, - "blocking": True, - }, -] - -SERVICE_COMMAND_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "pause_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "resume_program", - "service_data": { - "device_id": "DEVICE_ID", - }, - "blocking": True, - }, -] - - -SERVICE_PROGRAM_CALL_PARAMS = [ - { - "domain": DOMAIN, - "service": "select_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, - "value": "LaundryCare.Washer.EnumType.Temperature.GC40", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "start_program", - "service_data": { - "device_id": "DEVICE_ID", - "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, - "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, - "value": 43200, - "unit": "seconds", - }, - "blocking": True, - }, -] - -SERVICE_APPLIANCE_METHOD_MAPPING = { - "set_option_active": "set_active_program_option", - "set_option_selected": "set_selected_program_option", - "change_setting": "set_setting", - "pause_program": "put_command", - "resume_program": "put_command", - "select_program": "set_selected_program", - "start_program": "start_program", -} - -SERVICE_VALIDATION_ERROR_MAPPING = { - "set_option_active": r"Error.*setting.*options.*active.*program.*", - "set_option_selected": r"Error.*setting.*options.*selected.*program.*", - "change_setting": r"Error.*assigning.*value.*setting.*", - "pause_program": r"Error.*executing.*command.*", - "resume_program": r"Error.*executing.*command.*", - "select_program": r"Error.*selecting.*program.*", - "start_program": r"Error.*starting.*program.*", -} - - -SERVICES_SET_PROGRAM_AND_OPTIONS = [ - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "program": "dishcare_dishwasher_program_eco_50", - "b_s_h_common_option_start_in_relative": 1800, - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "program": "consumer_products_coffee_maker_program_beverage_coffee", - "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "active_program", - "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", - }, - "blocking": True, - }, - { - "domain": DOMAIN, - "service": "set_program_and_options", - "service_data": { - "device_id": "DEVICE_ID", - "affects_to": "selected_program", - "consumer_products_coffee_maker_option_fill_quantity": 35, - }, - "blocking": True, - }, -] async def test_entry_setup( @@ -401,197 +247,6 @@ async def test_client_rate_limit_error( asyncio_sleep_mock.assert_called_once_with(retry_after) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_key_value_services( - service_call: dict[str, Any], - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, -) -> None: - """Create and test services.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - 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_ha_id)}, - ) - - service_name = service_call["service"] - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - assert ( - getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 - ) - - -@pytest.mark.parametrize( - ("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, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test deprecated service keys.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - 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_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # 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( - ("service_call", "called_method"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - "set_selected_program", - "start_program", - "set_active_program_options", - "set_selected_program_options", - ], - strict=True, - ), -) -async def test_set_program_and_options( - service_call: dict[str, Any], - called_method: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - appliance_ha_id: str, - snapshot: SnapshotAssertion, -) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - 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_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - await hass.services.async_call(**service_call) - await hass.async_block_till_done() - method_mock: MagicMock = getattr(client, called_method) - assert method_mock.call_count == 1 - assert method_mock.call_args == snapshot - - -@pytest.mark.parametrize( - ("service_call", "error_regex"), - zip( - SERVICES_SET_PROGRAM_AND_OPTIONS, - [ - r"Error.*selecting.*program.*", - r"Error.*starting.*program.*", - r"Error.*setting.*options.*active.*program.*", - r"Error.*setting.*options.*selected.*program.*", - ], - strict=True, - ), -) -async def test_set_program_and_options_exceptions( - service_call: dict[str, Any], - error_regex: str, - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, -) -> None: - """Test recognized options.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - 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, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - with pytest.raises(HomeAssistantError, match=error_regex): - await hass.services.async_call(**service_call) - - async def test_required_program_or_at_least_an_option( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -626,113 +281,6 @@ async def test_required_program_or_at_least_an_option( ) -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception_device_id( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a HomeAssistantError when there is an API error.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - 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, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - with pytest.raises(HomeAssistantError): - await hass.services.async_call(**service_call) - - -async def test_services_appliance_not_found( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ServiceValidationError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - 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"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) - - -@pytest.mark.parametrize( - "service_call", - SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, -) -async def test_services_exception( - service_call: dict[str, Any], - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client_with_exception: MagicMock, - appliance_ha_id: str, - device_registry: dr.DeviceRegistry, -) -> None: - """Raise a ValueError when device id does not match.""" - assert config_entry.state == ConfigEntryState.NOT_LOADED - 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, appliance_ha_id)}, - ) - - service_call["service_data"]["device_id"] = device_entry.id - - service_name = service_call["service"] - with pytest.raises( - HomeAssistantError, - match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], - ): - await hass.services.async_call(**service_call) - - async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py new file mode 100644 index 00000000000..517564724a9 --- /dev/null +++ b/tests/components/home_connect/test_services.py @@ -0,0 +1,468 @@ +"""Tests for the Home Connect actions.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from typing import Any +from unittest.mock import MagicMock + +from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr +import homeassistant.helpers.issue_registry as ir + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + +DEPRECATED_SERVICE_KV_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "set_option_active", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_option_selected", + "service_data": { + "device_id": "DEVICE_ID", + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, +] + +SERVICE_KV_CALL_PARAMS = [ + *DEPRECATED_SERVICE_KV_CALL_PARAMS, + { + "domain": DOMAIN, + "service": "change_setting", + "service_data": { + "device_id": "DEVICE_ID", + "key": SettingKey.BSH_COMMON_CHILD_LOCK.value, + "value": True, + }, + "blocking": True, + }, +] + +SERVICE_COMMAND_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "pause_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "resume_program", + "service_data": { + "device_id": "DEVICE_ID", + }, + "blocking": True, + }, +] + + +SERVICE_PROGRAM_CALL_PARAMS = [ + { + "domain": DOMAIN, + "service": "select_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE.value, + "value": "LaundryCare.Washer.EnumType.Temperature.GC40", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "start_program", + "service_data": { + "device_id": "DEVICE_ID", + "program": ProgramKey.LAUNDRY_CARE_WASHER_COTTON.value, + "key": OptionKey.BSH_COMMON_FINISH_IN_RELATIVE.value, + "value": 43200, + "unit": "seconds", + }, + "blocking": True, + }, +] + +SERVICE_APPLIANCE_METHOD_MAPPING = { + "set_option_active": "set_active_program_option", + "set_option_selected": "set_selected_program_option", + "change_setting": "set_setting", + "pause_program": "put_command", + "resume_program": "put_command", + "select_program": "set_selected_program", + "start_program": "start_program", +} + +SERVICE_VALIDATION_ERROR_MAPPING = { + "set_option_active": r"Error.*setting.*options.*active.*program.*", + "set_option_selected": r"Error.*setting.*options.*selected.*program.*", + "change_setting": r"Error.*assigning.*value.*setting.*", + "pause_program": r"Error.*executing.*command.*", + "resume_program": r"Error.*executing.*command.*", + "select_program": r"Error.*selecting.*program.*", + "start_program": r"Error.*starting.*program.*", +} + + +SERVICES_SET_PROGRAM_AND_OPTIONS = [ + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "program": "dishcare_dishwasher_program_eco_50", + "b_s_h_common_option_start_in_relative": 1800, + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "program": "consumer_products_coffee_maker_program_beverage_coffee", + "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "consumer_products_coffee_maker_option_fill_quantity": 35, + }, + "blocking": True, + }, +] + + +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_key_value_services( + service_call: dict[str, Any], + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Create and test services.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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_ha_id)}, + ) + + service_name = service_call["service"] + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + assert ( + getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1 + ) + + +@pytest.mark.parametrize( + ("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, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test deprecated service keys.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # 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( + ("service_call", "called_method"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + "set_selected_program", + "start_program", + "set_active_program_options", + "set_selected_program_options", + ], + strict=True, + ), +) +async def test_set_program_and_options( + service_call: dict[str, Any], + called_method: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + snapshot: SnapshotAssertion, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + method_mock: MagicMock = getattr(client, called_method) + assert method_mock.call_count == 1 + assert method_mock.call_args == snapshot + + +@pytest.mark.parametrize( + ("service_call", "error_regex"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + r"Error.*selecting.*program.*", + r"Error.*starting.*program.*", + r"Error.*setting.*options.*active.*program.*", + r"Error.*setting.*options.*selected.*program.*", + ], + strict=True, + ), +) +async def test_set_program_and_options_exceptions( + service_call: dict[str, Any], + error_regex: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(HomeAssistantError, match=error_regex): + await hass.services.async_call(**service_call) + + +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception_device_id( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a HomeAssistantError when there is an API error.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + with pytest.raises(HomeAssistantError): + await hass.services.async_call(**service_call) + + +async def test_services_appliance_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ServiceValidationError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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"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) + + +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ValueError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + 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, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + service_name = service_call["service"] + with pytest.raises( + HomeAssistantError, + match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], + ): + await hass.services.async_call(**service_call) From dc146e393cc8e6fe9b678792a3f7bef9eb5f8a9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Mar 2025 12:42:54 +0100 Subject: [PATCH 1880/1941] Add coordinator context override to Home Connect entity constructor (#141104) * Improve Home Connect entity constructor to allow coordinator context override * Simplify context usage at entity constructor --- homeassistant/components/home_connect/button.py | 12 +++--------- homeassistant/components/home_connect/entity.py | 6 +++++- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py index 726ca8cf670..0bd31c6b7c9 100644 --- a/homeassistant/components/home_connect/button.py +++ b/homeassistant/components/home_connect/button.py @@ -1,6 +1,6 @@ """Provides button entities for Home Connect.""" -from aiohomeconnect.model import CommandKey, EventKey +from aiohomeconnect.model import CommandKey from aiohomeconnect.model.error import HomeConnectError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -94,15 +94,9 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity): 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, - ), + desc, + (appliance.info.ha_id,), ) - self.entity_description = desc - self.appliance = appliance - self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" def update_native_value(self) -> None: """Set the value of the entity.""" diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 8a0f9bd7640..facb3b14a9b 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -40,9 +40,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): coordinator: HomeConnectCoordinator, appliance: HomeConnectApplianceData, desc: EntityDescription, + context_override: Any | None = None, ) -> None: """Initialize the entity.""" - super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key))) + context = (appliance.info.ha_id, EventKey(desc.key)) + if context_override is not None: + context = context_override + super().__init__(coordinator, context) self.appliance = appliance self.entity_description = desc self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}" From b7d300b49f320e780924726102807f7ff200c26e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Mar 2025 02:06:49 -1000 Subject: [PATCH 1881/1941] Bump habluetooth to 3.37.0 (#141088) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.36.0...v3.37.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 7dfb21a6e0b..fbff513329c 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", "dbus-fast==2.39.6", - "habluetooth==3.36.0" + "habluetooth==3.37.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a797b1b5146..f03c7446614 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.39.6 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 -habluetooth==3.36.0 +habluetooth==3.37.0 hass-nabucasa==0.94.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index ab25d9571a8..8a4cd6dfd7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,7 +1112,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.3.7 # homeassistant.components.bluetooth -habluetooth==3.36.0 +habluetooth==3.37.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5b42042d81..db212b9a64e 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.36.0 +habluetooth==3.37.0 # homeassistant.components.cloud hass-nabucasa==0.94.0 From 5961a46fc0f998982c410d55df2e7b8c39cb0873 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 13:12:24 +0100 Subject: [PATCH 1882/1941] Start reauth for SmartThings if token expired (#141082) --- .../components/smartthings/__init__.py | 7 ++- tests/components/smartthings/test_init.py | 54 ++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 5cc7b3e2c36..a5e138639de 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -4,10 +4,11 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any, cast -from aiohttp import ClientError +from aiohttp import ClientResponseError from pysmartthings import ( Attribute, Capability, @@ -102,7 +103,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) try: await session.async_ensure_token_valid() - except ClientError as err: + except ClientResponseError as err: + if err.status == HTTPStatus.BAD_REQUEST: + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from err raise ConfigEntryNotReady from err client = SmartThings(session=async_get_clientsession(hass)) diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 2083bb7ea24..3eaa038027d 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -1,7 +1,8 @@ """Tests for the SmartThings component init module.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch +from aiohttp import ClientResponseError, RequestInfo from pysmartthings import ( Attribute, Capability, @@ -264,6 +265,57 @@ async def test_removing_stale_devices( assert not device_registry.async_get_device({(DOMAIN, "aaa-bbb-ccc")}) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_refreshing_expired_token( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing stale devices.""" + with patch( + "homeassistant.components.smartthings.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + request_info=RequestInfo( + url="http://example.com", + method="GET", + headers={}, + real_url="http://example.com", + ), + status=400, + history=(), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert len(hass.config_entries.flow.async_progress()) == 1 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_error_refreshing_token( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing stale devices.""" + with patch( + "homeassistant.components.smartthings.OAuth2Session.async_ensure_token_valid", + side_effect=ClientResponseError( + request_info=RequestInfo( + url="http://example.com", + method="GET", + headers={}, + real_url="http://example.com", + ), + status=500, + history=(), + ), + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_hub_via_device( hass: HomeAssistant, snapshot: SnapshotAssertion, From 1492c59abea1d6da261ba2bae1cf868ab2147706 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 13:12:37 +0100 Subject: [PATCH 1883/1941] Delete deleted devices on runtime in SmartThings (#141080) --- .../components/smartthings/__init__.py | 17 ++++++++++++++ .../components/smartthings/quality_scale.yaml | 2 +- tests/components/smartthings/test_init.py | 23 ++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index a5e138639de..b3f3e93eeb1 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -20,6 +20,7 @@ from pysmartthings import ( SmartThingsSinkError, Status, ) +from pysmartthings.models import Lifecycle from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -188,6 +189,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID]) } + def handle_deleted_device(device_id: str) -> None: + """Handle a deleted device.""" + dev_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)}, + ) + if dev_entry is not None: + device_registry.async_update_device( + dev_entry.id, remove_config_entry_id=entry.entry_id + ) + + entry.async_on_unload( + client.add_device_lifecycle_event_listener( + Lifecycle.DELETE, handle_deleted_device + ) + ) + entry.runtime_data = SmartThingsData( devices={ device_id: device diff --git a/homeassistant/components/smartthings/quality_scale.yaml b/homeassistant/components/smartthings/quality_scale.yaml index 8a902094687..be8a9039617 100644 --- a/homeassistant/components/smartthings/quality_scale.yaml +++ b/homeassistant/components/smartthings/quality_scale.yaml @@ -73,7 +73,7 @@ rules: status: exempt comment: | This integration doesn't have any cases where raising an issue is needed. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done inject-websession: done diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 3eaa038027d..c0d0b8b5840 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -10,10 +10,11 @@ from pysmartthings import ( DeviceStatus, SmartThingsSinkError, ) -from pysmartthings.models import Subscription +from pysmartthings.models import Lifecycle, Subscription import pytest from syrupy import SnapshotAssertion +from homeassistant.components.climate import HVACMode from homeassistant.components.smartthings import EVENT_BUTTON from homeassistant.components.smartthings.const import CONF_SUBSCRIPTION_ID, DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -345,3 +346,23 @@ async def test_hub_via_device( ).via_device_id == hub_device.id ) + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) +async def test_deleted_device_runtime( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test devices that are deleted in runtime.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("climate.ac_office_granit").state == HVACMode.OFF + + for call in devices.add_device_lifecycle_event_listener.call_args_list: + if call[0][0] == Lifecycle.DELETE: + call[0][1]("96a5ef74-5832-a84b-f1f7-ca799957065d") + await hass.async_block_till_done() + + assert hass.states.get("climate.ac_office_granit") is None From 4479b7b13d04df6bacc43097d5348816e5466fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 22 Mar 2025 14:31:28 +0100 Subject: [PATCH 1884/1941] Add missing Home Connect chiller doors (#141105) --- .../components/home_connect/binary_sensor.py | 18 ++++++++++++++++++ .../components/home_connect/strings.json | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 1f82aa71766..b7b7e50047e 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -106,8 +106,26 @@ BINARY_SENSORS = ( key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, device_class=BinarySensorDeviceClass.DOOR, + translation_key="common_chiller_door", + ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, translation_key="chiller_door", ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_LEFT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="left_chiller_door", + ), + HomeConnectBinarySensorEntityDescription( + key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_RIGHT, + boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, + device_class=BinarySensorDeviceClass.DOOR, + translation_key="right_chiller_door", + ), HomeConnectBinarySensorEntityDescription( key=StatusKey.REFRIGERATION_COMMON_DOOR_FLEX_COMPARTMENT, boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP, diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 1b4c79f6092..00ab29affd8 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -811,9 +811,18 @@ "bottle_cooler_door": { "name": "Bottle cooler door" }, + "common_chiller_door": { + "name": "Common chiller door" + }, "chiller_door": { "name": "Chiller door" }, + "left_chiller_door": { + "name": "Left chiller door" + }, + "right_chiller_door": { + "name": "Right chiller door" + }, "flex_compartment_door": { "name": "Flex compartment door" }, From 2453e7e6868e479764c34b64da305eabf2bcd886 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 22 Mar 2025 15:30:24 +0100 Subject: [PATCH 1885/1941] Improve descriptions of `fan_min_on_time` in `ecobee` actions (#141086) Add the explanations from the online docs to the `description` strings of both the `set_fan_min_on_time` action and its `fan_min_on_time` field. Make the `fan_min_on_time` field of the `create_vacation` action consistent by dropping "(0 to 60)" from it (the UI takes care of that). Fix sentence-casing of "Away indefinitely" state. --- homeassistant/components/ecobee/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 2b44c45edef..078643ee789 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -25,7 +25,7 @@ "state_attributes": { "preset_mode": { "state": { - "away_indefinitely": "Away Indefinitely" + "away_indefinitely": "Away indefinitely" } } } @@ -91,7 +91,7 @@ }, "fan_min_on_time": { "name": "Fan minimum on time", - "description": "Minimum number of minutes to run the fan each hour (0 to 60) during the vacation." + "description": "Minimum number of minutes to run the fan each hour during the vacation." } } }, @@ -125,7 +125,7 @@ }, "set_fan_min_on_time": { "name": "Set fan minimum on time", - "description": "Sets the minimum fan on time.", + "description": "Sets the minimum amount of time that the fan will run per hour.", "fields": { "entity_id": { "name": "Entity", @@ -133,7 +133,7 @@ }, "fan_min_on_time": { "name": "[%key:component::ecobee::services::create_vacation::fields::fan_min_on_time::name%]", - "description": "New value of fan min on time." + "description": "Minimum number of minutes to run the fan each hour." } } }, From 37a048a2cabad036c7d8e056fbdd70ae6406fa3f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 22 Mar 2025 15:53:12 +0100 Subject: [PATCH 1886/1941] Move Vodafone Station to silver quality scale (#141106) --- homeassistant/components/vodafone_station/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index e3a595d5af8..29cb3c070ab 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["aiovodafone==0.6.1"] } From b2942d61b3f521763916277cfd0f99d00bcd7ec8 Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Sat, 22 Mar 2025 09:57:30 -0500 Subject: [PATCH 1887/1941] Update pyaprilaire to 0.8.1 (#141094) * Update pyaprilaire to 0.8.1 * Update requirements --- homeassistant/components/aprilaire/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json index 577de8ae88d..b40460dd61b 100644 --- a/homeassistant/components/aprilaire/manifest.json +++ b/homeassistant/components/aprilaire/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyaprilaire"], - "requirements": ["pyaprilaire==0.7.7"] + "requirements": ["pyaprilaire==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a4cd6dfd7a..289184b8eca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1817,7 +1817,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.7 +pyaprilaire==0.8.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db212b9a64e..9ed83a90659 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1494,7 +1494,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.7.7 +pyaprilaire==0.8.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 From fc0dbcd6000fd8b697a9f803d3c16b5270bc38e8 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 22 Mar 2025 12:01:57 -0400 Subject: [PATCH 1888/1941] Refresh coordinator after map sleep for Roborock (#141093) Refresh coordinator after the map sleep --- homeassistant/components/roborock/select.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index c79bf817d09..208020dccab 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -149,8 +149,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): """Set the option.""" for map_id, map_ in self.coordinator.maps.items(): if map_.name == option: - await self.send( + await self._send_command( RoborockCommand.LOAD_MULTI_MAP, + self.api, [map_id], ) # Update the current map id manually so that nothing gets broken @@ -159,6 +160,7 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): # We need to wait after updating the map # so that other commands will be executed correctly. await asyncio.sleep(MAP_SLEEP) + await self.coordinator.async_refresh() break @property From 765691c84d570a73d56083e073d2af67ad82c575 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 17:59:15 +0100 Subject: [PATCH 1889/1941] Add power binary sensor for SmartThings (#141126) --- .../components/smartthings/binary_sensor.py | 25 ++- .../snapshots/test_binary_sensor.ambr | 192 ++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index b67b15dfdbc..22e21de399b 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings import Attribute, Capability, Category, SmartThings from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( @@ -33,6 +33,7 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): """Describe a SmartThings binary sensor entity.""" is_on_key: str + category: set[Category] | None = None CAPABILITY_TO_SENSORS: dict[ @@ -96,6 +97,14 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="detected", ) }, + Capability.SWITCH: { + Attribute.SWITCH: SmartThingsBinarySensorEntityDescription( + key=Attribute.SWITCH, + device_class=BinarySensorDeviceClass.POWER, + is_on_key="on", + category={Category.DRYER, Category.WASHER}, + ) + }, Capability.TAMPER_ALERT: { Attribute.TAMPER: SmartThingsBinarySensorEntityDescription( key=Attribute.TAMPER, @@ -122,6 +131,16 @@ CAPABILITY_TO_SENSORS: dict[ } +def get_main_component_category( + device: FullDevice, +) -> Category | str: + """Get the main component of a device.""" + main = next( + component for component in device.device.components if component.id == MAIN + ) + return main.user_category or main.manufacturer_category + + async def async_setup_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry, @@ -141,6 +160,10 @@ async def async_setup_entry( for capability, attribute_map in CAPABILITY_TO_SENSORS.items() if capability in device.status[MAIN] for attribute, description in attribute_map.items() + if ( + not description.category + or get_main_component_category(device) in description.category + ) ) diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 4edb3160cf8..602e3e1d56c 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -614,6 +614,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_power-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.dryer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Dryer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.dryer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001][binary_sensor.dryer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -708,6 +756,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_power-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.seca_roupa_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Seca-Roupa Power', + }), + 'context': , + 'entity_id': 'binary_sensor.seca_roupa_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001_1][binary_sensor.seca_roupa_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -802,6 +898,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_power-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.washer_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washer Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washer_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][binary_sensor.washer_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -896,6 +1040,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_power-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.washing_machine_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7.switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Washing Machine Power', + }), + 'context': , + 'entity_id': 'binary_sensor.washing_machine_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_wm_wm_000001_1][binary_sensor.washing_machine_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 1b8b348effbbf3cf3a1b337565ed5957fce84573 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 18:03:50 +0100 Subject: [PATCH 1890/1941] Add select platform to SmartThings (#141115) * Add select platform to SmartThings * Add select platform to SmartThings --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/icons.json | 9 + .../components/smartthings/select.py | 120 +++++++++ .../components/smartthings/strings.json | 9 + .../smartthings/snapshots/test_select.ambr | 233 ++++++++++++++++++ tests/components/smartthings/test_select.py | 121 +++++++++ 6 files changed, 493 insertions(+) create mode 100644 homeassistant/components/smartthings/select.py create mode 100644 tests/components/smartthings/snapshots/test_select.ambr create mode 100644 tests/components/smartthings/test_select.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index b3f3e93eeb1..9e72a71ee86 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -86,6 +86,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.LOCK, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 971550b8f69..666dc07e686 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -13,6 +13,15 @@ "on": "mdi:lock" } } + }, + "select": { + "operating_state": { + "state": { + "run": "mdi:play", + "pause": "mdi:pause", + "stop": "mdi:stop" + } + } } } } diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py new file mode 100644 index 00000000000..6011b7947b7 --- /dev/null +++ b/homeassistant/components/smartthings/select.py @@ -0,0 +1,120 @@ +"""Support for select entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsSelectDescription(SelectEntityDescription): + """Class describing SmartThings select entities.""" + + key: Capability + requires_remote_control_status: bool + options_attribute: Attribute + status_attribute: Attribute + command: Command + + +CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = { + Capability.DRYER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.DRYER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + ), + Capability.WASHER_OPERATING_STATE: SmartThingsSelectDescription( + key=Capability.WASHER_OPERATING_STATE, + name=None, + translation_key="operating_state", + requires_remote_control_status=True, + options_attribute=Attribute.SUPPORTED_MACHINE_STATES, + status_attribute=Attribute.MACHINE_STATE, + command=Command.SET_MACHINE_STATE, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add select entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsSelectEntity( + entry_data.client, device, CAPABILITIES_TO_SELECT[capability] + ) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES_TO_SELECT + ) + + +class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity): + """Define a SmartThings select.""" + + entity_description: SmartThingsSelectDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsSelectDescription, + ) -> None: + """Initialize the instance.""" + capabilities = {entity_description.key} + if entity_description.requires_remote_control_status: + capabilities.add(Capability.REMOTE_CONTROL_STATUS) + super().__init__(client, device, capabilities) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{device.device.device_id}_{MAIN}_{entity_description.key}" + ) + + @property + def options(self) -> list[str]: + """Return the list of options.""" + return self.get_attribute_value( + self.entity_description.key, self.entity_description.options_attribute + ) + + @property + def current_option(self) -> str | None: + """Return the current option.""" + return self.get_attribute_value( + self.entity_description.key, self.entity_description.status_attribute + ) + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + if ( + self.entity_description.requires_remote_control_status + and self.get_attribute_value( + Capability.REMOTE_CONTROL_STATUS, Attribute.REMOTE_CONTROL_ENABLED + ) + == "false" + ): + raise ServiceValidationError( + "Can only be updated when remote control is enabled" + ) + await self.execute_device_command( + self.entity_description.key, + self.entity_description.command, + option, + ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 39973ef5380..2f1310b9c27 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -78,6 +78,15 @@ } } }, + "select": { + "operating_state": { + "state": { + "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]", + "pause": "[%key:common::state::paused%]", + "stop": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::stop%]" + } + } + }, "sensor": { "lighting_mode": { "name": "Activity lighting mode" diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr new file mode 100644 index 00000000000..649e876bb9e --- /dev/null +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -0,0 +1,233 @@ +# serializer version: 1 +# name: test_all_entities[da_wm_wd_000001][select.dryer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.dryer', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_dryerOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][select.dryer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.dryer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][select.seca_roupa-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.seca_roupa', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_dryerOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][select.seca_roupa-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.seca_roupa', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washer', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_washerOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001][select.washer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.washer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.washing_machine', + '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': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operating_state', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_washerOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][select.washing_machine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine', + 'options': list([ + 'stop', + 'run', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'select.washing_machine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'run', + }) +# --- diff --git a/tests/components/smartthings/test_select.py b/tests/components/smartthings/test_select.py new file mode 100644 index 00000000000..2c5c55239f2 --- /dev/null +++ b/tests/components/smartthings/test_select.py @@ -0,0 +1,121 @@ +"""Test for the SmartThings select platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import ( + set_attribute_value, + setup_integration, + snapshot_smartthings_entities, + trigger_update, +) + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SELECT) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +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("select.dryer").state == "stop" + + await trigger_update( + hass, + devices, + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.DRYER_OPERATING_STATE, + Attribute.MACHINE_STATE, + "run", + ) + + assert hass.states.get("select.dryer").state == "run" + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_select_option( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + set_attribute_value( + devices, + Capability.REMOTE_CONTROL_STATUS, + Attribute.REMOTE_CONTROL_ENABLED, + "true", + ) + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.dryer", ATTR_OPTION: "run"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.DRYER_OPERATING_STATE, + Command.SET_MACHINE_STATE, + MAIN, + argument="run", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +async def test_select_option_without_remote_control( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + set_attribute_value( + devices, + Capability.REMOTE_CONTROL_STATUS, + Attribute.REMOTE_CONTROL_ENABLED, + "false", + ) + await setup_integration(hass, mock_config_entry) + + with pytest.raises( + ServiceValidationError, + match="Can only be updated when remote control is enabled", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.dryer", ATTR_OPTION: "run"}, + blocking=True, + ) + devices.execute_device_command.assert_not_called() From ec4de0dccee2c6d90275483f7dea9f964cf27268 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 22 Mar 2025 12:14:42 -0500 Subject: [PATCH 1891/1941] Always allow browsing TuneIn for HEOS (#141131) * Always allow browsing TuneIn * Update test snapshots * Retry CI --- homeassistant/components/heos/media_player.py | 6 ++++-- .../heos/snapshots/test_media_player.ambr | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 5c0a66a02fa..311190ccb74 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -580,7 +580,7 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity): children: list[BrowseMedia] = [ _media_to_browse_media(source) for source in self.coordinator.heos.music_sources.values() - if source.available + if source.available or source.source_id == heos_const.MUSIC_SOURCE_TUNEIN ] root = BrowseMedia( title="Music Sources", @@ -654,7 +654,9 @@ def _media_to_browse_media(media: MediaItem | MediaMusicSource) -> BrowseMedia: can_play = False if isinstance(media, MediaMusicSource): - can_expand = media.available + can_expand = ( + media.source_id == heos_const.MUSIC_SOURCE_TUNEIN or media.available + ) else: can_expand = media.browsable can_play = media.playable diff --git a/tests/components/heos/snapshots/test_media_player.ambr b/tests/components/heos/snapshots/test_media_player.ambr index d2cd8b3e12a..4cf84363ba0 100644 --- a/tests/components/heos/snapshots/test_media_player.ambr +++ b/tests/components/heos/snapshots/test_media_player.ambr @@ -79,6 +79,16 @@ 'thumbnail': '', 'title': 'Pandora', }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'TuneIn', + }), dict({ 'can_expand': True, 'can_play': False, @@ -114,6 +124,16 @@ 'thumbnail': '', 'title': 'Pandora', }), + dict({ + 'can_expand': True, + 'can_play': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', + 'media_content_type': '', + 'thumbnail': '', + 'title': 'TuneIn', + }), ]), 'children_media_class': 'directory', 'media_class': 'directory', From 436acaf3d036cc5574671fa64d261bdfcca9966b Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 22 Mar 2025 12:37:11 -0500 Subject: [PATCH 1892/1941] Remove uncalled function in HEOS (#141134) Remove uncalled function --- homeassistant/components/heos/coordinator.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 0333c60ec21..0bc948bccd7 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -268,15 +268,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]): else: self._source_list.extend([source.name for source in self._inputs]) - async def _async_update_players(self) -> None: - """Update players after reconnection.""" - try: - player_updates = await self.heos.load_players() - except HeosError as error: - _LOGGER.error("Unable to refresh players: %s", error) - return - self._async_handle_player_update_result(player_updates) - @callback def async_get_source_list(self) -> list[str]: """Return the list of sources for players.""" From 92c619cdd6bd3812f2fdd43eb3b743334adc7a15 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 22 Mar 2025 10:40:47 -0700 Subject: [PATCH 1893/1941] Create new entity base class for NUT (#141122) --- homeassistant/components/nut/entity.py | 62 ++++++++++++++++++++++++++ homeassistant/components/nut/sensor.py | 50 ++++----------------- 2 files changed, 70 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/nut/entity.py diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py new file mode 100644 index 00000000000..8179526acf3 --- /dev/null +++ b/homeassistant/components/nut/entity.py @@ -0,0 +1,62 @@ +"""Base entity for the NUT integration.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import cast + +from homeassistant.const import ( + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + ATTR_SW_VERSION, +) +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import PyNUTData +from .const import DOMAIN + +NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { + "manufacturer": ATTR_MANUFACTURER, + "model": ATTR_MODEL, + "firmware": ATTR_SW_VERSION, + "serial": ATTR_SERIAL_NUMBER, +} + + +class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): + """NUT base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator, + data: PyNUTData, + unique_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + device_name = data.name.title() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + self._attr_device_info.update(_get_nut_device_info(data)) + + +def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: + """Return a DeviceInfo object filled with NUT device info.""" + nut_dev_infos = asdict(data.device_info) + nut_infos = { + info_key: nut_dev_infos[nut_key] + for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items() + if nut_dev_infos[nut_key] is not None + } + + return cast(DeviceInfo, nut_infos) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index ceea426c06d..189d5906f6d 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -1,10 +1,9 @@ -"""Provides a sensor to track various status aspects of a UPS.""" +"""Provides a sensor to track various status aspects of a NUT device.""" from __future__ import annotations -from dataclasses import asdict import logging -from typing import Final, cast +from typing import Final from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,10 +12,6 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SERIAL_NUMBER, - ATTR_SW_VERSION, PERCENTAGE, STATE_UNKNOWN, EntityCategory, @@ -29,22 +24,12 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import NutConfigEntry, PyNUTData -from .const import DOMAIN, KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES - -NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { - "manufacturer": ATTR_MANUFACTURER, - "model": ATTR_MODEL, - "firmware": ATTR_SW_VERSION, - "serial": ATTR_SERIAL_NUMBER, -} +from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES +from .entity import NUTBaseEntity AMBIENT_PRESENT = "ambient.present" AMBIENT_SENSORS = { @@ -1011,18 +996,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { } -def _get_nut_device_info(data: PyNUTData) -> DeviceInfo: - """Return a DeviceInfo object filled with NUT device info.""" - nut_dev_infos = asdict(data.device_info) - nut_infos = { - info_key: nut_dev_infos[nut_key] - for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items() - if nut_dev_infos[nut_key] is not None - } - - return cast(DeviceInfo, nut_infos) - - async def async_setup_entry( hass: HomeAssistant, config_entry: NutConfigEntry, @@ -1113,7 +1086,7 @@ async def async_setup_entry( ) -class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], SensorEntity): +class NUTSensor(NUTBaseEntity, SensorEntity): """Representation of a sensor entity for NUT status values.""" _attr_has_entity_name = True @@ -1126,20 +1099,13 @@ class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], Sensor unique_id: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, data, unique_id) self.entity_description = sensor_description - - device_name = data.name.title() self._attr_unique_id = f"{unique_id}_{sensor_description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name=device_name, - ) - self._attr_device_info.update(_get_nut_device_info(data)) @property def native_value(self) -> str | None: - """Return entity state from ups.""" + """Return entity state from NUT device.""" status = self.coordinator.data if self.entity_description.key == KEY_STATUS_DISPLAY: return _format_display_state(status) From 931ce8951e7f1c9f32ed0f0503064390f4cf2a43 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 18:41:15 +0100 Subject: [PATCH 1894/1941] Use category to define SmartThings binary sensor device class (#141075) * Use category to define SmartThings binary sensor device class * Fix --- .../components/smartthings/binary_sensor.py | 13 ++ .../fixtures/devices/contact_sensor.json | 2 +- .../snapshots/test_binary_sensor.ambr | 112 +++++++++--------- 3 files changed, 70 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 22e21de399b..ef431c08f24 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -33,6 +33,7 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): """Describe a SmartThings binary sensor entity.""" is_on_key: str + category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None category: set[Category] | None = None @@ -52,6 +53,11 @@ CAPABILITY_TO_SENSORS: dict[ key=Attribute.CONTACT, device_class=BinarySensorDeviceClass.DOOR, is_on_key="open", + category_device_class={ + Category.GARAGE_DOOR: BinarySensorDeviceClass.GARAGE_DOOR, + Category.DOOR: BinarySensorDeviceClass.DOOR, + Category.WINDOW: BinarySensorDeviceClass.WINDOW, + }, ) }, Capability.FILTER_STATUS: { @@ -186,6 +192,13 @@ class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): self.capability = capability self.entity_description = entity_description self._attr_unique_id = f"{device.device.device_id}.{attribute}" + if ( + entity_description.category_device_class + and (category := get_main_component_category(device)) + in entity_description.category_device_class + ): + self._attr_device_class = entity_description.category_device_class[category] + self._attr_name = None @property def is_on(self) -> bool: diff --git a/tests/components/smartthings/fixtures/devices/contact_sensor.json b/tests/components/smartthings/fixtures/devices/contact_sensor.json index 68070abbfc3..9823a70cb61 100644 --- a/tests/components/smartthings/fixtures/devices/contact_sensor.json +++ b/tests/components/smartthings/fixtures/devices/contact_sensor.json @@ -42,7 +42,7 @@ "categoryType": "manufacturer" }, { - "name": "ContactSensor", + "name": "GarageDoor", "categoryType": "user" } ] diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 602e3e1d56c..d05cf3124fa 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -95,7 +95,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-entry] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -108,7 +108,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -118,9 +118,9 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Door', + 'original_name': None, 'platform': 'smartthings', 'previous_unique_id': None, 'supported_features': 0, @@ -129,14 +129,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor_door-state] +# name: test_all_entities[contact_sensor][binary_sensor.front_door_open_closed_sensor-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': '.Front Door Open/Closed Sensor Door', + 'device_class': 'garage_door', + 'friendly_name': '.Front Door Open/Closed Sensor', }), 'context': , - 'entity_id': 'binary_sensor.front_door_open_closed_sensor_door', + 'entity_id': 'binary_sensor.front_door_open_closed_sensor', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1279,6 +1279,54 @@ 'state': 'on', }) # --- +# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door-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', + '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': 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-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Deck Door', + }), + 'context': , + 'entity_id': 'binary_sensor.deck_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_acceleration-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1327,54 +1375,6 @@ 'state': 'off', }) # --- -# name: test_all_entities[multipurpose_sensor][binary_sensor.deck_door_door-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_door', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Door', - '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_door-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'door', - 'friendly_name': 'Deck Door Door', - }), - 'context': , - 'entity_id': 'binary_sensor.deck_door_door', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- # name: test_all_entities[virtual_valve][binary_sensor.volvo_valve-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 4b4d75063cb077820c358b2761d0e163ff03eb6d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 19:03:26 +0100 Subject: [PATCH 1895/1941] Add number platform to SmartThings (#141063) * Add select platform to SmartThings * Add number platform to SmartThings * Fix * Fix * Fix * Fix --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/icons.json | 5 + .../components/smartthings/number.py | 77 ++++++++++++ .../components/smartthings/strings.json | 6 + .../smartthings/snapshots/test_number.ambr | 115 ++++++++++++++++++ tests/components/smartthings/test_number.py | 81 ++++++++++++ 6 files changed, 285 insertions(+) create mode 100644 homeassistant/components/smartthings/number.py create mode 100644 tests/components/smartthings/snapshots/test_number.ambr create mode 100644 tests/components/smartthings/test_number.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 9e72a71ee86..31309b73a66 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -85,6 +85,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.LOCK, + Platform.NUMBER, Platform.SCENE, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 666dc07e686..c5c18efa5a1 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -14,6 +14,11 @@ } } }, + "number": { + "washer_rinse_cycles": { + "default": "mdi:waves-arrow-up" + } + }, "select": { "operating_state": { "state": { diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py new file mode 100644 index 00000000000..cbd200e20b6 --- /dev/null +++ b/homeassistant/components/smartthings/number.py @@ -0,0 +1,77 @@ +"""Support for number entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from pysmartthings import Attribute, Capability, Command, SmartThings + +from homeassistant.components.number import NumberEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add number entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsWasherRinseCyclesNumberEntity(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.CUSTOM_WASHER_RINSE_CYCLES in device.status[MAIN] + ) + + +class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity): + """Define a SmartThings number.""" + + _attr_translation_key = "washer_rinse_cycles" + _attr_native_step = 1.0 + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the instance.""" + super().__init__(client, device, {Capability.CUSTOM_WASHER_RINSE_CYCLES}) + self._attr_unique_id = ( + f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_WASHER_RINSE_CYCLES}" + ) + + @property + def options(self) -> list[int]: + """Return the list of options.""" + values = self.get_attribute_value( + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Attribute.SUPPORTED_WASHER_RINSE_CYCLES, + ) + return [int(value) for value in values] if values else [] + + @property + def native_value(self) -> float | None: + """Return the current value.""" + return int( + self.get_attribute_value( + Capability.CUSTOM_WASHER_RINSE_CYCLES, Attribute.WASHER_RINSE_CYCLES + ) + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return min(self.options) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return max(self.options) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.execute_device_command( + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Command.SET_WASHER_RINSE_CYCLES, + str(int(value)), + ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 2f1310b9c27..c534c2ba29d 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -78,6 +78,12 @@ } } }, + "number": { + "washer_rinse_cycles": { + "name": "Rinse cycles", + "unit_of_measurement": "cycles" + } + }, "select": { "operating_state": { "state": { diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr new file mode 100644 index 00000000000..18d0a775c95 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.washer_rinse_cycles', + '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': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': 'f984b91d-f250-9d42-3436-33f09a422a47_main_custom.washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washer Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.washer_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][number.washing_machine_rinse_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.washing_machine_rinse_cycles', + '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': 'Rinse cycles', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'washer_rinse_cycles', + 'unique_id': '63803fae-cbed-f356-a063-2cf148ae3ca7_main_custom.washerRinseCycles', + 'unit_of_measurement': 'cycles', + }) +# --- +# name: test_all_entities[da_wm_wm_000001_1][number.washing_machine_rinse_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Washing Machine Rinse cycles', + 'max': 5, + 'min': 0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'cycles', + }), + 'context': , + 'entity_id': 'number.washing_machine_rinse_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/smartthings/test_number.py b/tests/components/smartthings/test_number.py new file mode 100644 index 00000000000..578b94e050f --- /dev/null +++ b/tests/components/smartthings/test_number.py @@ -0,0 +1,81 @@ +"""Test for the SmartThings number platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.NUMBER) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +async def test_set_value( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting a value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.washer_rinse_cycles", ATTR_VALUE: 3}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "f984b91d-f250-9d42-3436-33f09a422a47", + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Command.SET_WASHER_RINSE_CYCLES, + MAIN, + argument="3", + ) + + +@pytest.mark.parametrize("device_fixture", ["da_wm_wm_000001"]) +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("number.washer_rinse_cycles").state == "2" + + await trigger_update( + hass, + devices, + "f984b91d-f250-9d42-3436-33f09a422a47", + Capability.CUSTOM_WASHER_RINSE_CYCLES, + Attribute.WASHER_RINSE_CYCLES, + "3", + ) + + assert hass.states.get("number.washer_rinse_cycles").state == "3" From c56b087d0ca5052fc2330ca95af1c4eae1ad8ee8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 19:05:21 +0100 Subject: [PATCH 1896/1941] Add Dryer Wrinkle Prevent switch to SmartThings (#141085) * Add Dryer Wrinkle Prevent switch to SmartThings * Fix --- .../components/smartthings/icons.json | 8 ++ .../components/smartthings/strings.json | 5 + .../components/smartthings/switch.py | 106 ++++++++++++++++-- .../smartthings/snapshots/test_switch.ambr | 94 ++++++++++++++++ tests/components/smartthings/test_switch.py | 33 ++++++ 5 files changed, 236 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index c5c18efa5a1..9cfdb8da7ec 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -27,6 +27,14 @@ "stop": "mdi:stop" } } + }, + "switch": { + "wrinkle_prevent": { + "default": "mdi:tumble-dryer", + "state": { + "off": "mdi:tumble-dryer-off" + } + } } } } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index c534c2ba29d..9616c97fbe1 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -442,6 +442,11 @@ "freeze_protection": "Freeze protection" } } + }, + "switch": { + "wrinkle_prevent": { + "name": "Wrinkle prevent" + } } }, "issues": { diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 380005f1b93..6e0dc1ac93d 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -2,15 +2,16 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any -from pysmartthings import Attribute, Capability, Command +from pysmartthings import Attribute, Capability, Command, SmartThings -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SmartThingsConfigEntry +from . import FullDevice, SmartThingsConfigEntry from .const import MAIN from .entity import SmartThingsEntity @@ -29,6 +30,37 @@ AC_CAPABILITIES = ( ) +@dataclass(frozen=True, kw_only=True) +class SmartThingsSwitchEntityDescription(SwitchEntityDescription): + """Describe a SmartThings switch entity.""" + + status_attribute: Attribute + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsCommandSwitchEntityDescription(SmartThingsSwitchEntityDescription): + """Describe a SmartThings switch entity.""" + + command: Command + + +SWITCH = SmartThingsSwitchEntityDescription( + key=Capability.SWITCH, + status_attribute=Attribute.SWITCH, + name=None, +) +CAPABILITY_TO_COMMAND_SWITCHES: dict[ + Capability | str, SmartThingsCommandSwitchEntityDescription +] = { + Capability.CUSTOM_DRYER_WRINKLE_PREVENT: SmartThingsCommandSwitchEntityDescription( + key=Capability.CUSTOM_DRYER_WRINKLE_PREVENT, + translation_key="wrinkle_prevent", + status_attribute=Attribute.DRYER_WRINKLE_PREVENT, + command=Command.SET_DRYER_WRINKLE_PREVENT, + ) +} + + async def async_setup_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry, @@ -36,35 +68,89 @@ async def async_setup_entry( ) -> None: """Add switches for a config entry.""" entry_data = entry.runtime_data - async_add_entities( - SmartThingsSwitch(entry_data.client, device, {Capability.SWITCH}) + entities: list[SmartThingsEntity] = [ + SmartThingsSwitch(entry_data.client, device, SWITCH, 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) + ] + entities.extend( + SmartThingsCommandSwitch( + entry_data.client, + device, + description, + Capability(capability), + ) + for device in entry_data.devices.values() + for capability, description in CAPABILITY_TO_COMMAND_SWITCHES.items() + if capability in device.status[MAIN] ) + async_add_entities(entities) class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): """Define a SmartThings switch.""" - _attr_name = None + entity_description: SmartThingsSwitchEntityDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsSwitchEntityDescription, + capability: Capability, + ) -> None: + """Initialize the switch.""" + super().__init__(client, device, {capability}) + self.entity_description = entity_description + self.switch_capability = capability + self._attr_unique_id = device.device.device_id + if capability is not Capability.SWITCH: + self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{capability}" async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.execute_device_command( - Capability.SWITCH, + self.switch_capability, Command.OFF, ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.execute_device_command( - Capability.SWITCH, + self.switch_capability, Command.ON, ) @property def is_on(self) -> bool: - """Return true if light is on.""" - return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" + """Return true if switch is on.""" + return ( + self.get_attribute_value( + self.switch_capability, self.entity_description.status_attribute + ) + == "on" + ) + + +class SmartThingsCommandSwitch(SmartThingsSwitch): + """Define a SmartThings command switch.""" + + entity_description: SmartThingsCommandSwitchEntityDescription + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.execute_device_command( + self.switch_capability, + self.entity_description.command, + "off", + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.execute_device_command( + self.switch_capability, + self.entity_description.command, + "on", + ) diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 08db5ffc244..40f242e82f5 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -281,6 +281,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-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_wrinkle_prevent', + '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': 'Wrinkle prevent', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wrinkle_prevent', + 'unique_id': '02f7256e-8353-5bdd-547f-bd5b1647e01b_main_custom.dryerWrinklePrevent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001][switch.dryer_wrinkle_prevent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dryer Wrinkle prevent', + }), + 'context': , + 'entity_id': 'switch.dryer_wrinkle_prevent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -328,6 +375,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-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.seca_roupa_wrinkle_prevent', + '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': 'Wrinkle prevent', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wrinkle_prevent', + 'unique_id': '3a6c4e05-811d-5041-e956-3d04c424cbcd_main_custom.dryerWrinklePrevent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_wm_wd_000001_1][switch.seca_roupa_wrinkle_prevent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Seca-Roupa Wrinkle prevent', + }), + 'context': , + 'entity_id': 'switch.seca_roupa_wrinkle_prevent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_wm_wm_000001][switch.washer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index a1e420a8edb..28bac49b0b0 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -66,6 +66,39 @@ async def test_switch_turn_on_off( ) +@pytest.mark.parametrize("device_fixture", ["da_wm_wd_000001"]) +@pytest.mark.parametrize( + ("action", "argument"), + [ + (SERVICE_TURN_ON, "on"), + (SERVICE_TURN_OFF, "off"), + ], +) +async def test_command_switch_turn_on_off( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + argument: str, +) -> None: + """Test switch turn on and off command.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + action, + {ATTR_ENTITY_ID: "switch.dryer_wrinkle_prevent"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "02f7256e-8353-5bdd-547f-bd5b1647e01b", + Capability.CUSTOM_DRYER_WRINKLE_PREVENT, + Command.SET_DRYER_WRINKLE_PREVENT, + MAIN, + argument, + ) + + @pytest.mark.parametrize("device_fixture", ["c2c_arlo_pro_3_switch"]) async def test_state_update( hass: HomeAssistant, From 1e0b89c3817f27bb24f66f29fcc724fd5abe47ee Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 22 Mar 2025 14:29:32 -0400 Subject: [PATCH 1897/1941] Bump python Roborock to 2.16.1 (#141033) * Bump python Roborock to 2.15.0 * Add aiohttp clientsession * inject websession * fix lint after merge * bump to 2.16 * bump and revert * revert formatting --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 45cfe4e12d8..ce797b0db4b 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.14.0", + "python-roborock==2.16.1", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 289184b8eca..42ec536c05d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2465,7 +2465,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.14.0 +python-roborock==2.16.1 # homeassistant.components.smarttub python-smarttub==0.0.39 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ed83a90659..0ea7bc6593f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1995,7 +1995,7 @@ python-picnic-api2==1.2.2 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.14.0 +python-roborock==2.16.1 # homeassistant.components.smarttub python-smarttub==0.0.39 From 99d0449cbe3872adad8274898b28bd5a386c4eba Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sat, 22 Mar 2025 19:40:47 +0100 Subject: [PATCH 1898/1941] Bump pyOverkiz to 1.16.4 in Overkiz (#141132) * Bump Overkiz to 1.16.3 * Add missing generated files --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 70857f0ba11..cfaed4ceb8b 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.16.3"], + "requirements": ["pyoverkiz==1.16.4"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 42ec536c05d..db1a05a376d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2203,7 +2203,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.3 +pyoverkiz==1.16.4 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ea7bc6593f..ce3ffd8f620 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1796,7 +1796,7 @@ pyotgw==2.2.2 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.16.3 +pyoverkiz==1.16.4 # homeassistant.components.onewire pyownet==0.10.0.post1 From b47d3076cc3ccb095be60aa2d305758cc160af67 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 19:51:41 +0100 Subject: [PATCH 1899/1941] Add oven stop button to SmartThings (#141142) --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/button.py | 75 +++++++++ .../components/smartthings/icons.json | 5 + .../components/smartthings/strings.json | 5 + .../smartthings/snapshots/test_button.ambr | 142 ++++++++++++++++++ tests/components/smartthings/test_button.py | 56 +++++++ 6 files changed, 284 insertions(+) create mode 100644 homeassistant/components/smartthings/button.py create mode 100644 tests/components/smartthings/snapshots/test_button.ambr create mode 100644 tests/components/smartthings/test_button.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 31309b73a66..e5351798219 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -79,6 +79,7 @@ type SmartThingsConfigEntry = ConfigEntry[SmartThingsData] PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.EVENT, diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py new file mode 100644 index 00000000000..ad61880f3b1 --- /dev/null +++ b/homeassistant/components/smartthings/button.py @@ -0,0 +1,75 @@ +"""Support for button entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pysmartthings import Capability, Command, SmartThings + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +@dataclass(frozen=True, kw_only=True) +class SmartThingsButtonDescription(ButtonEntityDescription): + """Class describing SmartThings button entities.""" + + key: Capability + command: Command + + +CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = { + Capability.OVEN_OPERATING_STATE: SmartThingsButtonDescription( + key=Capability.OVEN_OPERATING_STATE, + translation_key="stop", + command=Command.STOP, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add button entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsButtonEntity( + entry_data.client, device, CAPABILITIES_TO_BUTTONS[capability] + ) + for device in entry_data.devices.values() + for capability in device.status[MAIN] + if capability in CAPABILITIES_TO_BUTTONS + ) + + +class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity): + """Define a SmartThings button.""" + + entity_description: SmartThingsButtonDescription + + def __init__( + self, + client: SmartThings, + device: FullDevice, + entity_description: SmartThingsButtonDescription, + ) -> None: + """Initialize the instance.""" + super().__init__(client, device, set()) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{device.device.device_id}_{MAIN}_{entity_description.key}" + ) + + async def async_press(self) -> None: + """Press the button.""" + await self.execute_device_command( + self.entity_description.key, + self.entity_description.command, + ) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index 9cfdb8da7ec..80ac70edc3f 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -14,6 +14,11 @@ } } }, + "button": { + "stop": { + "default": "mdi:stop" + } + }, "number": { "washer_rinse_cycles": { "default": "mdi:waves-arrow-up" diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9616c97fbe1..13f4a6a2831 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -46,6 +46,11 @@ "name": "Valve" } }, + "button": { + "stop": { + "name": "Stop" + } + }, "event": { "button": { "state": { diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr new file mode 100644 index 00000000000..a16ad794929 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -0,0 +1,142 @@ +# serializer version: 1 +# name: test_all_entities[da_ks_microwave_0101x][button.microwave_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.microwave_stop', + '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': 'Stop', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a_main_ovenOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][button.microwave_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Microwave Stop', + }), + 'context': , + 'entity_id': 'button.microwave_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_oven_01061][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_stop', + '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': 'Stop', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f_main_ovenOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[da_ks_range_0101x][button.vulcan_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.vulcan_stop', + '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': 'Stop', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18_main_ovenOperatingState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][button.vulcan_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Vulcan Stop', + }), + 'context': , + 'entity_id': 'button.vulcan_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/test_button.py b/tests/components/smartthings/test_button.py new file mode 100644 index 00000000000..4a348d079ca --- /dev/null +++ b/tests/components/smartthings/test_button.py @@ -0,0 +1,56 @@ +"""Test for the SmartThings button platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pysmartthings import Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.smartthings import MAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.BUTTON) + + +@pytest.mark.parametrize("device_fixture", ["da_ks_microwave_0101x"]) +async def test_press( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + freezer.move_to("2023-10-21") + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.microwave_stop"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2bad3237-4886-e699-1b90-4a51a3d55c8a", + Capability.OVEN_OPERATING_STATE, + Command.STOP, + MAIN, + ) From f245bbd8ddd2224d535da59771d60691e268ff6d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 22 Mar 2025 20:04:21 +0100 Subject: [PATCH 1900/1941] Add door state binary sensor to SmartThings (#141143) --- .../components/smartthings/binary_sensor.py | 8 + .../components/smartthings/strings.json | 3 + .../snapshots/test_binary_sensor.ambr | 144 ++++++++++++++++++ 3 files changed, 155 insertions(+) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index ef431c08f24..8479852a6f6 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -134,6 +134,14 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="wet", ) }, + Capability.SAMSUNG_CE_DOOR_STATE: { + Attribute.DOOR_STATE: SmartThingsBinarySensorEntityDescription( + key=Attribute.DOOR_STATE, + translation_key="door", + device_class=BinarySensorDeviceClass.OPENING, + is_on_key="open", + ) + }, } diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 13f4a6a2831..7f6e13ab3ba 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -33,6 +33,9 @@ "acceleration": { "name": "Acceleration" }, + "door": { + "name": "[%key:component::binary_sensor::entity_component::door::name%]" + }, "filter_status": { "name": "Filter status" }, diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index d05cf3124fa..45534085ddf 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -190,6 +190,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_door-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.microwave_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '2bad3237-4886-e699-1b90-4a51a3d55c8a.doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Microwave Door', + }), + 'context': , + 'entity_id': 'binary_sensor.microwave_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_microwave_0101x][binary_sensor.microwave_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -284,6 +332,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_door-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.oven_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '9447959a-0dfa-6b27-d40d-650da525c53f.doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_oven_01061][binary_sensor.oven_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -378,6 +474,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_door-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.vulcan_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'door', + 'unique_id': '2c3cbaa0-1899-5ddc-7b58-9d657bd48f18.doorState', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'opening', + 'friendly_name': 'Vulcan Door', + }), + 'context': , + 'entity_id': 'binary_sensor.vulcan_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ks_range_0101x][binary_sensor.vulcan_remote_control-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 7f640252a1a199b14ca006e77fe7b41ff9a789c4 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 22 Mar 2025 14:12:51 -0500 Subject: [PATCH 1901/1941] Use Debouncer helper in HEOS Coordinator (#141133) Use Debouncer --- homeassistant/components/heos/coordinator.py | 41 ++++++-------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py index 0bc948bccd7..5e72eb1427e 100644 --- a/homeassistant/components/heos/coordinator.py +++ b/homeassistant/components/heos/coordinator.py @@ -6,7 +6,6 @@ entities to update. Entities subscribe to entity-specific updates within the ent """ from collections.abc import Callable, Sequence -from datetime import datetime, timedelta import logging from typing import Any @@ -25,10 +24,10 @@ from pyheos import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -60,7 +59,13 @@ class HeosCoordinator(DataUpdateCoordinator[None]): ) ) self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = [] - self._update_sources_pending: bool = False + self._update_sources_debouncer = Debouncer( + hass, + _LOGGER, + immediate=True, + cooldown=2.0, + function=self._async_update_sources, + ) self._source_list: list[str] = [] self._favorites: dict[int, MediaItem] = {} self._inputs: Sequence[MediaItem] = [] @@ -182,31 +187,9 @@ class HeosCoordinator(DataUpdateCoordinator[None]): if event == const.EVENT_PLAYERS_CHANGED: assert data is not None self._async_handle_player_update_result(data) - elif ( - event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED) - and not self._update_sources_pending - ): - # Update the sources after a brief delay as we may have received multiple qualifying - # events at once and devices cannot handle immediately attempting to refresh sources. - self._update_sources_pending = True - - async def update_sources_job(_: datetime | None = None) -> None: - await self._async_update_sources() - self._update_sources_pending = False - self.async_update_listeners() - - assert self.config_entry is not None - self.config_entry.async_on_unload( - async_call_later( - self.hass, - timedelta(seconds=1), - HassJob( - update_sources_job, - "heos_update_sources", - cancel_on_shutdown=True, - ), - ) - ) + elif event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED): + # Debounce because we may have received multiple qualifying events in rapid succession. + await self._update_sources_debouncer.async_call() self.async_update_listeners() def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None: From 6d91bdb02e0c3045f3cced95eec808112427d167 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 22 Mar 2025 15:19:54 -0400 Subject: [PATCH 1902/1941] Inject websession for Roborock api client (#141141) --- homeassistant/components/roborock/__init__.py | 7 ++++++- homeassistant/components/roborock/config_flow.py | 9 +++++++-- homeassistant/components/roborock/quality_scale.yaml | 4 +--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index a3ccf0c6eed..8140b58b86c 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -24,6 +24,7 @@ from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP 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 .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS from .coordinator import ( @@ -45,7 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> entry.async_on_unload(entry.add_update_listener(update_listener)) user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) - api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL]) + api_client = RoborockApiClient( + entry.data[CONF_USERNAME], + entry.data[CONF_BASE_URL], + session=async_get_clientsession(hass), + ) _LOGGER.debug("Getting home data") try: home_data = await api_client.get_home_data_v2(user_data) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 1a6b67286bb..6a5f1ce08f8 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -28,6 +28,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_BASE_URL, @@ -63,7 +64,9 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured(error="already_configured_account") self._username = username _LOGGER.debug("Requesting code for Roborock account") - self._client = RoborockApiClient(username) + self._client = RoborockApiClient( + username, session=async_get_clientsession(self.hass) + ) errors = await self._request_code() if not errors: return await self.async_step_code() @@ -140,7 +143,9 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): """Perform reauth upon an API authentication error.""" self._username = entry_data[CONF_USERNAME] assert self._username - self._client = RoborockApiClient(self._username) + self._client = RoborockApiClient( + self._username, session=async_get_clientsession(self.hass) + ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 06a7638c222..430bdd9c2b6 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -73,7 +73,5 @@ rules: stale-devices: done # Platinum async-dependency: todo - inject-websession: - status: todo - comment: Web API uses aiohttp but does not yet inject web session. + inject-websession: done strict-typing: todo From 61e30d0e912e7050af8848b672f5631269a175ec Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 22 Mar 2025 20:27:48 +0100 Subject: [PATCH 1903/1941] Add diagnostics to remote calendar (#141111) * Add diagnostics * add diagnostics * address review * ruff * ruff * use raw ics data * mypy * mypy * naming * redact ics * ruff * simpify * reduce data * ruff --- .../components/remote_calendar/coordinator.py | 5 ++- .../components/remote_calendar/diagnostics.py | 25 ++++++++++++ .../remote_calendar/quality_scale.yaml | 4 +- .../snapshots/test_diagnostics.ambr | 17 ++++++++ .../remote_calendar/test_diagnostics.py | 39 +++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/remote_calendar/diagnostics.py create mode 100644 tests/components/remote_calendar/snapshots/test_diagnostics.ambr create mode 100644 tests/components/remote_calendar/test_diagnostics.py diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 7f29f7e2ea8..6caec297c1a 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -26,6 +26,7 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): """Class to manage fetching calendar data.""" config_entry: RemoteCalendarConfigEntry + ics: str def __init__( self, @@ -40,7 +41,6 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): update_interval=SCAN_INTERVAL, always_update=True, ) - self._etag = None self._client = get_async_client(hass) self._url = config_entry.data[CONF_URL] @@ -59,8 +59,9 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): # calendar_from_ics will dynamically load packages # the first time it is called, so we need to do it # in a separate thread to avoid blocking the event loop + self.ics = res.text return await self.hass.async_add_executor_job( - IcsCalendarStream.calendar_from_ics, res.text + IcsCalendarStream.calendar_from_ics, self.ics ) except CalendarParseError as err: raise UpdateFailed( diff --git a/homeassistant/components/remote_calendar/diagnostics.py b/homeassistant/components/remote_calendar/diagnostics.py new file mode 100644 index 00000000000..5ebfb3d3812 --- /dev/null +++ b/homeassistant/components/remote_calendar/diagnostics.py @@ -0,0 +1,25 @@ +"""Provides diagnostics for the remote calendar.""" + +import datetime +from typing import Any + +from ical.diagnostics import redact_ics + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import RemoteCalendarConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: RemoteCalendarConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + payload: dict[str, Any] = { + "now": dt_util.now().isoformat(), + "timezone": str(dt_util.get_default_time_zone()), + "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), + } + payload["ics"] = "\n".join(redact_ics(coordinator.ics)) + return payload diff --git a/homeassistant/components/remote_calendar/quality_scale.yaml b/homeassistant/components/remote_calendar/quality_scale.yaml index 05dc32e5da9..964b63d7116 100644 --- a/homeassistant/components/remote_calendar/quality_scale.yaml +++ b/homeassistant/components/remote_calendar/quality_scale.yaml @@ -53,9 +53,7 @@ rules: devices: status: exempt comment: No devices. One URL is always assigned to one calendar. - diagnostics: - status: todo - comment: Diagnostics not implemented, yet. + diagnostics: done discovery-update-info: status: todo comment: No discovery protocol available. diff --git a/tests/components/remote_calendar/snapshots/test_diagnostics.ambr b/tests/components/remote_calendar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..de955f8a2aa --- /dev/null +++ b/tests/components/remote_calendar/snapshots/test_diagnostics.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'ics': ''' + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:*** + DTSTART:19970714T170000Z + DTEND:19970715T040000Z + END:VEVENT + END:VCALENDAR + ''', + 'now': '2023-06-04T18:00:00-06:00', + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- diff --git a/tests/components/remote_calendar/test_diagnostics.py b/tests/components/remote_calendar/test_diagnostics.py new file mode 100644 index 00000000000..428369b1180 --- /dev/null +++ b/tests/components/remote_calendar/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Test the remote calendar diagnostics.""" + +import datetime + +from httpx import Response +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import CALENDER_URL + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@respx.mock +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5)) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + ics_content: str, +) -> None: + """Test config entry diagnostics.""" + respx.get(CALENDER_URL).mock( + return_value=Response( + status_code=200, + text=ics_content, + ) + ) + await setup_integration(hass, config_entry) + await hass.async_block_till_done() + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot From 4e2dfba45fc2a02662c96d1f27e9d2f9f6ad359f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 22 Mar 2025 12:41:51 -0700 Subject: [PATCH 1904/1941] Omit state from the Assist LLM prompts (#141034) * Omit state from the Assist LLM prompts * Add back the stateful prompt --- .../components/mcp_server/llm_api.py | 41 ------------------ homeassistant/components/mcp_server/server.py | 10 ++--- homeassistant/helpers/llm.py | 30 +++++++------ tests/helpers/test_llm.py | 42 +++++++++++++++++-- 4 files changed, 60 insertions(+), 63 deletions(-) delete mode 100644 homeassistant/components/mcp_server/llm_api.py diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py deleted file mode 100644 index f7dd4421480..00000000000 --- a/homeassistant/components/mcp_server/llm_api.py +++ /dev/null @@ -1,41 +0,0 @@ -"""LLM API for MCP Server. - -This is a modified version of the AssistAPI that does not include the home state -in the prompt. This API is not registered with the LLM API registry since it is -only used by the MCP Server. The MCP server will substitute this API when the -user selects the Assist API. -""" - -from homeassistant.core import callback -from homeassistant.helpers import llm -from homeassistant.util import yaml as yaml_util - -EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"} - - -class StatelessAssistAPI(llm.AssistAPI): - """LLM API for MCP Server that provides the Assist API without state information in the prompt. - - Syncing the state information is possible, but may put unnecessary load on - the system so we are instead providing the prompt without entity state. Since - actions don't care about the current state, there is little quality loss. - """ - - @callback - def _async_get_exposed_entities_prompt( - self, llm_context: llm.LLMContext, exposed_entities: dict | None - ) -> list[str]: - """Return the prompt for the exposed entities.""" - prompt = [] - - if exposed_entities and exposed_entities["entities"]: - prompt.append( - "An overview of the areas and the devices in this smart home:" - ) - entities = [ - {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} - for entity_info in exposed_entities["entities"].values() - ] - prompt.append(yaml_util.dump(list(entities))) - - return prompt diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 307fcdda8f3..88b179ae7c2 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -22,7 +22,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import llm from .const import STATELESS_LLM_API -from .llm_api import StatelessAssistAPI _LOGGER = logging.getLogger(__name__) @@ -50,15 +49,14 @@ async def create_server( A Model Context Protocol Server object is associated with a single session. The MCP SDK handles the details of the protocol. """ + if llm_api_id == STATELESS_LLM_API: + llm_api_id = llm.LLM_API_ASSIST server = Server("home-assistant") async def get_api_instance() -> llm.APIInstance: - """Substitute the StatelessAssistAPI for the Assist API if selected.""" - if llm_api_id in (STATELESS_LLM_API, llm.LLM_API_ASSIST): - api = StatelessAssistAPI(hass) - return await api.async_get_api_instance(llm_context) - + """Get the LLM API selected.""" + # Backwards compatibility with old MCP Server config return await llm.async_get_api(hass, llm_api_id, llm_context) @server.list_prompts() # type: ignore[no-untyped-call, misc] diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5995543914f..7f6fe22ec70 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -316,7 +316,7 @@ class AssistAPI(API): """Return the instance of the API.""" if llm_context.assistant: exposed_entities: dict | None = _get_exposed_entities( - self.hass, llm_context.assistant + self.hass, llm_context.assistant, include_state=False ) else: exposed_entities = None @@ -463,7 +463,9 @@ class AssistAPI(API): def _get_exposed_entities( - hass: HomeAssistant, assistant: str + hass: HomeAssistant, + assistant: str, + include_state: bool = True, ) -> dict[str, dict[str, dict[str, Any]]]: """Get exposed entities. @@ -524,24 +526,28 @@ def _get_exposed_entities( info: dict[str, Any] = { "names": ", ".join(names), "domain": state.domain, - "state": state.state, } + if include_state: + info["state"] = state.state + if description: info["description"] = description if area_names: info["areas"] = ", ".join(area_names) - if attributes := { - 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 - }: + if include_state and ( + attributes := { + 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 + } + ): info["attributes"] = attributes if state.domain in data: diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 45ed009fcf1..19ada407550 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -622,6 +622,40 @@ async def test_assist_api_prompt( domain: light state: unavailable areas: Test Area 2 +""" + stateless_exposed_entities_prompt = """An overview of the areas and the devices in this smart home: +- names: Kitchen + domain: light +- names: Living Room + domain: light + areas: Test Area, Alternative name +- names: Test Device, my test light + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Service + domain: light + areas: Test Area, Alternative name +- names: Test Device 2 + domain: light + areas: Test Area 2 +- names: Test Device 3 + domain: light + areas: Test Area 2 +- names: Test Device 4 + domain: light + areas: Test Area 2 +- names: Unnamed Device + domain: light + areas: Test Area 2 +- names: '1' + domain: light + areas: Test Area 2 """ first_part_prompt = ( "When controlling Home Assistant always call the intent tools. " @@ -640,7 +674,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Verify that the get_home_state tool returns the same results as the exposed_entities_prompt @@ -663,7 +697,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Add floor @@ -678,7 +712,7 @@ async def test_assist_api_prompt( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Register device for timers @@ -689,7 +723,7 @@ async def test_assist_api_prompt( assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) From a9df341abf92952832848c6099ad3f7647ebb986 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 22 Mar 2025 14:11:48 -0700 Subject: [PATCH 1905/1941] Optimize entity creation by storing device name as data in NUT (#141147) --- homeassistant/components/nut/__init__.py | 5 ++++- homeassistant/components/nut/entity.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 169dbbbff5d..94a2599501a 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -240,6 +240,7 @@ class PyNUTData: self._client = AIONUTClient(self._host, port, username, password, 5, persistent) self.ups_list: dict[str, str] | None = None + self.device_name: str | None = None self._status: dict[str, str] | None = None self._device_info: NUTDeviceInfo | None = None @@ -250,7 +251,7 @@ class PyNUTData: @property def name(self) -> str: - """Return the name of the ups.""" + """Return the name of the NUT device.""" return self._alias or f"Nut-{self._host}" @property @@ -294,6 +295,8 @@ class PyNUTData: self._status = await self._async_get_status() if self._device_info is None: self._device_info = self._get_device_info() + if self.device_name is None: + self.device_name = self.name.title() return self._status async def async_run_command(self, command_name: str) -> None: diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py index 8179526acf3..5445b51c5cb 100644 --- a/homeassistant/components/nut/entity.py +++ b/homeassistant/components/nut/entity.py @@ -42,10 +42,10 @@ class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): """Initialize the entity.""" super().__init__(coordinator) - device_name = data.name.title() + self.pynut_data = data self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, - name=device_name, + name=self.pynut_data.device_name, ) self._attr_device_info.update(_get_nut_device_info(data)) From ddd67a7e58c2a13d6126e8542ac8eaa74cbf6a25 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 22 Mar 2025 16:04:20 -0700 Subject: [PATCH 1906/1941] Add PDU dynamic outlet buttons to NUT (#140317) --- homeassistant/components/nut/__init__.py | 26 +++++- homeassistant/components/nut/button.py | 65 ++++++++++++++ homeassistant/components/nut/const.py | 5 +- homeassistant/components/nut/entity.py | 5 ++ homeassistant/components/nut/icons.json | 5 ++ homeassistant/components/nut/sensor.py | 17 +--- homeassistant/components/nut/strings.json | 3 + tests/components/nut/test_button.py | 102 ++++++++++++++++++++++ 8 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/nut/button.py create mode 100644 tests/components/nut/test_button.py diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 94a2599501a..8ec8c132ffe 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -103,7 +103,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: ) status = coordinator.data - _LOGGER.debug("NUT Sensors Available: %s", status) + _LOGGER.debug("NUT Sensors Available: %s", status if status else None) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) unique_id = _unique_id_from_status(status) @@ -111,14 +111,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: unique_id = entry.entry_id if username is not None and password is not None: + # Dynamically add outlet integration commands + additional_integration_commands = set() + if (num_outlets := status.get("outlet.count")) is not None: + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str: str = str(outlet_num) + additional_integration_commands |= { + f"outlet.{outlet_num_str}.load.cycle", + } + + valid_integration_commands = ( + INTEGRATION_SUPPORTED_COMMANDS | additional_integration_commands + ) + user_available_commands = { - device_supported_command - for device_supported_command in await data.async_list_commands() or {} - if device_supported_command in INTEGRATION_SUPPORTED_COMMANDS + device_command + for device_command in await data.async_list_commands() or {} + if device_command in valid_integration_commands } else: user_available_commands = set() + _LOGGER.debug( + "NUT Commands Available: %s", + user_available_commands if user_available_commands else None, + ) + entry.runtime_data = NutRuntimeData( coordinator, data, unique_id, user_available_commands ) diff --git a/homeassistant/components/nut/button.py b/homeassistant/components/nut/button.py new file mode 100644 index 00000000000..436f06b44d7 --- /dev/null +++ b/homeassistant/components/nut/button.py @@ -0,0 +1,65 @@ +"""Provides a switch for switchable NUT outlets.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NutConfigEntry +from .entity import NUTBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NutConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NUT buttons.""" + pynut_data = config_entry.runtime_data + coordinator = pynut_data.coordinator + status = coordinator.data + + # Dynamically add outlet button types + if (num_outlets := status.get("outlet.count")) is None: + return + + data = pynut_data.data + unique_id = pynut_data.unique_id + valid_button_types: dict[str, ButtonEntityDescription] = {} + for outlet_num in range(1, int(num_outlets) + 1): + outlet_num_str = str(outlet_num) + outlet_name: str = status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str + valid_button_types |= { + f"outlet.{outlet_num_str}.load.cycle": ButtonEntityDescription( + key=f"outlet.{outlet_num_str}.load.cycle", + translation_key="outlet_number_load_cycle", + translation_placeholders={"outlet_name": outlet_name}, + device_class=ButtonDeviceClass.RESTART, + entity_registry_enabled_default=True, + ), + } + + async_add_entities( + NUTButton(coordinator, description, data, unique_id) + for button_id, description in valid_button_types.items() + if button_id in pynut_data.user_available_commands + ) + + +class NUTButton(NUTBaseEntity, ButtonEntity): + """Representation of a button entity for NUT.""" + + async def async_press(self) -> None: + """Press the button.""" + name_list = self.entity_description.key.split(".") + command_name = f"{name_list[0]}.{name_list[1]}.load.cycle" + await self.pynut_data.async_run_command(command_name) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index e67299aa9a3..a45b072fe65 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -6,7 +6,10 @@ from homeassistant.const import Platform DOMAIN = "nut" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [ + Platform.BUTTON, + Platform.SENSOR, +] DEFAULT_NAME = "NUT UPS" DEFAULT_HOST = "localhost" diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py index 5445b51c5cb..e6536d8aad6 100644 --- a/homeassistant/components/nut/entity.py +++ b/homeassistant/components/nut/entity.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ATTR_SW_VERSION, ) from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -36,12 +37,16 @@ class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): def __init__( self, coordinator: DataUpdateCoordinator, + entity_description: EntityDescription, data: PyNUTData, unique_id: str, ) -> None: """Initialize the entity.""" super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + self.pynut_data = data self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index bfd9407bb6c..e69d0405756 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -151,6 +151,11 @@ "ups_watchdog_status": { "default": "mdi:information-outline" } + }, + "button": { + "outlet_number_load_cycle": { + "default": "mdi:restart" + } } } } diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 189d5906f6d..80046c6ac22 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -25,9 +25,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import NutConfigEntry, PyNUTData +from . import NutConfigEntry from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES from .entity import NUTBaseEntity @@ -1089,20 +1088,6 @@ async def async_setup_entry( class NUTSensor(NUTBaseEntity, SensorEntity): """Representation of a sensor entity for NUT status values.""" - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, str]], - sensor_description: SensorEntityDescription, - data: PyNUTData, - unique_id: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, data, unique_id) - self.entity_description = sensor_description - self._attr_unique_id = f"{unique_id}_{sensor_description.key}" - @property def native_value(self) -> str | None: """Return entity state from NUT device.""" diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 76d6f6df0b7..7a913d44f9e 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -221,6 +221,9 @@ "ups_type": { "name": "UPS type" }, "ups_watchdog_status": { "name": "Watchdog status" }, "watts": { "name": "Watts" } + }, + "button": { + "outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" } } } } diff --git a/tests/components/nut/test_button.py b/tests/components/nut/test_button.py new file mode 100644 index 00000000000..bbcc521b7f3 --- /dev/null +++ b/tests/components/nut/test_button.py @@ -0,0 +1,102 @@ +"""Test the NUT button platform.""" + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import async_init_integration + + +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_buttons_ups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Tests that there are no standard buttons.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + button = hass.states.get("button.ups1_power_cycle_outlet_1") + assert not button + + +@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_buttons_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Tests that the button entities are correct.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + for num in range(1, 25): + command = f"outlet.{num!s}.load.cycle" + list_commands_return_value[command] = command + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + entity_id = "button.ups1_power_cycle_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{unique_id_base}outlet.1.load.cycle" + + button = hass.states.get(entity_id) + assert button + assert button.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + button = hass.states.get(entity_id) + assert button.state != STATE_UNKNOWN + + button = hass.states.get("button.ups1_power_cycle_outlet_25") + assert not button + + button = hass.states.get("button.ups1_power_cycle_outlet_a25") + assert not button From e2e80a850ccd1ee5ea31c675625721b470d05522 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 23 Mar 2025 00:21:43 -0400 Subject: [PATCH 1907/1941] Add dhcp discovery to Roborock (#141148) * Add discovery to Roborock * Update homeassistant/components/roborock/config_flow.py Co-authored-by: Allen Porter * MR comments * go back to removing the ":" * change method of getting devices --------- Co-authored-by: Allen Porter --- .../components/roborock/config_flow.py | 18 +++++ .../components/roborock/coordinator.py | 4 +- .../components/roborock/manifest.json | 11 +++ .../components/roborock/quality_scale.yaml | 4 +- homeassistant/generated/dhcp.py | 12 ++++ tests/components/roborock/conftest.py | 8 ++- tests/components/roborock/mock_data.py | 4 +- tests/components/roborock/test_config_flow.py | 68 ++++++++++++++++++- 8 files changed, 121 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 6a5f1ce08f8..c34f7cb87b0 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -28,7 +28,9 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import ( CONF_BASE_URL, @@ -137,6 +139,22 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle a flow started by a dhcp discovery.""" + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device( + connections={ + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(discovery_info.macaddress)) + } + ) + if device is not None and any( + identifier[0] == DOMAIN for identifier in device.identifiers + ): + return self.async_abort(reason="already_configured") + return await self.async_step_user() + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 6d0c9737a29..cc0bee1cd5f 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -129,7 +129,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.current_map: int | None = None if mac := self.roborock_device_info.network_info.mac: - self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)} + self.device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)) + } # Maps from map flag to map name self.maps: dict[int, RoborockMapInfo] = {} self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms} diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index ce797b0db4b..60036edb0bc 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -3,6 +3,17 @@ "name": "Roborock", "codeowners": ["@Lash-L", "@allenporter"], "config_flow": true, + "dhcp": [ + { + "macaddress": "249E7D*" + }, + { + "macaddress": "B04A39*" + }, + { + "hostname": "roborock-*" + } + ], "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index 430bdd9c2b6..c61db90350f 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -34,9 +34,7 @@ rules: # Gold devices: done diagnostics: done - discovery: - status: todo - comment: Determine if these devices can support discovery + discovery: done discovery-update-info: status: exempt comment: Devices do not support discovery. diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 3dba5a98f3c..8ee1ea270f3 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -498,6 +498,18 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "ring*", "macaddress": "341513*", }, + { + "domain": "roborock", + "macaddress": "249E7D*", + }, + { + "domain": "roborock", + "macaddress": "B04A39*", + }, + { + "domain": "roborock", + "hostname": "roborock-*", + }, { "domain": "roomba", "hostname": "irobot-*", diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 332a9143c51..758b002f534 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -229,7 +229,13 @@ async def setup_entry( @pytest.fixture(autouse=True) -async def cleanup_map_storage( +async def cleanup_map_storage(cleanup_map_storage_manual) -> Generator[pathlib.Path]: + """Test cleanup, remove any map storage persisted during the test.""" + return cleanup_map_storage_manual + + +@pytest.fixture +async def cleanup_map_storage_manual( hass: HomeAssistant, mock_roborock_entry: MockConfigEntry ) -> Generator[pathlib.Path]: """Test cleanup, remove any map storage persisted during the test.""" diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 507e8060653..82b51e67f8d 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1120,10 +1120,10 @@ PROP = DeviceProp( ) NETWORK_INFO = NetworkInfo( - ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90 + ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc:cc", bssid="bssid", rssi=90 ) NETWORK_INFO_2 = NetworkInfo( - ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd", bssid="bssid", rssi=90 + ip="123.232.12.2", ssid="wifi", mac="ac:cc:cc:cc:cd:cc", bssid="bssid", rssi=90 ) MULTI_MAP_LIST = MultiMapsList.from_dict( diff --git a/tests/components/roborock/test_config_flow.py b/tests/components/roborock/test_config_flow.py index 1bcb72c2f5b..abd19660fba 100644 --- a/tests/components/roborock/test_config_flow.py +++ b/tests/components/roborock/test_config_flow.py @@ -19,8 +19,9 @@ from homeassistant.components.roborock.const import CONF_ENTRY_CODE, DOMAIN, DRA from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .mock_data import MOCK_CONFIG, USER_DATA, USER_EMAIL +from .mock_data import MOCK_CONFIG, NETWORK_INFO, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry @@ -281,3 +282,68 @@ async def test_account_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured_account" + + +async def test_discovery_not_setup( + hass: HomeAssistant, + bypass_api_fixture, +) -> None: + """Handle the config flow and make sure it succeeds.""" + with ( + patch("homeassistant.components.roborock.async_setup_entry", return_value=True), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip=NETWORK_INFO.ip, + macaddress=NETWORK_INFO.mac.replace(":", ""), + hostname="roborock-vacuum-a72", + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.request_code" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USER_EMAIL} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "code" + assert result["errors"] == {} + with patch( + "homeassistant.components.roborock.config_flow.RoborockApiClient.code_login", + return_value=USER_DATA, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ENTRY_CODE: "123456"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == USER_EMAIL + assert result["data"] == MOCK_CONFIG + assert result["result"] + + +async def test_discovery_already_setup( + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, + cleanup_map_storage_manual, +) -> None: + """Handle aborting if the device is already setup.""" + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip=NETWORK_INFO.ip, + macaddress=NETWORK_INFO.mac.replace(":", ""), + hostname="roborock-vacuum-a72", + ), + ) + + assert result["type"] is FlowResultType.ABORT From 9e86ca2e9e9c33ed64e8eef72d85fe0bb30714d3 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sat, 22 Mar 2025 22:27:52 -0700 Subject: [PATCH 1908/1941] Add Switch platform and PDU dynamic outlet switches to NUT (#141159) --- homeassistant/components/nut/__init__.py | 2 + homeassistant/components/nut/const.py | 9 +- homeassistant/components/nut/icons.json | 5 + homeassistant/components/nut/strings.json | 9 +- homeassistant/components/nut/switch.py | 88 ++++++++++++ tests/components/nut/test_switch.py | 159 ++++++++++++++++++++++ 6 files changed, 259 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/nut/switch.py create mode 100644 tests/components/nut/test_switch.py diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 8ec8c132ffe..5b188868819 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -118,6 +118,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: outlet_num_str: str = str(outlet_num) additional_integration_commands |= { f"outlet.{outlet_num_str}.load.cycle", + f"outlet.{outlet_num_str}.load.on", + f"outlet.{outlet_num_str}.load.off", } valid_integration_commands = ( diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index a45b072fe65..d741d8e95f9 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -9,6 +9,7 @@ DOMAIN = "nut" PLATFORMS = [ Platform.BUTTON, Platform.SENSOR, + Platform.SWITCH, ] DEFAULT_NAME = "NUT UPS" @@ -66,10 +67,6 @@ COMMAND_TEST_FAILURE_STOP = "test.failure.stop" COMMAND_TEST_PANEL_START = "test.panel.start" COMMAND_TEST_PANEL_STOP = "test.panel.stop" COMMAND_TEST_SYSTEM_START = "test.system.start" -COMMAND_OUTLET_1_LOAD_OFF = "outlet.1.load.off" -COMMAND_OUTLET_1_LOAD_ON = "outlet.1.load.on" -COMMAND_OUTLET_2_LOAD_OFF = "outlet.2.load.off" -COMMAND_OUTLET_2_LOAD_ON = "outlet.2.load.on" INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_BEEPER_DISABLE, @@ -98,8 +95,4 @@ INTEGRATION_SUPPORTED_COMMANDS = { COMMAND_TEST_PANEL_START, COMMAND_TEST_PANEL_STOP, COMMAND_TEST_SYSTEM_START, - COMMAND_OUTLET_1_LOAD_OFF, - COMMAND_OUTLET_1_LOAD_ON, - COMMAND_OUTLET_2_LOAD_OFF, - COMMAND_OUTLET_2_LOAD_ON, } diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index e69d0405756..bfa4703d65e 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -156,6 +156,11 @@ "outlet_number_load_cycle": { "default": "mdi:restart" } + }, + "switch": { + "outlet_number_load_poweronoff": { + "default": "mdi:power" + } } } } diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index 7a913d44f9e..3ac5f23a0c1 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -74,11 +74,7 @@ "test_failure_stop": "Stop simulating a power failure", "test_panel_start": "Start testing the UPS panel", "test_panel_stop": "Stop a UPS panel test", - "test_system_start": "Start a system test", - "outlet_1_load_on": "Power outlet 1 on", - "outlet_1_load_off": "Power outlet 1 off", - "outlet_2_load_on": "Power outlet 2 on", - "outlet_2_load_off": "Power outlet 2 off" + "test_system_start": "Start a system test" } }, "entity": { @@ -224,6 +220,9 @@ }, "button": { "outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" } + }, + "switch": { + "outlet_number_load_poweronoff": { "name": "Power outlet {outlet_name}" } } } } diff --git a/homeassistant/components/nut/switch.py b/homeassistant/components/nut/switch.py new file mode 100644 index 00000000000..3ab8d0ec60a --- /dev/null +++ b/homeassistant/components/nut/switch.py @@ -0,0 +1,88 @@ +"""Provides a switch for switchable NUT outlets.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NutConfigEntry +from .entity import NUTBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NutConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NUT switches.""" + pynut_data = config_entry.runtime_data + coordinator = pynut_data.coordinator + status = coordinator.data + + # Dynamically add outlet switch types + if (num_outlets := status.get("outlet.count")) is None: + return + + data = pynut_data.data + unique_id = pynut_data.unique_id + user_available_commands = pynut_data.user_available_commands + switch_descriptions = [ + SwitchEntityDescription( + key=f"outlet.{outlet_num!s}.load.poweronoff", + translation_key="outlet_number_load_poweronoff", + translation_placeholders={ + "outlet_name": status.get(f"outlet.{outlet_num!s}.name") + or str(outlet_num) + }, + device_class=SwitchDeviceClass.OUTLET, + entity_registry_enabled_default=True, + ) + for outlet_num in range(1, int(num_outlets) + 1) + if ( + status.get(f"outlet.{outlet_num!s}.switchable") == "yes" + and f"outlet.{outlet_num!s}.load.on" in user_available_commands + and f"outlet.{outlet_num!s}.load.off" in user_available_commands + ) + ] + + async_add_entities( + NUTSwitch(coordinator, description, data, unique_id) + for description in switch_descriptions + ) + + +class NUTSwitch(NUTBaseEntity, SwitchEntity): + """Representation of a switch entity for NUT status values.""" + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + status = self.coordinator.data + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + if (state := status.get(f"{outlet}.{outlet_num_str}.status")) is None: + return None + return bool(state == "on") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the device.""" + + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + command_name = f"{outlet}.{outlet_num_str}.load.on" + await self.pynut_data.async_run_command(command_name) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + + outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2] + command_name = f"{outlet}.{outlet_num_str}.load.off" + await self.pynut_data.async_run_command(command_name) diff --git a/tests/components/nut/test_switch.py b/tests/components/nut/test_switch.py new file mode 100644 index 00000000000..f2de5eeb5e6 --- /dev/null +++ b/tests/components/nut/test_switch.py @@ -0,0 +1,159 @@ +"""Test the NUT switch platform.""" + +import json +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .util import async_init_integration + +from tests.common import load_fixture + + +@pytest.mark.parametrize( + "model", + [ + "CP1350C", + "5E650I", + "5E850I", + "CP1500PFCLCD", + "DL650ELCD", + "EATON5P1550", + "blazer_usb", + ], +) +async def test_switch_ups( + hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str +) -> None: + """Tests that there are no standard switches.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + await async_init_integration( + hass, + model, + list_commands_return_value=list_commands_return_value, + ) + + switch = hass.states.get("switch.ups1_power_outlet_1") + assert not switch + + +@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_switch_pdu_dynamic_outlets( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: str, + unique_id_base: str, +) -> None: + """Tests that the switch entities are correct.""" + + list_commands_return_value = { + supported_command: supported_command + for supported_command in INTEGRATION_SUPPORTED_COMMANDS + } + + for num in range(1, 25): + command = f"outlet.{num!s}.load.on" + list_commands_return_value[command] = command + command = f"outlet.{num!s}.load.off" + list_commands_return_value[command] = command + + ups_fixture = f"nut/{model}.json" + list_vars = json.loads(load_fixture(ups_fixture)) + + run_command = AsyncMock() + + await async_init_integration( + hass, + model, + list_vars=list_vars, + list_commands_return_value=list_commands_return_value, + run_command=run_command, + ) + + entity_id = "switch.ups1_power_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{unique_id_base}_outlet.1.load.poweronoff" + + switch = hass.states.get(entity_id) + assert switch + assert switch.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + run_command.assert_called_with("ups1", "outlet.1.load.off") + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + run_command.assert_called_with("ups1", "outlet.1.load.on") + + switch = hass.states.get("switch.ups1_power_outlet_25") + assert not switch + + switch = hass.states.get("switch.ups1_power_outlet_a25") + assert not switch + + +async def test_switch_pdu_dynamic_outlets_state_unknown( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test switch entity with missing status is reported as unknown.""" + + config_entry = await async_init_integration( + hass, + list_ups={"ups1": "UPS 1"}, + list_vars={ + "outlet.count": "1", + "outlet.1.switchable": "yes", + "outlet.1.name": "A1", + }, + list_commands_return_value={ + "outlet.1.load.on": None, + "outlet.1.load.off": None, + }, + ) + + entity_id = "switch.ups1_power_outlet_a1" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == f"{config_entry.entry_id}_outlet.1.load.poweronoff" + + switch = hass.states.get(entity_id) + assert switch + assert switch.state == STATE_UNKNOWN From 153ccf86b0f60dd0372165f4e8d2cf7f0b635bd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Mar 2025 22:33:44 -1000 Subject: [PATCH 1909/1941] Bump dbus-fast to 2.41.1 (#141162) * Bump dbus-fast to 2.41.0 changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.39.6...v2.41.0 * Apply suggestions from code review --- 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 fbff513329c..27fed6ad647 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.4.5", "bluetooth-data-tools==1.26.1", - "dbus-fast==2.39.6", + "dbus-fast==2.41.1", "habluetooth==3.37.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f03c7446614..476ab97fe1f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.1 -dbus-fast==2.39.6 +dbus-fast==2.41.1 fnv-hash-fast==1.4.0 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index db1a05a376d..b52a0614e31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -744,7 +744,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.6 +dbus-fast==2.41.1 # homeassistant.components.debugpy debugpy==1.8.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce3ffd8f620..b592491b173 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -640,7 +640,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.39.6 +dbus-fast==2.41.1 # homeassistant.components.debugpy debugpy==1.8.13 From 87db9817124ee015c49b5c2cdefd22ee5b33583f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Mar 2025 22:34:49 -1000 Subject: [PATCH 1910/1941] Bump anyio to 4.9.0 (#141161) changelog: https://github.com/agronholm/anyio/compare/4.8.0...4.9.0 --- homeassistant/components/mcp_server/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json index 18b2e5bc417..a3e00d13c4b 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.8.0"], + "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.9.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 476ab97fe1f..eef447193c4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -109,7 +109,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.8.0 +anyio==4.9.0 h11==0.14.0 httpcore==1.0.7 diff --git a/requirements_all.txt b/requirements_all.txt index b52a0614e31..8f7daaf2243 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -479,7 +479,7 @@ anthemav==1.4.1 anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.8.0 +anyio==4.9.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b592491b173..280327f8d23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -452,7 +452,7 @@ anthemav==1.4.1 anthropic==0.47.2 # homeassistant.components.mcp_server -anyio==4.8.0 +anyio==4.9.0 # homeassistant.components.weatherkit apple_weatherkit==1.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index fa823fa4834..1be6286d30c 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -139,7 +139,7 @@ uuid==1000000000.0.0 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==4.8.0 +anyio==4.9.0 h11==0.14.0 httpcore==1.0.7 From 65279c94ac38a920d6db2e4f28f5d560dd141835 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 23 Mar 2025 05:07:22 -0400 Subject: [PATCH 1911/1941] Finish strict typing for Roborock (#141165) Mark strict typing as done --- homeassistant/components/roborock/config_flow.py | 6 +++--- homeassistant/components/roborock/quality_scale.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index c34f7cb87b0..1a359faca10 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -21,7 +21,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -32,6 +31,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo +from . import RoborockConfigEntry from .const import ( CONF_BASE_URL, CONF_ENTRY_CODE, @@ -193,7 +193,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RoborockConfigEntry, ) -> RoborockOptionsFlowHandler: """Create the options flow.""" return RoborockOptionsFlowHandler(config_entry) @@ -202,7 +202,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): class RoborockOptionsFlowHandler(OptionsFlow): """Handle an option flow for Roborock.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: RoborockConfigEntry) -> None: """Initialize options flow.""" self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml index c61db90350f..d064c30ccf6 100644 --- a/homeassistant/components/roborock/quality_scale.yaml +++ b/homeassistant/components/roborock/quality_scale.yaml @@ -72,4 +72,4 @@ rules: # Platinum async-dependency: todo inject-websession: done - strict-typing: todo + strict-typing: done From 3a80a2d5b95909e5f4be28fc6e743ee5ca3cb051 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 23 Mar 2025 02:12:02 -0700 Subject: [PATCH 1912/1941] Bump openai to 1.68.2 (#141154) * Bump openai to 1.68.2 * Remove unused type ignore --- homeassistant/components/openai_conversation/conversation.py | 3 +-- homeassistant/components/openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 7a8830ffd95..6767734bb00 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -99,8 +99,7 @@ def _convert_content_to_param( if isinstance(content, conversation.AssistantContent) and content.tool_calls: messages.extend( - # https://github.com/openai/openai-python/issues/2205 - ResponseFunctionToolCallParam( # type: ignore[typeddict-item] + ResponseFunctionToolCallParam( type="function_call", name=tool_call.tool_name, arguments=json.dumps(tool_call.tool_args), diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index a4e46f6457b..988dd2321d5 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.66.3"] + "requirements": ["openai==1.68.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f7daaf2243..663287929cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1581,7 +1581,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.66.3 +openai==1.68.2 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 280327f8d23..3f0e1873d9c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1326,7 +1326,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.66.3 +openai==1.68.2 # homeassistant.components.openerz openerz-api==0.3.0 From 883ce6842d351197a1bd8d9cf3f8938ea5f91fa6 Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Sun, 23 Mar 2025 10:28:10 +0100 Subject: [PATCH 1913/1941] Fix icon for "Coffee and Milk counter" in HomeConnect (#141170) fix coffee and milk counter --- homeassistant/components/home_connect/icons.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index f781db3ab24..9b4c9276998 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -113,7 +113,7 @@ "milk_counter": { "default": "mdi:cup" }, - "coffee_and_milk": { + "coffee_and_milk_counter": { "default": "mdi:coffee" }, "ristretto_espresso_counter": { From d8a5881eaa8f6a90cc0fc6a9786a235c02a4a54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 23 Mar 2025 11:33:55 +0100 Subject: [PATCH 1914/1941] Home Connect test improvements (#141135) * Home Connect test improvements * Remove `appliance_ha_id` fixture in favour of `appliance` fixture --- tests/components/home_connect/conftest.py | 14 --- .../home_connect/snapshots/test_services.ambr | 8 +- .../home_connect/test_binary_sensor.py | 62 ++++++----- tests/components/home_connect/test_button.py | 47 ++++---- .../home_connect/test_coordinator.py | 62 ++++++----- tests/components/home_connect/test_entity.py | 29 ++--- tests/components/home_connect/test_init.py | 16 +-- tests/components/home_connect/test_light.py | 101 ++++++++++-------- tests/components/home_connect/test_number.py | 56 +++++----- tests/components/home_connect/test_select.py | 77 ++++++------- tests/components/home_connect/test_sensor.py | 89 +++++++-------- .../components/home_connect/test_services.py | 32 +++--- tests/components/home_connect/test_switch.py | 89 +++++++-------- tests/components/home_connect/test_time.py | 43 ++++---- 14 files changed, 386 insertions(+), 339 deletions(-) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index c0caf2b2bdd..21cd236b1a8 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -473,20 +473,6 @@ def mock_client_with_exception( return mock -@pytest.fixture(name="appliance_ha_id") -def mock_appliance_ha_id( - appliances: list[HomeAppliance], request: pytest.FixtureRequest -) -> str: - """Fixture to get the ha_id of an appliance.""" - appliance_type = "Washer" - if hasattr(request, "param") and request.param: - appliance_type = request.param - for appliance in appliances: - if appliance.type == appliance_type: - return appliance.ha_id - raise ValueError(f"Appliance {appliance_type} not found") - - @pytest.fixture(name="appliances") def mock_appliances( appliances_data: str, request: pytest.FixtureRequest diff --git a/tests/components/home_connect/snapshots/test_services.ambr b/tests/components/home_connect/snapshots/test_services.ambr index 709621aaefb..610e9fa1248 100644 --- a/tests/components/home_connect/snapshots/test_services.ambr +++ b/tests/components/home_connect/snapshots/test_services.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_set_program_and_options[service_call0-set_selected_program] +# name: test_set_program_and_options[service_call0-set_selected_program-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -18,7 +18,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call1-start_program] +# name: test_set_program_and_options[service_call1-start_program-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -37,7 +37,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call2-set_active_program_options] +# name: test_set_program_and_options[service_call2-set_active_program_options-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', @@ -57,7 +57,7 @@ }), ) # --- -# name: test_set_program_and_options[service_call3-set_selected_program_options] +# name: test_set_program_and_options[service_call3-set_selected_program_options-Washer] _Call( tuple( 'SIEMENS-HCS03WCH1-7BC6383CF794', diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index a06e386b84f..31c15ec00cf 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -3,7 +3,14 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, MagicMock -from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType +from aiohomeconnect.model import ( + ArrayOfEvents, + Event, + EventKey, + EventMessage, + EventType, + HomeAppliance, +) from aiohomeconnect.model.error import HomeConnectApiError import pytest @@ -52,8 +59,9 @@ async def test_binary_sensors( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -67,7 +75,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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 @@ -75,7 +83,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -83,7 +91,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) @@ -92,7 +100,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -100,13 +108,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -123,7 +132,7 @@ async def test_connected_devices( get_status_original_mock = client.get_status def get_status_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -135,14 +144,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_status = get_status_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -150,19 +159,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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_binary_sensors_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_binary_sensors_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if binary sensor entities availability are based on the appliance connection state.""" entity_ids = [ @@ -181,7 +191,7 @@ async def test_binary_sensors_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -195,7 +205,7 @@ async def test_binary_sensors_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -209,6 +219,7 @@ async def test_binary_sensors_entity_availabilty( assert state.state != STATE_UNAVAILABLE +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("value", "expected"), [ @@ -219,7 +230,7 @@ async def test_binary_sensors_entity_availabilty( ], ) async def test_binary_sensors_door_states( - appliance_ha_id: str, + appliance: HomeAppliance, expected: str, value: str, hass: HomeAssistant, @@ -237,7 +248,7 @@ async def test_binary_sensors_door_states( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -259,7 +270,7 @@ async def test_binary_sensors_door_states( @pytest.mark.parametrize( - ("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"), + ("entity_id", "event_key", "event_value_update", "expected", "appliance"), [ ( "binary_sensor.washer_remote_control", @@ -304,13 +315,13 @@ async def test_binary_sensors_door_states( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_binary_sensors_functionality( entity_id: str, event_key: EventKey, event_value_update: str, - appliance_ha_id: str, + appliance: HomeAppliance, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, @@ -325,7 +336,7 @@ async def test_binary_sensors_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -346,13 +357,14 @@ async def test_binary_sensors_functionality( assert hass.states.is_state(entity_id, expected) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_sensor_functionality( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if the connected binary sensor reports the right values.""" entity_id = "binary_sensor.washer_connectivity" @@ -365,7 +377,7 @@ async def test_connected_sensor_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -378,7 +390,7 @@ async def test_connected_sensor_functionality( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py index 5af7e40ca43..f894494792d 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -4,7 +4,12 @@ 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 import ( + ArrayOfCommands, + CommandKey, + EventMessage, + HomeAppliance, +) from aiohomeconnect.model.command import Command from aiohomeconnect.model.error import HomeConnectApiError from aiohomeconnect.model.event import ArrayOfEvents, EventType @@ -40,8 +45,9 @@ async def test_buttons( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -55,7 +61,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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 @@ -63,7 +69,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -71,7 +77,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) @@ -80,7 +86,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -88,13 +94,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -112,14 +119,14 @@ async def test_connected_devices( get_available_programs_mock = client.get_available_programs async def get_available_commands_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + 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: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -137,14 +144,14 @@ async def test_connected_devices( 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)}) + 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, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -152,19 +159,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_button_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" entity_ids = [ @@ -183,7 +191,7 @@ async def test_button_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -197,7 +205,7 @@ async def test_button_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -211,6 +219,7 @@ async def test_button_entity_availabilty( assert state.state != STATE_UNAVAILABLE +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("entity_id", "method_call", "expected_kwargs"), [ @@ -231,7 +240,7 @@ async def test_button_functionality( entity_id: str, method_call: str, expected_kwargs: dict[str, Any], - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if button entities availability are based on the appliance connection state.""" assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -248,7 +257,7 @@ async def test_button_functionality( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - getattr(client, method_call).assert_called_with(appliance_ha_id, **expected_kwargs) + getattr(client, method_call).assert_called_with(appliance.ha_id, **expected_kwargs) async def test_command_button_exception( diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index 84bef94d658..050758a6568 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -31,8 +31,17 @@ from homeassistant.components.home_connect.const import ( BSH_POWER_OFF, DOMAIN, ) +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.config_entries import ConfigEntries, ConfigEntryState -from homeassistant.const import EVENT_STATE_REPORTED, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_STATE_REPORTED, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import ( Event as HassEvent, EventStateReportedData, @@ -98,30 +107,30 @@ async def test_coordinator_failure_refresh_and_stream( ) entity_id_1 = "binary_sensor.washer_remote_control" entity_id_2 = "binary_sensor.washer_remote_start" - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, HA_DOMAIN, {}) await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED state = hass.states.get(entity_id_1) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state != "unavailable" + assert state.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 + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_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" + assert state.state == STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE # Test that the entity becomes available again after a successful update. @@ -137,16 +146,16 @@ async def test_coordinator_failure_refresh_and_stream( # Force a coordinator refresh. await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_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" + assert state.state != STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE # Test that the event stream makes the entity go available too. @@ -160,16 +169,16 @@ async def test_coordinator_failure_refresh_and_stream( # Force a coordinator refresh await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_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" + assert state.state == STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE # Now make the entity available again. client.get_home_appliances.side_effect = None @@ -199,10 +208,10 @@ async def test_coordinator_failure_refresh_and_stream( state = hass.states.get(entity_id_1) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE state = hass.states.get(entity_id_2) assert state - assert state.state != "unavailable" + assert state.state != STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -235,9 +244,9 @@ async def test_coordinator_update_failing( getattr(client, mock_method).assert_called() -@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) @pytest.mark.parametrize( - ("event_type", "event_key", "event_value", "entity_id"), + ("event_type", "event_key", "event_value", ATTR_ENTITY_ID), [ ( EventType.STATUS, @@ -269,7 +278,7 @@ async def test_event_listener( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, entity_registry: er.EntityRegistry, ) -> None: """Test that the event listener works.""" @@ -280,7 +289,7 @@ async def test_event_listener( state = hass.states.get(entity_id) assert state event_message = EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -327,13 +336,14 @@ async def test_event_listener( listener.assert_called_once_with(new_entity_id) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) 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, + appliance: HomeAppliance, ) -> None: """Test that the event listener is capable of receiving settings and status for the first time.""" client.get_setting = AsyncMock(return_value=ArrayOfSettings([])) @@ -346,7 +356,7 @@ async def tests_receive_setting_and_status_for_first_time_at_events( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -362,7 +372,7 @@ async def tests_receive_setting_and_status_for_first_time_at_events( ), ), EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.STATUS, ArrayOfEvents( [ @@ -519,7 +529,7 @@ async def test_devices_updated_on_refresh( return_value=ArrayOfHomeAppliances(appliances[:2]), ) - await async_setup_component(hass, "homeassistant", {}) + await async_setup_component(hass, HA_DOMAIN, {}) assert config_entry.state == ConfigEntryState.NOT_LOADED await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -532,9 +542,9 @@ async def test_devices_updated_on_refresh( return_value=ArrayOfHomeAppliances(appliances[1:3]), ) await hass.services.async_call( - "homeassistant", - "update_entity", - {"entity_id": "switch.dishwasher_power"}, + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "switch.dishwasher_power"}, blocking=True, ) diff --git a/tests/components/home_connect/test_entity.py b/tests/components/home_connect/test_entity.py index bad02888dbf..e91a01a907a 100644 --- a/tests/components/home_connect/test_entity.py +++ b/tests/components/home_connect/test_entity.py @@ -11,6 +11,7 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + HomeAppliance, Option, OptionKey, Program, @@ -67,7 +68,7 @@ def platforms() -> list[str]: ) @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "option_entity_id", "options_state_stage_1", "options_availability_stage_2", @@ -91,12 +92,12 @@ def platforms() -> list[str]: (OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY, "switch.dishwasher_extra_dry"), ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_options_retrieval( array_of_programs_program_arg: str, event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, option_entity_id: dict[OptionKey, str], options_state_stage_1: list[tuple[str, bool | None]], options_availability_stage_2: list[bool], @@ -122,7 +123,7 @@ async def test_program_options_retrieval( ] async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms: - if ha_id != appliance_ha_id: + 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) @@ -204,7 +205,7 @@ async def test_program_options_retrieval( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -235,6 +236,7 @@ async def test_program_options_retrieval( assert hass.states.is_state(entity_id, STATE_UNKNOWN) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("array_of_programs_program_arg", "event_key"), [ @@ -251,7 +253,7 @@ async def test_program_options_retrieval( async def test_no_options_retrieval_on_unknown_program( array_of_programs_program_arg: str, event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -285,7 +287,7 @@ async def test_no_options_retrieval_on_unknown_program( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -315,7 +317,7 @@ async def test_no_options_retrieval_on_unknown_program( ], ) @pytest.mark.parametrize( - ("appliance_ha_id", "option_key", "option_entity_id"), + ("appliance", "option_key", "option_entity_id"), [ ( "Dishwasher", @@ -323,11 +325,11 @@ async def test_no_options_retrieval_on_unknown_program( "switch.dishwasher_half_load", ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_options_retrieval_after_appliance_connection( event_key: EventKey, - appliance_ha_id: str, + appliance: HomeAppliance, option_key: OptionKey, option_entity_id: str, hass: HomeAssistant, @@ -344,7 +346,7 @@ async def test_program_options_retrieval_after_appliance_connection( [ appliance for appliance in array_of_home_appliances.homeappliances - if appliance.ha_id != appliance_ha_id + if appliance.ha_id != appliance.ha_id ] ) @@ -367,7 +369,7 @@ async def test_program_options_retrieval_after_appliance_connection( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents( [ @@ -405,7 +407,7 @@ async def test_program_options_retrieval_after_appliance_connection( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, data=ArrayOfEvents( [ @@ -450,7 +452,6 @@ async def test_program_options_retrieval_after_appliance_connection( 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]], diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index e0e586929a9..21bb0291e1a 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohomeconnect.const import OAUTH2_TOKEN -from aiohomeconnect.model import SettingKey, StatusKey +from aiohomeconnect.model import HomeAppliance, SettingKey, StatusKey from aiohomeconnect.model.error import ( HomeConnectError, TooManyRequestsError, @@ -247,6 +247,7 @@ async def test_client_rate_limit_error( asyncio_sleep_mock.assert_called_once_with(retry_after) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_required_program_or_at_least_an_option( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -254,7 +255,7 @@ async def test_required_program_or_at_least_an_option( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: "Test that the set_program_and_options does raise an exception if no program nor options are set." @@ -264,7 +265,7 @@ async def test_required_program_or_at_least_an_option( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) with pytest.raises( @@ -281,12 +282,13 @@ async def test_required_program_or_at_least_an_option( ) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, config_entry_v1_1: MockConfigEntry, - appliance_ha_id: str, + appliance: HomeAppliance, platforms: list[Platform], ) -> None: """Test entity migration.""" @@ -295,7 +297,7 @@ 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_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) test_entities = [ @@ -335,7 +337,7 @@ async def test_entity_migration( entity_registry.async_get_or_create( domain, DOMAIN, - f"{appliance_ha_id}-{old_unique_id_suffix}", + f"{appliance.ha_id}-{old_unique_id_suffix}", device_id=device_entry.id, config_entry=config_entry_v1_1, ) @@ -346,7 +348,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_ha_id}-{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 6021c99bb5e..50a1a1e374a 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -21,9 +22,15 @@ from homeassistant.components.home_connect.const import ( BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + DOMAIN as LIGHT_DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -58,9 +65,9 @@ async def test_light( assert config_entry.state == ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -74,7 +81,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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 @@ -82,7 +89,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -90,7 +97,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) @@ -99,7 +106,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -107,14 +114,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -132,14 +139,14 @@ async def test_connected_devices( get_available_programs_mock = client.get_available_programs async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_settings_original_mock.side_effect(ha_id) async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -155,14 +162,14 @@ async def test_connected_devices( client.get_settings = get_settings_original_mock client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -170,20 +177,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) -async def test_light_availabilty( +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) +async def test_light_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if light entities availability are based on the appliance connection state.""" entity_ids = [ @@ -201,7 +208,7 @@ async def test_light_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -215,7 +222,7 @@ async def test_light_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -236,7 +243,7 @@ async def test_light_availabilty( "service", "exprected_attributes", "state", - "appliance_ha_id", + "appliance", ), [ ( @@ -256,7 +263,7 @@ async def test_light_availabilty( SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 199}, + {ATTR_BRIGHTNESS: 199}, STATE_ON, "Hood", ), @@ -277,7 +284,7 @@ async def test_light_availabilty( SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80, }, SERVICE_TURN_ON, - {"brightness": 199}, + {ATTR_BRIGHTNESS: 199}, STATE_ON, "Hood", ), @@ -310,7 +317,7 @@ async def test_light_availabilty( }, SERVICE_TURN_ON, { - "rgb_color": (255, 255, 0), + ATTR_RGB_COLOR: (255, 255, 0), }, STATE_ON, "Hood", @@ -324,8 +331,8 @@ async def test_light_availabilty( }, SERVICE_TURN_ON, { - "hs_color": (255.484, 15.196), - "brightness": 199, + ATTR_HS_COLOR: (255.484, 15.196), + ATTR_BRIGHTNESS: 199, }, STATE_ON, "Hood", @@ -341,7 +348,7 @@ async def test_light_availabilty( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_light_functionality( entity_id: str, @@ -349,7 +356,7 @@ async def test_light_functionality( service: str, exprected_attributes: dict[str, Any], state: str, - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -362,7 +369,7 @@ async def test_light_functionality( assert config_entry.state == ConfigEntryState.LOADED service_data = exprected_attributes.copy() - service_data["entity_id"] = entity_id + service_data[ATTR_ENTITY_ID] = entity_id await hass.services.async_call( LIGHT_DOMAIN, service, @@ -371,7 +378,7 @@ async def test_light_functionality( await hass.async_block_till_done() client.set_setting.assert_has_calls( [ - call(appliance_ha_id, setting_key=setting_key, value=value) + call(appliance.ha_id, setting_key=setting_key, value=value) for setting_key, value in set_settings_args.items() ] ) @@ -386,7 +393,7 @@ async def test_light_functionality( ( "entity_id", "events", - "appliance_ha_id", + "appliance", ), [ ( @@ -397,12 +404,12 @@ async def test_light_functionality( "Hood", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_light_color_different_than_custom( entity_id: str, events: dict[EventKey, Any], - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -417,21 +424,21 @@ async def test_light_color_different_than_custom( LIGHT_DOMAIN, SERVICE_TURN_ON, { - "rgb_color": (255, 255, 0), - "entity_id": entity_id, + ATTR_RGB_COLOR: (255, 255, 0), + ATTR_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 + assert entity_state.attributes[ATTR_RGB_COLOR] is not None + assert entity_state.attributes[ATTR_HS_COLOR] is not None await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -454,8 +461,8 @@ async def test_light_color_different_than_custom( 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 + assert entity_state.attributes[ATTR_RGB_COLOR] is None + assert entity_state.attributes[ATTR_HS_COLOR] is None @pytest.mark.parametrize( @@ -485,7 +492,7 @@ async def test_light_color_different_than_custom( SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70, }, SERVICE_TURN_ON, - {"brightness": 200}, + {ATTR_BRIGHTNESS: 200}, [HomeConnectError, HomeConnectError], r"Error.*turn.*on.*", ), @@ -517,7 +524,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70, }, SERVICE_TURN_ON, - {"brightness": 200}, + {ATTR_BRIGHTNESS: 200}, [HomeConnectError, None, HomeConnectError], r"Error.*set.*brightness.*", ), @@ -530,7 +537,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, - {"rgb_color": (255, 255, 0)}, + {ATTR_RGB_COLOR: (255, 255, 0)}, [HomeConnectError, None, HomeConnectError], r"Error.*select.*custom color.*", ), @@ -543,7 +550,7 @@ async def test_light_color_different_than_custom( SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00", }, SERVICE_TURN_ON, - {"rgb_color": (255, 255, 0)}, + {ATTR_RGB_COLOR: (255, 255, 0)}, [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", ), @@ -556,8 +563,8 @@ async def test_light_color_different_than_custom( }, SERVICE_TURN_ON, { - "hs_color": (255.484, 15.196), - "brightness": 199, + ATTR_HS_COLOR: (255.484, 15.196), + ATTR_BRIGHTNESS: 199, }, [HomeConnectError, None, None, HomeConnectError], r"Error.*set.*color.*", @@ -600,7 +607,7 @@ async def test_light_exception_handling( with pytest.raises(HomeConnectError): await client_with_exception.set_setting() - service_data["entity_id"] = entity_id + service_data[ATTR_ENTITY_ID] = entity_id with pytest.raises(HomeAssistantError, match=exception_match): await hass.services.async_call( LIGHT_DOMAIN, service, service_data, blocking=True diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index bb87cf9f3dc..1de384303ce 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -69,8 +70,9 @@ async def test_number( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -95,7 +97,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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 @@ -103,7 +105,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -111,7 +113,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) @@ -120,7 +122,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -128,14 +130,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -152,7 +154,7 @@ async def test_connected_devices( get_settings_original_mock = client.get_settings def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -164,14 +166,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -179,20 +181,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) -async def test_number_entity_availabilty( +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) +async def test_number_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if number entities availability are based on the appliance connection state.""" entity_ids = [ @@ -215,7 +217,7 @@ async def test_number_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -229,7 +231,7 @@ async def test_number_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -243,7 +245,7 @@ async def test_number_entity_availabilty( assert state.state != STATE_UNAVAILABLE -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -279,7 +281,7 @@ async def test_number_entity_availabilty( ], ) async def test_number_entity_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, type: str, @@ -336,12 +338,12 @@ async def test_number_entity_functionality( ) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=setting_key, value=value + appliance.ha_id, setting_key=setting_key, value=value ) assert hass.states.is_state(entity_id, str(float(value))) -@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize("retry_after", [0, None]) @pytest.mark.parametrize( ( @@ -368,7 +370,7 @@ async def test_number_entity_functionality( @patch("homeassistant.components.home_connect.entity.API_DEFAULT_RETRY_AFTER", new=0) async def test_fetch_constraints_after_rate_limit_error( retry_after: int | None, - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, type: str, @@ -385,7 +387,7 @@ async def test_fetch_constraints_after_rate_limit_error( """Test that, if a API rate limit error is raised, the constraints are fetched later.""" def get_settings_side_effect(ha_id: str): - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfSettings([]) return ArrayOfSettings( [ @@ -511,7 +513,7 @@ async def test_number_entity_error( ], ) @pytest.mark.parametrize( - ("appliance_ha_id", "entity_id", "option_key", "min", "max", "step_size", "unit"), + ("appliance", "entity_id", "option_key", "min", "max", "step_size", "unit"), [ ( "Oven", @@ -523,12 +525,12 @@ async def test_number_entity_error( "°C", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_options_functionality( entity_id: str, option_key: OptionKey, - appliance_ha_id: str, + appliance: HomeAppliance, min: int, max: int, step_size: int, @@ -615,7 +617,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": 80, diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index f20be33081c..f6009640f72 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -12,6 +12,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -72,8 +73,9 @@ async def test_select( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -98,7 +100,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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 @@ -106,7 +108,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -114,7 +116,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) @@ -123,7 +125,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -131,13 +133,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -156,13 +159,13 @@ async def test_connected_devices( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -170,19 +173,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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 -async def test_select_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_select_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if select entities availability are based on the appliance connection state.""" entity_ids = [ @@ -200,7 +204,7 @@ async def test_select_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -214,7 +218,7 @@ async def test_select_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -290,7 +294,7 @@ async def test_filter_programs( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "expected_initial_state", "mock_method", @@ -318,10 +322,10 @@ async def test_filter_programs( EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_select_program_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, expected_initial_state: str, mock_method: str, @@ -347,14 +351,14 @@ async def test_select_program_functionality( ) await hass.async_block_till_done() getattr(client, mock_method).assert_awaited_once_with( - appliance_ha_id, program_key=program_key + 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, + appliance.ha_id, EventType.NOTIFY, ArrayOfEvents( [ @@ -433,13 +437,13 @@ async def test_select_exception_handling( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {"entity_id": entity_id, "option": program_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set}, blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -473,7 +477,7 @@ async def test_select_exception_handling( ], ) async def test_select_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, expected_options: set[str], @@ -497,12 +501,12 @@ async def test_select_functionality( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_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.args == (appliance.ha_id,) assert client.set_setting.call_args.kwargs == { "setting_key": setting_key, "value": expected_value_call_arg, @@ -510,7 +514,7 @@ 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("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -537,7 +541,7 @@ async def test_select_functionality( ], ) async def test_fetch_allowed_values( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, test_setting_key: SettingKey, allowed_values: list[str | None], @@ -554,7 +558,7 @@ async def test_fetch_allowed_values( 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: + 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, @@ -576,7 +580,7 @@ async def test_fetch_allowed_values( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -594,7 +598,7 @@ async def test_fetch_allowed_values( ], ) async def test_fetch_allowed_values_after_rate_limit_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, allowed_values: list[str | None], @@ -608,7 +612,7 @@ async def test_fetch_allowed_values_after_rate_limit_error( """Test fetch allowed values.""" def get_settings_side_effect(ha_id: str): - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfSettings([]) return ArrayOfSettings( [ @@ -648,7 +652,7 @@ async def test_fetch_allowed_values_after_rate_limit_error( assert set(entity_state.attributes[ATTR_OPTIONS]) == expected_options -@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +@pytest.mark.parametrize("appliance", ["Hood"], indirect=True) @pytest.mark.parametrize( ( "entity_id", @@ -669,7 +673,7 @@ async def test_fetch_allowed_values_after_rate_limit_error( ], ) async def test_default_values_after_fetch_allowed_values_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, exception: Exception, @@ -683,7 +687,7 @@ async def test_default_values_after_fetch_allowed_values_error( """Test fetch allowed values.""" def get_settings_side_effect(ha_id: str): - if ha_id != appliance_ha_id: + if ha_id != appliance.ha_id: return ArrayOfSettings([]) return ArrayOfSettings( [ @@ -758,12 +762,13 @@ async def test_select_entity_error( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: entity_id, "option": value_to_set}, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: value_to_set}, blocking=True, ) assert getattr(client_with_exception, mock_attr).call_count == 2 +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ( "set_active_program_options_side_effect", @@ -840,7 +845,7 @@ async def test_options_functionality( option_key: OptionKey, allowed_values: list[str | None] | None, expected_options: set[str], - appliance_ha_id: str, + appliance: HomeAppliance, set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, @@ -894,7 +899,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + 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", diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index a7836223737..f30723af7fa 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -10,6 +10,7 @@ from aiohomeconnect.model import ( EventKey, EventMessage, EventType, + HomeAppliance, Status, StatusKey, ) @@ -99,8 +100,9 @@ async def test_sensors( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -114,7 +116,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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 @@ -122,7 +124,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -130,7 +132,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) @@ -139,7 +141,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -147,13 +149,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -170,7 +173,7 @@ async def test_connected_devices( get_status_original_mock = client.get_status def get_status_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -182,14 +185,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_status = get_status_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -197,20 +200,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) -async def test_sensor_entity_availabilty( +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) +async def test_sensor_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if sensor entities availability are based on the appliance connection state.""" entity_ids = [ @@ -229,7 +232,7 @@ async def test_sensor_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -243,7 +246,7 @@ async def test_sensor_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -257,7 +260,7 @@ async def test_sensor_entity_availabilty( assert state.state != STATE_UNAVAILABLE -# Appliance_ha_id program sequence with a delayed start. +# Appliance program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, EVENT_PROG_RUN, @@ -292,7 +295,7 @@ ENTITY_ID_STATES = { } -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("states", "event_run"), list( @@ -305,7 +308,7 @@ ENTITY_ID_STATES = { ) async def test_program_sensors( client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, states: tuple, event_run: dict[EventType, dict[EventKey, str | int]], freezer: FrozenDateTimeFactory, @@ -335,7 +338,7 @@ async def test_program_sensors( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -359,7 +362,7 @@ async def test_program_sensors( assert hass.states.is_state(entity_id, state) -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) @pytest.mark.parametrize( ("initial_operation_state", "initial_state", "event_order", "entity_states"), [ @@ -382,7 +385,7 @@ async def test_program_sensor_edge_case( initial_state: str, event_order: tuple[EventType, EventType], entity_states: tuple[str, str], - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -413,7 +416,7 @@ async def test_program_sensor_edge_case( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -452,9 +455,9 @@ ENTITY_ID_EDGE_CASE_STATES = [ ] -@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True) async def test_remaining_prog_time_edge_cases( - appliance_ha_id: str, + appliance: HomeAppliance, freezer: FrozenDateTimeFactory, hass: HomeAssistant, config_entry: MockConfigEntry, @@ -478,7 +481,7 @@ async def test_remaining_prog_time_edge_cases( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -509,7 +512,7 @@ async def test_remaining_prog_time_edge_cases( "event_type", "event_value_update", "expected", - "appliance_ha_id", + "appliance", ), [ ( @@ -601,14 +604,14 @@ async def test_remaining_prog_time_edge_cases( "CoffeeMaker", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensors_states( entity_id: str, event_key: EventKey, event_type: EventType, event_value_update: str, - appliance_ha_id: str, + appliance: HomeAppliance, expected: str, hass: HomeAssistant, config_entry: MockConfigEntry, @@ -616,7 +619,7 @@ async def test_sensors_states( setup_credentials: None, client: MagicMock, ) -> None: - """Tests for Appliance_ha_id alarm sensors.""" + """Tests for appliance alarm sensors.""" assert config_entry.state == ConfigEntryState.NOT_LOADED assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED @@ -624,7 +627,7 @@ async def test_sensors_states( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, event_type, ArrayOfEvents( [ @@ -647,7 +650,7 @@ async def test_sensors_states( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "status_key", "unit_get_status", @@ -672,10 +675,10 @@ async def test_sensors_states( 1, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, unit_get_status: str | None, @@ -690,7 +693,7 @@ async def test_sensor_unit_fetching( """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: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ @@ -729,7 +732,7 @@ async def test_sensor_unit_fetching( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "status_key", ), @@ -740,10 +743,10 @@ async def test_sensor_unit_fetching( StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE, ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, hass: HomeAssistant, @@ -755,7 +758,7 @@ async def test_sensor_unit_fetching_error( """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: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ @@ -779,7 +782,7 @@ async def test_sensor_unit_fetching_error( @pytest.mark.parametrize( ( - "appliance_ha_id", + "appliance", "entity_id", "status_key", "unit", @@ -792,10 +795,10 @@ async def test_sensor_unit_fetching_error( "°C", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_sensor_unit_fetching_after_rate_limit_error( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, status_key: StatusKey, unit: str, @@ -808,7 +811,7 @@ async def test_sensor_unit_fetching_after_rate_limit_error( """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: + if ha_id != appliance.ha_id: return ArrayOfStatus([]) return ArrayOfStatus( [ diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py index 517564724a9..2915cbe4f69 100644 --- a/tests/components/home_connect/test_services.py +++ b/tests/components/home_connect/test_services.py @@ -5,7 +5,7 @@ from http import HTTPStatus from typing import Any from unittest.mock import MagicMock -from aiohomeconnect.model import OptionKey, ProgramKey, SettingKey +from aiohomeconnect.model import HomeAppliance, OptionKey, ProgramKey, SettingKey import pytest from syrupy.assertion import SnapshotAssertion @@ -170,6 +170,7 @@ SERVICES_SET_PROGRAM_AND_OPTIONS = [ ] +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, @@ -182,7 +183,7 @@ async def test_key_value_services( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Create and test services.""" assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -191,7 +192,7 @@ async def test_key_value_services( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_name = service_call["service"] @@ -203,6 +204,7 @@ async def test_key_value_services( ) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("service_call", "issue_id"), [ @@ -231,7 +233,7 @@ async def test_programs_and_options_actions_deprecation( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, issue_registry: ir.IssueRegistry, hass_client: ClientSessionGenerator, ) -> None: @@ -242,7 +244,7 @@ async def test_programs_and_options_actions_deprecation( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -279,6 +281,7 @@ async def test_programs_and_options_actions_deprecation( assert len(issue_registry.issues) == 0 +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("service_call", "called_method"), zip( @@ -301,7 +304,7 @@ async def test_set_program_and_options( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, snapshot: SnapshotAssertion, ) -> None: """Test recognized options.""" @@ -311,7 +314,7 @@ async def test_set_program_and_options( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -322,6 +325,7 @@ async def test_set_program_and_options( assert method_mock.call_args == snapshot +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( ("service_call", "error_regex"), zip( @@ -344,7 +348,7 @@ async def test_set_program_and_options_exceptions( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client_with_exception: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test recognized options.""" assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -353,7 +357,7 @@ async def test_set_program_and_options_exceptions( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -361,6 +365,7 @@ async def test_set_program_and_options_exceptions( await hass.services.async_call(**service_call) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, @@ -372,7 +377,7 @@ async def test_services_exception_device_id( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client_with_exception: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, device_registry: dr.DeviceRegistry, ) -> None: """Raise a HomeAssistantError when there is an API error.""" @@ -382,7 +387,7 @@ async def test_services_exception_device_id( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id @@ -434,6 +439,7 @@ async def test_services_appliance_not_found( await hass.services.async_call(**service_call) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, @@ -445,7 +451,7 @@ async def test_services_exception( integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client_with_exception: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, device_registry: dr.DeviceRegistry, ) -> None: """Raise a ValueError when device id does not match.""" @@ -455,7 +461,7 @@ async def test_services_exception( device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, appliance_ha_id)}, + identifiers={(DOMAIN, appliance.ha_id)}, ) service_call["service_data"]["device_id"] = device_entry.id diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 1b38809dc05..2903c8ac718 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -13,6 +13,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, OptionKey, ProgramDefinition, ProgramKey, @@ -79,8 +80,9 @@ async def test_switches( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -105,7 +107,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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 @@ -113,7 +115,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -121,7 +123,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) @@ -130,7 +132,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -138,13 +140,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -162,14 +165,14 @@ async def test_connected_devices( get_available_programs_mock = client.get_available_programs async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) return await get_settings_original_mock.side_effect(ha_id) async def get_available_programs_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -185,14 +188,14 @@ async def test_connected_devices( client.get_settings = get_settings_original_mock client.get_available_programs = get_available_programs_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -200,20 +203,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) -@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) -async def test_switch_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True) +async def test_switch_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if switch entities availability are based on the appliance connection state.""" entity_ids = [ @@ -233,7 +236,7 @@ async def test_switch_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -247,7 +250,7 @@ async def test_switch_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -268,7 +271,7 @@ async def test_switch_entity_availabilty( "settings_key_arg", "setting_value_arg", "state", - "appliance_ha_id", + "appliance", ), [ ( @@ -288,7 +291,7 @@ async def test_switch_entity_availabilty( "Dishwasher", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_switch_functionality( entity_id: str, @@ -300,7 +303,7 @@ async def test_switch_functionality( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test switch functionality.""" @@ -312,13 +315,13 @@ async def test_switch_functionality( 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 + 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", "initial_state", "appliance_ha_id"), + ("entity_id", "program_key", "initial_state", "appliance"), [ ( "switch.dryer_program_mix", @@ -333,7 +336,7 @@ async def test_switch_functionality( "Dryer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_program_switch_functionality( entity_id: str, @@ -343,7 +346,7 @@ async def test_program_switch_functionality( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test switch functionality.""" @@ -383,7 +386,7 @@ async def test_program_switch_functionality( 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 + appliance.ha_id, program_key=program_key ) await hass.services.async_call( @@ -391,7 +394,7 @@ async def test_program_switch_functionality( ) 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) + client.stop_program.assert_awaited_once_with(appliance.ha_id) @pytest.mark.parametrize( @@ -496,7 +499,7 @@ async def test_switch_exception_handling( @pytest.mark.parametrize( - ("entity_id", "status", "service", "state", "appliance_ha_id"), + ("entity_id", "status", "service", "state", "appliance"), [ ( "switch.fridgefreezer_freezer_super_mode", @@ -513,7 +516,7 @@ async def test_switch_exception_handling( "FridgeFreezer", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_ent_desc_switch_functionality( entity_id: str, @@ -524,7 +527,7 @@ async def test_ent_desc_switch_functionality( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test switch functionality - entity description setup.""" @@ -544,7 +547,7 @@ async def test_ent_desc_switch_functionality( "status", "service", "mock_attr", - "appliance_ha_id", + "appliance", "exception_match", ), [ @@ -565,7 +568,7 @@ async def test_ent_desc_switch_functionality( r"Error.*turn.*off.*", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_ent_desc_switch_exception_handling( entity_id: str, @@ -577,7 +580,7 @@ async def test_ent_desc_switch_exception_handling( integration_setup: Callable[[MagicMock], Awaitable[bool]], config_entry: MockConfigEntry, setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client_with_exception: MagicMock, ) -> None: """Test switch exception handling - entity description setup.""" @@ -613,7 +616,7 @@ async def test_ent_desc_switch_exception_handling( "service", "setting_value_arg", "power_state", - "appliance_ha_id", + "appliance", ), [ ( @@ -649,9 +652,9 @@ async def test_ent_desc_switch_exception_handling( "Dishwasher", ), ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) -async def test_power_swtich( +async def test_power_switch( entity_id: str, allowed_values: list[str | None] | None, service: str, @@ -661,7 +664,7 @@ async def test_power_swtich( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, - appliance_ha_id: str, + appliance: HomeAppliance, client: MagicMock, ) -> None: """Test power switch functionality.""" @@ -686,7 +689,7 @@ async def test_power_swtich( 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, + appliance.ha_id, setting_key=SettingKey.BSH_COMMON_POWER_STATE, value=setting_value_arg, ) @@ -800,7 +803,7 @@ async def test_power_switch_service_validation_errors( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_create_issue( hass: HomeAssistant, - appliance_ha_id: str, + appliance: HomeAppliance, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, @@ -882,7 +885,7 @@ async def test_create_issue( ], ) @pytest.mark.parametrize( - ("entity_id", "option_key", "appliance_ha_id"), + ("entity_id", "option_key", "appliance"), [ ( "switch.dishwasher_half_load", @@ -890,12 +893,12 @@ async def test_create_issue( "Dishwasher", ) ], - indirect=["appliance_ha_id"], + indirect=["appliance"], ) async def test_options_functionality( entity_id: str, option_key: OptionKey, - appliance_ha_id: str, + appliance: HomeAppliance, set_active_program_options_side_effect: ActiveProgramNotSetError | None, set_selected_program_options_side_effect: SelectedProgramNotSetError | None, called_mock_method: str, @@ -933,7 +936,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": False, @@ -946,7 +949,7 @@ async def test_options_functionality( await hass.async_block_till_done() assert called_mock.called - assert called_mock.call_args.args == (appliance_ha_id,) + assert called_mock.call_args.args == (appliance.ha_id,) assert called_mock.call_args.kwargs == { "option_key": option_key, "value": True, diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index affb5ecfedf..6be23460cac 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -10,6 +10,7 @@ from aiohomeconnect.model import ( EventMessage, EventType, GetSetting, + HomeAppliance, SettingKey, ) from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError @@ -44,9 +45,9 @@ async def test_time( assert config_entry.state is ConfigEntryState.LOADED -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_paired_depaired_devices_flow( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -60,7 +61,7 @@ async def test_paired_depaired_devices_flow( assert await integration_setup(client) assert config_entry.state == ConfigEntryState.LOADED - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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 @@ -68,7 +69,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DEPAIRED, data=ArrayOfEvents([]), ) @@ -76,7 +77,7 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) @@ -85,7 +86,7 @@ async def test_paired_depaired_devices_flow( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.PAIRED, data=ArrayOfEvents([]), ) @@ -93,14 +94,14 @@ async def test_paired_depaired_devices_flow( ) await hass.async_block_till_done() - assert device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) async def test_connected_devices( - appliance_ha_id: str, + appliance: HomeAppliance, hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -117,7 +118,7 @@ async def test_connected_devices( get_settings_original_mock = client.get_settings async def get_settings_side_effect(ha_id: str): - if ha_id == appliance_ha_id: + if ha_id == appliance.ha_id: raise HomeConnectApiError( "SDK.Error.HomeAppliance.Connection.Initialization.Failed" ) @@ -129,14 +130,14 @@ async def test_connected_devices( assert config_entry.state == ConfigEntryState.LOADED client.get_settings = get_settings_original_mock - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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, + appliance.ha_id, EventType.CONNECTED, data=ArrayOfEvents([]), ) @@ -144,20 +145,20 @@ async def test_connected_devices( ) await hass.async_block_till_done() - device = device_registry.async_get_device(identifiers={(DOMAIN, appliance_ha_id)}) + 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) -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) -async def test_time_entity_availabilty( +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) +async def test_time_entity_availability( hass: HomeAssistant, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], setup_credentials: None, client: MagicMock, - appliance_ha_id: str, + appliance: HomeAppliance, ) -> None: """Test if time entities availability are based on the appliance connection state.""" entity_ids = [ @@ -175,7 +176,7 @@ async def test_time_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.DISCONNECTED, ArrayOfEvents([]), ) @@ -189,7 +190,7 @@ async def test_time_entity_availabilty( await client.add_events( [ EventMessage( - appliance_ha_id, + appliance.ha_id, EventType.CONNECTED, ArrayOfEvents([]), ) @@ -203,7 +204,7 @@ async def test_time_entity_availabilty( assert state.state != STATE_UNAVAILABLE -@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +@pytest.mark.parametrize("appliance", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key"), [ @@ -214,7 +215,7 @@ async def test_time_entity_availabilty( ], ) async def test_time_entity_functionality( - appliance_ha_id: str, + appliance: HomeAppliance, entity_id: str, setting_key: SettingKey, hass: HomeAssistant, @@ -242,7 +243,7 @@ async def test_time_entity_functionality( ) await hass.async_block_till_done() client.set_setting.assert_awaited_once_with( - appliance_ha_id, setting_key=setting_key, value=value + appliance.ha_id, setting_key=setting_key, value=value ) assert hass.states.is_state(entity_id, str(time(second=value))) From 489c4862786b627d56dc286d670d6ffb9f6212f9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Mar 2025 12:05:40 +0100 Subject: [PATCH 1915/1941] Rework Synology DSM to use config entry runtime_data (#141084) rework to use config entry runtime_data --- .../components/synology_dsm/__init__.py | 24 ++++++++------- .../components/synology_dsm/backup.py | 20 ++++++------- .../components/synology_dsm/binary_sensor.py | 9 ++---- .../components/synology_dsm/button.py | 7 ++--- .../components/synology_dsm/camera.py | 8 ++--- .../components/synology_dsm/config_flow.py | 8 +++-- .../components/synology_dsm/coordinator.py | 24 +++++++++++---- .../components/synology_dsm/diagnostics.py | 9 +++--- .../components/synology_dsm/media_source.py | 29 ++++++++++++++----- .../components/synology_dsm/models.py | 22 -------------- .../components/synology_dsm/repairs.py | 7 ++--- .../components/synology_dsm/sensor.py | 10 +++---- .../components/synology_dsm/service.py | 22 ++++++++++---- .../components/synology_dsm/switch.py | 8 ++--- .../components/synology_dsm/update.py | 9 ++---- 15 files changed, 110 insertions(+), 106 deletions(-) delete mode 100644 homeassistant/components/synology_dsm/models.py diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 1b26b7df84d..70c7e76a53a 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -9,7 +9,6 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera 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 from homeassistant.exceptions import ConfigEntryNotReady @@ -31,15 +30,16 @@ from .const import ( from .coordinator import ( SynologyDSMCameraUpdateCoordinator, SynologyDSMCentralUpdateCoordinator, + SynologyDSMConfigEntry, + SynologyDSMData, SynologyDSMSwitchUpdateCoordinator, ) -from .models import SynologyDSMData from .service import async_setup_services _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) -> bool: """Set up Synology DSM sensors.""" # Migrate device identifiers @@ -120,13 +120,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SYNOLOGY_CONNECTION_EXCEPTIONS as ex: raise ConfigEntryNotReady from ex - synology_data = SynologyDSMData( + entry.runtime_data = SynologyDSMData( api=api, coordinator_central=coordinator_central, coordinator_cameras=coordinator_cameras, coordinator_switches=coordinator_switches, ) - hass.data.setdefault(DOMAIN, {})[entry.unique_id] = synology_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -143,25 +142,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: SynologyDSMConfigEntry +) -> bool: """Unload Synology DSM sensors.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + entry_data = entry.runtime_data await entry_data.api.async_unload() - hass.data[DOMAIN].pop(entry.unique_id) return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: SynologyDSMConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) async def async_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove synology_dsm config entry from a device.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api assert api.information is not None serial = api.information.serial diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py index c4b44542059..11f4287dea2 100644 --- a/homeassistant/components/synology_dsm/backup.py +++ b/homeassistant/components/synology_dsm/backup.py @@ -17,7 +17,6 @@ from homeassistant.components.backup import ( BackupNotFound, suggested_filename, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator from homeassistant.helpers.json import json_dumps @@ -29,7 +28,7 @@ from .const import ( DATA_BACKUP_AGENT_LISTENERS, DOMAIN, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -47,18 +46,17 @@ async def async_get_backup_agents( hass: HomeAssistant, ) -> list[BackupAgent]: """Return a list of backup agents.""" - if not ( - entries := hass.config_entries.async_loaded_entries(DOMAIN) - ) or not hass.data.get(DOMAIN): + entries: list[SynologyDSMConfigEntry] = hass.config_entries.async_loaded_entries( + DOMAIN + ) + if not entries: LOGGER.debug("No proper config entry found") return [] - syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN] return [ SynologyDSMBackupAgent(hass, entry, entry.unique_id) for entry in entries if entry.unique_id is not None - and (syno_data := syno_datas.get(entry.unique_id)) - and syno_data.api.file_station + and entry.runtime_data.api.file_station and entry.options.get(CONF_BACKUP_PATH) ] @@ -91,7 +89,9 @@ class SynologyDSMBackupAgent(BackupAgent): domain = DOMAIN - def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None: + def __init__( + self, hass: HomeAssistant, entry: SynologyDSMConfigEntry, unique_id: str + ) -> None: """Initialize the Synology DSM backup agent.""" super().__init__() LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id) @@ -100,7 +100,7 @@ class SynologyDSMBackupAgent(BackupAgent): self.path = ( f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}" ) - syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + syno_data = entry.runtime_data self.api = syno_data.api self.backup_base_names: dict[str, str] = {} diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 2f7d041cb10..1ae5fa90760 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -12,20 +12,17 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISKS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi -from .const import DOMAIN -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -64,11 +61,11 @@ STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ... async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS binary sensor.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api coordinator = data.coordinator_central assert api.storage is not None diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 6512c370334..79297b1f1b4 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -12,7 +12,6 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -52,11 +51,11 @@ BUTTONS: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set buttons for device.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data async_add_entities(SynologyDSMButton(data.api, button) for button in BUTTONS) diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index acbcccb8894..f393b8efb55 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -16,7 +16,6 @@ from homeassistant.components.camera import ( CameraEntityDescription, CameraEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -29,9 +28,8 @@ from .const import ( DOMAIN, SIGNAL_CAMERA_SOURCE_CHANGED, ) -from .coordinator import SynologyDSMCameraUpdateCoordinator +from .coordinator import SynologyDSMCameraUpdateCoordinator, SynologyDSMConfigEntry from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -47,11 +45,11 @@ class SynologyDSMCameraEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS cameras.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data if coordinator := data.coordinator_cameras: async_add_entities( SynoDSMCamera(data.api, coordinator, camera_id) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 58784862305..f0da6f8fe47 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -72,7 +72,7 @@ from .const import ( DOMAIN, SYNOLOGY_CONNECTION_EXCEPTIONS, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry _LOGGER = logging.getLogger(__name__) @@ -131,7 +131,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SynologyDSMConfigEntry, ) -> SynologyDSMOptionsFlowHandler: """Get the options flow for this handler.""" return SynologyDSMOptionsFlowHandler() @@ -444,6 +444,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): class SynologyDSMOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" + config_entry: SynologyDSMConfigEntry + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -451,7 +453,7 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) - syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.config_entry.unique_id] + syno_data = self.config_entry.runtime_data data_schema = vol.Schema( { diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 1b3e21090b8..a35432f0774 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, Concatenate @@ -28,6 +29,19 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +@dataclass +class SynologyDSMData: + """Data for the synology_dsm integration.""" + + api: SynoApi + coordinator_central: SynologyDSMCentralUpdateCoordinator + coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None + coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None + + +type SynologyDSMConfigEntry = ConfigEntry[SynologyDSMData] + + def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( func: Callable[Concatenate[_T, _P], Awaitable[_R]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]: @@ -57,12 +71,12 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R]( class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator base class for synology_dsm.""" - config_entry: ConfigEntry + config_entry: SynologyDSMConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, update_interval: timedelta, ) -> None: @@ -85,7 +99,7 @@ class SynologyDSMSwitchUpdateCoordinator( def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for switch devices.""" @@ -116,7 +130,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]): def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for central device.""" @@ -136,7 +150,7 @@ class SynologyDSMCameraUpdateCoordinator( def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for cameras.""" diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index b30955ae682..a673be23096 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -6,21 +6,20 @@ from typing import Any from homeassistant.components.camera import diagnostics as camera_diagnostics from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CONF_DEVICE_TOKEN, DOMAIN -from .models import SynologyDSMData +from .const import CONF_DEVICE_TOKEN +from .coordinator import SynologyDSMConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TOKEN} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SynologyDSMConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data syno_api = data.api dsm_info = syno_api.dsm.information diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index d35b262809c..6234f5e8dd0 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -2,6 +2,7 @@ from __future__ import annotations +from logging import getLogger import mimetypes from aiohttp import web @@ -22,7 +23,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import DOMAIN, SHARED_SUFFIX -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry, SynologyDSMData + +LOGGER = getLogger(__name__) async def async_get_media_source(hass: HomeAssistant) -> MediaSource: @@ -41,15 +44,13 @@ class SynologyPhotosMediaSourceIdentifier: """Split identifier into parts.""" parts = identifier.split("/") - self.unique_id = None + self.unique_id = parts[0] self.album_id = None self.cache_key = None self.file_name = None self.is_shared = False self.passphrase = "" - self.unique_id = parts[0] - if len(parts) > 1: album_parts = parts[1].split("_") self.album_id = album_parts[0] @@ -82,7 +83,7 @@ class SynologyPhotosMediaSource(MediaSource): item: MediaSourceItem, ) -> BrowseMediaSource: """Return media.""" - if not self.hass.data.get(DOMAIN): + if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise BrowseError("Diskstation not initialized") return BrowseMediaSource( domain=DOMAIN, @@ -116,7 +117,13 @@ class SynologyPhotosMediaSource(MediaSource): for entry in self.entries ] identifier = SynologyPhotosMediaSourceIdentifier(item.identifier) - diskstation: SynologyDSMData = self.hass.data[DOMAIN][identifier.unique_id] + entry: SynologyDSMConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, identifier.unique_id + ) + ) + assert entry + diskstation = entry.runtime_data assert diskstation.api.photos is not None if identifier.album_id is None: @@ -244,7 +251,7 @@ class SynologyDsmMediaView(http.HomeAssistantView): self, request: web.Request, source_dir_id: str, location: str ) -> web.Response: """Start a GET request.""" - if not self.hass.data.get(DOMAIN): + if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise web.HTTPNotFound # location: {cache_key}/{filename} cache_key, file_name, passphrase = location.split("/") @@ -257,7 +264,13 @@ class SynologyDsmMediaView(http.HomeAssistantView): if not isinstance(mime_type, str): raise web.HTTPNotFound - diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id] + entry: SynologyDSMConfigEntry | None = ( + self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, source_dir_id + ) + ) + assert entry + diskstation = entry.runtime_data assert diskstation.api.photos is not None item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase) try: diff --git a/homeassistant/components/synology_dsm/models.py b/homeassistant/components/synology_dsm/models.py deleted file mode 100644 index 4f51d329ded..00000000000 --- a/homeassistant/components/synology_dsm/models.py +++ /dev/null @@ -1,22 +0,0 @@ -"""The synology_dsm integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from .common import SynoApi -from .coordinator import ( - SynologyDSMCameraUpdateCoordinator, - SynologyDSMCentralUpdateCoordinator, - SynologyDSMSwitchUpdateCoordinator, -) - - -@dataclass -class SynologyDSMData: - """Data for the synology_dsm integration.""" - - api: SynoApi - coordinator_central: SynologyDSMCentralUpdateCoordinator - coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None - coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py index 725e77a2593..8a4e47a32b5 100644 --- a/homeassistant/components/synology_dsm/repairs.py +++ b/homeassistant/components/synology_dsm/repairs.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.selector import ( @@ -28,7 +27,7 @@ from .const import ( ISSUE_MISSING_BACKUP_SETUP, SYNOLOGY_CONNECTION_EXCEPTIONS, ) -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -36,7 +35,7 @@ LOGGER = logging.getLogger(__name__) class MissingBackupSetupRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, entry: ConfigEntry, issue_id: str) -> None: + def __init__(self, entry: SynologyDSMConfigEntry, issue_id: str) -> None: """Create flow.""" self.entry = entry self.issue_id = issue_id @@ -59,7 +58,7 @@ class MissingBackupSetupRepairFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" - syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.entry.unique_id] + syno_data = self.entry.runtime_data if user_input is not None: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 2987de7a7c7..566885e3989 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DISKS, PERCENTAGE, @@ -31,14 +30,13 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import SynoApi -from .const import CONF_VOLUMES, DOMAIN, ENTITY_UNIT_LOAD -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .const import CONF_VOLUMES, ENTITY_UNIT_LOAD +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -287,11 +285,11 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS Sensor.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data api = data.api coordinator = data.coordinator_central storage = api.storage diff --git a/homeassistant/components/synology_dsm/service.py b/homeassistant/components/synology_dsm/service.py index 366f7d4ba3a..40b6fd4bc30 100644 --- a/homeassistant/components/synology_dsm/service.py +++ b/homeassistant/components/synology_dsm/service.py @@ -3,13 +3,14 @@ from __future__ import annotations import logging +from typing import cast from synology_dsm.exceptions import SynologyDSMException from homeassistant.core import HomeAssistant, ServiceCall from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES -from .models import SynologyDSMData +from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) @@ -19,11 +20,20 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def service_handler(call: ServiceCall) -> None: """Handle service call.""" - serial = call.data.get(CONF_SERIAL) - dsm_devices = hass.data[DOMAIN] + serial: str | None = call.data.get(CONF_SERIAL) + entries: list[SynologyDSMConfigEntry] = ( + hass.config_entries.async_loaded_entries(DOMAIN) + ) + dsm_devices = { + cast(str, entry.unique_id): entry.runtime_data for entry in entries + } if serial: - dsm_device: SynologyDSMData = hass.data[DOMAIN][serial] + entry: SynologyDSMConfigEntry | None = ( + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) + ) + assert entry + dsm_device = entry.runtime_data elif len(dsm_devices) == 1: dsm_device = next(iter(dsm_devices.values())) serial = next(iter(dsm_devices)) @@ -39,7 +49,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: return if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: - if serial not in hass.data[DOMAIN]: + if serial not in dsm_devices: LOGGER.error("DSM with specified serial %s not found", serial) return LOGGER.debug("%s DSM with serial %s", call.service, serial) @@ -50,7 +60,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: ), call.service, ) - dsm_device = hass.data[DOMAIN][serial] + dsm_device = dsm_devices[serial] dsm_api = dsm_device.api try: await getattr(dsm_api, f"async_{call.service}")() diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index c4f1572ceea..91863ff3a26 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -9,16 +9,14 @@ from typing import Any from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SynoApi from .const import DOMAIN -from .coordinator import SynologyDSMSwitchUpdateCoordinator +from .coordinator import SynologyDSMConfigEntry, SynologyDSMSwitchUpdateCoordinator from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -41,11 +39,11 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Synology NAS switch.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data if coordinator := data.coordinator_switches: assert coordinator.version is not None async_add_entities( diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 71eed2d7f1f..3048a38cb9c 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -9,15 +9,12 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade from yarl import URL from homeassistant.components.update import UpdateEntity, UpdateEntityDescription -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 DOMAIN -from .coordinator import SynologyDSMCentralUpdateCoordinator +from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription -from .models import SynologyDSMData @dataclass(frozen=True, kw_only=True) @@ -39,11 +36,11 @@ UPDATE_ENTITIES: Final = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SynologyDSMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Synology DSM update entities.""" - data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + data = entry.runtime_data async_add_entities( SynoDSMUpdateEntity(data.api, data.coordinator_central, description) for description in UPDATE_ENTITIES From 5c642ef62626eb4afab8034b43fd8fc2072e2e8d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 12:21:07 +0100 Subject: [PATCH 1916/1941] Fix spelling of user-facing strings in `adax` integration (#141190) - capitalize "Bluetooth" and "LED" - sentence-case "Wi-Fi password" --- homeassistant/components/adax/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json index 6157b7dfc91..9ba497a9aca 100644 --- a/homeassistant/components/adax/strings.json +++ b/homeassistant/components/adax/strings.json @@ -5,14 +5,14 @@ "data": { "connection_type": "Select connection type" }, - "description": "Select connection type. Local requires heaters with bluetooth" + "description": "Select connection type. Local requires heaters with Bluetooth" }, "local": { "data": { "wifi_ssid": "Wi-Fi SSID", - "wifi_pswd": "Wi-Fi Password" + "wifi_pswd": "Wi-Fi password" }, - "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes." + "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue LED starts blinking before pressing Submit. Configuring heater might take some minutes." }, "cloud": { "data": { From 77f8ddd948ee048761f26ed834cb348a590da61d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 23 Mar 2025 12:32:38 +0100 Subject: [PATCH 1917/1941] Fix climate and humidifier platform for Comelit (#140611) fix climate and humidifier platform for Comelit --- homeassistant/components/comelit/climate.py | 16 +++++++++++++--- homeassistant/components/comelit/humidifier.py | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 8064d478c32..3ec79001d55 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -119,10 +119,10 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device, device.type) + self._update_attributes() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _update_attributes(self) -> None: + """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): raise HomeAssistantError( @@ -158,6 +158,12 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity self._attr_target_temperature = values[4] / 10 + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if ( @@ -171,6 +177,8 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, ClimaComelitCommand.SET, target_temp ) + self._attr_target_temperature = target_temp + self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" @@ -182,3 +190,5 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, MODE_TO_ACTION[hvac_mode] ) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py index c5edfb1c2de..ad8f49ed5e2 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -124,10 +124,10 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier self._active_mode = active_mode self._active_action = active_action self._set_command = set_command + self._update_attributes() - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _update_attributes(self) -> None: + """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] if not isinstance(device.val, list): raise HomeAssistantError( @@ -154,6 +154,12 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier self._attr_mode = MODE_AUTO if _automatic else MODE_NORMAL self._attr_target_humidity = values[4] / 10 + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attributes() + super()._handle_coordinator_update() + async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if self.mode == HumidifierComelitMode.OFF: @@ -168,12 +174,16 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier await self.coordinator.api.set_humidity_status( self._device.index, HumidifierComelitCommand.SET, humidity ) + self._attr_target_humidity = humidity + self.async_write_ha_state() async def async_set_mode(self, mode: str) -> None: """Set humidifier mode.""" await self.coordinator.api.set_humidity_status( self._device.index, MODE_TO_ACTION[mode] ) + self._attr_mode = mode + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" From ca10618dc7b0fa9fb6b3e639a7e73bcd1716e3cb Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 23 Mar 2025 12:50:02 +0100 Subject: [PATCH 1918/1941] Update strings for Comelit (#140925) * Update strings for Comelit * apply review comment * apply review comment * Update homeassistant/components/comelit/strings.json Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --------- Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- homeassistant/components/comelit/strings.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 5ff4fa54688..496d62655a9 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -3,19 +3,25 @@ "flow_title": "{host}", "step": { "reauth_confirm": { - "description": "Please enter the correct PIN for {host}", "data": { "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "The PIN of your Comelit device." } }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", - "pin": "[%key:common::config_flow::data::pin%]" + "pin": "[%key:common::config_flow::data::pin%]", + "type": "Device type" }, "data_description": { - "host": "The hostname or IP address of your Comelit device." + "host": "The hostname or IP address of your Comelit device.", + "port": "The port of your Comelit device.", + "pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]", + "type": "The type of your Comelit device." } } }, From 798ee60ae505c293e18668b110852836c0fa3e63 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 23 Mar 2025 14:07:52 +0100 Subject: [PATCH 1919/1941] Make variables action not restricted to local scopes (#141114) Make variables action in scripts not restricted to local scopes --- homeassistant/helpers/script.py | 11 +++---- tests/helpers/test_script.py | 56 ++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index bf7a4a0971c..1242ef3e4d5 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -966,12 +966,11 @@ class _ScriptRun: ## Variable actions ## async def _async_step_variables(self) -> None: - """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) + """Assign values to variables.""" + self._step_log("assigning variables") + self._variables.update( + self._action[CONF_VARIABLES].async_simple_render(self._variables) + ) ## External actions ## diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index df589a41daa..f8552fcefed 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -494,7 +494,7 @@ async def test_calling_service_response_data_in_scopes(hass: HomeAssistant) -> N assert result.variables["my_response"] == expected_var expected_trace = { - "0": [{"variables": {"my_response": expected_var}}], + "0": [{"variables": {"my_response": expected_var, "state": "off"}}], "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/1": [ { @@ -1797,7 +1797,7 @@ async def test_wait_in_sequence(hass: HomeAssistant) -> None: assert result.variables["wait"] == expected_var expected_trace = { - "0": [{"variables": {"wait": expected_var}}], + "0": [{"variables": {"wait": expected_var, "state": "off"}}], "0/sequence/0": [{"variables": {"state": "off"}}], "0/sequence/1": [ { @@ -1840,7 +1840,7 @@ async def test_wait_in_parallel(hass: HomeAssistant) -> None: assert "wait" not in result.variables expected_trace = { - "0": [{}], + "0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/0": [{"variables": {"state": "off"}}], "0/parallel/0/sequence/1": [ { @@ -5277,11 +5277,23 @@ async def test_set_variable( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test setting variables in scripts.""" - alias = "variables step" sequence = cv.SCRIPT_SCHEMA( [ - {"alias": alias, "variables": {"variable": "value"}}, - {"action": "test.script", "data": {"value": "{{ variable }}"}}, + {"alias": "variables", "variables": {"x": 1, "y": 1}}, + { + "alias": "scope", + "sequence": [ + {"variables": {"y": 3, "z": 3}}, + { + "action": "test.script", + "data": {"value": "x={{ x }}, y={{ y }}, z={{ z }}"}, + }, + ], + }, + { + "action": "test.script", + "data": {"value": "x={{ x }}, y={{ y }}, z={{ z }}"}, + }, ] ) script_obj = script.Script(hass, sequence, "test script", "test_domain") @@ -5291,18 +5303,36 @@ async def test_set_variable( await script_obj.async_run(context=Context()) await hass.async_block_till_done() - assert mock_calls[0].data["value"] == "value" - assert f"Executing step {alias}" in caplog.text + assert len(mock_calls) == 2 + assert mock_calls[0].data["value"] == "x=1, y=3, z=3" + assert mock_calls[1].data["value"] == "x=1, y=3, z=3" + + assert "Executing step variables" in caplog.text expected_trace = { - "0": [{"variables": {"variable": "value"}}], - "1": [ + "0": [{"variables": {"x": 1, "y": 1}}], + "1": [{"variables": {"y": 3, "z": 3}}], + "1/sequence/0": [{"variables": {"y": 3, "z": 3}}], + "1/sequence/1": [ { "result": { "params": { "domain": "test", "service": "script", - "service_data": {"value": "value"}, + "service_data": {"value": "x=1, y=3, z=3"}, + "target": {}, + }, + "running_script": False, + }, + } + ], + "2": [ + { + "result": { + "params": { + "domain": "test", + "service": "script", + "service_data": {"value": "x=1, y=3, z=3"}, "target": {}, }, "running_script": False, @@ -5899,7 +5929,9 @@ async def test_stop_action_nested_response_variables( "variables": {"var": var, "output": {"value": "Testing 123"}}, } ], - "1": [{"result": {"choice": choice}}], + "1": [ + {"result": {"choice": choice}, "variables": {"output": {"value": response}}} + ], "1/if": [{"result": {"result": if_result}}], "1/if/condition/0": [{"result": {"result": var == 1, "entities": []}}], f"1/{choice}/0": [{"variables": {"output": {"value": response}}}], From 34504f45a54b90aa4a875e6e368877dc2ce2b42b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Mar 2025 14:15:09 +0100 Subject: [PATCH 1920/1941] Patch Z-Wave platforms in climate tests (#141204) --- tests/components/zwave_js/test_climate.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 5d711528a28..f312284d897 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -42,6 +42,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -56,6 +57,12 @@ from .common import ( ) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + async def test_thermostat_v2( hass: HomeAssistant, client, From ef2485be3bdee82ebc4bf0d7934453cfaaf8027a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 14:15:41 +0100 Subject: [PATCH 1921/1941] Fix sentence-casing in part of `airq` sensor names (#141203) --- homeassistant/components/airq/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 26b944467e6..9c16975a3ab 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -91,7 +91,7 @@ "name": "Hydrogen fluoride" }, "health_index": { - "name": "Health Index" + "name": "Health index" }, "absolute_humidity": { "name": "Absolute humidity" @@ -112,10 +112,10 @@ "name": "Oxygen" }, "performance_index": { - "name": "Performance Index" + "name": "Performance index" }, "hydrogen_phosphide": { - "name": "Hydrogen Phosphide" + "name": "Hydrogen phosphide" }, "relative_pressure": { "name": "Relative pressure" @@ -127,22 +127,22 @@ "name": "Refrigerant" }, "silicon_hydride": { - "name": "Silicon Hydride" + "name": "Silicon hydride" }, "noise": { "name": "Noise" }, "maximum_noise": { - "name": "Noise (Maximum)" + "name": "Noise (maximum)" }, "radon": { "name": "Radon" }, "industrial_volatile_organic_compounds": { - "name": "VOCs (Industrial)" + "name": "VOCs (industrial)" }, "virus_index": { - "name": "Virus Index" + "name": "Virus index" } } } From 8874fbe9c7056439dd913ca121ab73d54bd2af74 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 14:16:54 +0100 Subject: [PATCH 1922/1941] Fix sentence-casing of "Station radius" in `airnow` (#141200) --- homeassistant/components/airnow/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index d5fb22106f9..a69f67948cb 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -7,7 +7,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "radius": "Station Radius (miles; optional)" + "radius": "Station radius (miles; optional)" } } }, @@ -25,7 +25,7 @@ "step": { "init": { "data": { - "radius": "Station Radius (miles)" + "radius": "Station radius (miles)" } } } From c7d1e5a28cf6839e72a4cc31753e3b95c9b541ac Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 14:17:32 +0100 Subject: [PATCH 1923/1941] Fix spelling of "Do you want to set up?" in `airgradient` (#141199) --- homeassistant/components/airgradient/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 4cf3a6a34ea..2d9b6be529d 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -11,7 +11,7 @@ } }, "discovery_confirm": { - "description": "Do you want to setup {model}?" + "description": "Do you want to set up {model}?" } }, "abort": { From 588d6ad4cf7d17e19a03d9d6e3d6878aabedb855 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Mar 2025 14:35:29 +0100 Subject: [PATCH 1924/1941] Patch Z-Wave platforms in cover tests (#141205) --- tests/components/zwave_js/test_cover.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index b13d4f9787f..13f519725fd 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -2,6 +2,7 @@ import logging +import pytest from zwave_js_server.const import ( CURRENT_STATE_PROPERTY, CURRENT_VALUE_PROPERTY, @@ -35,6 +36,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_UNKNOWN, + Platform, ) from homeassistant.core import HomeAssistant @@ -50,6 +52,12 @@ FIBARO_FGR_223_SHUTTER_COVER_ENTITY = "cover.fgr_223_test_cover" LOGGER.setLevel(logging.DEBUG) +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.COVER] + + async def test_window_cover( hass: HomeAssistant, client, chain_actuator_zws12, integration ) -> None: From 4758452e920dd15ab5a08173ea9418f0d2d011bd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 14:35:59 +0100 Subject: [PATCH 1925/1941] Use correct unit symbol "min" for minutes in `asuswrt` integration (#141206) * Use correct unit symbol "min" for minutes in `asuswrt` integration * Sentence-case all "temperature" sensors --- homeassistant/components/asuswrt/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 9d50f50c7e9..cac37c0cfd0 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -66,28 +66,28 @@ "name": "Upload" }, "load_avg_1m": { - "name": "Average load (1m)" + "name": "Average load (1 min)" }, "load_avg_5m": { - "name": "Average load (5m)" + "name": "Average load (5 min)" }, "load_avg_15m": { - "name": "Average load (15m)" + "name": "Average load (15 min)" }, "24ghz_temperature": { - "name": "2.4GHz Temperature" + "name": "2.4GHz temperature" }, "5ghz_temperature": { - "name": "5GHz Temperature" + "name": "5GHz temperature" }, "cpu_temperature": { - "name": "CPU Temperature" + "name": "CPU temperature" }, "5ghz_2_temperature": { - "name": "5GHz Temperature (Radio 2)" + "name": "5GHz temperature (Radio 2)" }, "6ghz_temperature": { - "name": "6GHz Temperature" + "name": "6GHz temperature" }, "cpu_usage": { "name": "CPU usage" From 2465d0db7b8f7b3110770862740e1180ab4d8d10 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 23 Mar 2025 14:52:22 +0100 Subject: [PATCH 1926/1941] Cleanup Vodafone Station strings (#141202) --- homeassistant/components/vodafone_station/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index e05e1877798..6e308c35e4f 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -7,7 +7,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "password": "Please enter the correct password for host: {host}" + "password": "[%key:component::vodafone_station::config::step::user::data_description::password%]" } }, "user": { @@ -33,10 +33,10 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { - "already_logged": "User already logged-in, please try again later.", + "already_logged": "[%key:component::vodafone_station::config::abort::already_logged%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "model_not_supported": "The device model is currently unsupported.", + "model_not_supported": "[%key:component::vodafone_station::config::abort::model_not_supported%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, From 6b724603c8b5db40d66af0c6121d7c2b1e952856 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:01:53 +0100 Subject: [PATCH 1927/1941] Remove orphan fuel type settings from Tankerkoening (#141207) remove orphan fule type settings --- homeassistant/components/tankerkoenig/config_flow.py | 6 +----- homeassistant/components/tankerkoenig/const.py | 3 --- homeassistant/components/tankerkoenig/coordinator.py | 3 +-- homeassistant/components/tankerkoenig/strings.json | 1 - tests/components/tankerkoenig/const.py | 3 +-- .../tankerkoenig/snapshots/test_diagnostics.ambr | 3 --- tests/components/tankerkoenig/test_config_flow.py | 8 +------- 7 files changed, 4 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 8796ae46ab7..b269eaaaf55 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -39,7 +39,7 @@ from homeassistant.helpers.selector import ( NumberSelectorConfig, ) -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_TYPES +from .const import CONF_STATIONS, DEFAULT_RADIUS, DOMAIN async def async_get_nearby_stations( @@ -175,10 +175,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): vol.Required( CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") ): cv.string, - vol.Required( - CONF_FUEL_TYPES, - default=user_input.get(CONF_FUEL_TYPES, list(FUEL_TYPES)), - ): cv.multi_select(FUEL_TYPES), vol.Required( CONF_LOCATION, default=user_input.get( diff --git a/homeassistant/components/tankerkoenig/const.py b/homeassistant/components/tankerkoenig/const.py index c2a1dba9b6a..6761d20f4ce 100644 --- a/homeassistant/components/tankerkoenig/const.py +++ b/homeassistant/components/tankerkoenig/const.py @@ -3,14 +3,11 @@ DOMAIN = "tankerkoenig" NAME = "tankerkoenig" -CONF_FUEL_TYPES = "fuel_types" CONF_STATIONS = "stations" DEFAULT_RADIUS = 2 DEFAULT_SCAN_INTERVAL = 30 -FUEL_TYPES = {"e5": "Super", "e10": "Super E10", "diesel": "Diesel"} - ATTR_BRAND = "brand" ATTR_CITY = "city" ATTR_FUEL_TYPE = "fuel_type" diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 1f73d0577b3..f1e6bc8c865 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN +from .const import CONF_STATIONS, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf self._selected_stations: list[str] = self.config_entry.data[CONF_STATIONS] self.stations: dict[str, Station] = {} - self.fuel_types: list[str] = self.config_entry.data[CONF_FUEL_TYPES] self.show_on_map: bool = self.config_entry.options[CONF_SHOW_ON_MAP] self._tankerkoenig = Tankerkoenig( diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 29f4f439dd5..db620b2b11c 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -5,7 +5,6 @@ "data": { "name": "Region name", "api_key": "[%key:common::config_flow::data::api_key%]", - "fuel_types": "Fuel types", "location": "[%key:common::config_flow::data::location%]", "stations": "Additional fuel stations", "radius": "Search radius" diff --git a/tests/components/tankerkoenig/const.py b/tests/components/tankerkoenig/const.py index 2c28753a7f3..9a2ecb3a2be 100644 --- a/tests/components/tankerkoenig/const.py +++ b/tests/components/tankerkoenig/const.py @@ -2,7 +2,7 @@ from aiotankerkoenig import PriceInfo, Station, Status -from homeassistant.components.tankerkoenig.const import CONF_FUEL_TYPES, CONF_STATIONS +from homeassistant.components.tankerkoenig.const import CONF_STATIONS from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -98,7 +98,6 @@ PRICES_MISSING_FUELTYPE = { CONFIG_DATA = { CONF_NAME: "Home", CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", - CONF_FUEL_TYPES: ["e5"], CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, CONF_RADIUS: 2.0, CONF_STATIONS: [ diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index b5b33d7c246..71d9d9c75f8 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -12,9 +12,6 @@ 'entry': dict({ 'data': dict({ 'api_key': '**REDACTED**', - 'fuel_types': list([ - 'e5', - ]), 'location': dict({ 'latitude': '**REDACTED**', 'longitude': '**REDACTED**', diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index bb1e943bbb9..967470c2c16 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -4,11 +4,7 @@ from unittest.mock import AsyncMock, patch from aiotankerkoenig.exceptions import TankerkoenigInvalidKeyError -from homeassistant.components.tankerkoenig.const import ( - CONF_FUEL_TYPES, - CONF_STATIONS, - DOMAIN, -) +from homeassistant.components.tankerkoenig.const import CONF_STATIONS, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, @@ -30,7 +26,6 @@ from tests.common import MockConfigEntry MOCK_USER_DATA = { CONF_NAME: "Home", CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", - CONF_FUEL_TYPES: ["e5"], CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, CONF_RADIUS: 2.0, } @@ -81,7 +76,6 @@ async def test_user(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_NAME] == "Home" assert result["data"][CONF_API_KEY] == "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx" - assert result["data"][CONF_FUEL_TYPES] == ["e5"] assert result["data"][CONF_LOCATION] == {"latitude": 51.0, "longitude": 13.0} assert result["data"][CONF_RADIUS] == 2.0 assert result["data"][CONF_STATIONS] == [ From ba8ec2258745bc936903aa005ee4cb93cc218d40 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 23 Mar 2025 16:20:37 +0200 Subject: [PATCH 1928/1941] Add Switcher missing data descriptions (#141077) --- homeassistant/components/switcher_kis/strings.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index e380711303d..c3cf111199f 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -9,13 +9,21 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "The email address used to sign in to the Switcher app.", + "token": "The local control token received from Switcher." } }, "reauth_confirm": { - "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see https://www.home-assistant.io/integrations/switcher_kis/#prerequisites", + "description": "[%key:component::switcher_kis::config::step::credentials::description%]", "data": { "username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "[%key:component::switcher_kis::config::step::credentials::data_description::username%]", + "token": "[%key:component::switcher_kis::config::step::credentials::data_description::token%]" } } }, From 703848766a8da71eb10d8e6a506eaddef31eeff1 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 15:21:48 +0100 Subject: [PATCH 1929/1941] Capitalize "URL" in `feedreader` error message (#141210) --- homeassistant/components/feedreader/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/feedreader/strings.json b/homeassistant/components/feedreader/strings.json index 3132aadbda8..35022e82bb1 100644 --- a/homeassistant/components/feedreader/strings.json +++ b/homeassistant/components/feedreader/strings.json @@ -36,7 +36,7 @@ "issues": { "import_yaml_error_url_error": { "title": "The Feedreader YAML configuration import failed", - "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." + "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that the URL is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually." } } } From fdaba003ce437dc280e3f34b6bf2dc36cd87fe8a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 23 Mar 2025 15:22:18 +0100 Subject: [PATCH 1930/1941] Patch Z-Wave platforms in event tests (#141209) --- tests/components/zwave_js/test_event.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/components/zwave_js/test_event.py b/tests/components/zwave_js/test_event.py index 1db02662f4e..84b1ade2632 100644 --- a/tests/components/zwave_js/test_event.py +++ b/tests/components/zwave_js/test_event.py @@ -3,11 +3,12 @@ from datetime import timedelta from freezegun import freeze_time +import pytest from zwave_js_server.event import Event from homeassistant.components.event import ATTR_EVENT_TYPE from homeassistant.components.zwave_js.const import ATTR_VALUE -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -15,6 +16,12 @@ BASIC_EVENT_VALUE_ENTITY = "event.honeywell_in_wall_smart_fan_control_event_valu CENTRAL_SCENE_ENTITY = "event.node_51_scene_002" +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.EVENT] + + async def test_basic( hass: HomeAssistant, client, fan_honeywell_39358, integration ) -> None: From f94b55b6088db851e1acda095d08644d217b9ff6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 15:22:57 +0100 Subject: [PATCH 1931/1941] Fixes to user-facing strings of `azure_devops` integration (#141208) * Fixes to user-facing strings of `azure_devops` integration - capitalize abbreviations "ID" and "URL" - sentence-case "project" - consistently capitalize "Personal Access Token" as a name * Update test_sensor.ambr --- homeassistant/components/azure_devops/strings.json | 8 ++++---- .../azure_devops/snapshots/test_sensor.ambr | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json index f5fe5cd06a7..611a8b9a758 100644 --- a/homeassistant/components/azure_devops/strings.json +++ b/homeassistant/components/azure_devops/strings.json @@ -14,7 +14,7 @@ "personal_access_token": "Personal Access Token (PAT)" }, "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", - "title": "Add Azure DevOps Project" + "title": "Add Azure DevOps project" }, "reauth_confirm": { "data": { @@ -32,7 +32,7 @@ "entity": { "sensor": { "build_id": { - "name": "{definition_name} latest build id" + "name": "{definition_name} latest build ID" }, "finish_time": { "name": "{definition_name} latest build finish time" @@ -59,7 +59,7 @@ "name": "{definition_name} latest build start time" }, "url": { - "name": "{definition_name} latest build url" + "name": "{definition_name} latest build URL" }, "work_item_count": { "name": "{item_type} {item_state} work items" @@ -68,7 +68,7 @@ }, "exceptions": { "authentication_failed": { - "message": "Could not authorize with Azure DevOps for {title}. You will need to update your personal access token." + "message": "Could not authorize with Azure DevOps for {title}. You will need to update your Personal Access Token." } } } diff --git a/tests/components/azure_devops/snapshots/test_sensor.ambr b/tests/components/azure_devops/snapshots/test_sensor.ambr index 0b8f35497c6..3fe4d470a63 100644 --- a/tests/components/azure_devops/snapshots/test_sensor.ambr +++ b/tests/components/azure_devops/snapshots/test_sensor.ambr @@ -131,7 +131,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CI latest build id', + 'original_name': 'CI latest build ID', 'platform': 'azure_devops', 'previous_unique_id': None, 'supported_features': 0, @@ -143,7 +143,7 @@ # name: test_sensors[sensor.testproject_ci_latest_build_id-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build id', + 'friendly_name': 'testproject CI latest build ID', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_id', @@ -462,7 +462,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'CI latest build url', + 'original_name': 'CI latest build URL', 'platform': 'azure_devops', 'previous_unique_id': None, 'supported_features': 0, @@ -474,7 +474,7 @@ # name: test_sensors[sensor.testproject_ci_latest_build_url-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build url', + 'friendly_name': 'testproject CI latest build URL', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_url', @@ -526,7 +526,7 @@ # name: test_sensors_missing_data[sensor.testproject_ci_latest_build_id-state-missing-data] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build id', + 'friendly_name': 'testproject CI latest build ID', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_id', @@ -619,7 +619,7 @@ # name: test_sensors_missing_data[sensor.testproject_ci_latest_build_url-state-missing-data] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'testproject CI latest build url', + 'friendly_name': 'testproject CI latest build URL', }), 'context': , 'entity_id': 'sensor.testproject_ci_latest_build_url', From 8869236e9cc9b7c2d05a6387ca520f4b5f5298be Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Mar 2025 04:26:14 -1000 Subject: [PATCH 1932/1941] Bump google-cloud-pubsub to 2.29.0 (#141178) changelog: https://github.com/googleapis/python-pubsub/compare/v2.28.0...v2.29.0 --- homeassistant/components/google_pubsub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index d3e57c26e39..b96f4e9ebc0 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_pubsub", "iot_class": "cloud_push", "quality_scale": "legacy", - "requirements": ["google-cloud-pubsub==2.28.0"] + "requirements": ["google-cloud-pubsub==2.29.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 663287929cf..a2f06f812af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1030,7 +1030,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.28.0 +google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud google-cloud-speech==2.27.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f0e1873d9c..32b80165ced 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.28.0 +google-cloud-pubsub==2.29.0 # homeassistant.components.google_cloud google-cloud-speech==2.27.0 From 56f553e352392e8d42a72d9689a73781ef4503a3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 15:26:44 +0100 Subject: [PATCH 1933/1941] Clarify meaning of "level" in `dynalite.request_channel_level` action (#141184) Without context it's very difficult to come up with a good translation of "level" as there are many different words for this in other languages. This commit adds "brightness" to explain the meaning of "channel level" in `dynalite`. --- homeassistant/components/dynalite/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json index 468cdebf0b1..4f73f91113b 100644 --- a/homeassistant/components/dynalite/strings.json +++ b/homeassistant/components/dynalite/strings.json @@ -36,7 +36,7 @@ }, "request_channel_level": { "name": "Request channel level", - "description": "Requests Dynalite to report the level of a specific channel.", + "description": "Requests Dynalite to report the brightness level of a specific channel.", "fields": { "host": { "name": "[%key:common::config_flow::data::host%]", @@ -48,7 +48,7 @@ }, "channel": { "name": "Channel", - "description": "Channel to request the level for." + "description": "Channel to request the brightness level for." } } } From 5f3344cd3d1088785107fd12b72b00fff227ff77 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:27:40 +0100 Subject: [PATCH 1934/1941] Bump linkplay to v0.2.0 (#141098) * Bump linkplay to v0.2.0 * Fix invalid reference on items() * Ruff --- homeassistant/components/linkplay/manifest.json | 2 +- .../components/linkplay/media_player.py | 16 ++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index ec9a8759a30..0fceed1f691 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.1.3"], + "requirements": ["python-linkplay==0.2.0"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index b27616f1e09..16b0d5f75f1 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -86,16 +86,10 @@ REPEAT_MAP: dict[LoopMode, RepeatMode] = { REPEAT_MAP_INV: dict[RepeatMode, LoopMode] = {v: k for k, v in REPEAT_MAP.items()} -EQUALIZER_MAP: dict[EqualizerMode, str] = { - EqualizerMode.NONE: "None", - EqualizerMode.CLASSIC: "Classic", - EqualizerMode.POP: "Pop", - EqualizerMode.JAZZ: "Jazz", - EqualizerMode.VOCAL: "Vocal", +EQUALIZER_MAP_INV: dict[str, EqualizerMode] = { + mode.value: mode for mode in EqualizerMode } -EQUALIZER_MAP_INV: dict[str, EqualizerMode] = {v: k for k, v in EQUALIZER_MAP.items()} - DEFAULT_FEATURES: MediaPlayerEntityFeature = ( MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA @@ -148,7 +142,6 @@ async def async_setup_entry( class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): """Representation of a LinkPlay media player.""" - _attr_sound_mode_list = list(EQUALIZER_MAP.values()) _attr_device_class = MediaPlayerDeviceClass.RECEIVER _attr_media_content_type = MediaType.MUSIC _attr_name = None @@ -163,6 +156,9 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): self._attr_source_list = [ SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support ] + self._attr_sound_mode_list = [ + mode.value for mode in bridge.player.available_equalizer_modes + ] @exception_wrap async def async_update(self) -> None: @@ -348,7 +344,7 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): self._attr_is_volume_muted = self._bridge.player.muted self._attr_repeat = REPEAT_MAP[self._bridge.player.loop_mode] self._attr_shuffle = self._bridge.player.loop_mode == LoopMode.RANDOM_PLAYBACK - self._attr_sound_mode = EQUALIZER_MAP[self._bridge.player.equalizer_mode] + self._attr_sound_mode = self._bridge.player.equalizer_mode.value self._attr_supported_features = DEFAULT_FEATURES if self._bridge.player.status == PlayingStatus.PLAYING: diff --git a/requirements_all.txt b/requirements_all.txt index a2f06f812af..d30280144a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2425,7 +2425,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.3 +python-linkplay==0.2.0 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32b80165ced..5384d917e15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1961,7 +1961,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.1.3 +python-linkplay==0.2.0 # homeassistant.components.matter python-matter-server==7.0.0 From 3df1ebf2fc2e5d73c1ed23aafa3d5d5f3661346b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 16:25:05 +0100 Subject: [PATCH 1935/1941] Fix typo "to setup" and sentence-casing in `twilio` (#141218) --- homeassistant/components/twilio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/twilio/strings.json b/homeassistant/components/twilio/strings.json index 871711ff087..f4b7dee707f 100644 --- a/homeassistant/components/twilio/strings.json +++ b/homeassistant/components/twilio/strings.json @@ -12,7 +12,7 @@ "webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]" }, "create_entry": { - "default": "To send events to Home Assistant, you will need to setup [Webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." + "default": "To send events to Home Assistant, you will need to set up [webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } } } From a6ff5391e5eded886863a75c46459e27eb1d919f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 16:26:28 +0100 Subject: [PATCH 1936/1941] Fix typo "to setup" in `homeassistant_hardware` (#141212) Fix typo "to setup" in multiple integrations --- homeassistant/components/homeassistant_hardware/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 5456f418c75..6dda01561f1 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -39,7 +39,7 @@ "description": "The OpenThread Border Router (OTBR) add-on is now starting." }, "otbr_failed": { - "title": "Failed to setup OpenThread Border Router", + "title": "Failed to set up OpenThread Border Router", "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists." }, "confirm_otbr": { From 663a204c044c94535a7e2ea260b69d245a8a0121 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Sun, 23 Mar 2025 17:01:35 +0100 Subject: [PATCH 1937/1941] Fix Python path for vscode run core task (#141090) Fix Python path for vscode launch core task --- .vscode/tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b699ed44b96..09c1d374299 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Run Home Assistant Core", "type": "shell", - "command": "hass -c ./config", + "command": "${command:python.interpreterPath} -m homeassistant -c ./config", "group": "test", "presentation": { "reveal": "always", From f14b76c54b46999a2726f42fa71d63fbebbc0806 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 23 Mar 2025 09:03:06 -0700 Subject: [PATCH 1938/1941] Add Gemini/OpenAI token stats to the conversation trace (#141118) * Add gemini token status to the conversation trace * Add OpenAI Token Stats * Revert input_tokens_details since its not in the openai version yet * Fix ruff lint errors --- .../components/conversation/chat_log.py | 14 ++++++++++---- .../conversation.py | 12 ++++++++++++ .../openai_conversation/conversation.py | 16 +++++++++++++++- .../test_conversation.py | 11 ++++++++++- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 2de785dae7d..cb7b8dd22f7 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -8,7 +8,7 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass, field, replace import logging -from typing import Literal, TypedDict +from typing import Any, Literal, TypedDict import voluptuous as vol @@ -456,10 +456,16 @@ class ChatLog: LOGGER.debug("Prompt: %s", self.content) LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None) - trace.async_conversation_trace_append( - trace.ConversationTraceEventType.AGENT_DETAIL, + self.async_trace( { "messages": self.content, "tools": self.llm_api.tools if self.llm_api else None, - }, + } + ) + + def async_trace(self, agent_details: dict[str, Any]) -> None: + """Append agent specific details to the conversation trace.""" + trace.async_conversation_trace_append( + trace.ConversationTraceEventType.AGENT_DETAIL, + agent_details, ) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 4648f1afb4c..e35346cc745 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -403,6 +403,18 @@ class GoogleGenerativeAIConversationEntity( error = f"Sorry, I had a problem talking to Google Generative AI: {err}" raise HomeAssistantError(error) from err + if (usage_metadata := chat_response.usage_metadata) is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": usage_metadata.prompt_token_count, + "cached_input_tokens": usage_metadata.cached_content_token_count + or 0, + "output_tokens": usage_metadata.candidates_token_count, + } + } + ) + response_parts = chat_response.candidates[0].content.parts if not response_parts: raise HomeAssistantError( diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 6767734bb00..32ac20b2680 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -9,6 +9,7 @@ from openai._streaming import AsyncStream from openai.types.responses import ( EasyInputMessageParam, FunctionToolParam, + ResponseCompletedEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionCallArgumentsDoneEvent, ResponseFunctionToolCall, @@ -111,6 +112,7 @@ def _convert_content_to_param( async def _transform_stream( + chat_log: conversation.ChatLog, result: AsyncStream[ResponseStreamEvent], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform an OpenAI delta stream into HA format.""" @@ -137,6 +139,18 @@ async def _transform_stream( ) ] } + elif ( + isinstance(event, ResponseCompletedEvent) + and (usage := event.response.usage) is not None + ): + chat_log.async_trace( + { + "stats": { + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + } + } + ) class OpenAIConversationEntity( @@ -252,7 +266,7 @@ class OpenAIConversationEntity( raise HomeAssistantError("Error talking to OpenAI") from err async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(result) + user_input.agent_id, _transform_stream(chat_log, result) ): messages.extend(_convert_content_to_param(content)) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 64f71c18bf2..22bc079a21f 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -156,8 +156,10 @@ async def test_function_call( trace_events = last_trace.get("events", []) assert [event["event_type"] for event in trace_events] == [ trace.ConversationTraceEventType.ASYNC_PROCESS, - trace.ConversationTraceEventType.AGENT_DETAIL, + trace.ConversationTraceEventType.AGENT_DETAIL, # prompt and tools + trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response trace.ConversationTraceEventType.TOOL_CALL, + trace.ConversationTraceEventType.AGENT_DETAIL, # stats for response ] # AGENT_DETAIL event contains the raw prompt passed to the model detail_event = trace_events[1] @@ -166,6 +168,13 @@ async def test_function_call( p["tool_name"] for p in detail_event["data"]["messages"][2]["tool_calls"] ] == ["test_tool"] + detail_event = trace_events[2] + assert set(detail_event["data"]["stats"].keys()) == { + "input_tokens", + "cached_input_tokens", + "output_tokens", + } + @patch( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" From c451518959027cfb16e70a5ebb000ac8136af269 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 23 Mar 2025 09:07:42 -0700 Subject: [PATCH 1939/1941] Fix google calendar working location event filtering (#141222) --- homeassistant/components/google/calendar.py | 6 +++--- tests/components/google/test_calendar.py | 13 +++++++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 4f8ffba1d19..4ae8c8cce03 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -383,9 +383,9 @@ class GoogleCalendarEntity( for attendee in event.attendees ): return False - - if event.event_type == EventTypeEnum.WORKING_LOCATION: - return self.entity_description.working_location + is_working_location_event = event.event_type == EventTypeEnum.WORKING_LOCATION + if self.entity_description.working_location != is_working_location_event: + return False if self._ignore_availability: return True return event.transparency == OPAQUE diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3d10e753714..274e310fbce 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1451,6 +1451,13 @@ async def test_working_location_ignored( assert state.attributes.get("message") == expected_event_message +@pytest.mark.parametrize( + ("event_type", "expected_event_message"), + [ + ("workingLocation", "Test All Day Event"), + ("default", None), + ], +) @pytest.mark.parametrize("calendar_is_primary", [True]) async def test_working_location_entity( hass: HomeAssistant, @@ -1458,12 +1465,14 @@ async def test_working_location_entity( entity_registry: er.EntityRegistry, mock_events_list_items: Callable[[list[dict[str, Any]]], None], component_setup: ComponentSetup, + event_type: str, + expected_event_message: str | None, ) -> None: """Test that working location events are registered under a disabled by default entity.""" event = { **TEST_EVENT, **upcoming(), - "eventType": "workingLocation", + "eventType": event_type, } mock_events_list_items([event]) assert await component_setup() @@ -1484,7 +1493,7 @@ async def test_working_location_entity( state = hass.states.get("calendar.working_location") assert state assert state.name == "Working location" - assert state.attributes.get("message") == "Test All Day Event" + assert state.attributes.get("message") == expected_event_message @pytest.mark.parametrize("calendar_is_primary", [False]) From 28ef0a33ad38b901870343e0088c14c9777e43a6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 23 Mar 2025 09:11:40 -0700 Subject: [PATCH 1940/1941] Update MCP to reconnect to the server on demand (#141215) * Reconnect to the MCP client on deman * Remove debug log * Update log messages --- homeassistant/components/mcp/__init__.py | 1 - homeassistant/components/mcp/coordinator.py | 77 ++++++--------------- 2 files changed, 20 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index 4a2b4da990d..41b6a260d9f 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -39,7 +39,6 @@ async def async_setup_entry( entry.async_on_unload(unsub) entry.runtime_data = coordinator - entry.async_on_unload(coordinator.close) return True diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py index a5c5ee55dbf..6e66036c548 100644 --- a/homeassistant/components/mcp/coordinator.py +++ b/homeassistant/components/mcp/coordinator.py @@ -40,6 +40,7 @@ async def mcp_client(url: str) -> AsyncGenerator[ClientSession]: await session.initialize() yield session except ExceptionGroup as err: + _LOGGER.debug("Error creating MCP client: %s", err) raise err.exceptions[0] from err @@ -51,13 +52,13 @@ class ModelContextProtocolTool(llm.Tool): name: str, description: str | None, parameters: vol.Schema, - session: ClientSession, + server_url: str, ) -> None: """Initialize the tool.""" self.name = name self.description = description self.parameters = parameters - self.session = session + self.server_url = server_url async def async_call( self, @@ -67,10 +68,16 @@ class ModelContextProtocolTool(llm.Tool): ) -> JsonObjectType: """Call the tool.""" try: - result = await self.session.call_tool( - tool_input.tool_name, tool_input.tool_args - ) + async with asyncio.timeout(TIMEOUT): + async with mcp_client(self.server_url) as session: + result = await session.call_tool( + tool_input.tool_name, tool_input.tool_args + ) + except TimeoutError as error: + _LOGGER.debug("Timeout when calling tool: %s", error) + raise HomeAssistantError(f"Timeout when calling tool: {error}") from error except httpx.HTTPStatusError as error: + _LOGGER.debug("Error when calling tool: %s", error) raise HomeAssistantError(f"Error when calling tool: {error}") from error return result.model_dump(exclude_unset=True, exclude_none=True) @@ -79,8 +86,6 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): """Define an object to hold MCP data.""" config_entry: ConfigEntry - _session: ClientSession | None = None - _setup_error: Exception | None = None def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize ModelContextProtocolCoordinator.""" @@ -91,52 +96,6 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): config_entry=config_entry, update_interval=UPDATE_INTERVAL, ) - self._stop = asyncio.Event() - - async def _async_setup(self) -> None: - """Set up the client connection.""" - connected = asyncio.Event() - stop = asyncio.Event() - self.config_entry.async_create_background_task( - self.hass, self._connect(connected, stop), "mcp-client" - ) - try: - async with asyncio.timeout(TIMEOUT): - await connected.wait() - self._stop = stop - finally: - if self._setup_error is not None: - raise self._setup_error - - async def _connect(self, connected: asyncio.Event, stop: asyncio.Event) -> None: - """Create a server-sent event MCP client.""" - url = self.config_entry.data[CONF_URL] - try: - async with ( - sse_client(url=url) as streams, - ClientSession(*streams) as session, - ): - await session.initialize() - self._session = session - connected.set() - await stop.wait() - except httpx.HTTPStatusError as err: - self._setup_error = err - _LOGGER.debug("Error connecting to MCP server: %s", err) - raise UpdateFailed(f"Error connecting to MCP server: {err}") from err - except ExceptionGroup as err: - self._setup_error = err.exceptions[0] - _LOGGER.debug("Error connecting to MCP server: %s", err) - raise UpdateFailed( - "Error connecting to MCP server: {err.exceptions[0]}" - ) from err.exceptions[0] - finally: - self._session = None - - async def close(self) -> None: - """Close the client connection.""" - if self._stop is not None: - self._stop.set() async def _async_update_data(self) -> list[llm.Tool]: """Fetch data from API endpoint. @@ -144,11 +103,15 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): This is the place to pre-process the data to lookup tables so entities can quickly look up their data. """ - if self._session is None: - raise UpdateFailed("No session available") try: - result = await self._session.list_tools() + async with asyncio.timeout(TIMEOUT): + async with mcp_client(self.config_entry.data[CONF_URL]) as session: + result = await session.list_tools() + except TimeoutError as error: + _LOGGER.debug("Timeout when listing tools: %s", error) + raise UpdateFailed(f"Timeout when listing tools: {error}") from error except httpx.HTTPError as err: + _LOGGER.debug("Error communicating with API: %s", err) raise UpdateFailed(f"Error communicating with API: {err}") from err _LOGGER.debug("Received tools: %s", result.tools) @@ -165,7 +128,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]): tool.name, tool.description, parameters, - self._session, + self.config_entry.data[CONF_URL], ) ) return tools From d23a724f796ccf14676f66574303397443f2c84d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 23 Mar 2025 19:28:45 +0100 Subject: [PATCH 1941/1941] Fix typo "to setup" in `reolink` (#141214) --- homeassistant/components/reolink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 74823c4bd32..7ad2e1ea217 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -102,7 +102,7 @@ "message": "Error trying to update Reolink firmware: {err}" }, "config_entry_not_ready": { - "message": "Error while trying to setup {host}: {err}" + "message": "Error while trying to set up {host}: {err}" } }, "issues": {